diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 164f229..8f4d710 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,6 @@ name: CI on: - push: pull_request: jobs: diff --git a/.opencode/tools/hashline-core.ts b/.opencode/lib/hashline-core.ts similarity index 91% rename from .opencode/tools/hashline-core.ts rename to .opencode/lib/hashline-core.ts index 212d6c8..a4b6fe9 100644 --- a/.opencode/tools/hashline-core.ts +++ b/.opencode/lib/hashline-core.ts @@ -24,6 +24,28 @@ export interface HashlineOperation { content?: string } +export interface HashlineOperationResultMetadata { + filediff: { + file: string + before: string + after: string + additions: number + deletions: number + } + files: Array<{ + filePath: string + before: string + after: string + additions: number + deletions: number + }> +} + +export interface HashlineOperationResult { + summary: string + metadata: HashlineOperationResultMetadata +} + interface FileSnapshot { absolutePath: string raw: string @@ -801,6 +823,50 @@ function formatEditResult(params: { return body.join("\n") } +interface HashlineExecutionParams { + filePath: string + operations: HashlineOperation[] + expectedFileHash?: string + fileRev?: string + safeReapply?: boolean + dryRun?: boolean + context?: { directory?: string } +} + +function buildOperationResult(params: { + filePath: string + mode: "hashline" | "legacy" + dryRun: boolean + before: FileSnapshot + after: FileSnapshot + operations: number + additions: number + removals: number + diffPreview?: string +}): HashlineOperationResult { + return { + summary: formatEditResult(params), + metadata: { + filediff: { + file: params.filePath, + before: params.before.raw, + after: params.after.raw, + additions: params.additions, + deletions: params.removals, + }, + files: [ + { + filePath: params.filePath, + before: params.before.raw, + after: params.after.raw, + additions: params.additions, + deletions: params.removals, + }, + ], + }, + } +} + function countOccurrences(haystack: string, needle: string): number { if (needle.length === 0) { return 0 @@ -868,15 +934,7 @@ export async function runHashlineRead(params: { ].join("\n") } -export async function runHashlineOperations(params: { - filePath: string - operations: HashlineOperation[] - expectedFileHash?: string - fileRev?: string - safeReapply?: boolean - dryRun?: boolean - context?: HashlineToolContext -}): Promise { +export async function runHashlineOperationsDetailed(params: HashlineExecutionParams): Promise { const absolutePath = resolveFilePath(params.filePath, params.context) const existingSnapshot = await readSnapshotIfExists(absolutePath) @@ -903,21 +961,7 @@ export async function runHashlineOperations(params: { await writeSnapshot(after) } - emitHashlineOperationMetadata({ - filePath: params.filePath, - dryRun: Boolean(params.dryRun), - before: snapshot, - after, - operations: normalizedOps.length, - additions: applied.additions, - removals: applied.removals, - existed: Boolean(existingSnapshot), - diff, - diffPreview, - context: params.context, - }) - - return formatEditResult({ + return buildOperationResult({ filePath: params.filePath, mode: "hashline", dryRun: Boolean(params.dryRun), @@ -930,99 +974,9 @@ export async function runHashlineOperations(params: { }) } -export async function runHashlineCheck(params: { - filePath: string - targets?: Array<{ - op?: HashlineOpName - ref?: string - startRef?: string - endRef?: string - }> - expectedFileHash?: string - fileRev?: string - safeReapply?: boolean - verbose?: boolean - context?: { directory?: string } -}): Promise { - const absolutePath = resolveFilePath(params.filePath, params.context) - const existingSnapshot = await readSnapshotIfExists(absolutePath) - const snapshot = existingSnapshot ?? emptySnapshot(absolutePath) - - if (params.expectedFileHash && snapshot.fileHash !== params.expectedFileHash.toUpperCase()) { - throw new Error( - `File hash mismatch for ${params.filePath}. Expected ${params.expectedFileHash.toUpperCase()}, actual ${snapshot.fileHash}. Read the file again before editing.`, - ) - } - - assertFileRevisionMatches(snapshot, params.filePath, params.fileRev) - - const safeReapply = Boolean(params.safeReapply) - const targets = Array.isArray(params.targets) ? params.targets : [] - const resolvedTargets: string[] = [] - - for (let idx = 0; idx < targets.length; idx += 1) { - const target = targets[idx] ?? {} - const op: HashlineOpName = target.op ?? (target.startRef && target.endRef ? "replace_range" : target.ref || target.startRef ? "replace" : "set_file") - const label = `target[${idx + 1}] ${op}` - - switch (op) { - case "set_file": { - resolvedTargets.push(`${label}: set_file (no refs)`) - break - } - - case "replace_range": { - if (!target.startRef || !target.endRef) { - throw new Error(`${label} requires startRef and endRef`) - } - - const start = resolveRef(target.startRef, snapshot, safeReapply) - const end = resolveRef(target.endRef, snapshot, safeReapply) - if (start.index > end.index) { - throw new Error(`${label} startRef must be on or before endRef`) - } - - resolvedTargets.push(`${label}: ${start.lineNumber}-${end.lineNumber}`) - break - } - - case "replace": - case "delete": - case "insert_before": - case "insert_after": { - const resolvedRange = resolveRefRange({ - snapshot, - ref: target.ref, - startRef: target.startRef, - endRef: target.endRef, - safeReapply, - label, - }) - - const span = - resolvedRange.start.lineNumber === resolvedRange.end.lineNumber - ? `${resolvedRange.start.lineNumber}` - : `${resolvedRange.start.lineNumber}-${resolvedRange.end.lineNumber}` - resolvedTargets.push(`${label}: ${span}`) - break - } - - default: { - throw new Error(`Unsupported check operation: ${(op as string) ?? "unknown"}`) - } - } - } - - const summary = [ - `Hashline check passed for ${params.filePath}.`, - `file_hash=${snapshot.fileHash} file_rev=${computeFileRev(snapshot.raw)} targets=${targets.length}`, - ] - - if (params.verbose && resolvedTargets.length > 0) { - summary.push(...resolvedTargets.map((item) => `- ${item}`)) - } - - return summary.join("\n") +export async function runHashlineOperations(params: HashlineExecutionParams): Promise { + const result = await runHashlineOperationsDetailed(params) + return result.summary } export async function runLegacyEdit(params: { @@ -1164,3 +1118,61 @@ export function mapOperationInput(input: HashlineOperationInput): HashlineOperat content: input.content ?? input.replacement, } } + +export interface HashlineResolveEditResult { + filePath: string + oldString: string + newString: string + fileRev?: string + summary: { + operations: number + additions: number + removals: number + linesBefore: number + linesAfter: number + } +} + +export async function resolveHashlineEdit(params: HashlineExecutionParams): Promise { + const absolutePath = resolveFilePath(params.filePath, params.context) + const existingSnapshot = await readSnapshotIfExists(absolutePath) + + const snapshot = existingSnapshot ?? emptySnapshot(absolutePath) + const normalizedOps = normalizeOperations(params.operations) + + if (params.expectedFileHash && snapshot.fileHash !== params.expectedFileHash.toUpperCase()) { + throw new Error( + `File hash mismatch for ${params.filePath}. Expected ${params.expectedFileHash.toUpperCase()}, actual ${snapshot.fileHash}. Read the file again before editing.`, + ) + } + + if (params.fileRev) { + const expectedRev = params.fileRev.toUpperCase() + const actualRev = computeFileRev(snapshot.raw) + if (actualRev !== expectedRev) { + throw new Error( + `File revision mismatch for ${params.filePath}. Expected ${expectedRev}, actual ${actualRev}. Read the file again before editing.`, + ) + } + } + + const changes = resolveChanges(snapshot, normalizedOps, Boolean(params.safeReapply)) + validateChangeConflicts(changes) + + const applied = applyChanges(snapshot, changes) + const after = snapshotFromLines(snapshot, applied.lines) + + return { + filePath: params.filePath, + oldString: snapshot.raw, + newString: after.raw, + fileRev: computeFileRev(snapshot.raw), + summary: { + operations: normalizedOps.length, + additions: applied.additions, + removals: applied.removals, + linesBefore: snapshot.lines.length, + linesAfter: after.lines.length, + }, + } +} diff --git a/.opencode/plugins/hashline-hooks.ts b/.opencode/plugins/hashline-hooks.ts index 3580b23..3c52b08 100644 --- a/.opencode/plugins/hashline-hooks.ts +++ b/.opencode/plugins/hashline-hooks.ts @@ -4,6 +4,13 @@ import { randomBytes } from "node:crypto" import { tmpdir } from "node:os" import { fileURLToPath, pathToFileURL } from "node:url" import type { Hooks } from "@opencode-ai/plugin" +import { + mapOperationInput, + resolveFilePath, + runHashlineRead, + runHashlineOperationsDetailed, + type HashlineOperationInput, +} from "../lib/hashline-core.js" import { buildHashlineSystemInstruction, extractPathFromToolArgs, @@ -15,8 +22,8 @@ import { type HashlineRuntimeConfig, } from "./hashline-shared" -const FILE_READ_TOOLS = ["read"] -const FILE_EDIT_TOOLS = ["edit", "write", "patch", "hash-check"] +const FILE_READ_TOOLS = ["hashline_read", "read", "file_read", "read_file", "cat", "view"] +const FILE_EDIT_TOOLS = ["hashline_edit", "hashline_write", "hashline_patch", "edit", "write", "patch", "apply_patch", "file_edit", "file_write", "edit_file", "multiedit", "batch"] function toolEndsWith(tool: string, known: string[]): boolean { const lower = tool.toLowerCase() @@ -42,6 +49,142 @@ function isFileEditTool(tool: string): boolean { return toolEndsWith(tool, FILE_EDIT_TOOLS) } +function isNativeEditTool(tool: string): boolean { + return toolEndsWith(tool, ["edit"]) +} + +function getCanonicalPath(filePath: string, input?: Record): string { + try { + return resolveFilePath(filePath, { + directory: typeof input?.directory === "string" ? input.directory : undefined, + }) + } catch { + return filePath + } +} + +function invalidateFileCache( + cache: HashlineAnnotationCache, + args: Record, + input?: Record, +): void { + const filePath = extractPathFromToolArgs(args) + if (!filePath) { + return + } + + const canonicalPath = getCanonicalPath(filePath, input) + cache.invalidate(filePath) + cache.invalidate(canonicalPath) +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.length > 0) { + return value + } + } + + return undefined +} + +function firstBoolean(...values: unknown[]): boolean | undefined { + for (const value of values) { + if (typeof value === "boolean") { + return value + } + } + + return undefined +} + +function hasHashlineEditShape(args: Record): boolean { + return ( + Array.isArray(args.operations) || + typeof args.operation === "string" || + typeof args.ref === "string" || + typeof args.startRef === "string" || + typeof args.start_ref === "string" + ) +} + +function toHashlineOperations(args: Record): HashlineOperationInput[] | null { + if (Array.isArray(args.operations) && args.operations.length > 0) { + return args.operations.map((entry) => { + const item = (entry ?? {}) as Record + return { + op: String(item.op ?? "") as HashlineOperationInput["op"], + ref: firstString(item.ref), + startRef: firstString(item.startRef, item.start_ref), + endRef: firstString(item.endRef, item.end_ref), + content: firstString(item.content, item.replacement), + } + }) + } + + const operation = firstString(args.operation) + if (!operation) { + return null + } + + const ref = firstString(args.ref) + const startRef = firstString(args.startRef, args.start_ref, ref) + const endRef = firstString(args.endRef, args.end_ref) + const content = firstString(args.replacement, args.content) + + if (!startRef && !ref) { + return null + } + + return [ + { + op: operation === "replace" && endRef ? "replace_range" : (operation as HashlineOperationInput["op"]), + ref, + startRef, + endRef, + content, + }, + ] +} + +async function translateHashlineEditArgs( + args: Record, + input: Record, + config: HashlineRuntimeConfig, +): Promise | null> { + if (!hasHashlineEditShape(args)) { + return null + } + + const filePath = firstString(args.filePath, args.file_path, args.path, args.file) + if (!filePath) { + return null + } + + const operations = toHashlineOperations(args) + if (!operations || operations.length === 0) { + return null + } + + const result = await runHashlineOperationsDetailed({ + filePath, + operations: operations.map(mapOperationInput), + expectedFileHash: firstString(args.expectedFileHash, args.expected_file_hash), + fileRev: firstString(args.fileRev, args.file_rev), + safeReapply: firstBoolean(args.safeReapply, args.safe_reapply) ?? config.safeReapply, + dryRun: true, + context: { + directory: typeof input.directory === "string" ? input.directory : undefined, + }, + }) + + return { + filePath, + oldString: result.metadata.filediff.before, + newString: result.metadata.filediff.after, + } +} + const CONTENT_FIELD_KEYS = new Set([ "content", "new_content", @@ -191,7 +334,9 @@ type HashlinePluginHooks = Pick< "tool.execute.before" | "tool.execute.after" | "experimental.chat.system.transform" | "chat.message" > -export function createHashlineHooks(config: HashlineRuntimeConfig, cache: HashlineAnnotationCache): HashlinePluginHooks { +export function createHashlineHooks(config: HashlineRuntimeConfig, cache?: HashlineAnnotationCache): HashlinePluginHooks { + const effectiveCache = cache ?? new HashlineAnnotationCache(config.cacheSize ?? 128) + return { "tool.execute.before": async (input, output) => { const name = input.tool @@ -201,23 +346,30 @@ export function createHashlineHooks(config: HashlineRuntimeConfig, cache: Hashli } const args = (output.args ?? {}) as Record - const stripped = stripNestedHashes(args, config.prefix) - if (!stripped || typeof stripped !== "object" || Array.isArray(stripped)) { - return - } - - const strippedArgs = stripped as Record - for (const key of Object.keys(args)) { - delete args[key] + const sanitizedArgs = stripNestedHashes(args, config.prefix) as Record + + if (isNativeEditTool(name)) { + const translatedArgs = await translateHashlineEditArgs( + sanitizedArgs, + input as Record, + config, + ) + if (translatedArgs) { + output.args = translatedArgs + return + } } - for (const [key, value] of Object.entries(strippedArgs)) { - args[key] = value - } + output.args = sanitizedArgs }, "tool.execute.after": async (input, output) => { const args = (input.args ?? {}) as Record + + if (isFileEditTool(input.tool)) { + invalidateFileCache(effectiveCache, args, input as Record) + } + if (!isFileReadTool(input.tool, args)) { return } @@ -226,36 +378,43 @@ export function createHashlineHooks(config: HashlineRuntimeConfig, cache: Hashli return } - const source = output.output - const alreadyAnnotated = stripHashlinePrefixes(source, config.prefix) !== source - if ( - source.includes("##|") || - source.includes("# format: #|") - ) { + const filePathFromArgs = extractPathFromToolArgs(args) + if (typeof filePathFromArgs !== "string") { return } - if (config.maxFileSize > 0 && getByteLength(source) > config.maxFileSize) { - return - } + const canonicalPath = getCanonicalPath(filePathFromArgs, input as Record) - const filePathFromArgs = extractPathFromToolArgs(args) - if (typeof filePathFromArgs === "string" && shouldExclude(filePathFromArgs, config.exclude)) { + if (shouldExclude(filePathFromArgs, config.exclude)) { return } - const cacheKey = filePathFromArgs ?? `${input.tool}:${source.length}` - const cached = cache.get(cacheKey, source) + const offset = typeof args.offset === "number" ? args.offset : undefined + const limit = typeof args.limit === "number" ? args.limit : undefined + const sourceKey = JSON.stringify({ filePath: canonicalPath, offset, limit }) + const cacheKey = canonicalPath + const cached = effectiveCache.get(cacheKey, sourceKey) if (cached) { output.output = cached return } - const annotated = formatWithRuntimeConfig(source, config) + const annotated = await runHashlineRead({ + filePath: filePathFromArgs, + offset, + limit, + context: { + directory: typeof (input as Record).directory === "string" + ? ((input as Record).directory as string) + : undefined, + }, + }) + + if (config.maxFileSize > 0 && getByteLength(annotated) > config.maxFileSize) { + return + } - cache.set(cacheKey, source, annotated) + effectiveCache.set(cacheKey, sourceKey, annotated) output.output = annotated }, @@ -272,7 +431,7 @@ export function createHashlineHooks(config: HashlineRuntimeConfig, cache: Hashli output as { parts?: Array> }, input as Record, config, - cache, + effectiveCache, ) }, } diff --git a/.opencode/plugins/hashline-routing.ts b/.opencode/plugins/hashline-routing.ts index 37d5a5e..ba9f6d9 100644 --- a/.opencode/plugins/hashline-routing.ts +++ b/.opencode/plugins/hashline-routing.ts @@ -87,7 +87,7 @@ function normalizeArgsInPlace(toolName: string, args: Record): export const HashlineRouting: Plugin = async (input) => { const projectDirectory = typeof input?.directory === "string" ? input.directory : undefined const config = resolveHashlineConfig(projectDirectory) - const cache = new HashlineAnnotationCache(config.cacheSize) + const cache = new HashlineAnnotationCache(config.cacheSize ?? 128) const hooks = createHashlineHooks(config, cache) return { diff --git a/.opencode/plugins/hashline-shared.ts b/.opencode/plugins/hashline-shared.ts index d9f56f9..eb3a033 100644 --- a/.opencode/plugins/hashline-shared.ts +++ b/.opencode/plugins/hashline-shared.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto" import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import path from "node:path" -import { computeFileRev, getAdaptiveHashLength, hashlineAnchorHash, hashlineLineHash } from "../tools/hashline-core.js" +import { computeFileRev, getAdaptiveHashLength, hashlineAnchorHash, hashlineLineHash } from "../lib/hashline-core.js" export { computeFileRev } export interface HashlineRuntimeConfig { @@ -151,9 +151,10 @@ function normalizeGlobPath(value: string): string { return value.replace(/\\/g, "/") } -export function shouldExclude(filePath: string, patterns: string[]): boolean { +export function shouldExclude(filePath: string, patterns?: string[]): boolean { const normalizedPath = normalizeGlobPath(filePath) - return patterns.some((pattern) => path.matchesGlob(normalizedPath, normalizeGlobPath(pattern))) + const effectivePatterns = Array.isArray(patterns) ? patterns : DEFAULT_EXCLUDE_PATTERNS + return effectivePatterns.some((pattern) => path.matchesGlob(normalizedPath, normalizeGlobPath(pattern))) } const textEncoder = new TextEncoder() @@ -230,12 +231,18 @@ export function buildHashlineSystemInstruction(config: Pick##|\` (example: \`${prefix}12#A3F#9BC|const value = 1\`).`, - `Read output also includes \`${prefix}REV:\`; pass that value as \`file_rev\`/\`fileRev\` when editing.`, + `Read output also includes \`${prefix}REV:\`; pass that exact full value as \`file_rev\`/\`fileRev\` when editing. Do not truncate it.`, + "", + "### Recommended Flow", + "", + "1. **Read** the file first to get hashline refs and fileRev", + "2. **Resolve** hashline operations using `hashline-resolve-edit_hashlineResolveEditTool` helper tool", + "3. **Edit** using native `edit` tool with the resolved oldString/newString", + "4. **Read again** to verify changes and get fresh refs", "", "### Read first (required before edits)", "```json", @@ -248,7 +255,8 @@ export function buildHashlineSystemInstruction(config: Pick): string | undefined { - for (const value of values) { - if (typeof value === "string" && value.trim().length > 0) { - return value.trim() - } - } - return undefined -} - -function firstNonEmptyString(...values: Array): string | undefined { - for (const value of values) { - if (typeof value === "string" && value.length > 0) { - return value - } - } - return undefined -} - -function hasAnySingleOperationValue(args: Record): boolean { - return ( - firstNonEmptyTrimmedString(args.ref) !== undefined || - firstNonEmptyTrimmedString(args.startRef) !== undefined || - firstNonEmptyTrimmedString(args.endRef) !== undefined || - firstNonEmptyString(args.replacement) !== undefined || - firstNonEmptyString(args.content) !== undefined - ) -} - -export default tool({ - description: - "Hashline-aware edit tool for edit. Use operations[] for batch edits or operation + startRef/ref for a single hashline edit.", - args: { - filePath: tool.schema - .string() - .describe("Absolute or workspace-relative file path."), - - operations: tool.schema - .array(operationSchema) - .optional() - .describe( - "Preferred mode. Each operation uses hashline refs like 22#A3F#9BC. Supported ops: replace, delete, insert_before, insert_after, replace_range, set_file. If operation/startRef/ref are also present, operations[] takes precedence." - ), - - operation: singleOperationSchema - .optional() - .describe("Single-operation mode. Supports replace, delete, insert_before, insert_after."), - ref: tool.schema - .string() - .optional() - .describe("Single-operation mode: target ref (alias of startRef)."), - startRef: tool.schema - .string() - .optional() - .describe("Single-operation mode: start reference (required if ref not provided)."), - endRef: tool.schema - .string() - .optional() - .describe("Single-operation mode: optional end reference for range targeting."), - replacement: tool.schema - .string() - .optional() - .describe("Single-operation mode: replacement/inserted content."), - content: tool.schema - .string() - .optional() - .describe("Single-operation mode alias for replacement."), - - expectedFileHash: tool.schema - .string() - .optional() - .describe("Optional optimistic concurrency guard from read header file_hash."), - fileRev: tool.schema - .string() - .optional() - .describe("Optional file revision guard from read output '#HL REV:'."), - - safeReapply: tool.schema - .boolean() - .optional() - .describe("If true, attempts to relocate refs by hash when line numbers drift and match is unique."), - - dryRun: tool.schema - .boolean() - .optional() - .describe("Validate and compute result without writing file."), - }, - - async execute(args, context) { - const filePath = args.filePath - - const hasOperations = Array.isArray(args.operations) && args.operations.length > 0 - - if (hasOperations) { - // Compatibility behavior: when callers send both payload styles, - // prefer operations[] and ignore top-level single-operation fields. - return runHashlineOperations({ - filePath, - operations: (args.operations as HashlineOperationInput[]).map(mapOperationInput), - expectedFileHash: args.expectedFileHash, - fileRev: args.fileRev, - safeReapply: args.safeReapply, - dryRun: args.dryRun, - context, - }) - } - - if (!args.operation) { - throw new Error("No edit operation provided. Use operations[] or provide operation + startRef/ref.") - } - - const hasAnySingleFields = hasAnySingleOperationValue(args as Record) - if (!hasAnySingleFields) { - throw new Error( - "Single-operation payload is empty: operation was provided but no ref/startRef/replacement values were set. " + - "Call read first, then pass startRef (or ref) and replacement/content; or use operations[]." - ) - } - - const startRef = firstNonEmptyTrimmedString(args.startRef, args.ref) - if (!startRef) { - throw new Error( - "Single-operation edit requires startRef (or ref). Received empty values. " + - "Use hash-read output refs like 12#A3F#9BC.", - ) - } - - try { - parseLineRef(startRef) - } catch { - throw new Error( - `Invalid startRef/ref \"${startRef}\". Expected format # or ## (example: 12#A3F#9BC).`, - ) - } - - const endRef = firstNonEmptyTrimmedString(args.endRef) - if (endRef) { - try { - parseLineRef(endRef) - } catch { - throw new Error( - `Invalid endRef \"${endRef}\". Expected format # or ## (example: 14#B1C#4DE).`, - ) - } - } - - const content = firstNonEmptyString(args.replacement, args.content) - if ((args.operation === "replace" || args.operation === "insert_before" || args.operation === "insert_after") && !content) { - throw new Error( - `Operation \"${args.operation}\" requires replacement/content. Received empty value.`, - ) - } - - const singleOperation: HashlineOperationInput = { - op: args.operation, - startRef, - endRef, - content, - } - - return runHashlineOperations({ - filePath, - operations: [mapOperationInput(singleOperation)], - expectedFileHash: args.expectedFileHash, - fileRev: args.fileRev, - safeReapply: args.safeReapply, - dryRun: args.dryRun, - context, - }) - }, -}) diff --git a/.opencode/tools/hashline-resolve-edit.ts b/.opencode/tools/hashline-resolve-edit.ts new file mode 100644 index 0000000..44f7720 --- /dev/null +++ b/.opencode/tools/hashline-resolve-edit.ts @@ -0,0 +1,73 @@ +import { tool } from "@opencode-ai/plugin" +import { resolveFilePath, resolveHashlineEdit, type HashlineOperationInput, type HashlineOpName, mapOperationInput } from "../lib/hashline-core.js" +import { resolveHashlineConfig } from "../plugins/hashline-shared.js" + +export interface HashlineResolveEditArgs { + filePath: string + operations: HashlineOperationInput[] + fileRev?: string + safeReapply?: boolean +} + +export const hashlineResolveEditTool = tool({ + description: + "Resolves hashline line references to native edit format. Use after read() to convert hashline operations to oldString/newString for native edit(). Returns JSON with filePath, oldString, newString, and fileRev.", + args: { + filePath: tool.schema.string().describe("Path to the file to edit"), + operations: tool.schema + .array( + tool.schema.object({ + op: tool.schema.enum(["replace", "delete", "insert_before", "insert_after", "replace_range"]), + ref: tool.schema.string().optional(), + startRef: tool.schema.string().optional(), + endRef: tool.schema.string().optional(), + content: tool.schema.string().optional(), + }), + ) + .describe("Array of hashline operations with refs"), + fileRev: tool.schema.string().optional().describe("Optional file revision from read output (e.g., 1A2B3C4D)"), + safeReapply: tool.schema.boolean().optional().describe("Allow relocating refs if hash matches but line number changed"), + }, + execute: async (args, context) => { + const projectDirectory = context.directory + const config = resolveHashlineConfig(projectDirectory) + + const absolutePath = resolveFilePath(args.filePath) + + try { + const result = await resolveHashlineEdit({ + filePath: args.filePath, + operations: args.operations.map((op) => mapOperationInput({ ...op, op: op.op as HashlineOpName })), + fileRev: args.fileRev, + safeReapply: args.safeReapply ?? config.safeReapply, + dryRun: true, + context: { + directory: projectDirectory, + }, + }) + + return JSON.stringify( + { + filePath: result.filePath, + oldString: result.oldString, + newString: result.newString, + fileRev: result.fileRev, + summary: result.summary, + }, + null, + 2, + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return JSON.stringify( + { + error: message, + filePath: absolutePath, + hint: "Read the file again to get fresh refs and fileRev", + }, + null, + 2, + ) + } + }, +}) diff --git a/.opencode/tools/patch.ts b/.opencode/tools/patch.ts deleted file mode 100644 index 17fcbfc..0000000 --- a/.opencode/tools/patch.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import { - mapOperationInput, - parsePatchText, - runHashlineOperations, - type HashlineOperationInput, -} from "./hashline-core" - -export default tool({ - description: - "Hashline patch tool for patch. Expects patchText JSON containing hashline operations instead of textual diff matching.", - args: { - patchText: tool.schema - .string() - .describe( - "JSON string: either array of operations or object { filePath, operations, expectedFileHash, fileRev }." - ), - filePath: tool.schema - .string() - .optional() - .describe("Optional fallback file path when patchText omits filePath."), - expectedFileHash: tool.schema - .string() - .optional() - .describe("Optional fallback optimistic concurrency guard."), - fileRev: tool.schema - .string() - .optional() - .describe("Optional fallback file revision guard from read output '#HL REV:'."), - dryRun: tool.schema - .boolean() - .optional() - .describe("Validate patch without writing file."), - }, - async execute(args, context) { - const parsed = parsePatchText(args.patchText) - const filePath = parsed.filePath ?? args.filePath - - if (!filePath) { - throw new Error("Missing file path. Provide filePath in args or inside patchText object.") - } - - const operations = parsed.operations - if (!operations || operations.length === 0) { - throw new Error("No operations found in patchText") - } - - return runHashlineOperations({ - filePath, - operations: (operations as HashlineOperationInput[]).map(mapOperationInput), - expectedFileHash: parsed.expectedFileHash ?? args.expectedFileHash, - fileRev: parsed.fileRev ?? args.fileRev, - dryRun: args.dryRun, - context, - }) - }, -}) diff --git a/.opencode/tools/read.ts b/.opencode/tools/read.ts deleted file mode 100644 index ad19506..0000000 --- a/.opencode/tools/read.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import { runHashlineRead } from "./hashline-core" - -export default tool({ - description: - "Hashline file reader for read. Returns line-stable refs in format ##|.", - args: { - filePath: tool.schema - .string() - .describe("Absolute or workspace-relative file path to read."), - offset: tool.schema - .number() - .int() - .positive() - .optional() - .describe("1-based starting line number. Defaults to 1."), - limit: tool.schema - .number() - .int() - .positive() - .optional() - .describe("Maximum number of lines to return. Defaults to 2000."), - }, - async execute(args, context) { - return runHashlineRead({ - filePath: args.filePath, - offset: args.offset, - limit: args.limit, - context, - }) - }, -}) diff --git a/.opencode/tools/write.ts b/.opencode/tools/write.ts deleted file mode 100644 index b0d63c5..0000000 --- a/.opencode/tools/write.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import { runHashlineOperations } from "./hashline-core" - -export default tool({ - description: "Hashline-compatible full file writer for write implemented through set_file operation.", - args: { - filePath: tool.schema - .string() - .describe("Absolute or workspace-relative file path."), - content: tool.schema - .string() - .describe("Full file content to write."), - expectedFileHash: tool.schema - .string() - .optional() - .describe("Optional optimistic concurrency guard from read header file_hash."), - fileRev: tool.schema - .string() - .optional() - .describe("Optional file revision guard from read output '#HL REV:'."), - dryRun: tool.schema - .boolean() - .optional() - .describe("Validate and compute result without writing file."), - }, - async execute(args, context) { - return runHashlineOperations({ - filePath: args.filePath, - operations: [ - { - op: "set_file", - content: args.content, - }, - ], - expectedFileHash: args.expectedFileHash, - fileRev: args.fileRev, - dryRun: args.dryRun, - context, - }) - }, -}) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4bdc2..c6c2bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.4.0] - 2026-03-22 + +### Added + +- Moved hashline-core from tools/ to lib/ to prevent auto-loading as a custom tool. +- Created hashline-resolve-edit tool for hashline operations resolution. + +### Fixed + +- Cleaned up test files and improved tool file organization. + ## [1.3.1] - 2026-03-18 ### Fixed diff --git a/file-tester.txt b/file-tester.txt new file mode 100644 index 0000000..4357de9 --- /dev/null +++ b/file-tester.txt @@ -0,0 +1,4 @@ +This is a complete file replacement using the WRITE tool. +MODIFIED line 1: Hashline resolved edit! +New line 2: All previous content was replaced. +New line 3: This proves the write tool works! diff --git a/opencode.json b/opencode.json index 0254cef..b76e73a 100644 --- a/opencode.json +++ b/opencode.json @@ -1,6 +1,9 @@ { "$schema": "https://opencode.ai/config.json", - "plugin": ["@angdrew/opencode-hashline-plugin"], + "plugin": ["hashline-routing"], + "permission": { + "*": "allow" + }, "agent": { "hashline-test": { "description": "Minimal smoke-test agent for hashline-backed read/edit/patch/write tools", diff --git a/package.json b/package.json index 2124324..451f766 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@angdrew/opencode-hashline-plugin", - "version": "1.3.1", + "version": "1.4.0", "description": "Hashline-based read/edit/patch/write tool overrides for OpenCode.", "repository": { "type": "git", diff --git a/scripts/benchmark.mjs b/scripts/benchmark.mjs index f65ce91..5db5c32 100644 --- a/scripts/benchmark.mjs +++ b/scripts/benchmark.mjs @@ -11,11 +11,11 @@ import { runHashlineOperations, runHashlineRead, stringifyLines, -} from "../dist/.opencode/tools/hashline-core.js" +} from "../dist/.opencode/lib/hashline-core.js" const PROJECT_ROOT = process.cwd() -const SHARED_STUB_FILE = 'import { getAdaptiveHashLength, hashlineAnchorHash, hashlineLineHash } from "../tools/hashline-core.js"' -const SHARED_STUB_REGEX = /import\s*\{\s*getAdaptiveHashLength\s*,\s*hashlineAnchorHash\s*,\s*hashlineLineHash\s*\}\s*from\s*"\.\.\/tools\/hashline-core"\s*;?/ +const SHARED_STUB_FILE = 'import { getAdaptiveHashLength, hashlineAnchorHash, hashlineLineHash } from "../lib/hashline-core.js"' +const SHARED_STUB_REGEX = /import\s*\{\s*getAdaptiveHashLength\s*,\s*hashlineAnchorHash\s*,\s*hashlineLineHash\s*\}\s*from\s*"\.\.\/lib\/hashline-core"\s*;?/ const PERF_ITERATIONS = readPositiveIntEnv("BENCH_ITERATIONS", 200) const CORRECTNESS_FIXTURES = readPositiveIntEnv("BENCH_FIXTURES", 120) @@ -216,7 +216,7 @@ async function loadFormatWithHashline() { await fs.mkdir(toolsDir, { recursive: true }) await fs.mkdir(pluginsDir, { recursive: true }) - await fs.copyFile(path.join(PROJECT_ROOT, "dist/.opencode/tools/hashline-core.js"), path.join(toolsDir, "hashline-core.js")) + await fs.copyFile(path.join(PROJECT_ROOT, "dist/.opencode/lib/hashline-core.js"), path.join(toolsDir, "hashline-core.js")) const originalShared = await fs.readFile(path.join(PROJECT_ROOT, "dist/.opencode/plugins/hashline-shared.js"), "utf8") const patchedShared = originalShared.replace(SHARED_STUB_REGEX, SHARED_STUB_FILE) diff --git a/src/index.ts b/src/index.ts index 2c1d87f..cbf4ef0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,18 @@ import type { Plugin } from "@opencode-ai/plugin" import { HashlineRouting as routingPlugin } from "../.opencode/plugins/hashline-routing" -import readTool from "../.opencode/tools/read" -import editTool from "../.opencode/tools/edit" -import patchTool from "../.opencode/tools/patch" -import writeTool from "../.opencode/tools/write" -import hashCheckTool from "../.opencode/tools/hash-check" +import { hashlineResolveEditTool } from "../.opencode/tools/hashline-resolve-edit" const hashlinePlugin: Plugin = async (input) => { const routingHooks = await routingPlugin(input as unknown as Parameters[0]) return { ...routingHooks, + // Register helper tool for hashline-to-native edit conversion tool: { - read: readTool, - edit: editTool, - patch: patchTool, - write: writeTool, - "hash-check": hashCheckTool, - } + hashline_resolve_edit: hashlineResolveEditTool, + }, + // Don't override read/edit/write/patch - let OpenCode's native tools handle them + // The hooks will intercept and transform inputs/outputs } } diff --git a/test-hashline.txt b/test-hashline.txt new file mode 100644 index 0000000..ab450df --- /dev/null +++ b/test-hashline.txt @@ -0,0 +1,5 @@ +Line 1: Initial content +Line 2: Second line +Line 3: Third line +Line 4: Fourth line +Line 5: Fifth line \ No newline at end of file diff --git a/test/hashline-hardening.test.mjs b/test/hashline-hardening.test.mjs index f92dae4..88253cf 100644 --- a/test/hashline-hardening.test.mjs +++ b/test/hashline-hardening.test.mjs @@ -9,28 +9,27 @@ import { pathToFileURL } from "node:url" import { computeFileRev as computeCoreFileRev, getAdaptiveHashLength, - parsePatchText, - runHashlineRead, runHashlineOperations, - runHashlineCheck, -} from "../dist/.opencode/tools/hashline-core.js" + runHashlineRead, +} from "../dist/.opencode/lib/hashline-core.js" + const PROJECT_ROOT = process.cwd() -const SHARED_STUB_IMPORT = "../tools/hashline-core.js" +const SHARED_STUB_IMPORT = "../lib/hashline-core.js" const SHARED_STUB_FILE = `import { getAdaptiveHashLength, hashlineAnchorHash, hashlineLineHash } from \"${SHARED_STUB_IMPORT}\"\n` -const SHARED_STUB_REGEX = /import\s*\{\s*getAdaptiveHashLength\s*,\s*hashlineAnchorHash\s*,\s*hashlineLineHash\s*\}\s*from\s*"\.\.\/tools\/hashline-core"\s*;?/ +const SHARED_STUB_REGEX = /import\s*\{\s*getAdaptiveHashLength\s*,\s*hashlineAnchorHash\s*,\s*hashlineLineHash\s*\}\s*from\s*"\.\.\/lib\/hashline-core"\s*;?/ async function loadSharedModule() { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-shared-test-")) - const toolsDir = path.join(tempDir, "tools") + const libDir = path.join(tempDir, "lib") const pluginsDir = path.join(tempDir, "plugins") - await fs.mkdir(toolsDir, { recursive: true }) + await fs.mkdir(libDir, { recursive: true }) await fs.mkdir(pluginsDir, { recursive: true }) await fs.copyFile( - path.join(PROJECT_ROOT, "dist/.opencode/tools/hashline-core.js"), - path.join(toolsDir, "hashline-core.js"), + path.join(PROJECT_ROOT, "dist/.opencode/lib/hashline-core.js"), + path.join(libDir, "hashline-core.js"), ) const originalShared = await fs.readFile(path.join(PROJECT_ROOT, "dist/.opencode/plugins/hashline-shared.js"), "utf8") @@ -322,60 +321,6 @@ test("replace accepts equivalent ref + startRef/endRef payloads", async () => { await fs.rm(tempDir, { recursive: true, force: true }) } }) - -test("hash-check validates guards and refs without writing", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-check-")) - const filePath = path.join(tempDir, "sample.txt") - - try { - const original = "alpha\nbeta\ngamma\n" - await fs.writeFile(filePath, original, "utf8") - - const readText = await runHashlineRead({ - filePath, - offset: 1, - limit: 200, - context: { directory: PROJECT_ROOT }, - }) - - const fileHash10 = (() => { - const match = String(readText).match(/file_hash=\"([A-F0-9]{10})\"/) - return match ? match[1] : undefined - })() - const line2Ref = (() => { - const match = String(readText).match(/#HL\s+2#([A-F0-9]{3,4})#([A-F0-9]{3,4})\|beta/m) - return match ? `2#${match[1]}#${match[2]}` : undefined - })() - - assert.equal(typeof fileHash10, "string") - assert.equal(typeof line2Ref, "string") - - const ok = await runHashlineCheck({ - filePath, - fileRev: computeCoreFileRev(original), - expectedFileHash: fileHash10, - targets: [{ op: "replace", ref: line2Ref }], - context: { directory: PROJECT_ROOT }, - }) - - assert.match(ok, /Hashline check passed/) - - const after = await fs.readFile(filePath, "utf8") - assert.equal(after, original) - - await assert.rejects( - runHashlineCheck({ - filePath, - fileRev: "00000000", - context: { directory: PROJECT_ROOT }, - }), - /File revision mismatch/, - ) - } finally { - await fs.rm(tempDir, { recursive: true, force: true }) - } -}) - test("hashline operation result includes diff preview", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-diff-preview-")) const filePath = path.join(tempDir, "sample.txt") diff --git a/test_hashline.txt b/test_hashline.txt new file mode 100644 index 0000000..ce8b9d7 --- /dev/null +++ b/test_hashline.txt @@ -0,0 +1,5 @@ +Line 1: First line of test file +Line 2: Second line of test file +Line 3: Third line of test file +Line 4: Fourth line of test file +Line 5: Fifth line of test file \ No newline at end of file diff --git a/testdata/proof.txt b/testdata/proof.txt new file mode 100644 index 0000000..de07f07 --- /dev/null +++ b/testdata/proof.txt @@ -0,0 +1,10 @@ +# New Test File +This file was created by the hashline plugin. + +Features demonstrated: +- Read +- Edit +- Patch +- Write + +Status: All operations successful! \ No newline at end of file diff --git a/testdata/sample.txt b/testdata/sample.txt index 85c3040..3cc64f7 100644 --- a/testdata/sample.txt +++ b/testdata/sample.txt @@ -1,3 +1,4 @@ alpha -beta +BETA gamma +delta diff --git a/tsconfig.build.json b/tsconfig.build.json index 774d308..a69caa8 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -14,7 +14,7 @@ }, "include": [ ".opencode/plugins/**/*.ts", - ".opencode/tools/**/*.ts", + ".opencode/lib/**/*.ts", "src/**/*.ts" ] }