diff --git a/.opencode/.DS_Store b/.opencode/.DS_Store deleted file mode 100644 index d506893..0000000 Binary files a/.opencode/.DS_Store and /dev/null differ diff --git a/.opencode/lib/hashline-core.ts b/.opencode/lib/hashline-core.ts deleted file mode 100644 index 2f34936..0000000 --- a/.opencode/lib/hashline-core.ts +++ /dev/null @@ -1,932 +0,0 @@ -import { createHash } from "node:crypto" -import { promises as fs } from "node:fs" -import path from "node:path" -import { DEFAULT_PREFIX, formatAnnotatedLine, formatRev } from "../plugins/hashline-contract.js" - -const DEFAULT_LIMIT = 2000 -const MAX_LINE_LENGTH = 2000 -const SMALL_FILE_HASH_LEN = 3 -const LARGE_FILE_HASH_LEN = 4 -const HASH_LENGTH_THRESHOLD = 4096 - -export type HashlineOpName = - | "replace" - | "delete" - | "insert_before" - | "insert_after" - | "replace_range" - | "set_file" - -export interface HashlineOperation { - op: HashlineOpName - ref?: string - startRef?: string - endRef?: string - 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 - lines: string[] - eol: "\n" | "\r\n" - endsWithNewline: boolean - fileHash: string -} - -export interface ParsedHashlineFile { - raw: string - lines: string[] - eol: "\n" | "\r\n" - endsWithNewline: boolean - fileHash: string -} - -interface ResolvedChange { - op: HashlineOpName - spliceStart: number - deleteCount: number - insertLines: string[] - order: number - anchorIndex?: number - label: string -} - -function hashText(text: string, length = 10): string { - return createHash("sha1").update(text, "utf8").digest("hex").slice(0, length).toUpperCase() -} - -export function getAdaptiveHashLength(totalLines: number): number { - return totalLines > HASH_LENGTH_THRESHOLD ? LARGE_FILE_HASH_LEN : SMALL_FILE_HASH_LEN -} - -export function hashlineLineHash(line: string, length = LARGE_FILE_HASH_LEN): string { - return hashText(line, length) -} - -export function hashlineAnchorHash( - previousLine: string | undefined, - line: string, - nextLine: string | undefined, - length = LARGE_FILE_HASH_LEN, -): string { - return hashText(`${previousLine ?? ""}\u241E${line}\u241E${nextLine ?? ""}`, length) -} - -export function computeFileRev(raw: string): string { - const normalized = raw.includes("\r\n") ? raw.replace(/\r\n/g, "\n") : raw - return hashText(normalized, 8) -} - -export function firstNonEmptyString(...values: Array): string | undefined { - for (const value of values) { - if (typeof value === "string" && value.length > 0) { - return value - } - } - - return undefined -} - -export function parseRaw(raw: string): ParsedHashlineFile { - const eol: "\n" | "\r\n" = raw.includes("\r\n") ? "\r\n" : "\n" - const normalized = raw.replace(/\r\n/g, "\n") - const endsWithNewline = normalized.endsWith("\n") - - let lines: string[] = [] - if (normalized.length > 0) { - lines = normalized.split("\n") - if (endsWithNewline) { - lines.pop() - } - } - - return { - raw, - lines, - eol, - endsWithNewline, - fileHash: hashText(raw), - } -} - -export function stringifyLines(lines: string[], eol: "\n" | "\r\n", endsWithNewline: boolean): string { - if (lines.length === 0) { - return "" - } - - const body = lines.join(eol) - return endsWithNewline ? `${body}${eol}` : body -} - -export function splitContentToLines(content: string): string[] { - const normalized = content.replace(/\r\n/g, "\n") - const hasTrailingNewline = normalized.endsWith("\n") - const parts = normalized.split("\n") - - if (hasTrailingNewline && parts.length > 0) { - parts.pop() - } - - return parts -} - -export function parseLineRef(rawRef: string): { lineNumber: number; hash: string; anchor?: string } { - const text = rawRef.trim().replace(/^(?:#HL|;;;)\s*/i, "") - const beforePipe = text.split("|")[0].trim() - const match = beforePipe.match(/^(\d+)\s*[#: ]\s*([A-Za-z0-9]+)(?:\s*[#: ]\s*([A-Za-z0-9]+))?$/) - if (!match) { - throw new Error( - `Invalid line reference "${rawRef}". Expected format: # or ## (example: 22#A3F or 22#A3F#9BC)`, - ) - } - - const lineNumber = Number.parseInt(match[1], 10) - if (!Number.isFinite(lineNumber) || lineNumber < 1) { - throw new Error(`Invalid line number in reference "${rawRef}"`) - } - - return { - lineNumber, - hash: match[2].toUpperCase(), - anchor: match[3]?.toUpperCase(), - } -} - -interface RefCandidate { - index: number - lineNumber: number -} - -function findRefCandidates( - parsed: { lineNumber: number; hash: string; anchor?: string }, - snapshot: FileSnapshot, - hashLength: number, -): RefCandidate[] { - const candidates: RefCandidate[] = [] - - for (let idx = 0; idx < snapshot.lines.length; idx += 1) { - const line = snapshot.lines[idx] - const lineHash = hashlineLineHash(line, hashLength) - if (lineHash !== parsed.hash) { - continue - } - - if (parsed.anchor) { - const anchorHash = hashlineAnchorHash(snapshot.lines[idx - 1], line, snapshot.lines[idx + 1], hashLength) - if (anchorHash !== parsed.anchor) { - continue - } - } - - candidates.push({ - index: idx, - lineNumber: idx + 1, - }) - } - - return candidates -} - -function resolveRef(ref: string, snapshot: FileSnapshot, safeReapply = false): { index: number; lineNumber: number } { - const parsed = parseLineRef(ref) - if (parsed.lineNumber > snapshot.lines.length) { - throw new Error( - `Reference ${ref} points to line ${parsed.lineNumber}, but file only has ${snapshot.lines.length} lines. Read the file again.`, - ) - } - - const hashLength = getAdaptiveHashLength(snapshot.lines.length) - const index = parsed.lineNumber - 1 - const actualLine = snapshot.lines[index] - const actualHash = hashlineLineHash(actualLine, hashLength) - const actualAnchor = hashlineAnchorHash(snapshot.lines[index - 1], actualLine, snapshot.lines[index + 1], hashLength) - - if (actualHash !== parsed.hash || (parsed.anchor && actualAnchor !== parsed.anchor)) { - if (safeReapply) { - const candidates = findRefCandidates(parsed, snapshot, hashLength) - if (candidates.length === 1) { - return { - index: candidates[0].index, - lineNumber: candidates[0].lineNumber, - } - } - - if (candidates.length > 1) { - const candidateLines = candidates.map((candidate) => candidate.lineNumber).join(", ") - throw new Error( - `Hash mismatch for line ${parsed.lineNumber}; found multiple relocation candidates (${candidateLines}). Read the file again.`, - ) - } - - throw new Error(`Hash mismatch for line ${parsed.lineNumber}; no relocation candidates found. Read the file again.`) - } - - const expectedRef = parsed.anchor - ? `${parsed.lineNumber}#${parsed.hash}#${parsed.anchor}` - : `${parsed.lineNumber}#${parsed.hash}` - const actualRef = `${parsed.lineNumber}#${actualHash}#${actualAnchor}` - throw new Error( - `Hash mismatch for line ${parsed.lineNumber}. Expected ${expectedRef}, actual ${actualRef}. Read the file again.`, - ) - } - - return { - index, - lineNumber: parsed.lineNumber, - } -} - -export function resolveFilePath(filePath: string, context?: { directory?: string }): string { - const baseDirectory = typeof context?.directory === "string" && context.directory.length > 0 ? context.directory : process.cwd() - return path.isAbsolute(filePath) ? path.normalize(filePath) : path.resolve(baseDirectory, filePath) -} - -async function readSnapshot(absolutePath: string): Promise { - const raw = await fs.readFile(absolutePath, "utf8") - const parsed = parseRaw(raw) - return { - absolutePath, - ...parsed, - } -} - -async function readSnapshotIfExists(absolutePath: string): Promise { - try { - return await readSnapshot(absolutePath) - } catch (error) { - if (error instanceof Error && "code" in error && (error as { code?: string }).code === "ENOENT") { - return null - } - throw error - } -} - -function emptySnapshot(absolutePath: string): FileSnapshot { - return { - absolutePath, - raw: "", - lines: [], - eol: "\n", - endsWithNewline: false, - fileHash: hashText(""), - } -} - -function normalizeOperations(operations: HashlineOperation[]): HashlineOperation[] { - return operations.map((op) => ({ - op: op.op, - ref: op.ref?.trim(), - startRef: op.startRef?.trim(), - endRef: op.endRef?.trim(), - content: op.content, - })) -} - -function resolveRefRange(params: { - snapshot: FileSnapshot - ref?: string - startRef?: string - endRef?: string - safeReapply: boolean - label: string -}): { start: { index: number; lineNumber: number }; end: { index: number; lineNumber: number } } { - if (params.ref && params.startRef) { - throw new Error(`${params.label} accepts either ref or startRef/endRef, not both`) - } - - const baseStartRef = params.startRef ?? params.ref - if (!baseStartRef) { - throw new Error(`${params.label} requires ref or startRef`) - } - - let start = resolveRef(baseStartRef, params.snapshot, params.safeReapply) - let end = params.endRef ? resolveRef(params.endRef, params.snapshot, params.safeReapply) : start - - if (start.index > end.index) { - const first = start - start = end - end = first - } - - return { - start, - end, - } -} - -function resolveChanges(snapshot: FileSnapshot, operations: HashlineOperation[], safeReapply: boolean): ResolvedChange[] { - if (operations.length === 0) { - throw new Error("No operations provided") - } - - const setFileCount = operations.filter((op) => op.op === "set_file").length - if (setFileCount > 0 && operations.length > 1) { - throw new Error("set_file cannot be combined with other operations") - } - - return operations.map((op, order): ResolvedChange => { - switch (op.op) { - case "replace": { - if (op.content === undefined) { - throw new Error("replace requires content") - } - - const resolvedRange = resolveRefRange({ - snapshot, - ref: op.ref, - startRef: op.startRef, - endRef: op.endRef, - safeReapply, - label: "replace", - }) - return { - op: op.op, - spliceStart: resolvedRange.start.index, - deleteCount: resolvedRange.end.index - resolvedRange.start.index + 1, - insertLines: splitContentToLines(op.content), - order, - anchorIndex: resolvedRange.start.index, - label: - op.startRef || op.endRef - ? `replace(${op.startRef ?? op.ref}..${op.endRef ?? op.startRef ?? op.ref})` - : `replace(${op.ref})`, - } - } - - case "delete": { - const resolvedRange = resolveRefRange({ - snapshot, - ref: op.ref, - startRef: op.startRef, - endRef: op.endRef, - safeReapply, - label: "delete", - }) - return { - op: op.op, - spliceStart: resolvedRange.start.index, - deleteCount: resolvedRange.end.index - resolvedRange.start.index + 1, - insertLines: [], - order, - anchorIndex: resolvedRange.start.index, - label: - op.startRef || op.endRef - ? `delete(${op.startRef ?? op.ref}..${op.endRef ?? op.startRef ?? op.ref})` - : `delete(${op.ref})`, - } - } - - case "insert_before": { - if (op.content === undefined) { - throw new Error("insert_before requires content") - } - - const resolvedRange = resolveRefRange({ - snapshot, - ref: op.ref, - startRef: op.startRef, - endRef: op.endRef, - safeReapply, - label: "insert_before", - }) - return { - op: op.op, - spliceStart: resolvedRange.start.index, - deleteCount: 0, - insertLines: splitContentToLines(op.content), - order, - anchorIndex: resolvedRange.start.index, - label: - op.startRef || op.endRef - ? `insert_before(${op.startRef ?? op.ref}..${op.endRef ?? op.startRef ?? op.ref})` - : `insert_before(${op.ref})`, - } - } - - case "insert_after": { - if (op.content === undefined) { - throw new Error("insert_after requires content") - } - - const resolvedRange = resolveRefRange({ - snapshot, - ref: op.ref, - startRef: op.startRef, - endRef: op.endRef, - safeReapply, - label: "insert_after", - }) - return { - op: op.op, - spliceStart: resolvedRange.end.index + 1, - deleteCount: 0, - insertLines: splitContentToLines(op.content), - order, - anchorIndex: resolvedRange.end.index, - label: - op.startRef || op.endRef - ? `insert_after(${op.startRef ?? op.ref}..${op.endRef ?? op.startRef ?? op.ref})` - : `insert_after(${op.ref})`, - } - } - - case "replace_range": { - if (!op.startRef || !op.endRef) { - throw new Error("replace_range requires startRef and endRef") - } - if (op.content === undefined) { - throw new Error("replace_range requires content") - } - - const start = resolveRef(op.startRef, snapshot, safeReapply) - const end = resolveRef(op.endRef, snapshot, safeReapply) - - if (start.index > end.index) { - throw new Error("replace_range startRef must be on or before endRef") - } - - return { - op: op.op, - spliceStart: start.index, - deleteCount: end.index - start.index + 1, - insertLines: splitContentToLines(op.content), - order, - anchorIndex: start.index, - label: `replace_range(${op.startRef}..${op.endRef})`, - } - } - - case "set_file": { - if (op.content === undefined) { - throw new Error("set_file requires content") - } - - return { - op: op.op, - spliceStart: 0, - deleteCount: snapshot.lines.length, - insertLines: splitContentToLines(op.content), - order, - anchorIndex: undefined, - label: "set_file", - } - } - - default: - throw new Error(`Unsupported operation: ${(op as { op?: string }).op ?? "unknown"}`) - } - }) -} - -function validateChangeConflicts(changes: ResolvedChange[]): void { - const consumed = new Map() - - for (const change of changes) { - if (change.deleteCount === 0) { - continue - } - - for (let idx = change.spliceStart; idx < change.spliceStart + change.deleteCount; idx += 1) { - const existing = consumed.get(idx) - if (existing) { - throw new Error(`Overlapping operations are not allowed: ${change.label} conflicts with ${existing}`) - } - consumed.set(idx, change.label) - } - } - - for (const change of changes) { - if (change.deleteCount !== 0 || change.anchorIndex === undefined) { - continue - } - - const existing = consumed.get(change.anchorIndex) - if (existing) { - throw new Error(`Operation conflict: ${change.label} references a line already modified by ${existing}`) - } - } -} - -function applyChanges(snapshot: FileSnapshot, changes: ResolvedChange[]): { lines: string[]; additions: number; removals: number } { - const nextLines = [...snapshot.lines] - const ordered = [...changes].sort((a, b) => { - if (a.spliceStart !== b.spliceStart) { - return b.spliceStart - a.spliceStart - } - return b.order - a.order - }) - - let additions = 0 - let removals = 0 - - for (const change of ordered) { - additions += change.insertLines.length - removals += change.deleteCount - nextLines.splice(change.spliceStart, change.deleteCount, ...change.insertLines) - } - - return { - lines: nextLines, - additions, - removals, - } -} - -function snapshotFromLines(base: FileSnapshot, nextLines: string[]): FileSnapshot { - const nextRaw = stringifyLines(nextLines, base.eol, base.endsWithNewline) - const parsed = parseRaw(nextRaw) - return { - absolutePath: base.absolutePath, - ...parsed, - } -} - -async function writeSnapshot(snapshot: FileSnapshot): Promise { - await fs.mkdir(path.dirname(snapshot.absolutePath), { recursive: true }) - await fs.writeFile(snapshot.absolutePath, snapshot.raw, "utf8") -} - -function formatEditResult(params: { - filePath: string - mode: "hashline" | "legacy" - dryRun: boolean - before: FileSnapshot - after: FileSnapshot - operations: number - additions: number - removals: number -}): string { - return [ - `Hashline ${params.mode} edit ${params.dryRun ? "(dry run) " : ""}completed for ${params.filePath}.`, - `File hash: ${params.before.fileHash} -> ${params.after.fileHash}`, - `Operations: ${params.operations}; additions: ${params.additions}; removals: ${params.removals}`, - `Lines: ${params.before.lines.length} -> ${params.after.lines.length}`, - "Read the file again before issuing additional hashline refs.", - ].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 -}): 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 - } - - let count = 0 - let from = 0 - - while (true) { - const idx = haystack.indexOf(needle, from) - if (idx === -1) { - return count - } - - count += 1 - from = idx + needle.length - } -} - -export async function runHashlineRead(params: { - filePath: string - offset?: number - limit?: number - context?: { directory?: string } -}): Promise { - const absolutePath = resolveFilePath(params.filePath, params.context) - const snapshot = await readSnapshot(absolutePath) - - const startLine = Math.max(1, Math.floor(params.offset ?? 1)) - const limit = Math.max(1, Math.floor(params.limit ?? DEFAULT_LIMIT)) - - const startIndex = startLine - 1 - const endIndex = Math.min(snapshot.lines.length, startIndex + limit) - const body: string[] = [] - - body.push(`${DEFAULT_PREFIX} ${formatRev(computeFileRev(snapshot.raw))}`) - - for (let idx = startIndex; idx < endIndex; idx += 1) { - const line = snapshot.lines[idx] - const displayLine = line.length > MAX_LINE_LENGTH ? `${line.slice(0, MAX_LINE_LENGTH)}…` : line - const annotatedLine = formatAnnotatedLine(line, idx, snapshot.lines, DEFAULT_PREFIX) - const separatorIndex = annotatedLine.indexOf("|") - body.push(`${annotatedLine.slice(0, separatorIndex + 1)}${displayLine}`) - } - - if (snapshot.lines.length === 0) { - body.push("# file is empty") - } - - if (startIndex > 0) { - body.unshift(`# skipped lines: 1-${startIndex}`) - } - if (endIndex < snapshot.lines.length) { - body.push(`# truncated: ${snapshot.lines.length - endIndex} lines not shown`) - } - - return [ - ``, - "# format: ##|", - "# use refs exactly as shown in hashline edit/patch tools", - ...body, - "", - ].join("\n") -} - -export async function runHashlineOperationsDetailed(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) - - if (!params.dryRun) { - await writeSnapshot(after) - } - - return buildOperationResult({ - filePath: params.filePath, - mode: "hashline", - dryRun: Boolean(params.dryRun), - before: snapshot, - after, - operations: normalizedOps.length, - additions: applied.additions, - removals: applied.removals, - }) -} - -export async function runHashlineOperations(params: HashlineExecutionParams): Promise { - const result = await runHashlineOperationsDetailed(params) - return result.summary -} - -export async function runLegacyEdit(params: { - filePath: string - oldString?: string - newString?: string - expectedFileHash?: string - fileRev?: string - dryRun?: 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.`, - ) - } - - 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 oldString = params.oldString ?? "" - const newString = params.newString ?? "" - let nextRaw = snapshot.raw - - if (oldString.length === 0) { - nextRaw = newString - } else { - const occurrences = countOccurrences(snapshot.raw, oldString) - if (occurrences === 0) { - throw new Error("old_string was not found in file") - } - if (occurrences > 1) { - throw new Error("old_string must match exactly one location") - } - - const start = snapshot.raw.indexOf(oldString) - nextRaw = `${snapshot.raw.slice(0, start)}${newString}${snapshot.raw.slice(start + oldString.length)}` - } - - const parsed = parseRaw(nextRaw) - const after: FileSnapshot = { - absolutePath, - ...parsed, - } - - if (!params.dryRun) { - await writeSnapshot(after) - } - - return formatEditResult({ - filePath: params.filePath, - mode: "legacy", - dryRun: Boolean(params.dryRun), - before: snapshot, - after, - operations: 1, - additions: Math.max(0, after.lines.length - snapshot.lines.length), - removals: Math.max(0, snapshot.lines.length - after.lines.length), - }) -} - -export function parsePatchText(patchText: string): { - filePath?: string - operations?: HashlineOperation[] - expectedFileHash?: string - fileRev?: string -} { - let parsed: unknown - try { - parsed = JSON.parse(patchText) - } catch { - throw new Error( - "patchText must be JSON for hashline patching. Use either an array of operations or an object { filePath, operations, expectedFileHash, fileRev }.", - ) - } - - if (Array.isArray(parsed)) { - return { - operations: parsed as HashlineOperation[], - } - } - - if (parsed && typeof parsed === "object") { - const obj = parsed as { - filePath?: string - operations?: HashlineOperation[] - expectedFileHash?: string - fileRev?: string - } - return { - filePath: obj.filePath, - operations: obj.operations, - expectedFileHash: obj.expectedFileHash, - fileRev: obj.fileRev, - } - } - - throw new Error("patch_text JSON must be an array or object") -} - -export type HashlineOperationInput = { - op: HashlineOpName - ref?: string - startRef?: string - endRef?: string - content?: string -} - -export function mapOperationInput(input: HashlineOperationInput): HashlineOperation { - return { - op: input.op, - ref: input.ref, - startRef: input.startRef, - endRef: input.endRef, - content: input.content, - } -} - -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/package-lock.json b/.opencode/package-lock.json deleted file mode 100644 index abded7b..0000000 --- a/.opencode/package-lock.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "name": ".opencode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@opencode-ai/plugin": "1.4.3" - } - }, - "node_modules/@opencode-ai/plugin": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz", - "integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.4.3", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.97", - "@opentui/solid": ">=0.1.97" - }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.3.tgz", - "integrity": "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/.opencode/plugins/hashline-contract.ts b/.opencode/plugins/hashline-contract.ts deleted file mode 100644 index 5c98027..0000000 --- a/.opencode/plugins/hashline-contract.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { createHash } from "node:crypto" - -const SMALL_LINE_HASH_LENGTH = 3 -const LARGE_LINE_HASH_LENGTH = 4 -const HASH_LENGTH_THRESHOLD = 4096 - -export const DEFAULT_PREFIX = "#HL" - -export const CANONICAL_REF_PATTERN = /^\d+#[A-F0-9]+(?:#[A-F0-9]+)?$/ - -export const REV_PATTERN = /^[A-F0-9]{8}$/ - -export interface HashlineRef { - lineNumber: number - hash: string - anchor?: string -} - -export interface HashlineReadExample { - filePath: string - offset: number - limit: number -} - -export interface HashlineEditOperationExample { - op: "replace" - ref: string - content: string -} - -export interface HashlineEditExample { - filePath: string - operations: HashlineEditOperationExample[] -} - -function hashText(text: string, length = 10): string { - return createHash("sha1").update(text, "utf8").digest("hex").slice(0, length).toUpperCase() -} - -function getAdaptiveHashLength(totalLines: number): number { - return totalLines > HASH_LENGTH_THRESHOLD ? LARGE_LINE_HASH_LENGTH : SMALL_LINE_HASH_LENGTH -} - -function hashlineLineHash(line: string, length: number): string { - return hashText(line, length) -} - -function hashlineAnchorHash( - previousLine: string | undefined, - line: string, - nextLine: string | undefined, - length: number, -): string { - return hashText(`${previousLine ?? ""}\u241E${line}\u241E${nextLine ?? ""}`, length) -} - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} - -function normalizeLineBreaks(content: string): { text: string; eol: "\n" | "\r\n" } { - const eol: "\n" | "\r\n" = content.includes("\r\n") ? "\r\n" : "\n" - return { - text: eol === "\r\n" ? content.replace(/\r\n/g, "\n") : content, - eol, - } -} - -function restoreLineBreaks(content: string, eol: "\n" | "\r\n"): string { - return eol === "\r\n" ? content.replace(/\n/g, "\r\n") : content -} - -function normalizePrefix(prefix?: string | false): string { - if (prefix === false) { - return "" - } - - return typeof prefix === "string" ? prefix : DEFAULT_PREFIX -} - -function stripDiffMarker(line: string): { marker: string; body: string } { - const marker = line.startsWith("+") || line.startsWith("-") || line.startsWith(" ") ? line[0] : "" - return { - marker, - body: marker ? line.slice(1) : line, - } -} - -function normalizeRefText(refString: string): string { - let text = refString.trim() - - if (text.length === 0) { - return text - } - - if (text.startsWith("+") || text.startsWith("-") || text.startsWith(" ")) { - text = text.slice(1).trimStart() - } - - text = text.replace(/^(?:#HL|;;;)\s*/i, "") - - text = text.split("|")[0].trim() - return text.toUpperCase() -} - -function normalizeRevToken(revInput: string): string { - const text = revInput.trim() - if (REV_PATTERN.test(text.toUpperCase())) { - return text.toUpperCase() - } - - const match = text.match(/^(?:#HL|;;;)?\s*REV:([A-F0-9]{8})$/i) - if (!match) { - throw new Error(`Invalid REV token "${revInput}". Expected REV:<8-char hex> or a raw 8-char hash.`) - } - - return match[1].toUpperCase() -} - -function buildPrefixFragment(prefix?: string | false): string { - const effectivePrefix = normalizePrefix(prefix) - return effectivePrefix.length > 0 ? `${escapeRegex(effectivePrefix)}\\s*` : "" -} - -export function formatRef(lineNumber: number, lineHash: string, anchorHash?: string): string { - if (!Number.isInteger(lineNumber) || lineNumber < 1) { - throw new Error(`Invalid line number "${lineNumber}". Expected a positive integer.`) - } - - const normalizedHash = lineHash.trim().toUpperCase() - if (!normalizedHash) { - throw new Error("lineHash is required") - } - - const normalizedAnchor = typeof anchorHash === "string" && anchorHash.trim().length > 0 ? anchorHash.trim().toUpperCase() : "" - return normalizedAnchor.length > 0 ? `${lineNumber}#${normalizedHash}#${normalizedAnchor}` : `${lineNumber}#${normalizedHash}` -} - -export function formatRev(fileHash: string): string { - return `REV:${normalizeRev(fileHash)}` -} - -export function formatAnnotatedLine(line: string, index: number, lines: string[], prefix?: string | false): string { - const hashLength = getAdaptiveHashLength(Math.max(1, lines.length)) - const lineHash = hashlineLineHash(line, hashLength) - const anchorHash = hashlineAnchorHash(lines[index - 1], line, lines[index + 1], hashLength) - const prefixText = normalizePrefix(prefix) - const prefixPart = prefixText.length > 0 ? `${prefixText} ` : "" - - return `${prefixPart}${formatRef(index + 1, lineHash, anchorHash)}|${line}` -} - -export function parseRef(refString: string): HashlineRef { - const normalized = normalizeRefText(refString) - const match = normalized.match(CANONICAL_REF_PATTERN) - - if (!match) { - throw new Error( - `Invalid line reference "${refString}". Expected # or ## (for example: 22#A3F or 22#A3F#9BC).`, - ) - } - - const [linePart, hashPart, anchorPart] = normalized.split("#") - const lineNumber = Number.parseInt(linePart, 10) - - if (!Number.isInteger(lineNumber) || lineNumber < 1) { - throw new Error(`Invalid line number in reference "${refString}"`) - } - - return { - lineNumber, - hash: hashPart.toUpperCase(), - anchor: anchorPart ? anchorPart.toUpperCase() : undefined, - } -} - -export function normalizeRev(revInput: string): string { - return extractHashFromRev(revInput) -} - -export function extractHashFromRev(revToken: string): string { - return normalizeRevToken(revToken) -} - -export function stripHashlinePrefix(content: string, prefix?: string | false): string { - const { text, eol } = normalizeLineBreaks(content) - const prefixFragment = buildPrefixFragment(prefix) - const refPattern = new RegExp(`^([+\\- ])?${prefixFragment}(\\d+)\\s*#\\s*([A-F0-9]+)(?:\\s*#\\s*([A-F0-9]+))?\\|`, "i") - - const stripped = text - .split("\n") - .map((line) => { - const match = line.match(refPattern) - if (!match) { - return line - } - - return `${match[1] ?? ""}${line.slice(match[0].length)}` - }) - .join("\n") - - return restoreLineBreaks(stripped, eol) -} - -export function stripRevLine(content: string, prefix?: string | false): string { - const { text, eol } = normalizeLineBreaks(content) - const prefixFragment = buildPrefixFragment(prefix) - const revPattern = new RegExp(`^([+\\- ])?${prefixFragment}REV:[A-F0-9]{8}$`, "i") - - const stripped = text - .split("\n") - .filter((line) => !revPattern.test(line)) - .join("\n") - - return restoreLineBreaks(stripped, eol) -} - -export function isValidRef(refString: string): boolean { - try { - parseRef(refString) - return true - } catch { - return false - } -} - -export function isValidRev(revString: string): boolean { - try { - normalizeRev(revString) - return true - } catch { - return false - } -} - -export function buildReadExample(filePath: string): HashlineReadExample { - return { - filePath, - offset: 1, - limit: 200, - } -} - -export function buildEditExample(filePath: string, ref: string, content: string): HashlineEditExample { - return { - filePath, - operations: [ - { - op: "replace", - ref, - content, - }, - ], - } -} diff --git a/.opencode/plugins/hashline-hooks.ts b/.opencode/plugins/hashline-hooks.ts deleted file mode 100644 index 8ec5243..0000000 --- a/.opencode/plugins/hashline-hooks.ts +++ /dev/null @@ -1,520 +0,0 @@ -import path from "node:path" -import { promises as fs, rmSync } from "node:fs" -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 { - buildCacheEntryKey, - buildHashlineSystemInstruction, - DEFAULT_PREFIX, - extractPathFromToolArgs, - formatWithRuntimeConfig, - getByteLength, - HashlineAnnotationCache, - shouldExclude, - stripHashlinePrefixes, - type HashlineRuntimeConfig, -} from "./hashline-shared.js" - -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() - return known.some((item) => lower === item || lower.endsWith(`.${item}`)) -} - -function isFileReadTool(tool: string, _args?: Record): boolean { - const lower = tool.toLowerCase() - return lower === "read" || lower === "view" || lower.endsWith(".read") || lower.endsWith(".view") -} - -function isFileEditTool(tool: string): boolean { - return toolEndsWith(tool, FILE_EDIT_TOOLS) -} - -function isNativeEditTool(tool: string): boolean { - return toolEndsWith(tool, ["edit"]) -} - -const HASHLINE_SYSTEM_INSTRUCTION_MARKER_RE = //i -const HASHLINE_SYSTEM_INSTRUCTION_BLOCK_RE = /[\s\S]*?(?:|$)/gi -const MAX_SYSTEM_ENTRIES = 128 - -function normalizeHashlineInstructionEntry(entry: string, instruction: string, keepInstruction: boolean): string { - let insertedInstruction = false - - return entry.replace(HASHLINE_SYSTEM_INSTRUCTION_BLOCK_RE, () => { - if (!keepInstruction) { - return "" - } - - if (insertedInstruction) { - return "" - } - - insertedInstruction = true - return instruction - }) -} - -function updateSystemInstructions(system: string[], instruction: string): string[] { - const nextSystem: string[] = [] - let insertedInstruction = false - - for (const entry of system) { - if (!HASHLINE_SYSTEM_INSTRUCTION_MARKER_RE.test(entry)) { - nextSystem.push(entry) - continue - } - - if (!insertedInstruction) { - nextSystem.push(normalizeHashlineInstructionEntry(entry, instruction, true)) - insertedInstruction = true - continue - } - - const cleaned = normalizeHashlineInstructionEntry(entry, instruction, false) - if (cleaned.trim().length > 0) { - nextSystem.push(cleaned) - } - } - - if (!insertedInstruction) { - nextSystem.push(instruction) - } - - return nextSystem -} - -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.invalidateVariants(filePath) - cache.invalidateVariants(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", - "old_content", - "old_string", - "new_string", - "replacement", - "text", - "diff", - "patch", - "patch_text", - "patchText", - "body", -]) - -function stripNestedHashes(value: unknown, prefix: string | false): unknown { - if (typeof value === "string") { - return stripHashlinePrefixes(value, prefix) - } - - if (Array.isArray(value)) { - return value.map((entry) => stripNestedHashes(entry, prefix)) - } - - if (!value || typeof value !== "object") { - return value - } - - const out: Record = { ...(value as Record) } - for (const key of Object.keys(out)) { - if (CONTENT_FIELD_KEYS.has(key)) { - out[key] = stripNestedHashes(out[key], prefix) - continue - } - - const candidate = out[key] - if (Array.isArray(candidate) || (candidate && typeof candidate === "object")) { - out[key] = stripNestedHashes(candidate, prefix) - } - } - - return out -} - -let tempDirPromise: Promise | null = null -let tempDirPath: string | null = null -let tempCleanupRegistered = false - -async function getTempDirectory(): Promise { - if (!tempDirPromise) { - tempDirPromise = fs.mkdtemp(path.join(tmpdir(), "hashline-chat-")).then((dir) => { - tempDirPath = dir - - if (!tempCleanupRegistered) { - tempCleanupRegistered = true - process.on("exit", () => { - if (!tempDirPath) { - return - } - - try { - rmSync(tempDirPath, { recursive: true, force: true }) - } catch { - // ignore cleanup errors on exit - } - }) - } - - return dir - }) - } - - return tempDirPromise -} - -async function writeAnnotatedTempFile(content: string): Promise { - const tempDir = await getTempDirectory() - const fileName = `hl-${Date.now()}-${randomBytes(6).toString("hex")}.txt` - const tempPath = path.join(tempDir, fileName) - await fs.writeFile(tempPath, content, "utf8") - return tempPath -} - -async function annotateChatMessageParts( - output: { parts?: Array> }, - input: Record, - config: HashlineRuntimeConfig, - cache: HashlineAnnotationCache, -): Promise { - if (!Array.isArray(output.parts) || output.parts.length === 0) { - return - } - - const contextDirectory = typeof input.directory === "string" ? input.directory : process.cwd() - - for (const part of output.parts) { - if (!part || part.type !== "file") { - continue - } - - const url = typeof part.url === "string" ? part.url : undefined - if (!url || !url.startsWith("file://")) { - continue - } - - let absolutePath: string - try { - absolutePath = path.normalize(fileURLToPath(url)) - } catch { - continue - } - - if (shouldExclude(absolutePath, config.exclude)) { - continue - } - - let source: string - try { - source = await fs.readFile(absolutePath, "utf8") - } catch { - continue - } - - if (config.maxFileSize > 0 && getByteLength(source) > config.maxFileSize) { - continue - } - - const cacheKey = path.isAbsolute(absolutePath) - ? absolutePath - : path.resolve(contextDirectory, absolutePath) - - const cached = cache.get(cacheKey, source) - const annotated = cached ?? formatWithRuntimeConfig(source, config) - - if (!cached) { - cache.set(cacheKey, source, annotated) - } - - const tempPath = await writeAnnotatedTempFile(annotated) - part.url = pathToFileURL(tempPath).href - part.content = annotated - } -} - -type HashlinePluginHooks = Pick< - Hooks, - | "tool.definition" - | "tool.execute.before" - | "tool.execute.after" - | "experimental.chat.system.transform" - | "chat.message" -> - -export function createHashlineHooks(config: HashlineRuntimeConfig, cache?: HashlineAnnotationCache): HashlinePluginHooks { - const effectiveCache = cache ?? new HashlineAnnotationCache(config.cacheSize ?? 128) - - return { - "tool.definition": async (input, output) => { - if (input.toolID === "read" || input.toolID === "view") { - output.description = `${output.description}\n\nHashline: Returns canonical ${DEFAULT_PREFIX} refs plus a REV token. Copy refs exactly from the output, then plan all same-file changes before calling edit.` - } - - if (input.toolID === "edit") { - output.description = `${output.description}\n\nHashline: Accepts refs copied from read. Prefer one batched call per file with { filePath, fileRev?, operations:[{ op, ref|startRef/endRef, content? }] } instead of many single edits.` - } - - if (input.toolID === "write") { - output.description = `${output.description}\n\nHashline: Use write for new files or full rewrites. Prefer edit for targeted existing-file changes; hashline prefixes inside content are stripped automatically.` - } - - if (input.toolID === "patch") { - output.description = `${output.description}\n\nHashline: Compatibility path only. Prefer read -> one batched edit per file for a faster, lower-read workflow.` - } - }, - - "tool.execute.before": async (input, output) => { - const name = input.tool - - if (!isFileEditTool(name)) { - return - } - - const args = (output.args ?? {}) as Record - 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 - } - } - - 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 - } - - if (typeof output.output !== "string") { - return - } - - const source = output.output - if (source.includes("directory")) { - return - } - - const filePathFromArgs = extractPathFromToolArgs(args) - if (typeof filePathFromArgs !== "string") { - return - } - - const canonicalPath = getCanonicalPath(filePathFromArgs, input as Record) - - if (shouldExclude(filePathFromArgs, config.exclude)) { - return - } - - const offset = typeof args.offset === "number" ? args.offset : undefined - const limit = typeof args.limit === "number" ? args.limit : undefined - const cacheKey = buildCacheEntryKey(canonicalPath, offset, limit) - const cached = effectiveCache.get(cacheKey, source) - if (cached) { - output.output = cached - return - } - - try { - const annotated = await runHashlineRead({ - filePath: filePathFromArgs, - offset, - limit, - context: { - directory: typeof (input as Record).directory === "string" - ? ((input as Record).directory as string) - : undefined, - }, - }) - - if (typeof annotated !== "string") { - return - } - - if (config.maxFileSize > 0 && getByteLength(annotated) > config.maxFileSize) { - return - } - - effectiveCache.set(cacheKey, source, annotated) - output.output = annotated - } catch { - return - } - }, - - "experimental.chat.system.transform": async (_input, output) => { - const target = output as { system?: string[] } - if (!Array.isArray(target.system)) { - target.system = [] - } - - if (target.system.length > MAX_SYSTEM_ENTRIES) { - console.warn( - `hashline: experimental.chat.system.transform received ${target.system.length} system entries; deduplicating the hashline instruction block to avoid prompt bloat.`, - ) - } - - target.system = updateSystemInstructions(target.system, buildHashlineSystemInstruction(config)) - }, - - "chat.message": async (input, output) => { - await annotateChatMessageParts( - output as { parts?: Array> }, - input as Record, - config, - effectiveCache, - ) - }, - } -} diff --git a/.opencode/plugins/hashline-routing.ts b/.opencode/plugins/hashline-routing.ts deleted file mode 100644 index 3166207..0000000 --- a/.opencode/plugins/hashline-routing.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Plugin } from "@opencode-ai/plugin" -import { createHashlineHooks } from "./hashline-hooks.js" -import { HashlineAnnotationCache, resolveHashlineConfig } from "./hashline-shared.js" - -/** - * Hashline routing only normalizes tool arguments and aliases tool names. - * - * It does not perform the actual hashline read/edit/write work; that lives in - * hashline-hooks.ts, which handles the heavy lifting against the core runtime. - */ - -const known = new Set(["read", "view", "edit", "patch", "write"]) - -function normalizeName(name: string): string { - return name === "view" ? "read" : name -} - -function normalizeArgs(toolName: string, args: Record): Record { - const out = { ...args } - - // The native read contract expects `filePath`, while the rest of the routing - // layer keeps the legacy snake_case -> camelCase bridge for edit-style tools. - if (toolName === "read") { - if (typeof out.filePath !== "string") { - if (typeof out.path === "string") out.filePath = out.path - else if (typeof out.file_path === "string") out.filePath = out.file_path - else if (typeof out.file === "string") out.filePath = out.file - } - } - - if (toolName === "edit") { - if (typeof out.file_path === "string" && typeof out.filePath !== "string") out.filePath = out.file_path - if (typeof out.start_ref === "string" && typeof out.startRef !== "string") out.startRef = out.start_ref - if (typeof out.end_ref === "string" && typeof out.endRef !== "string") out.endRef = out.end_ref - if (typeof out.safe_reapply === "boolean" && typeof out.safeReapply !== "boolean") out.safeReapply = out.safe_reapply - if (typeof out.expected_file_hash === "string" && typeof out.expectedFileHash !== "string") out.expectedFileHash = out.expected_file_hash - if (typeof out.file_rev === "string" && typeof out.fileRev !== "string") out.fileRev = out.file_rev - if (typeof out.dry_run === "boolean" && typeof out.dryRun !== "boolean") out.dryRun = out.dry_run - } - - if (toolName === "patch") { - if (typeof out.patch_text === "string" && typeof out.patchText !== "string") out.patchText = out.patch_text - if (typeof out.file_path === "string" && typeof out.filePath !== "string") out.filePath = out.file_path - if (typeof out.expected_file_hash === "string" && typeof out.expectedFileHash !== "string") out.expectedFileHash = out.expected_file_hash - if (typeof out.file_rev === "string" && typeof out.fileRev !== "string") out.fileRev = out.file_rev - if (typeof out.dry_run === "boolean" && typeof out.dryRun !== "boolean") out.dryRun = out.dry_run - } - - if (toolName === "write") { - if (typeof out.file_path === "string" && typeof out.filePath !== "string") out.filePath = out.file_path - if (typeof out.expected_file_hash === "string" && typeof out.expectedFileHash !== "string") out.expectedFileHash = out.expected_file_hash - if (typeof out.file_rev === "string" && typeof out.fileRev !== "string") out.fileRev = out.file_rev - if (typeof out.dry_run === "boolean" && typeof out.dryRun !== "boolean") out.dryRun = out.dry_run - } - - return out -} - -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 ?? 128) - const hooks = createHashlineHooks(config, cache) - - return { - ...hooks, - "tool.execute.before": async (input, output) => { - const name = normalizeName(input.tool) - if (known.has(name)) { - output.args = normalizeArgs(name, (output.args ?? {}) as Record) - } - - if (hooks["tool.execute.before"]) { - await hooks["tool.execute.before"](input, output) - } - }, - } -} diff --git a/.opencode/plugins_disabled/hashline-routing.ts b/.opencode/plugins_disabled/hashline-routing.ts deleted file mode 100644 index 00b2a11..0000000 --- a/.opencode/plugins_disabled/hashline-routing.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Plugin } from "@opencode-ai/plugin" -import { createHashlineHooks } from "./hashline-hooks.js" -import { HashlineAnnotationCache, resolveHashlineConfig } from "./hashline-shared.js" - -/** - * Hashline routing only normalizes tool arguments and aliases tool names. - * - * It does not perform the actual hashline read/edit/write work; that lives in - * hashline-hooks.ts, which handles the heavy lifting against the core runtime. - */ - -const known = new Set(["read", "view", "edit", "patch", "write"]) - -function normalizeName(name: string): string { - return name === "view" ? "read" : name -} - -function normalizeArgs(toolName: string, args: Record): Record { - const out = { ...args } - - // The native read contract expects `path`, while the rest of the routing - // layer keeps the legacy snake_case -> camelCase bridge for edit-style tools. - if (toolName === "read") { - if (typeof out.path !== "string") { - if (typeof out.file_path === "string") out.path = out.file_path - else if (typeof out.filePath === "string") out.path = out.filePath - else if (typeof out.file === "string") out.path = out.file - } - } - - if (toolName === "edit") { - if (typeof out.file_path === "string" && typeof out.filePath !== "string") out.filePath = out.file_path - if (typeof out.start_ref === "string" && typeof out.startRef !== "string") out.startRef = out.start_ref - if (typeof out.end_ref === "string" && typeof out.endRef !== "string") out.endRef = out.end_ref - if (typeof out.safe_reapply === "boolean" && typeof out.safeReapply !== "boolean") out.safeReapply = out.safe_reapply - if (typeof out.expected_file_hash === "string" && typeof out.expectedFileHash !== "string") out.expectedFileHash = out.expected_file_hash - if (typeof out.file_rev === "string" && typeof out.fileRev !== "string") out.fileRev = out.file_rev - if (typeof out.dry_run === "boolean" && typeof out.dryRun !== "boolean") out.dryRun = out.dry_run - } - - if (toolName === "patch") { - if (typeof out.patch_text === "string" && typeof out.patchText !== "string") out.patchText = out.patch_text - if (typeof out.file_path === "string" && typeof out.filePath !== "string") out.filePath = out.file_path - if (typeof out.expected_file_hash === "string" && typeof out.expectedFileHash !== "string") out.expectedFileHash = out.expected_file_hash - if (typeof out.file_rev === "string" && typeof out.fileRev !== "string") out.fileRev = out.file_rev - if (typeof out.dry_run === "boolean" && typeof out.dryRun !== "boolean") out.dryRun = out.dry_run - } - - if (toolName === "write") { - if (typeof out.file_path === "string" && typeof out.filePath !== "string") out.filePath = out.file_path - if (typeof out.expected_file_hash === "string" && typeof out.expectedFileHash !== "string") out.expectedFileHash = out.expected_file_hash - if (typeof out.file_rev === "string" && typeof out.fileRev !== "string") out.fileRev = out.file_rev - if (typeof out.dry_run === "boolean" && typeof out.dryRun !== "boolean") out.dryRun = out.dry_run - } - - return out -} - -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 ?? 128) - const hooks = createHashlineHooks(config, cache) - - return { - ...hooks, - "tool.execute.before": async (input, output) => { - const name = normalizeName(input.tool) - if (known.has(name)) { - output.args = normalizeArgs(name, (output.args ?? {}) as Record) - } - - if (hooks["tool.execute.before"]) { - await hooks["tool.execute.before"](input, output) - } - }, - } -} diff --git a/.opencode/tests/hashline-contract.test.ts b/.opencode/tests/hashline-contract.test.ts deleted file mode 100644 index 1217adc..0000000 --- a/.opencode/tests/hashline-contract.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" - -import { - DEFAULT_PREFIX, - buildEditExample, - buildReadExample, - formatAnnotatedLine, - formatRef, - formatRev, - normalizeRev, - parseRef, -} from "../../dist/.opencode/plugins/hashline-contract.js" -import { DEFAULT_PREFIX as SHARED_DEFAULT_PREFIX } from "../../dist/.opencode/plugins/hashline-shared.js" - -test("formatRef produces canonical line references", () => { - assert.equal(formatRef(12, "a3f"), "12#A3F") - assert.equal(formatRef(12, "a3f", "9bc"), "12#A3F#9BC") -}) - -test("formatRev produces uppercase REV tokens", () => { - const rev = formatRev("1a2b3c4d") - - assert.equal(rev, "REV:1A2B3C4D") - assert.match(rev, /^REV:[A-F0-9]{8}$/) -}) - -test("parseRef parses valid refs and rejects invalid refs", () => { - assert.deepEqual(parseRef("12#a3f#9bc"), { - lineNumber: 12, - hash: "A3F", - anchor: "9BC", - }) - - assert.deepEqual(parseRef("+ #HL 7#abc"), { - lineNumber: 7, - hash: "ABC", - anchor: undefined, - }) - - assert.throws(() => parseRef(""), /Invalid line reference/) - assert.throws(() => parseRef("12#not-hex"), /Invalid line reference/) - assert.throws(() => parseRef("0#ABC"), /Invalid line number/) -}) - -test("normalizeRev handles hash tokens and raw hashes", () => { - assert.equal(normalizeRev("REV:1a2b3c4d"), "1A2B3C4D") - assert.equal(normalizeRev("1a2b3c4d"), "1A2B3C4D") - assert.equal(normalizeRev("#HL REV:1a2b3c4d"), "1A2B3C4D") -}) - -test("example builders return valid structures", () => { - assert.deepEqual(buildReadExample("src/file.ts"), { - filePath: "src/file.ts", - offset: 1, - limit: 200, - }) - - const example = buildReadExample("src/file.ts") - assert.equal(example.filePath, "src/file.ts") - assert.equal("path" in example, false) - - assert.deepEqual(buildEditExample("src/file.ts", "12#A3F#9BC", "const value = 2"), { - filePath: "src/file.ts", - operations: [ - { - op: "replace", - ref: "12#A3F#9BC", - content: "const value = 2", - }, - ], - }) -}) - -test("contract constants stay aligned with shared defaults", () => { - assert.equal(DEFAULT_PREFIX, SHARED_DEFAULT_PREFIX) -}) - -test("formatAnnotatedLine matches the canonical annotated line pattern", () => { - const annotated = formatAnnotatedLine("const value = 1", 0, ["const value = 1", "next line"]) - - assert.match(annotated, new RegExp(`^${DEFAULT_PREFIX} 1#[A-F0-9]{3}#[A-F0-9]{3}\\|const value = 1$`)) -}) - -test("formatRef and parseRef round-trip canonically", () => { - const ref = formatRef(42, "abc", "def") - const parsed = parseRef(ref) - - assert.equal(ref, "42#ABC#DEF") - assert.deepEqual(parsed, { - lineNumber: 42, - hash: "ABC", - anchor: "DEF", - }) - assert.equal(formatRef(parsed.lineNumber, parsed.hash, parsed.anchor), ref) -}) diff --git a/.opencode/tests/hashline-hooks.test.ts b/.opencode/tests/hashline-hooks.test.ts deleted file mode 100644 index 77c2d21..0000000 --- a/.opencode/tests/hashline-hooks.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" - -import { createHashlineHooks } from "../../dist/.opencode/plugins/hashline-hooks.js" - -const config = { - exclude: [], - maxFileSize: 1_048_576, - cacheSize: 16, - prefix: "#HL", - fileRev: true, - safeReapply: false, -} - -function makeHooks(overrides = {}) { - return createHashlineHooks({ - ...config, - ...overrides, - }) -} - -async function runSystemTransform(system, overrides = {}) { - const hooks = makeHooks(overrides) - const output = { system: [...system] } - const transform = hooks["experimental.chat.system.transform"] - - if (!transform) { - throw new Error("Missing system transform hook") - } - - await transform({ model: {} as any }, output) - - return output.system -} - -test("system instruction transform is idempotent", async () => { - const hooks = makeHooks() - const output = { system: ["bootstrap"] } - const transform = hooks["experimental.chat.system.transform"] - - if (!transform) { - throw new Error("Missing system transform hook") - } - - await transform({ model: {} as any }, output) - const afterFirst = [...output.system] - - await transform({ model: {} as any }, output) - - assert.deepEqual(output.system, afterFirst) - assert.equal( - output.system.filter((entry) => entry.includes("hashline-instruction-v1")).length, - 1, - ) -}) - -test("old instruction markers are cleaned up", async () => { - const oldInstruction = [ - "", - "legacy guidance", - "", - ].join("\n") - - const system = await runSystemTransform(["intro", oldInstruction, "outro"]) - - assert.equal(system.some((entry) => /hashline-instruction-v0/i.test(entry)), false) - assert.equal(system.filter((entry) => entry.includes("hashline-instruction-v1")).length, 1) - assert.equal(system[0], "intro") - assert.equal(system[system.length - 1], "outro") -}) - -test("instruction is injected when missing", async () => { - const system = await runSystemTransform(["intro", "outro"]) - - assert.equal(system.filter((entry) => entry.includes("hashline-instruction-v1")).length, 1) - assert.equal(system.length, 3) - assert.equal(system[0], "intro") - assert.equal(system[1], "outro") - assert.equal(system[2].includes("hashline-instruction-v1"), true) -}) - -test("instruction includes batch-first workflow guidance and config-aware prefix notes", async () => { - const system = await runSystemTransform(["intro"], { prefix: ";;;" }) - const instruction = system[1] - - assert.match(instruction, /Hashline workflow:/) - assert.match(instruction, /Read returns canonical refs like `#HL 12#A3F#9BC` and `#HL REV:72C4946C`/) - assert.match(instruction, /Active helper prefix from config: ";;;"/) - assert.match(instruction, /do not rewrite refs just to match config/i) - assert.match(instruction, /batch same-file changes into one edit call with operations\[\]/i) - assert.match(instruction, /Reread only when you need more context or an edit fails because refs are stale/i) -}) - -test("instruction handles prefix disabled", async () => { - const system = await runSystemTransform(["intro"], { prefix: false }) - const instruction = system[1] - - assert.match(instruction, /Active helper prefix from config: none/) - assert.match(instruction, /Read output stays canonical `#HL`/) -}) - -test("instruction falls back to the default prefix when config prefix is missing", async () => { - const system = await runSystemTransform(["intro"], { prefix: undefined }) - const instruction = system[1] - - assert.match(instruction, /Active helper prefix from config: "#HL"/) - assert.match(instruction, /Read output stays canonical `#HL`/) -}) - -test("tool descriptions nudge agents toward the efficient hashline workflow", async () => { - const hooks = makeHooks() - const definition = hooks["tool.definition"] - - if (!definition) { - throw new Error("Missing tool definition hook") - } - - const readOutput = { description: "native read", parameters: {} } - await definition({ toolID: "read" } as any, readOutput as any) - assert.match(readOutput.description, /canonical #HL refs plus a REV token/i) - assert.match(readOutput.description, /plan all same-file changes before calling edit/i) - - const editOutput = { description: "native edit", parameters: {} } - await definition({ toolID: "edit" } as any, editOutput as any) - assert.match(editOutput.description, /Accepts refs copied from read/i) - assert.match(editOutput.description, /Prefer one batched call per file/i) - assert.match(editOutput.description, /operations:\[\{ op, ref\|startRef\/endRef, content\? \}\]/i) - - const writeOutput = { description: "native write", parameters: {} } - await definition({ toolID: "write" } as any, writeOutput as any) - assert.match(writeOutput.description, /Use write for new files or full rewrites/i) - assert.match(writeOutput.description, /Prefer edit for targeted existing-file changes/i) -}) diff --git a/.opencode/tests/hashline-routing.test.ts b/.opencode/tests/hashline-routing.test.ts deleted file mode 100644 index 5390ffb..0000000 --- a/.opencode/tests/hashline-routing.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" - -import { HashlineRouting } from "../../dist/.opencode/plugins/hashline-routing.js" - -test("read normalizes path to filePath", async () => { - const plugin = await HashlineRouting({ directory: process.cwd() } as any) - const before = plugin["tool.execute.before"] - - assert.equal(typeof before, "function") - - const output: any = { args: { path: "src/file.ts" } } - - await before?.({ tool: "read" } as never, output as never) - - assert.equal(output.args.filePath, "src/file.ts") -}) diff --git a/.opencode/tools/resolve-hash-edit.ts b/.opencode/tools/resolve-hash-edit.ts deleted file mode 100644 index 44f7720..0000000 --- a/.opencode/tools/resolve-hash-edit.ts +++ /dev/null @@ -1,73 +0,0 @@ -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_disabled/resolve-hash-edit.ts b/.opencode/tools_disabled/resolve-hash-edit.ts deleted file mode 100644 index 44f7720..0000000 --- a/.opencode/tools_disabled/resolve-hash-edit.ts +++ /dev/null @@ -1,73 +0,0 @@ -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.json b/opencode.json index 0e31052..c217d55 100644 --- a/opencode.json +++ b/opencode.json @@ -1,16 +1,9 @@ { "$schema": "https://opencode.ai/config.json", - "plugin": ["hashline-routing"], - "permission": { - "*": "allow" - }, + "plugin": ["@angdrew/opencode-hashline-plugin"], "agent": { - // Sample smoke-test agent for the simplified canonical flow. - // Keep the permissions narrow so the test exercises only the core file path. - // `patch` is intentionally omitted because the standard edit flow uses hashline refs. - // No resolver helper is granted here; this agent stays on built-in file tools only. "hashline-test": { - "description": "Minimal smoke-test agent for hashline read/edit/write", + "description": "Minimal smoke-test agent for hashline read/edit", "mode": "primary", "model": "proxy/gpt-5.1-codex-mini", "permission": { diff --git a/package-lock.json b/package-lock.json index 8c97985..6f415ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,20 @@ { "name": "@angdrew/opencode-hashline-plugin", - "version": "1.6.6", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@angdrew/opencode-hashline-plugin", - "version": "1.6.6", + "version": "2.0.0", "license": "MIT", + "dependencies": { + "diff": "^5.2.0", + "effect": "4.0.0-beta.65" + }, "devDependencies": { "@opencode-ai/plugin": "1.2.27", + "@types/diff": "^5.2.3", "@types/node": "^22.13.10", "typescript": "^5.8.2" }, @@ -17,6 +22,84 @@ "@opencode-ai/plugin": "^1.2.22" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@opencode-ai/plugin": { "version": "1.2.27", "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.27.tgz", @@ -35,6 +118,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/diff": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.3.tgz", + "integrity": "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -45,6 +141,163 @@ "undici-types": "~6.21.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.65", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.65.tgz", + "integrity": "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", + "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -66,6 +319,34 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", diff --git a/package.json b/package.json index edd9ee4..47d928f 100644 --- a/package.json +++ b/package.json @@ -1,51 +1,36 @@ { "name": "@angdrew/opencode-hashline-plugin", - "version": "1.6.6", - "description": "Hashline annotation and native-tool edit translation for OpenCode.", - "repository": { - "type": "git", - "url": "git+https://github.com/AngDrew/opencode-hashline.git" - }, - "homepage": "https://github.com/AngDrew/opencode-hashline#readme", - "bugs": { - "url": "https://github.com/AngDrew/opencode-hashline/issues" - }, + "version": "2.0.0", + "description": "Hashline-powered read/edit for OpenCode. Annotates reads with stable #HL refs and replaces native edit with direct hashline-powered file editing.", "type": "module", - "main": "./dist/src/index.js", - "types": "./dist/src/index.d.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./dist/src/index.d.ts", - "import": "./dist/src/index.js" + "types": "./dist/index.d.ts", + "import": "./dist/index.js" } }, - "files": [ - "dist", - "README.md" - ], + "files": ["dist", "README.md"], "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "tsc", "test": "node --test", - "bench": "node bench/runner.mjs", - "bench:legacy": "node scripts/benchmark.mjs", "pack:check": "npm pack --dry-run", "prepublishOnly": "npm run build" }, - "publishConfig": { - "access": "public" - }, - "keywords": [ - "opencode", - "plugin", - "hashline", - "tools" - ], + "publishConfig": { "access": "public" }, + "keywords": ["opencode", "plugin", "hashline", "edit", "read"], "license": "MIT", "peerDependencies": { "@opencode-ai/plugin": "^1.2.22" }, + "dependencies": { + "diff": "^5.2.0", + "effect": "4.0.0-beta.65" + }, "devDependencies": { "@opencode-ai/plugin": "1.2.27", + "@types/diff": "^5.2.3", "@types/node": "^22.13.10", "typescript": "^5.8.2" } diff --git a/src/codemap.md b/src/codemap.md deleted file mode 100644 index 1321da2..0000000 --- a/src/codemap.md +++ /dev/null @@ -1,48 +0,0 @@ -# src/ - -## Responsibility - -- Provides the **OpenCode plugin entrypoint** for this repository. -- Exposes the **Hashline tool suite** (line-stable file operations) to the OpenCode runtime via `tool.read`, `tool.edit`, `tool.patch`, and `tool.write`. -- Composes routing/dispatch behavior from `HashlineRouting` so tool calls are routed consistently. - -Primary module: -- `src/index.ts`: exports the default plugin factory `hashlinePlugin`. - -## Design - -- **Plugin factory**: `const hashlinePlugin: Plugin = async (input) => { ... }` (default export). - - Uses the `Plugin` type from `@opencode-ai/plugin`. - - Returns a single object that merges: - - routing hooks from `HashlineRouting` (imported as `routingPlugin`), and - - a `tool` map with concrete tool handlers. - -- **Tool registration** (wiring only, implementations live outside `src/`): - - `readTool` imported from `../.opencode/tools/read` - - `editTool` imported from `../.opencode/tools/edit` - - `patchTool` imported from `../.opencode/tools/patch` - - `writeTool` imported from `../.opencode/tools/write` - -- **Routing composition**: - - `HashlineRouting` is imported from `../.opencode/plugins/hashline-routing` as `routingPlugin`. - - `routingHooks` is produced by `await routingPlugin(input)` and spread into the plugin return value. - -Overall, `src/` is intentionally minimal: it is the **assembly layer** that binds routing + tool handlers into an OpenCode-compatible plugin export. - -## Flow - -1. OpenCode loads `src/index.ts` and executes the default export `hashlinePlugin(input)`. -2. `hashlinePlugin` awaits routing initialization: - - `const routingHooks = await routingPlugin(input)` -3. `hashlinePlugin` returns the plugin contract: - - `return { ...routingHooks, tool: { read, edit, patch, write } }` -4. At runtime, OpenCode invokes tools via `tool.`; routing hooks (from `HashlineRouting`) participate in dispatch as provided by `routingHooks`. - -## Integration - -- **External runtime contract**: `@opencode-ai/plugin` (`Plugin` type) defines the expected shape/behavior of the exported plugin factory. -- **Routing integration**: `../.opencode/plugins/hashline-routing` provides `HashlineRouting` (imported as `routingPlugin`) whose returned hooks are merged into the exported plugin. -- **Tool integration**: `../.opencode/tools/{read,edit,patch,write}` supply the concrete tool handlers registered under `tool`. - -Entry point used by the host: -- `src/index.ts` (default export: `hashlinePlugin`). diff --git a/src/edit-executor.ts b/src/edit-executor.ts new file mode 100644 index 0000000..3a14d1a --- /dev/null +++ b/src/edit-executor.ts @@ -0,0 +1,200 @@ +import { access, readFile, unlink, writeFile } from "node:fs/promises" +import * as path from "node:path" +import { createTwoFilesPatch, diffLines } from "diff" +import { Effect } from "effect" +import type { ToolContext } from "@opencode-ai/plugin" + +async function runMaybeEffect(value: T | Promise | any): Promise { + if (value == null) return value as T + if (typeof (value as any).then === "function") return await (value as Promise) + const isEffectV4 = + typeof value === "object" && "~effect/Effect/args" in (value as any) + const isEffectV3 = (value as any)._id === "Effect" + if (isEffectV4 || isEffectV3) { + return await Effect.runPromise(value as any) + } + return value as T +} +import type { HashlineEdit } from "./edit-ops.js" +import { applyHashlineEdits, HashlineMismatchError } from "./edit-ops.js" +import { canonicalizeFileText, restoreFileText } from "./file-text.js" +import { getAdaptiveHashLength, lineHash, anchorHash, computeFileRev } from "./hash.js" + +function trimDiff(diff: string): string { + const lines = diff.split("\n") + const contentLines = lines.filter( + (line) => + (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && + !line.startsWith("---") && + !line.startsWith("+++"), + ) + if (contentLines.length === 0) return diff + let min = Infinity + for (const line of contentLines) { + const content = line.slice(1) + if (content.trim().length > 0) { + const match = content.match(/^(\s*)/) + if (match) min = Math.min(min, match[1].length) + } + } + if (min === Infinity || min === 0) return diff + return lines + .map((line) => { + if ( + (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && + !line.startsWith("---") && + !line.startsWith("+++") + ) { + return line[0] + line.slice(1).slice(min) + } + return line + }) + .join("\n") +} + +export interface EditToolArgs { + filePath: string + operations?: Array<{ + op?: string + ref?: string + startRef?: string + endRef?: string + content?: string + replacement?: string + lines?: string[] | string | null + }> + oldString?: string + newString?: string + expectedFileHash?: string + fileRev?: string + safeReapply?: boolean +} + +function toEditOps(args: EditToolArgs): HashlineEdit[] { + if (Array.isArray(args.operations) && args.operations.length > 0) { + return args.operations.map((op) => { + const ref = op.startRef ?? op.ref + const lines = op.content ?? op.replacement ?? op.lines ?? null + if (!ref && op.op === "replace" && typeof lines === "string") { + return { op: "replace" as const, lines } + } + return { + op: (op.op as HashlineEdit["op"]) ?? "replace", + pos: ref, + end: op.endRef, + lines, + } + }) + } + if (args.oldString !== undefined && args.newString !== undefined) { + return [{ op: "replace" as const, lines: args.newString }] + } + return [] +} + +export async function executeEditTool( + args: EditToolArgs, + context: ToolContext, +): Promise { + if (!args.filePath) return "Error: filePath is required" + const filePath = path.isAbsolute(args.filePath) + ? args.filePath + : path.join(context.directory, args.filePath) + const worktree = context.worktree ?? context.directory + const relPath = path.relative(worktree, filePath) || filePath + + const operations = toEditOps(args) + if (operations.length === 0 && args.oldString === undefined) { + return "Error: No operations or oldString provided" + } + + try { + const exists = await access(filePath).then(() => true).catch(() => false) + if (!exists) { + if (operations.length === 1 && operations[0].op === "append" && !operations[0].pos) { + await writeFile(filePath, "", "utf8") + } else { + return `Error: File not found: ${filePath}` + } + } + + const rawOldContent = exists ? await readFile(filePath, "utf-8") : "" + const envelope = canonicalizeFileText(rawOldContent) + + if (args.expectedFileHash) { + const actualHash = computeFileRev(rawOldContent) + if (actualHash !== args.expectedFileHash.toUpperCase()) { + return `Error: File hash mismatch. Expected ${args.expectedFileHash.toUpperCase()}, actual ${actualHash}. Read the file again.` + } + } + if (args.fileRev) { + const actualRev = computeFileRev(rawOldContent) + if (actualRev !== args.fileRev.toUpperCase()) { + return `Error: File revision mismatch. Expected ${args.fileRev.toUpperCase()}, actual ${actualRev}. Read the file again.` + } + } + + let result: { content: string; noopEdits: number; deduplicatedEdits: number } + + if (operations.length > 0 && operations[0].pos !== undefined) { + result = applyHashlineEdits(envelope.content, operations) + } else if (args.oldString !== undefined) { + const idx = envelope.content.indexOf(args.oldString) + if (idx === -1) return "Error: old_string was not found in file" + const newContent = envelope.content.slice(0, idx) + (args.newString ?? "") + envelope.content.slice(idx + args.oldString.length) + result = { content: newContent, noopEdits: newContent === envelope.content ? 1 : 0, deduplicatedEdits: 0 } + } else { + result = applyHashlineEdits(envelope.content, operations) + } + + if (result.noopEdits > 0 && result.content === envelope.content) { + return `Error: No changes made to ${filePath}. The edits produced identical content.` + } + const writeContent = restoreFileText(result.content, envelope) + + let additions = 0 + let deletions = 0 + for (const change of diffLines(envelope.content, result.content)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + + const diffStr = trimDiff( + createTwoFilesPatch(filePath, filePath, envelope.content, result.content), + ) + + await runMaybeEffect( + context.ask({ + permission: "edit", + patterns: [relPath], + always: [relPath], + metadata: { filepath: filePath, diff: diffStr }, + }), + ) + + await writeFile(filePath, writeContent, "utf-8") + + if (typeof (context as any).metadata === "function") { + (context as any).metadata({ + metadata: { + diff: diffStr, + filediff: { + file: filePath, + patch: diffStr, + additions, + deletions, + }, + diagnostics: {}, + }, + }) + } + + return `Updated ${filePath} (${additions > 0 ? `+${additions}` : ""}${deletions > 0 ? ` -${deletions}` : ""})` + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (error instanceof HashlineMismatchError) { + return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.` + } + return `Error: ${message}` + } +} diff --git a/src/edit-ops.ts b/src/edit-ops.ts new file mode 100644 index 0000000..c29d1b8 --- /dev/null +++ b/src/edit-ops.ts @@ -0,0 +1,281 @@ +import { getAdaptiveHashLength, lineHash, anchorHash } from "./hash.js" +import { parseLineRef, normalizeLineRef } from "./ref.js" + +export interface HashlineEdit { + op: "replace" | "append" | "prepend" + pos?: string + end?: string + lines: string[] | string | null +} + +export interface EditReport { + content: string + noopEdits: number + deduplicatedEdits: number +} + +export class HashlineMismatchError extends Error { + readonly remaps: Map + + constructor( + private readonly mismatches: Array<{ line: number; expected: string }>, + private readonly fileLines: string[], + ) { + super(HashlineMismatchError.formatMessage(mismatches, fileLines)) + this.name = "HashlineMismatchError" + const remaps = new Map() + const hashLen = getAdaptiveHashLength(fileLines.length) + for (const m of mismatches) { + const actual = lineHash(fileLines[m.line - 1] ?? "", hashLen) + remaps.set(`${m.line}#${m.expected}`, `${m.line}#${actual}`) + } + this.remaps = remaps + } + + private static formatMessage( + mismatches: Array<{ line: number; expected: string }>, + fileLines: string[], + ): string { + const ctx = 2 + const display = new Set() + for (const m of mismatches) { + const lo = Math.max(1, m.line - ctx) + const hi = Math.min(fileLines.length, m.line + ctx) + for (let l = lo; l <= hi; l++) display.add(l) + } + const sorted = [...display].sort((a, b) => a - b) + const lines: string[] = [ + `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use updated LINE#ID references below (>>> marks changed lines).`, + "", + ] + const hashLen = getAdaptiveHashLength(fileLines.length) + let prev = -1 + for (const line of sorted) { + if (prev !== -1 && line > prev + 1) lines.push(" ...") + prev = line + const content = fileLines[line - 1] ?? "" + const hash = lineHash(content, hashLen) + const prefix = mismatches.some((m) => m.line === line) ? ">>> " : " " + lines.push(`${prefix}${line}#${hash}#${anchorHash(fileLines[line - 2], content, fileLines[line], hashLen)}|${content}`) + } + return lines.join("\n") + } +} + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false + return true +} + +function toLines(content: string): string[] { + return content.length === 0 ? [] : content.split("\n") +} + +function getEditLineNumber(edit: HashlineEdit): number { + if (edit.pos) { + const parsed = parseLineRef(edit.pos) + return parsed.lineNumber + } + if (edit.op === "append") return Infinity + return 0 +} + +function collectLineRefs(edits: HashlineEdit[]): string[] { + const refs: string[] = [] + for (const edit of edits) { + if (edit.pos) refs.push(normalizeLineRef(edit.pos)) + if (edit.end) refs.push(normalizeLineRef(edit.end)) + } + return refs +} + +function resolveRefLine(ref: string): number { + return parseLineRef(ref).lineNumber +} + +function validateRef(lines: string[], ref: string): void { + const parsed = parseLineRef(ref) + if (parsed.lineNumber < 1 || parsed.lineNumber > lines.length) { + throw new Error(`Line ${parsed.lineNumber} out of bounds (file has ${lines.length} lines)`) + } + const hashLen = getAdaptiveHashLength(lines.length) + const idx = parsed.lineNumber - 1 + const actual = lineHash(lines[idx], hashLen) + if (actual !== parsed.hash) { + throw new HashlineMismatchError( + [{ line: parsed.lineNumber, expected: parsed.hash }], + lines, + ) + } + if (parsed.anchor) { + const actualAnchor = anchorHash(lines[idx - 1], lines[idx], lines[idx + 1], hashLen) + if (actualAnchor !== parsed.anchor) { + throw new HashlineMismatchError( + [{ line: parsed.lineNumber, expected: parsed.hash }], + lines, + ) + } + } +} + +function validateRefs(lines: string[], refs: string[]): void { + const mismatches: Array<{ line: number; expected: string }> = [] + const hashLen = getAdaptiveHashLength(lines.length) + for (const ref of refs) { + const parsed = parseLineRef(ref) + if (parsed.lineNumber < 1 || parsed.lineNumber > lines.length) { + throw new Error(`Line ${parsed.lineNumber} out of bounds (file has ${lines.length} lines)`) + } + const idx = parsed.lineNumber - 1 + const actual = lineHash(lines[idx], hashLen) + if (actual !== parsed.hash) { + mismatches.push({ line: parsed.lineNumber, expected: parsed.hash }) + } else if (parsed.anchor) { + const actualAnchor = anchorHash(lines[idx - 1], lines[idx], lines[idx + 1], hashLen) + if (actualAnchor !== parsed.anchor) { + mismatches.push({ line: parsed.lineNumber, expected: parsed.hash }) + } + } + } + if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, lines) +} + +function applySetLine(lines: string[], pos: string, replacement: string[]): string[] { + const line = resolveRefLine(pos) + const idx = line - 1 + const result = [...lines] + if (arraysEqual([result[idx]], replacement)) return lines + result.splice(idx, 1, ...replacement) + return result +} + +function applyReplaceRange(lines: string[], start: string, end: string, replacement: string[]): string[] { + const s = resolveRefLine(start) - 1 + const e = resolveRefLine(end) - 1 + if (s > e) throw new Error("replace_range start must be before end") + const result = [...lines] + const removed = result.slice(s, e + 1) + if (arraysEqual(removed, replacement)) return lines + result.splice(s, e - s + 1, ...replacement) + return result +} + +function applyInsertAfter(lines: string[], anchor: string, insert: string[]): string[] { + const idx = resolveRefLine(anchor) + const result = [...lines] + result.splice(idx, 0, ...insert) + return result +} + +function applyInsertBefore(lines: string[], anchor: string, insert: string[]): string[] { + const idx = resolveRefLine(anchor) - 1 + const result = [...lines] + result.splice(idx, 0, ...insert) + return result +} + +function applyAppend(lines: string[], insert: string[]): string[] { + return [...lines, ...insert] +} + +function applyPrepend(lines: string[], insert: string[]): string[] { + return [...insert, ...lines] +} + +function dedupeEdits(edits: HashlineEdit[]): { edits: HashlineEdit[]; deduplicatedEdits: number } { + const seen = new Map() + const result: HashlineEdit[] = [] + let dedups = 0 + for (const edit of edits) { + if (edit.op === "replace" && edit.pos) { + const key = `${edit.op}:${edit.pos}` + if (seen.has(key)) { dedups++; continue } + seen.set(key, edit) + result.push(edit) + } else { + result.push(edit) + } + } + return { edits: result, deduplicatedEdits: dedups } +} + +function detectOverlappingRanges(edits: HashlineEdit[]): string | null { + const ranges: Array<[number, number, string]> = [] + for (const edit of edits) { + if (edit.op !== "replace") continue + if (!edit.pos && !edit.end) continue + const start = edit.pos ? resolveRefLine(edit.pos) - 1 : 0 + const end = edit.end ? resolveRefLine(edit.end) - 1 : start + for (const [rs, re, label] of ranges) { + if (start <= re && end >= rs) { + return `Overlapping operations: ${label} conflicts with edits on lines ${rs + 1}-${re + 1}` + } + } + ranges.push([start, end, `${start + 1}#${edit.end ? edit.end.split("#")[1] : edit.pos?.split("#")[1] ?? ""}`]) + } + return null +} + +function toLinesArray(edit: HashlineEdit): string[] { + if (edit.lines === null || edit.lines === undefined) return [] + if (Array.isArray(edit.lines)) return edit.lines + if (typeof edit.lines === "string" && edit.lines.length === 0) return [] + if (typeof edit.lines === "string") return edit.lines.split("\n") + return [] +} + +export function applyHashlineEdits(content: string, edits: HashlineEdit[]): EditReport { + if (edits.length === 0) return { content, noopEdits: 0, deduplicatedEdits: 0 } + + const deduped = dedupeEdits(edits) + const EDIT_PRECEDENCE: Record = { replace: 0, append: 1, prepend: 2 } + const sorted = [...deduped.edits].sort((a, b) => { + const la = getEditLineNumber(a) + const lb = getEditLineNumber(b) + if (lb !== la) return lb - la + return (EDIT_PRECEDENCE[a.op] ?? 3) - (EDIT_PRECEDENCE[b.op] ?? 3) + }) + + let noopEdits = 0 + let lines = toLines(content) + const refs = collectLineRefs(sorted) + validateRefs(lines, refs) + const overlap = detectOverlappingRanges(sorted) + if (overlap) throw new Error(overlap) + + for (const edit of sorted) { + const insert = toLinesArray(edit) + let next: string[] + switch (edit.op) { + case "replace": + if (edit.pos && edit.end) { + next = applyReplaceRange(lines, edit.pos, edit.end, insert) + } else if (edit.pos) { + next = applySetLine(lines, edit.pos, insert) + } else { + throw new Error("replace requires pos") + } + break + case "append": + next = edit.pos ? applyInsertAfter(lines, edit.pos, insert) : applyAppend(lines, insert) + break + case "prepend": + next = edit.pos ? applyInsertBefore(lines, edit.pos, insert) : applyPrepend(lines, insert) + break + default: + throw new Error(`Unsupported op: ${(edit as any).op}`) + } + if (arraysEqual(next, lines)) { + noopEdits++ + } else { + lines = next + } + } + + return { + content: lines.join("\n"), + noopEdits, + deduplicatedEdits: deduped.deduplicatedEdits, + } +} diff --git a/src/edit-tool.ts b/src/edit-tool.ts new file mode 100644 index 0000000..f95ec81 --- /dev/null +++ b/src/edit-tool.ts @@ -0,0 +1,114 @@ +import { tool } from "@opencode-ai/plugin/tool" +import type { ToolContext } from "@opencode-ai/plugin" +import { executeEditTool } from "./edit-executor.js" + +const HASHLINE_EDIT_DESCRIPTION = `Edit files using LINE#ID format for precise, safe modifications. + +WORKFLOW: +1. Read target file/range and copy exact LINE#ID tags. +2. Pick the smallest operation per logical mutation site. +3. Submit one edit call per file with all related operations. +4. If same file needs another call, re-read first. +5. Use anchors as "LINE#ID" only (never include trailing "|content"). + +LINE#ID FORMAT: + Each line reference uses {line_number}#{hash_id}#{anchor_id} where: + - {hash_id}: hex hash (3-4 chars) of line content + - {anchor_id}: hex hash (3-4 chars) of surrounding context + +Copy refs exactly as shown in hashline read/edit tools. + +OPERATIONS: + replace with pos only -> replace one line at pos + replace with pos+end -> replace range pos..end + append with pos -> insert after anchor + prepend with pos -> insert before anchor + append/prepend without pos -> EOF/BOF insertion + +RULES: + 1. Minimize scope: one logical mutation per operation. + 2. Anchor to structural lines (function/class/brace), NEVER blank lines. + 3. No no-ops: content must differ from current lines. + 4. For swaps/moves: use one range operation over multiple single-line ops. + 5. Re-read after successful edit before calling another on the same file.` + +const argsSchema = { + filePath: tool.schema + .string() + .describe("Absolute path to the file to edit"), + + operations: tool.schema + .array( + tool.schema.object({ + op: tool.schema + .string() + .optional() + .describe("Operation: replace, append, or prepend"), + ref: tool.schema + .string() + .optional() + .describe("Primary anchor LINE#HASH#ANCHOR"), + startRef: tool.schema + .string() + .optional() + .describe("Primary anchor LINE#HASH#ANCHOR"), + endRef: tool.schema + .string() + .optional() + .describe("Range end anchor LINE#HASH#ANCHOR"), + content: tool.schema + .string() + .optional() + .describe("Replacement content"), + replacement: tool.schema + .string() + .optional() + .describe("Replacement content alias"), + lines: tool.schema + .union([ + tool.schema.array(tool.schema.string()), + tool.schema.string(), + tool.schema.null(), + ]) + .optional() + .describe("Replacement lines. null deletes with replace"), + }), + ) + .optional() + .describe("Batch edit operations"), + + oldString: tool.schema + .string() + .optional() + .describe("Exact text to replace (legacy mode)"), + + newString: tool.schema + .string() + .optional() + .describe("Replacement text (legacy mode)"), + + expectedFileHash: tool.schema + .string() + .optional() + .describe("Expected file hash for stale edit protection"), + + fileRev: tool.schema + .string() + .optional() + .describe("Expected file revision (8-char hex) from read output"), + + safeReapply: tool.schema + .boolean() + .optional() + .describe("Allow ref relocation when hashes still match"), +} as const + +export function createEditTool() { + return tool({ + description: HASHLINE_EDIT_DESCRIPTION, + args: argsSchema as any, + execute: async (args: Record, context: ToolContext): Promise => { + return executeEditTool(args as any, context) + }, + }) +} diff --git a/src/file-text.ts b/src/file-text.ts new file mode 100644 index 0000000..08076af --- /dev/null +++ b/src/file-text.ts @@ -0,0 +1,24 @@ +export interface FileTextEnvelope { + content: string + hadBom: boolean + lineEnding: "\n" | "\r\n" +} + +export function canonicalizeFileText(raw: string): FileTextEnvelope { + let content = raw + const hadBom = content.charCodeAt(0) === 0xfeff + if (hadBom) content = content.slice(1) + const lineEnding: "\n" | "\r\n" = content.includes("\r\n") ? "\r\n" : "\n" + if (lineEnding === "\r\n") content = content.replace(/\r\n/g, "\n") + return { content, hadBom, lineEnding } +} + +export function restoreFileText( + canonicalContent: string, + envelope: FileTextEnvelope, +): string { + let result = canonicalContent + if (envelope.lineEnding === "\r\n") result = result.replace(/\n/g, "\r\n") + if (envelope.hadBom) result = "\ufeff" + result + return result +} diff --git a/src/hash.ts b/src/hash.ts new file mode 100644 index 0000000..63c8577 --- /dev/null +++ b/src/hash.ts @@ -0,0 +1,47 @@ +import { createHash } from "node:crypto" + +const SMALL_LEN = 3 +const LARGE_LEN = 4 +const THRESHOLD = 4096 +const REV_LEN = 8 + +function hashText(text: string, length: number): string { + return createHash("sha1").update(text, "utf8").digest("hex").slice(0, length).toUpperCase() +} + +export function getAdaptiveHashLength(totalLines: number): number { + return totalLines > THRESHOLD ? LARGE_LEN : SMALL_LEN +} + +export function lineHash(line: string, length?: number): string { + return hashText(line, length ?? LARGE_LEN) +} + +export function anchorHash( + previousLine: string | undefined, + currentLine: string, + nextLine: string | undefined, + hashLength?: number, +): string { + return hashText( + `${previousLine ?? ""}\u241E${currentLine}\u241E${nextLine ?? ""}`, + hashLength ?? LARGE_LEN, + ) +} + +export function computeFileRev(raw: string): string { + const normalized = raw.includes("\r\n") ? raw.replace(/\r\n/g, "\n") : raw + return hashText(normalized, REV_LEN) +} + +export function formatHashLine( + lineIndex: number, + line: string, + lines: string[], + prefix: string, +): string { + const hashLen = getAdaptiveHashLength(lines.length) + const lh = lineHash(line, hashLen) + const ah = anchorHash(lines[lineIndex - 1], line, lines[lineIndex + 1], hashLen) + return `${prefix} ${lineIndex + 1}#${lh}#${ah}|${line}` +} diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..502d490 --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,242 @@ +import { promises as fs, rmSync } from "node:fs" +import { randomBytes } from "node:crypto" +import { tmpdir } from "node:os" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import type { Hooks } from "@opencode-ai/plugin" +import { DEFAULT_PREFIX, type HashlineRuntimeConfig, HashlineAnnotationCache, shouldExclude } from "./shared.js" +import { computeFileRev, getAdaptiveHashLength } from "./hash.js" +import { formatAnnotatedLine } from "./ref.js" +import { canonicalizeFileText, restoreFileText } from "./file-text.js" +import { applyHashlineEdits } from "./edit-ops.js" + +const HASHLINE_SYSTEM_INSTRUCTION_MARKER_RE = //i +const HASHLINE_SYSTEM_INSTRUCTION_BLOCK_RE = /[\s\S]*?(?:|$)/gi + +const CONTENT_FIELD_KEYS = new Set([ + "content", "new_content", "old_content", "old_string", "new_string", + "replacement", "text", "diff", "patch", "patch_text", "patchText", "body", +]) + +function stripNestedHashes(value: unknown, prefix: string | false): unknown { + if (typeof value === "string") return stripHashlinePrefixes(value, prefix) + if (Array.isArray(value)) return value.map((e) => stripNestedHashes(e, prefix)) + if (!value || typeof value !== "object") return value + const out: Record = { ...(value as Record) } + for (const key of Object.keys(out)) { + if (CONTENT_FIELD_KEYS.has(key)) { + out[key] = stripNestedHashes(out[key], prefix) + continue + } + const candidate = out[key] + if (Array.isArray(candidate) || (candidate && typeof candidate === "object")) { + out[key] = stripNestedHashes(candidate, prefix) + } + } + return out +} + +function stripHashlinePrefixes(content: string, prefix: string | false): string { + const effectivePrefix = prefix === false ? "" : prefix || DEFAULT_PREFIX + const escapedPrefix = effectivePrefix ? effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : "" + const prefixPattern = escapedPrefix ? `${escapedPrefix}\\s*` : "" + const refPattern = new RegExp( + `^([+\\- ])?${prefixPattern}\\d+\\s*[#: ]\\s*[A-Za-z0-9]+(?:\\s*[#: ]\\s*[A-Za-z0-9]+)?\\|`, "i", + ) + const revPattern = new RegExp(`^${prefixPattern}REV:[A-Za-z0-9]{8}$`, "i") + const lineEnding = content.includes("\r\n") ? "\r\n" : "\n" + const normalized = content.replace(/\r\n/g, "\n") + return normalized + .split("\n") + .filter((line) => !revPattern.test(line)) + .map((line) => { + const match = line.match(refPattern) + return match ? (match[1] ?? "") + line.slice(match[0].length) : line + }) + .join(lineEnding === "\r\n" ? "\r\n" : "\n") +} + +function getByteLength(content: string): number { + return new TextEncoder().encode(content).length +} + +function extractPathFromToolArgs(args?: Record): string | undefined { + if (!args) return undefined + const c = args.path ?? args.filePath ?? args.file_path ?? args.file + return typeof c === "string" && c.length > 0 ? (c as string) : undefined +} + +function resolveFilePath(filePath: string, directory?: string): string { + const base = typeof directory === "string" && directory.length > 0 ? directory : process.cwd() + return path.isAbsolute(filePath) ? path.normalize(filePath) : path.resolve(base, filePath) +} + +function formatWithRuntimeConfig(content: string, config: HashlineRuntimeConfig): string { + const effectivePrefix = config.prefix === false ? "" : config.prefix || DEFAULT_PREFIX + const prefixPart = effectivePrefix ? `${effectivePrefix} ` : "" + const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content + const lines = normalized.split("\n") + const out: string[] = [] + const hashLen = getAdaptiveHashLength(lines.length) + if (config.fileRev !== false) { + out.push(`${prefixPart}REV:${computeFileRev(normalized)}`) + } + for (let idx = 0; idx < lines.length; idx++) { + const line = lines[idx] + const displayLine = line.length > 2000 ? `${line.slice(0, 2000)}…` : line + const annotated = formatAnnotatedLine(line, idx, lines, effectivePrefix, hashLen) + const sepIdx = annotated.indexOf("|") + out.push(`${annotated.slice(0, sepIdx + 1)}${displayLine}`) + } + if (lines.length === 0) out.push("# file is empty") + return out.join("\n") +} + +function buildHashlineSystemInstruction(config: HashlineRuntimeConfig): string { + const prefixLabel = config.prefix === false ? "none" : `"${config.prefix || DEFAULT_PREFIX}"` + return [ + "", + "Hashline workflow:", + `- Read returns canonical refs like \`${DEFAULT_PREFIX} 12#A3F#9BC\` and \`${DEFAULT_PREFIX} REV:72C4946C\`. Copy refs exactly as shown.`, + `- Active prefix: ${prefixLabel}. Read output stays canonical \`${DEFAULT_PREFIX}\`.`, + "- After one read, batch same-file changes into one edit call with operations[] instead of many single edits.", + "- Send fileRev when the read output includes a REV line.", + "- Reread only when you need more context or an edit fails because refs are stale.", + "- Prefer edit for targeted changes; use write only for new files or full rewrites.", + "", + ].join("\n") +} + +function updateSystemInstructions(system: string[], instruction: string): string[] { + const next: string[] = [] + let inserted = false + for (const entry of system) { + if (!HASHLINE_SYSTEM_INSTRUCTION_MARKER_RE.test(entry)) { + next.push(entry) + continue + } + if (!inserted) { + next.push(entry.replace(HASHLINE_SYSTEM_INSTRUCTION_BLOCK_RE, () => instruction)) + inserted = true + } else { + const cleaned = entry.replace(HASHLINE_SYSTEM_INSTRUCTION_BLOCK_RE, () => "") + if (cleaned.trim().length > 0) next.push(cleaned) + } + } + if (!inserted) next.push(instruction) + return next +} + +async function readAndAnnotate( + absolutePath: string, + config: HashlineRuntimeConfig, + cache: HashlineAnnotationCache, + directory?: string, +): Promise { + let source: string + try { + source = await fs.readFile(absolutePath, "utf8") + } catch { return null } + if (config.maxFileSize > 0 && getByteLength(source) > config.maxFileSize) return null + const cacheKey = path.isAbsolute(absolutePath) ? absolutePath : path.resolve(directory ?? process.cwd(), absolutePath) + const cached = cache.get(cacheKey, source) + if (cached) return cached + const annotated = formatWithRuntimeConfig(source, config) + cache.set(cacheKey, source, annotated) + return annotated +} + +let tempDirPath: string | null = null +let tempCleanupRegistered = false + +async function getTempDir(): Promise { + if (!tempDirPath) { + tempDirPath = await fs.mkdtemp(path.join(tmpdir(), "hashline-chat-")) + if (!tempCleanupRegistered) { + tempCleanupRegistered = true + process.on("exit", () => { + if (tempDirPath) try { rmSync(tempDirPath, { recursive: true, force: true }) } catch { } + }) + } + } + return tempDirPath +} + +export function createHashlineHooks( + config: HashlineRuntimeConfig, + cache?: HashlineAnnotationCache, +): Pick { + const effectiveCache = cache ?? new HashlineAnnotationCache(config.cacheSize ?? 128) + + return { + "tool.definition": async (_input, output) => { + if (_input.toolID === "read" || _input.toolID === "view") { + output.description = `${output.description}\n\nHashline: Returns canonical ${DEFAULT_PREFIX} refs plus a REV token. Copy refs exactly from the output, then plan all same-file changes before calling edit.` + } + if (_input.toolID === "write") { + output.description = `${output.description}\n\nHashline: Use write for new files or full rewrites. Prefer edit for targeted existing-file changes; hashline prefixes inside content are stripped automatically.` + } + if (_input.toolID === "patch") { + output.description = `${output.description}\n\nHashline: Compatibility path only. Prefer read -> one batched edit per file for a faster, lower-read workflow.` + } + }, + + "tool.execute.after": async (_input, output) => { + const args = (_input.args ?? {}) as Record + const isEditLike = ["edit", "write", "patch", "hashline_edit"].some( + (t) => _input.tool === t || _input.tool.toLowerCase().endsWith(`.${t}`), + ) + if (isEditLike) { + const fp = extractPathFromToolArgs(args) + if (fp) { + const dir = typeof (_input as any).directory === "string" ? (_input as any).directory : undefined + const absPath = resolveFilePath(fp, dir) + effectiveCache.invalidateVariants(absPath) + } + } + const isReadLike = _input.tool === "read" || _input.tool === "view" || + _input.tool.toLowerCase().endsWith(".read") || _input.tool.toLowerCase().endsWith(".view") + if (!isReadLike) return + if (typeof output.output !== "string") return + if (output.output.includes("directory")) return + const filePathFromArgs = extractPathFromToolArgs(args) + if (typeof filePathFromArgs !== "string") return + const dir = typeof (_input as any).directory === "string" ? (_input as any).directory : undefined + const canonicalPath = resolveFilePath(filePathFromArgs, dir) + if (shouldExclude(canonicalPath, config.exclude)) return + const offset = typeof args.offset === "number" ? args.offset : undefined + const limit = typeof args.limit === "number" ? args.limit : undefined + const cacheKey = `${canonicalPath}\u0000${offset ?? ""}\u0000${limit ?? ""}` + const cached = effectiveCache.get(cacheKey, output.output) + if (cached) { output.output = cached; return } + const absolutePath = resolveFilePath(filePathFromArgs, dir) + const annotated = await readAndAnnotate(absolutePath, config, effectiveCache, dir) + if (annotated) output.output = annotated + }, + + "experimental.chat.system.transform": async (_input, output) => { + const target = output as { system?: string[] } + if (!Array.isArray(target.system)) target.system = [] + target.system = updateSystemInstructions(target.system, buildHashlineSystemInstruction(config)) + }, + + "chat.message": async (_input, output) => { + const parts = (output as any).parts as Array> | undefined + if (!Array.isArray(parts) || parts.length === 0) return + for (const part of parts) { + if (!part || part.type !== "file") continue + const url = typeof part.url === "string" ? part.url : undefined + if (!url || !url.startsWith("file://")) continue + let absolutePath: string + try { absolutePath = path.normalize(fileURLToPath(url)) } catch { continue } + if (shouldExclude(absolutePath, config.exclude)) continue + const annotated = await readAndAnnotate(absolutePath, config, effectiveCache) + if (!annotated) continue + const tempPath = path.join(await getTempDir(), `hl-${Date.now()}-${randomBytes(6).toString("hex")}.txt`) + await fs.writeFile(tempPath, annotated, "utf8") + part.url = pathToFileURL(tempPath).href + part.content = annotated + } + }, + } +} diff --git a/src/index.ts b/src/index.ts index 94d071e..476286e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,21 @@ -import { HashlineRouting as routingPlugin } from "../.opencode/plugins/hashline-routing" +import type { Plugin } from "@opencode-ai/plugin" +import { resolveHashlineConfig, HashlineAnnotationCache } from "./shared.js" +import { createHashlineHooks } from "./hooks.js" +import { createEditTool } from "./edit-tool.js" -const hashlinePlugin = async (input: Parameters[0]) => { - const routingHooks = await routingPlugin(input) +const hashlinePlugin: Plugin = async (input) => { + const projectDirectory = typeof input?.directory === "string" ? input.directory : undefined + const config = resolveHashlineConfig(projectDirectory) + const cache = new HashlineAnnotationCache(config.cacheSize ?? 128) + const hooks = createHashlineHooks(config, cache) + const editTool = createEditTool() - return routingHooks + return { + ...hooks, + tool: { + edit: editTool, + }, + } } export default hashlinePlugin diff --git a/src/ref.ts b/src/ref.ts new file mode 100644 index 0000000..219da1c --- /dev/null +++ b/src/ref.ts @@ -0,0 +1,69 @@ +import { lineHash, anchorHash } from "./hash.js" + +const HASHLINE_PREFIX = "#HL" + +export const CANONICAL_REF_PATTERN = /^(\d+)#([A-F0-9]+)(?:#([A-F0-9]+))?$/ + +export function computeHashes( + line: string, + index: number, + lines: string[], + hashLength: number, +): { lineHash: string; anchorHash: string } { + return { + lineHash: lineHash(line, hashLength), + anchorHash: anchorHash(lines[index - 1], line, lines[index + 1], hashLength), + } +} + +export function formatRef(lineNumber: number, lineHash: string, anchorHash?: string): string { + const hash = lineHash.trim().toUpperCase() + if (!hash) throw new Error("lineHash is required") + if (!Number.isInteger(lineNumber) || lineNumber < 1) { + throw new Error(`Invalid line number ${lineNumber}`) + } + const anchor = typeof anchorHash === "string" && anchorHash.trim().length > 0 + ? anchorHash.trim().toUpperCase() + : "" + return anchor ? `${lineNumber}#${hash}#${anchor}` : `${lineNumber}#${hash}` +} + +export function formatAnnotatedLine( + line: string, + index: number, + lines: string[], + prefix: string, + hashLength: number, +): string { + const lh = computeHashes(line, index, lines, hashLength) + const prefixPart = prefix ? `${prefix} ` : "" + return `${prefixPart}${formatRef(index + 1, lh.lineHash, lh.anchorHash)}|${line}` +} + +export function parseLineRef(rawRef: string): { lineNumber: number; hash: string; anchor?: string } { + const trimmed = rawRef.trim() + const withoutPrefix = trimmed.replace(/^(?:#HL|;;;)\s*/i, "") + const beforePipe = withoutPrefix.split("|")[0].trim() + const match = beforePipe.match(/^(\d+)\s*[#: ]\s*([A-Za-z0-9]+)(?:\s*[#: ]\s*([A-Za-z0-9]+))?$/) + if (!match) { + throw new Error( + `Invalid line reference "${rawRef}". Expected # or ## (example: 22#A3F or 22#A3F#9BC)`, + ) + } + const lineNumber = Number.parseInt(match[1], 10) + if (!Number.isFinite(lineNumber) || lineNumber < 1) { + throw new Error(`Invalid line number in reference "${rawRef}"`) + } + return { + lineNumber, + hash: match[2].toUpperCase(), + anchor: match[3]?.toUpperCase(), + } +} + +export function normalizeLineRef(raw: string): string { + let ref = raw.trim() + ref = ref.replace(/^(?:>>>|[+-])\s*/, "") + const parsed = parseLineRef(ref) + return formatRef(parsed.lineNumber, parsed.hash, parsed.anchor) +} diff --git a/.opencode/plugins/hashline-shared.ts b/src/shared.ts similarity index 56% rename from .opencode/plugins/hashline-shared.ts rename to src/shared.ts index 14c827b..4af360b 100644 --- a/.opencode/plugins/hashline-shared.ts +++ b/src/shared.ts @@ -2,7 +2,8 @@ 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 "../lib/hashline-core.js" +import { computeFileRev, getAdaptiveHashLength } from "./hash.js" +import { formatAnnotatedLine } from "./ref.js" export { computeFileRev } @@ -19,49 +20,18 @@ const CONFIG_FILENAME = "opencode-hashline.json" export const DEFAULT_PREFIX = "#HL" -export const DEFAULT_EXCLUDE_PATTERNS: string[] = [ - "**/node_modules/**", - "**/*.lock", - "**/package-lock.json", - "**/yarn.lock", - "**/pnpm-lock.yaml", - "**/*.min.js", - "**/*.min.css", - "**/*.map", - "**/*.wasm", - "**/*.png", - "**/*.jpg", - "**/*.jpeg", - "**/*.gif", - "**/*.ico", - "**/*.svg", - "**/*.woff", - "**/*.woff2", - "**/*.ttf", - "**/*.eot", - "**/*.pdf", - "**/*.zip", - "**/*.tar", - "**/*.gz", - "**/*.exe", - "**/*.dll", - "**/*.so", - "**/*.dylib", - "**/.env", - "**/.env.*", - "**/*.pem", - "**/*.key", - "**/*.p12", - "**/*.pfx", - "**/id_rsa", - "**/id_rsa.pub", - "**/id_ed25519", - "**/id_ed25519.pub", - "**/id_ecdsa", - "**/id_ecdsa.pub", +const DEFAULT_EXCLUDE_PATTERNS: string[] = [ + "**/node_modules/**", "**/*.lock", "**/package-lock.json", "**/yarn.lock", + "**/pnpm-lock.yaml", "**/*.min.js", "**/*.min.css", "**/*.map", "**/*.wasm", + "**/*.png", "**/*.jpg", "**/*.jpeg", "**/*.gif", "**/*.ico", "**/*.svg", + "**/*.woff", "**/*.woff2", "**/*.ttf", "**/*.eot", "**/*.pdf", + "**/*.zip", "**/*.tar", "**/*.gz", "**/*.exe", "**/*.dll", "**/*.so", "**/*.dylib", + "**/.env", "**/.env.*", "**/*.pem", "**/*.key", "**/*.p12", "**/*.pfx", + "**/id_rsa", "**/id_rsa.pub", "**/id_ed25519", "**/id_ed25519.pub", + "**/id_ecdsa", "**/id_ecdsa.pub", ] -export const DEFAULT_HASHLINE_RUNTIME_CONFIG: HashlineRuntimeConfig = { +export const DEFAULT_CONFIG: HashlineRuntimeConfig = { exclude: DEFAULT_EXCLUDE_PATTERNS, maxFileSize: 1_048_576, cacheSize: 100, @@ -70,191 +40,134 @@ export const DEFAULT_HASHLINE_RUNTIME_CONFIG: HashlineRuntimeConfig = { safeReapply: false, } -function hashText(text: string, length = 10): string { - return createHash("sha1").update(text, "utf8").digest("hex").slice(0, length).toUpperCase() -} - function sanitizeConfig(input: unknown): Partial { - if (!input || typeof input !== "object" || Array.isArray(input)) { - return {} - } - + if (!input || typeof input !== "object" || Array.isArray(input)) return {} const source = input as Record const out: Partial = {} - if (Array.isArray(source.exclude)) { out.exclude = source.exclude.filter( (item): item is string => typeof item === "string" && item.length > 0 && item.length <= 512, ) } - if (typeof source.maxFileSize === "number" && Number.isFinite(source.maxFileSize) && source.maxFileSize >= 0) { out.maxFileSize = Math.floor(source.maxFileSize) } - if (typeof source.cacheSize === "number" && Number.isFinite(source.cacheSize) && source.cacheSize > 0) { out.cacheSize = Math.floor(source.cacheSize) } - if (source.prefix === false) { out.prefix = false } else if (typeof source.prefix === "string") { - if (/^[\x20-\x7E]{0,20}$/.test(source.prefix)) { - out.prefix = source.prefix - } - } - - if (typeof source.fileRev === "boolean") { - out.fileRev = source.fileRev + if (/^[\x20-\x7E]{0,20}$/.test(source.prefix)) out.prefix = source.prefix } - - if (typeof source.safeReapply === "boolean") { - out.safeReapply = source.safeReapply - } - + if (typeof source.fileRev === "boolean") out.fileRev = source.fileRev + if (typeof source.safeReapply === "boolean") out.safeReapply = source.safeReapply return out } function readConfigFile(filePath: string): Partial | undefined { - if (!existsSync(filePath)) { - return undefined - } - + if (!existsSync(filePath)) return undefined try { - const raw = readFileSync(filePath, "utf8") - return sanitizeConfig(JSON.parse(raw)) - } catch { - return undefined - } + return sanitizeConfig(JSON.parse(readFileSync(filePath, "utf8"))) + } catch { return undefined } } export function resolveHashlineConfig(projectDir?: string): HashlineRuntimeConfig { const globalPath = path.join(homedir(), ".config", "opencode", CONFIG_FILENAME) const projectPath = projectDir ? path.join(projectDir, CONFIG_FILENAME) : undefined - const globalConfig = readConfigFile(globalPath) const projectConfig = projectPath ? readConfigFile(projectPath) : undefined - return { - ...DEFAULT_HASHLINE_RUNTIME_CONFIG, + ...DEFAULT_CONFIG, ...globalConfig, ...projectConfig, exclude: (projectConfig?.exclude ?? globalConfig?.exclude ?? DEFAULT_EXCLUDE_PATTERNS).slice(), } } -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} - -function normalizeGlobPath(value: string): string { - return value.replace(/\\/g, "/") -} - -export function shouldExclude(filePath: string, patterns?: string[]): boolean { - const normalizedPath = normalizeGlobPath(filePath) - const effectivePatterns = Array.isArray(patterns) ? patterns : DEFAULT_EXCLUDE_PATTERNS - return effectivePatterns.some((pattern) => path.matchesGlob(normalizedPath, normalizeGlobPath(pattern))) -} - -const textEncoder = new TextEncoder() - -export function getByteLength(content: string): number { - return textEncoder.encode(content).length -} - -interface HashlineFormatOptions { - prefix?: string | false - includeFileRev?: boolean +function hashText(text: string, length = 10): string { + return createHash("sha1").update(text, "utf8").digest("hex").slice(0, length).toUpperCase() } -export function formatWithHashline(content: string, options?: HashlineFormatOptions): string { - const effectivePrefix = options?.prefix === undefined ? DEFAULT_PREFIX : options.prefix === false ? "" : options.prefix - const prefixPart = effectivePrefix.length > 0 ? `${effectivePrefix} ` : "" +export function formatWithHashline(content: string, prefix: string | false, includeFileRev: boolean): string { + const effectivePrefix = prefix === false ? "" : prefix || DEFAULT_PREFIX + const prefixPart = effectivePrefix ? `${effectivePrefix} ` : "" const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content const lines = normalized.split("\n") - const output: string[] = [] - - if (options?.includeFileRev) { - output.push(`${prefixPart}REV:${computeFileRev(normalized)}`) - } - - const hashLength = getAdaptiveHashLength(lines.length) - for (let idx = 0; idx < lines.length; idx += 1) { + const out: string[] = [] + const hashLen = getAdaptiveHashLength(lines.length) + if (includeFileRev) out.push(`${prefixPart}REV:${computeFileRev(normalized)}`) + for (let idx = 0; idx < lines.length; idx++) { const line = lines[idx] - const lineHash = hashlineLineHash(line, hashLength) - const anchorHash = hashlineAnchorHash(lines[idx - 1], line, lines[idx + 1], hashLength) - output.push(`${prefixPart}${idx + 1}#${lineHash}#${anchorHash}|${line}`) + const displayLine = line.length > 2000 ? `${line.slice(0, 2000)}…` : line + const annotated = formatAnnotatedLine(line, idx, lines, effectivePrefix, hashLen) + const sepIdx = annotated.indexOf("|") + out.push(`${annotated.slice(0, sepIdx + 1)}${displayLine}`) } - - return output.join("\n") -} - -export function formatWithRuntimeConfig( - content: string, - config: Pick, -): string { - return formatWithHashline(content, { - prefix: config.prefix, - includeFileRev: config.fileRev, - }) + if (lines.length === 0) out.push("# file is empty") + return out.join("\n") } export function stripHashlinePrefixes(content: string, prefix?: string | false): string { const effectivePrefix = prefix === undefined ? DEFAULT_PREFIX : prefix === false ? "" : prefix - const escapedPrefix = effectivePrefix.length > 0 ? `${escapeRegex(effectivePrefix)}\\s*` : "" + const escapedPrefix = effectivePrefix ? effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : "" + const prefixPattern = escapedPrefix ? `${escapedPrefix}\\s*` : "" + const refPattern = new RegExp( + `^([+\\- ])?${prefixPattern}\\d+\\s*[#: ]\\s*[A-Za-z0-9]+(?:\\s*[#: ]\\s*[A-Za-z0-9]+)?\\|`, + "i", + ) + const revPattern = new RegExp(`^${prefixPattern}REV:[A-Za-z0-9]{8}$`, "i") const lineEnding = content.includes("\r\n") ? "\r\n" : "\n" - const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content - - const refPattern = new RegExp(`^([+\\- ])?${escapedPrefix}\\d+\\s*[#: ]\\s*[A-Za-z0-9]+(?:\\s*[#: ]\\s*[A-Za-z0-9]+)?\\|`, "i") - const revPattern = new RegExp(`^${escapedPrefix}REV:[A-Za-z0-9]{8}$`, "i") - + const normalized = content.replace(/\r\n/g, "\n") const stripped = normalized .split("\n") .filter((line) => !revPattern.test(line)) .map((line) => { const match = line.match(refPattern) - if (!match) { - return line - } - + if (!match) return line const marker = match[1] ?? "" return marker + line.slice(match[0].length) }) .join("\n") - return lineEnding === "\r\n" ? stripped.replace(/\n/g, "\r\n") : stripped } -export const HASHLINE_SYSTEM_INSTRUCTION_MARKER = "" -const HASHLINE_SYSTEM_INSTRUCTION_END_MARKER = "" +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} -function getConfiguredPrefixLabel(prefix?: string | false): string { - if (prefix === false) { - return "none" - } +function normalizeGlobPath(value: string): string { + return value.replace(/\\/g, "/") +} - if (typeof prefix !== "string") { - return `"${DEFAULT_PREFIX}"` - } +export function shouldExclude(filePath: string, patterns?: string[]): boolean { + const normalizedPath = normalizeGlobPath(filePath) + const effectivePatterns = Array.isArray(patterns) ? patterns : DEFAULT_EXCLUDE_PATTERNS + return effectivePatterns.some((pattern) => path.matchesGlob(normalizedPath, normalizeGlobPath(pattern))) +} - if (prefix.length === 0) { - return '""' - } +export function getByteLength(content: string): number { + return new TextEncoder().encode(content).length +} - return `"${prefix}"` +export function extractPathFromToolArgs(args?: Record): string | undefined { + if (!args) return undefined + const candidate = args.path ?? args.filePath ?? args.file_path ?? args.file + return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined } +export const HASHLINE_SYSTEM_INSTRUCTION_MARKER = "" +const HASHLINE_SYSTEM_INSTRUCTION_END_MARKER = "" + export function buildHashlineSystemInstruction(config: Pick): string { - const configuredPrefix = getConfiguredPrefixLabel(config.prefix) - const canonicalReadRef = `${DEFAULT_PREFIX} 12#A3F#9BC` + const prefixLabel = config.prefix === false ? "none" : `"${config.prefix || DEFAULT_PREFIX}"` + const canonicalRef = `${DEFAULT_PREFIX} 12#A3F#9BC` const canonicalRev = `${DEFAULT_PREFIX} REV:72C4946C` - return [ HASHLINE_SYSTEM_INSTRUCTION_MARKER, "Hashline workflow:", - `- Read returns canonical refs like \`${canonicalReadRef}\` and \`${canonicalRev}\`. Copy them exactly as shown.`, - `- Active helper prefix from config: ${configuredPrefix}. Read output stays canonical \`${DEFAULT_PREFIX}\`, so do not rewrite refs just to match config.`, + `- Read returns canonical refs like \\\`${canonicalRef}\\\` and \\\`${canonicalRev}\\\`. Copy them exactly as shown.`, + `- Active prefix: ${prefixLabel}. Read output stays canonical ${"`" + DEFAULT_PREFIX + "`"}.`, "- After one read, batch same-file changes into one edit call with operations[] instead of many single edits.", "- Send fileRev when the read output includes a REV line.", "- Reread only when you need more context or an edit fails because refs are stale.", @@ -266,14 +179,8 @@ export function buildHashlineSystemInstruction(config: Pick): string { - if (parts.length === 0) { - return baseKey - } - - return [ - baseKey, - ...parts.map((part) => (part === undefined ? "" : String(part))), - ].join(CACHE_KEY_SEPARATOR) + if (parts.length === 0) return baseKey + return [baseKey, ...parts.map((p) => (p === undefined ? "" : String(p)))].join(CACHE_KEY_SEPARATOR) } interface CacheEntry { @@ -288,64 +195,35 @@ export class HashlineAnnotationCache { get(key: string, source: string): string | null { const entry = this.entries.get(key) - if (!entry) { - return null - } - + if (!entry) return null const currentHash = hashText(source, 12) if (entry.sourceHash !== currentHash) { this.entries.delete(key) return null } - this.entries.delete(key) this.entries.set(key, entry) return entry.annotated } set(key: string, source: string, annotated: string): void { - if (this.entries.has(key)) { - this.entries.delete(key) - } - + if (this.entries.has(key)) this.entries.delete(key) if (this.entries.size >= this.maxSize) { const oldestKey = this.entries.keys().next().value - if (typeof oldestKey === "string") { - this.entries.delete(oldestKey) - } + if (typeof oldestKey === "string") this.entries.delete(oldestKey) } - - this.entries.set(key, { - sourceHash: hashText(source, 12), - annotated, - }) + this.entries.set(key, { sourceHash: hashText(source, 12), annotated }) } - invalidate(key: string): void { - this.entries.delete(key) - } + invalidate(key: string): void { this.entries.delete(key) } invalidateVariants(baseKey: string): void { this.entries.delete(baseKey) - - const variantPrefix = `${baseKey}${CACHE_KEY_SEPARATOR}` + const prefix = `${baseKey}${CACHE_KEY_SEPARATOR}` for (const key of Array.from(this.entries.keys())) { - if (key.startsWith(variantPrefix)) { - this.entries.delete(key) - } + if (key.startsWith(prefix)) this.entries.delete(key) } } - clear(): void { - this.entries.clear() - } -} - -export function extractPathFromToolArgs(args?: Record): string | undefined { - if (!args) { - return undefined - } - - const candidate = args.path ?? args.filePath ?? args.file_path ?? args.file - return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined + clear(): void { this.entries.clear() } } diff --git a/test/hashline-hardening.test.mjs b/test/hashline-hardening.test.mjs index 0efc1eb..4df1e07 100644 --- a/test/hashline-hardening.test.mjs +++ b/test/hashline-hardening.test.mjs @@ -1,60 +1,34 @@ import test from "node:test" import assert from "node:assert/strict" - import { promises as fs } from "node:fs" import path from "node:path" import os from "node:os" -import { pathToFileURL } from "node:url" import { - computeFileRev as computeCoreFileRev, getAdaptiveHashLength, -} from "../dist/.opencode/lib/hashline-core.js" -import { createHashlineHooks } from "../dist/.opencode/plugins/hashline-hooks.js" - -const PROJECT_ROOT = process.cwd() - -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*"\.\.\/lib\/hashline-core"\s*;?/ + lineHash, + anchorHash, + computeFileRev, +} from "../dist/hash.js" -async function loadSharedModule() { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-shared-test-")) - const libDir = path.join(tempDir, "lib") - const pluginsDir = path.join(tempDir, "plugins") +import { parseLineRef, formatAnnotatedLine } from "../dist/ref.js" - await fs.mkdir(libDir, { recursive: true }) - await fs.mkdir(pluginsDir, { recursive: true }) +import { canonicalizeFileText, restoreFileText } from "../dist/file-text.js" - await fs.copyFile( - path.join(PROJECT_ROOT, "dist/.opencode/lib/hashline-core.js"), - path.join(libDir, "hashline-core.js"), - ) - await fs.copyFile( - path.join(PROJECT_ROOT, "dist/.opencode/plugins/hashline-contract.js"), - path.join(pluginsDir, "hashline-contract.js"), - ) +import { applyHashlineEdits, HashlineMismatchError } from "../dist/edit-ops.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.trimEnd()) - - await fs.writeFile(path.join(pluginsDir, "hashline-shared.js"), patchedShared, "utf8") - await fs.writeFile(path.join(tempDir, "package.json"), '{"type":"module"}', "utf8") - - const moduleUrl = pathToFileURL(path.join(pluginsDir, "hashline-shared.js")) - const shared = await import(moduleUrl.href) - - return { tempDir, shared } -} - -const { tempDir: sharedTempDir, shared } = await loadSharedModule() -const { - buildHashlineSystemInstruction, - computeFileRev: computeSharedFileRev, +import { formatWithHashline, - shouldExclude, stripHashlinePrefixes, -} = shared + shouldExclude, + buildHashlineSystemInstruction, + buildCacheEntryKey, + HashlineAnnotationCache, +} from "../dist/shared.js" + +import { createHashlineHooks } from "../dist/hooks.js" + +// ── helpers ────────────────────────────────────────────── const BASE_CONFIG = { exclude: [], @@ -66,78 +40,303 @@ const BASE_CONFIG = { } function makeHooks(overrides = {}) { - return createHashlineHooks({ - ...BASE_CONFIG, - ...overrides, - }) + return createHashlineHooks({ ...BASE_CONFIG, ...overrides }) } -test.after(async () => { - await fs.rm(sharedTempDir, { recursive: true, force: true }) -}) +// ── hash ───────────────────────────────────────────────── -test("getAdaptiveHashLength uses 3 chars <=4096 lines and 4 chars above", () => { +test("getAdaptiveHashLength uses 3 chars <=4096 and 4 chars above", () => { assert.equal(getAdaptiveHashLength(1), 3) assert.equal(getAdaptiveHashLength(4096), 3) assert.equal(getAdaptiveHashLength(4097), 4) }) +test("lineHash is deterministic SHA1 hex", () => { + const h = lineHash("hello world") + assert.equal(h.length, 4) + assert.match(h, /^[A-F0-9]+$/) + + const h3 = lineHash("hello world", 3) + assert.equal(h3.length, 3) + + const same = lineHash("hello world", 4) + assert.equal(h, same) + + const different = lineHash("hello world!") + assert.notEqual(h, different) +}) + +test("anchorHash uses tri-line context", () => { + const ah = anchorHash("line1", "line2", "line3", 3) + assert.equal(ah.length, 3) + assert.match(ah, /^[A-F0-9]+$/) + + const ah2 = anchorHash("line1", "line2", "line3", 3) + assert.equal(ah, ah2) + + const ah3 = anchorHash("line1", "line2", "line4", 3) + assert.notEqual(ah, ah3) +}) + test("computeFileRev stays consistent across newline styles", () => { const lf = "alpha\nbeta\ngamma\n" const crlf = lf.replace(/\n/g, "\r\n") + const lfRev = computeFileRev(lf) + const crlfRev = computeFileRev(crlf) + assert.match(lfRev, /^[A-F0-9]{8}$/) + assert.equal(lfRev, crlfRev) + assert.notEqual(lfRev, computeFileRev("alpha\nbeta\ngamma\ndelta\n")) +}) - const coreLf = computeCoreFileRev(lf) - const coreCrlf = computeCoreFileRev(crlf) - const sharedLf = computeSharedFileRev(lf) - const sharedCrlf = computeSharedFileRev(crlf) +// ── ref ────────────────────────────────────────────────── - assert.match(coreLf, /^[A-F0-9]{8}$/) - assert.equal(coreLf, coreCrlf) - assert.equal(coreLf, sharedLf) - assert.equal(sharedLf, sharedCrlf) - assert.notEqual(coreLf, computeCoreFileRev("alpha\nbeta\ngamma\ndelta\n")) +test("parseLineRef parses hex refs with anchor", () => { + const r = parseLineRef("#HL 12#A3F#9BC") + assert.equal(r.lineNumber, 12) + assert.equal(r.hash, "A3F") + assert.equal(r.anchor, "9BC") }) -test("formatWithHashline and stripHashlinePrefixes round-trip basics", () => { - const source = "one\ntwo\nthree" +test("parseLineRef parses refs without prefix", () => { + const r = parseLineRef("5#BEE") + assert.equal(r.lineNumber, 5) + assert.equal(r.hash, "BEE") + assert.equal(r.anchor, undefined) +}) + +test("parseLineRef rejects invalid refs", () => { + assert.throws(() => parseLineRef("abc#123"), /Invalid line reference/) + assert.throws(() => parseLineRef("0#ABC"), /Invalid line number/) +}) - const formatted = formatWithHashline(source, { includeFileRev: true }) +test("formatAnnotatedLine produces correct output", () => { + const lines = ["foo", "bar", "baz"] + const result = formatAnnotatedLine("bar", 1, lines, "#HL", 3) + assert.match(result, /^#HL 2#[A-F0-9]{3}#[A-F0-9]{3}\|bar$/) +}) + +// ── file-text ──────────────────────────────────────────── + +test("canonicalizeFileText strips BOM", () => { + const e = canonicalizeFileText("\ufeffhello\nworld\n") + assert.equal(e.hadBom, true) + assert.equal(e.content, "hello\nworld\n") + assert.equal(e.lineEnding, "\n") +}) + +test("canonicalizeFileText normalizes CRLF to LF", () => { + const e = canonicalizeFileText("hello\r\nworld\r\n") + assert.equal(e.lineEnding, "\r\n") + assert.equal(e.content, "hello\nworld\n") +}) + +test("restoreFileText round-trips", () => { + const raw = "\ufeffhello\r\nworld\r\n" + const e = canonicalizeFileText(raw) + const restored = restoreFileText(e.content, e) + assert.equal(restored, raw) +}) + +// ── edit-ops ───────────────────────────────────────────── + +test("applyHashlineEdits single line replace", () => { + const content = "a\nb\nc" + const result = applyHashlineEdits(content, [ + { op: "replace", pos: "2#" + lineHash("b", 3), lines: "B" }, + ]) + assert.equal(result.content, "a\nB\nc") + assert.equal(result.noopEdits, 0) +}) + +test("applyHashlineEdits append to EOF", () => { + const content = "a\nb" + const result = applyHashlineEdits(content, [ + { op: "append", lines: ["c", "d"] }, + ]) + assert.equal(result.content, "a\nb\nc\nd") +}) + +test("applyHashlineEdits prepend to BOF", () => { + const content = "b\nc" + const result = applyHashlineEdits(content, [ + { op: "prepend", lines: ["a"] }, + ]) + assert.equal(result.content, "a\nb\nc") +}) + +test("applyHashlineEdits range replace", () => { + const content = "a\nb\nc\nd\ne" + const bHash = lineHash("b", 3) + const dHash = lineHash("d", 3) + const result = applyHashlineEdits(content, [ + { + op: "replace", + pos: "2#" + bHash, + end: "4#" + dHash, + lines: "X\nY", + }, + ]) + assert.equal(result.content, "a\nX\nY\ne") +}) + +test("applyHashlineEdits insert after", () => { + const content = "a\nb\nc" + const bHash = lineHash("b", 3) + const result = applyHashlineEdits(content, [ + { + op: "append", + pos: "2#" + bHash, + lines: "B.5", + }, + ]) + assert.equal(result.content, "a\nb\nB.5\nc") +}) + +test("applyHashlineEdits insert before", () => { + const content = "a\nb\nc" + const bHash = lineHash("b", 3) + const result = applyHashlineEdits(content, [ + { + op: "prepend", + pos: "2#" + bHash, + lines: "A.5", + }, + ]) + assert.equal(result.content, "a\nA.5\nb\nc") +}) + +test("applyHashlineEdits noop detection", () => { + const content = "a\nb\nc" + const bHash = lineHash("b", 3) + const result = applyHashlineEdits(content, [ + { op: "replace", pos: "2#" + bHash, lines: "b" }, + ]) + assert.equal(result.noopEdits, 1) + assert.equal(result.content, "a\nb\nc") +}) + +test("applyHashlineEdits detects mismatches", () => { + assert.throws( + () => applyHashlineEdits("a\nb\nc", [ + { op: "replace", pos: "2#ZZZ", lines: "X" }, + ]), + HashlineMismatchError, + ) +}) + +test("HashlineMismatchError has remaps", () => { + try { + applyHashlineEdits("a\nb\nc", [ + { op: "replace", pos: "2#ZZZ", lines: "X" }, + ]) + assert.fail("should throw") + } catch (e) { + if (e instanceof HashlineMismatchError) { + assert.equal(e.remaps.size, 1) + assert.match(e.message, />>> /) + } else { + throw e + } + } +}) + +// ── shared ─────────────────────────────────────────────── + +test("formatWithHashline and stripHashlinePrefixes round-trip", () => { + const source = "one\ntwo\nthree" + const formatted = formatWithHashline(source, "#HL", true) assert.match(formatted, /^#HL REV:[A-F0-9]{8}$/m) assert.match(formatted, /^#HL 1#[A-F0-9]{3}#[A-F0-9]{3}\|one$/m) - assert.equal(stripHashlinePrefixes(formatted), source) + assert.equal(stripHashlinePrefixes(formatted, "#HL"), source) - const noPrefixFormatted = formatWithHashline(source, { prefix: false }) - assert.match(noPrefixFormatted, /^1#[A-F0-9]{3}#[A-F0-9]{3}\|one$/m) - assert.equal(stripHashlinePrefixes(noPrefixFormatted, false), source) + const noPrefix = formatWithHashline(source, false, false) + assert.match(noPrefix, /^1#[A-F0-9]{3}#[A-F0-9]{3}\|one$/m) + assert.equal(stripHashlinePrefixes(noPrefix, false), source) }) +test("shouldExclude matches glob patterns", () => { + const patterns = ["**/node_modules/**", "**/*.min.js", "src/**/*.ts", "**/.env.*"] + assert.equal(shouldExclude("packages/node_modules/lib/index.js", patterns), true) + assert.equal(shouldExclude("dist/app.min.js", patterns), true) + assert.equal(shouldExclude("src/utils/file.ts", patterns), true) + assert.equal(shouldExclude("config/.env.production", patterns), true) + assert.equal(shouldExclude("src/utils/file.js", patterns), false) + assert.equal(shouldExclude("README.md", patterns), false) +}) + +test("system instruction is config-aware", () => { + const instruction = buildHashlineSystemInstruction({ + prefix: ";;;", fileRev: true, maxFileSize: 0, cacheSize: 10, exclude: [], safeReapply: false, + }) + assert.match(instruction, /Hashline workflow:/) + assert.match(instruction, /batch same-file changes into one edit call/i) + assert.match(instruction, /Reread only when you need more context/i) +}) + +test("system instruction handles prefix disabled", () => { + const instruction = buildHashlineSystemInstruction({ + prefix: false, fileRev: true, maxFileSize: 0, cacheSize: 10, exclude: [], safeReapply: false, + }) + assert.match(instruction, /Active prefix: none/) +}) + +test("buildCacheEntryKey handles various parts", () => { + const key = buildCacheEntryKey("/base", 1, 50) + assert.equal(key, "/base\u00001\u000050") + const key2 = buildCacheEntryKey("/base") + assert.equal(key2, "/base") +}) + +test("HashlineAnnotationCache basic operations", () => { + const cache = new HashlineAnnotationCache(3) + assert.equal(cache.get("k1", "source"), null) + cache.set("k1", "source", "annotated") + assert.equal(cache.get("k1", "source"), "annotated") + cache.invalidate("k1") + assert.equal(cache.get("k1", "source"), null) +}) + +test("HashlineAnnotationCache evicts oldest when full", () => { + const cache = new HashlineAnnotationCache(2) + cache.set("a", "1", "A") + cache.set("b", "2", "B") + cache.set("c", "3", "C") + assert.equal(cache.get("a", "1"), null) + assert.equal(cache.get("b", "2"), "B") + assert.equal(cache.get("c", "3"), "C") +}) + +// ── hooks ──────────────────────────────────────────────── + test("glob and grep are not treated as reads", async () => { const hooks = makeHooks() - const globOutput = { output: "src/file.ts\nsrc/other.ts" } - await hooks["tool.execute.after"]?.({ tool: "glob", args: { path: "src/file.ts" } }, globOutput) + await hooks["tool.execute.after"]?.( + { tool: "glob", args: { path: "src/file.ts" } }, + globOutput, + ) assert.equal(globOutput.output, "src/file.ts\nsrc/other.ts") const grepOutput = { output: "src/file.ts:1:hello" } - await hooks["tool.execute.after"]?.({ tool: "grep", args: { path: "src/file.ts" } }, grepOutput) + await hooks["tool.execute.after"]?.( + { tool: "grep", args: { path: "src/file.ts" } }, + grepOutput, + ) assert.equal(grepOutput.output, "src/file.ts:1:hello") }) -test("read hook refreshes cached annotations when file content changes", async () => { +test("read hook refreshes cached annotations when file changes", async () => { const hooks = makeHooks() - const afterHook = hooks["tool.execute.after"] assert.equal(typeof afterHook, "function") const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-read-cache-test-")) const filePath = path.join(tempDir, "sample.txt") - try { await fs.writeFile(filePath, "alpha\nbeta\n", "utf8") const firstOutput = { output: "alpha\nbeta\n" } await afterHook?.({ tool: "read", args: { path: filePath, offset: 1, limit: 50 } }, firstOutput) - assert.equal(String(firstOutput.output).includes("beta"), true) assert.equal(String(firstOutput.output).includes("gamma"), false) @@ -145,7 +344,6 @@ test("read hook refreshes cached annotations when file content changes", async ( const secondOutput = { output: "alpha\ngamma\n" } await afterHook?.({ tool: "read", args: { path: filePath, offset: 1, limit: 50 } }, secondOutput) - assert.equal(String(secondOutput.output).includes("beta"), false) assert.equal(String(secondOutput.output).includes("gamma"), true) } finally { @@ -153,72 +351,87 @@ test("read hook refreshes cached annotations when file content changes", async ( } }) -test("shouldExclude matches common glob-style patterns", () => { - const patterns = ["**/node_modules/**", "**/*.min.js", "src/**/*.ts", "**/.env.*"] +test("system instruction is injected via transform hook", async () => { + const hooks = makeHooks({ prefix: ";;;" }) + const transform = hooks["experimental.chat.system.transform"] + assert.equal(typeof transform, "function") - assert.equal(shouldExclude("packages/node_modules/lib/index.js", patterns), true) - assert.equal(shouldExclude("dist/app.min.js", patterns), true) - assert.equal(shouldExclude("src/utils/file.ts", patterns), true) - assert.equal(shouldExclude("src\\utils\\file.ts", patterns), true) - assert.equal(shouldExclude("config/.env.production", patterns), true) - assert.equal(shouldExclude("src/utils/file.js", patterns), false) - assert.equal(shouldExclude("README.md", patterns), false) + const output = { system: ["intro"] } + await transform?.({ model: {} }, output) + assert.equal(output.system.length, 2) + assert.match(output.system[1], /Hashline workflow:/) + assert.match(output.system[1], /Active prefix: ";;;"/) }) -test("system instruction is config-aware and batch-first", () => { - const instruction = buildHashlineSystemInstruction({ prefix: ";;;" }) - - assert.match(instruction, /Hashline workflow:/) - assert.match(instruction, /`#HL 12#A3F#9BC`/) - assert.match(instruction, /`#HL REV:72C4946C`/) - assert.match(instruction, /Active helper prefix from config: ";;;"/) - assert.match(instruction, /Read output stays canonical `#HL`/) - assert.match(instruction, /batch same-file changes into one edit call with operations\[\]/i) - assert.match(instruction, /Reread only when you need more context or an edit fails because refs are stale/i) -}) +test("tool definitions guide agents", async () => { + const hooks = makeHooks() + const definition = hooks["tool.definition"] + assert.equal(typeof definition, "function") -test("system instruction handles prefix disabled", () => { - const instruction = buildHashlineSystemInstruction({ prefix: false }) + const readOut = { description: "native read", parameters: {} } + await definition?.({ toolID: "read" }, readOut) + assert.match(readOut.description, /canonical #HL refs plus a REV token/i) + assert.match(readOut.description, /plan all same-file changes before calling edit/i) - assert.match(instruction, /Active helper prefix from config: none/) - assert.match(instruction, /Read output stays canonical `#HL`/) + const writeOut = { description: "native write", parameters: {} } + await definition?.({ toolID: "write" }, writeOut) + assert.match(writeOut.description, /Use write for new files or full rewrites/i) }) -test("system instruction falls back to the default prefix when config prefix is missing", async () => { - const hooks = makeHooks({ prefix: undefined }) - const output = { system: ["intro"] } - const transform = hooks["experimental.chat.system.transform"] +// ── edit-executor integration ──────────────────────────── - if (!transform) { - throw new Error("Missing system transform hook") +test("edit executor applies operations to real files", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-exec-test-")) + const filePath = path.join(tempDir, "test.txt") + try { + await fs.writeFile(filePath, "line1\nline2\nline3\n", "utf8") + const raw = await fs.readFile(filePath, "utf8") + const lines = raw.split("\n").filter(Boolean) + const hash2 = lineHash(lines[1], getAdaptiveHashLength(lines.length)) + + const { executeEditTool } = await import("../dist/edit-executor.js") + const mockCtx = { + sessionID: "test", + messageID: "test", + agent: "test", + directory: tempDir, + worktree: tempDir, + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + } + + const result = await executeEditTool({ + filePath, + operations: [{ op: "replace", startRef: `2#${hash2}`, content: "LINE2" }], + }, mockCtx) + + assert.match(result, /^Updated /) + const content = await fs.readFile(filePath, "utf8") + assert.equal(content.trim(), "line1\nLINE2\nline3") + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) } - - await transform({ model: {} }, output) - - assert.equal(output.system.length, 2) - assert.match(output.system[1], /Active helper prefix from config: "#HL"/) - assert.match(output.system[1], /Read output stays canonical `#HL`/) }) -test("tool descriptions guide agents toward batched edit workflows", async () => { - const hooks = makeHooks() - const definition = hooks["tool.definition"] - - assert.equal(typeof definition, "function") - - const readOutput = { description: "native read", parameters: {} } - await definition?.({ toolID: "read" }, readOutput) - assert.match(readOutput.description, /canonical #HL refs plus a REV token/i) - assert.match(readOutput.description, /plan all same-file changes before calling edit/i) - - const editOutput = { description: "native edit", parameters: {} } - await definition?.({ toolID: "edit" }, editOutput) - assert.match(editOutput.description, /Accepts refs copied from read/i) - assert.match(editOutput.description, /Prefer one batched call per file/i) - assert.match(editOutput.description, /operations:\[\{ op, ref\|startRef\/endRef, content\? \}\]/i) - - const writeOutput = { description: "native write", parameters: {} } - await definition?.({ toolID: "write" }, writeOutput) - assert.match(writeOutput.description, /Use write for new files or full rewrites/i) - assert.match(writeOutput.description, /Prefer edit for targeted existing-file changes/i) +test("edit executor handles oldString/newString fallback", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-legacy-test-")) + const filePath = path.join(tempDir, "test.txt") + try { + await fs.writeFile(filePath, "hello world\n", "utf8") + const { executeEditTool } = await import("../dist/edit-executor.js") + const mockCtx = { ask: async () => {}, metadata: () => {} } + + const result = await executeEditTool({ + filePath, + oldString: "hello world", + newString: "HELLO WORLD", + }, mockCtx) + + assert.match(result, /^Updated /) + const content = await fs.readFile(filePath, "utf8") + assert.equal(content.trim(), "HELLO WORLD") + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } }) diff --git a/tsconfig.build.json b/tsconfig.build.json deleted file mode 100644 index a69caa8..0000000 --- a/tsconfig.build.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": false, - "noImplicitAny": false, - "declaration": true, - "outDir": "dist", - "rootDir": ".", - "skipLibCheck": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true - }, - "include": [ - ".opencode/plugins/**/*.ts", - ".opencode/lib/**/*.ts", - "src/**/*.ts" - ] -} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a21ffcf --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": "./src", + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}