fold: hashline_edit + hashline_read → Edit({match}) + Read({format}) modes

Per operator R-entry sf-mp9wo7e3-sdxqss + no-compat directive.

- Edit gains `match: "substring"|"anchor"` arg; anchor mode routes to the
  existing applyHashlineEdits logic. Substring stays default.
- Read gains `format: "plain"|"tagged"` arg; tagged mode emits LINE#HASH
  prefixes via formatHashLines.
- Delete hashline-edit.ts, hashline-read.ts. KEEP hashline.ts (helpers
  are now Edit/Read internals).
- tools/index.ts: drop the two tools + the createHashlineCodingTools
  preset.
- agent-session.ts: setEditMode no longer swaps tool instances (single
  tool surface; mode preserved for system-prompt context only).
- sdk.ts + index.ts: remove hashline tool re-exports.
- headless-ui.ts + test: remove hashline_edit case.

Net agent-visible tool surface: -2 tools. Capability preserved as modes.
No backward-compat alias for the removed tool names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-17 17:39:59 +02:00
parent d03758d803
commit ffdec0feee
9 changed files with 47 additions and 755 deletions

View file

@ -980,33 +980,12 @@ export class AgentSession {
/** /**
* Switch edit mode between standard (text-match) and hashline (LINE#ID anchors). * Switch edit mode between standard (text-match) and hashline (LINE#ID anchors).
* Swaps the active read/edit tools and rebuilds the system prompt. * Both modes now use the same Read/Edit tools; the mode setting is persisted for
* system-prompt context only. Read({format:"tagged"}) and Edit({match:"anchor"})
* are the caller-controlled arguments.
*/ */
setEditMode(mode: "standard" | "hashline"): void { setEditMode(mode: "standard" | "hashline"): void {
this.settingsManager.setEditMode(mode); this.settingsManager.setEditMode(mode);
// Get current active tool registry keys
const currentKeys = new Set<string>();
for (const [key, tool] of this._toolRegistry.entries()) {
if (this.agent.state.tools.includes(tool)) {
currentKeys.add(key);
}
}
// Swap read tools
if (mode === "hashline") {
currentKeys.delete("Read");
currentKeys.add("hashline_read");
currentKeys.delete("Edit");
currentKeys.add("hashline_edit");
} else {
currentKeys.delete("hashline_read");
currentKeys.add("Read");
currentKeys.delete("hashline_edit");
currentKeys.add("Edit");
}
this.setActiveToolsByName([...currentKeys]);
} }
/** Current edit mode */ /** Current edit mode */

View file

@ -82,9 +82,6 @@ import {
createEditTool, createEditTool,
createFindTool, createFindTool,
createGrepTool, createGrepTool,
createHashlineCodingTools,
createHashlineEditTool,
createHashlineReadTool,
createLsTool, createLsTool,
createReadOnlyTools, createReadOnlyTools,
createReadTool, createReadTool,
@ -92,9 +89,6 @@ import {
editTool, editTool,
findTool, findTool,
grepTool, grepTool,
hashlineCodingTools,
hashlineEditTool,
hashlineReadTool,
lsTool, lsTool,
readOnlyTools, readOnlyTools,
readTool, readTool,
@ -180,9 +174,6 @@ export {
createEditTool, createEditTool,
createFindTool, createFindTool,
createGrepTool, createGrepTool,
createHashlineCodingTools,
createHashlineEditTool,
createHashlineReadTool,
createLsTool, createLsTool,
createReadOnlyTools, createReadOnlyTools,
createReadTool, createReadTool,
@ -190,10 +181,6 @@ export {
editTool, editTool,
findTool, findTool,
grepTool, grepTool,
// Hashline edit mode
hashlineCodingTools,
hashlineEditTool,
hashlineReadTool,
lsTool, lsTool,
readOnlyTools, readOnlyTools,
// Pre-built tools (use process.cwd()) // Pre-built tools (use process.cwd())
@ -358,20 +345,16 @@ export async function createAgentSession(
thinkingLevel = "off"; thinkingLevel = "off";
} }
const editMode = settingsManager.getEditMode(); const defaultActiveToolNames: ToolName[] = [
const defaultActiveToolNames: ToolName[] = "Read",
editMode === "hashline" "Grep",
? [ "Glob",
"hashline_read", "LS",
"Grep", "Bash",
"Glob", "Edit",
"LS", "Write",
"Bash", "lsp",
"hashline_edit", ];
"Write",
"lsp",
]
: ["Read", "Grep", "Glob", "LS", "Bash", "Edit", "Write", "lsp"];
const initialActiveToolNames: ToolName[] = options.tools const initialActiveToolNames: ToolName[] = options.tools
? options.tools ? options.tools
.map((t) => t.name) .map((t) => t.name)

View file

@ -1,365 +0,0 @@
/**
* Hashline edit tool applies file edits using line-hash anchors.
*
* The model references lines by `LINE#ID` tags from read output.
* Each tag uniquely identifies a line, so edits remain stable even when lines shift.
*/
import { constants } from "node:fs";
import {
access as fsAccess,
readFile as fsReadFile,
unlink as fsUnlink,
writeFile as fsWriteFile,
} from "node:fs/promises";
import { type Static, Type } from "@sinclair/typebox";
import type { AgentTool } from "@singularity-forge/agent-core";
import {
detectLineEnding,
generateDiffString,
normalizeToLF,
restoreLineEndings,
stripBom,
} from "./edit-diff.js";
import {
type Anchor,
applyHashlineEdits,
type HashlineEdit,
parseHashlineText,
parseTag,
} from "./hashline.js";
import { resolveToCwd } from "./path-utils.js";
// ═══════════════════════════════════════════════════════════════════════════
// Schema
// ═══════════════════════════════════════════════════════════════════════════
const hashlineEditItemSchema = Type.Object(
{
op: Type.Union([
Type.Literal("replace"),
Type.Literal("append"),
Type.Literal("prepend"),
]),
pos: Type.Optional(
Type.String({ description: 'Anchor tag (e.g. "5#QQ")' }),
),
end: Type.Optional(
Type.String({ description: "End anchor for range replace" }),
),
lines: Type.Union([
Type.Array(Type.String(), { description: "Replacement content lines" }),
Type.String(),
Type.Null(),
]),
},
{ additionalProperties: false },
);
const hashlineEditSchema = Type.Object(
{
path: Type.String({ description: "Path to the file to edit" }),
edits: Type.Array(hashlineEditItemSchema, {
description:
"Edits to apply (referenced by LINE#ID tags from read output)",
}),
delete: Type.Optional(
Type.Boolean({ description: "If true, delete the file" }),
),
move: Type.Optional(
Type.String({ description: "If set, move/rename the file to this path" }),
),
},
{ additionalProperties: false },
);
export type HashlineEditInput = Static<typeof hashlineEditSchema>;
export type HashlineEditItem = Static<typeof hashlineEditItemSchema>;
export interface HashlineEditToolDetails {
/** Unified diff of the changes made */
diff: string;
/** Line number of the first change in the new file */
firstChangedLine?: number;
}
/**
* Pluggable operations for the hashline edit tool.
*/
export interface HashlineEditOperations {
readFile: (absolutePath: string) => Promise<Buffer>;
writeFile: (absolutePath: string, content: string) => Promise<void>;
access: (absolutePath: string) => Promise<void>;
unlink: (absolutePath: string) => Promise<void>;
}
const defaultHashlineEditOperations: HashlineEditOperations = {
readFile: (path) => fsReadFile(path),
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
unlink: (path) => fsUnlink(path),
};
export interface HashlineEditToolOptions {
operations?: HashlineEditOperations;
}
/** Parse a tag, returning undefined instead of throwing on garbage. */
function tryParseTag(raw: string): Anchor | undefined {
try {
return parseTag(raw);
} catch {
return undefined;
}
}
/**
* Map flat tool-schema edits into typed HashlineEdit objects.
*/
function resolveEditAnchors(edits: HashlineEditItem[]): HashlineEdit[] {
const result: HashlineEdit[] = [];
for (const edit of edits) {
const lines = parseHashlineText(edit.lines);
const tag = edit.pos ? tryParseTag(edit.pos) : undefined;
const end = edit.end ? tryParseTag(edit.end) : undefined;
const op =
edit.op === "append" || edit.op === "prepend" ? edit.op : "replace";
switch (op) {
case "replace": {
if (tag && end) {
result.push({ op: "replace", pos: tag, end, lines });
} else if (tag || end) {
result.push({ op: "replace", pos: tag || end!, lines });
} else {
throw new Error("Replace requires at least one anchor (pos or end).");
}
break;
}
case "append": {
result.push({ op: "append", pos: tag ?? end, lines });
break;
}
case "prepend": {
result.push({ op: "prepend", pos: end ?? tag, lines });
break;
}
}
}
return result;
}
const HASHLINE_EDIT_DESCRIPTION = `Edit a file by referencing LINE#ID tags from read output. Each tag uniquely identifies a line via content hash, so edits remain stable even when lines shift.
Read the file first to get fresh tags. Submit one edit call per file with all operations batched.
Operations:
- replace: Replace line(s) at pos (and optionally through end) with lines content
- append: Insert lines after pos (omit pos for end of file)
- prepend: Insert lines before pos (omit pos for beginning of file)
Set lines to null or [] to delete lines. Set delete:true to delete the file.`;
export function createHashlineEditTool(
cwd: string,
options?: HashlineEditToolOptions,
): AgentTool<typeof hashlineEditSchema> {
const ops = options?.operations ?? defaultHashlineEditOperations;
return {
name: "hashline_edit",
label: "hashline_edit",
description: HASHLINE_EDIT_DESCRIPTION,
parameters: hashlineEditSchema,
execute: async (
_toolCallId: string,
params: HashlineEditInput,
signal?: AbortSignal,
) => {
const { path, edits, delete: deleteFile, move } = params;
const absolutePath = resolveToCwd(path, cwd);
return new Promise<{
content: Array<{ type: "text"; text: string }>;
details: HashlineEditToolDetails | undefined;
}>((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
(async () => {
try {
// Handle delete
if (deleteFile) {
let fileExists = true;
try {
await ops.access(absolutePath);
} catch {
fileExists = false;
}
if (fileExists) {
await ops.unlink(absolutePath);
}
if (signal) signal.removeEventListener("abort", onAbort);
resolve({
content: [
{
type: "text",
text: fileExists
? `Deleted ${path}`
: `File not found, nothing to delete: ${path}`,
},
],
details: { diff: "" },
});
return;
}
// Handle file creation (no existing file, anchorless appends/prepends)
let fileExists = true;
try {
await ops.access(absolutePath);
} catch {
fileExists = false;
}
if (!fileExists) {
const lines: string[] = [];
for (const edit of edits) {
if (
(edit.op === "append" || edit.op === "prepend") &&
!edit.pos &&
!edit.end
) {
if (edit.op === "prepend") {
lines.unshift(...parseHashlineText(edit.lines));
} else {
lines.push(...parseHashlineText(edit.lines));
}
} else {
throw new Error(`File not found: ${path}`);
}
}
await ops.writeFile(absolutePath, lines.join("\n"));
if (signal) signal.removeEventListener("abort", onAbort);
resolve({
content: [{ type: "text", text: `Created ${path}` }],
details: { diff: "" },
});
return;
}
if (aborted) return;
// Read file
const rawContent = (await ops.readFile(absolutePath)).toString(
"utf-8",
);
const { bom, text } = stripBom(rawContent);
const originalEnding = detectLineEnding(text);
const originalNormalized = normalizeToLF(text);
if (aborted) return;
// Resolve and apply edits
const anchorEdits = resolveEditAnchors(edits);
const result = applyHashlineEdits(originalNormalized, anchorEdits);
if (originalNormalized === result.lines && !move) {
let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
if (result.noopEdits && result.noopEdits.length > 0) {
const details = result.noopEdits
.map(
(e) =>
`Edit ${e.editIndex}: replacement for ${e.loc} is identical to current content:\n ${e.loc}| ${e.current}`,
)
.join("\n");
diagnostic += `\n${details}`;
diagnostic +=
"\nYour content must differ from what the file already contains. Re-read the file to see the current state.";
}
throw new Error(diagnostic);
}
if (aborted) return;
// Write result
const finalContent =
bom + restoreLineEndings(result.lines, originalEnding);
const writePath = move ? resolveToCwd(move, cwd) : absolutePath;
// Prevent silent overwrite when moving to an existing file
if (move && writePath !== absolutePath) {
try {
await ops.access(writePath);
// If access succeeds, the file exists — refuse the move
throw new Error(
`Destination file already exists: ${writePath}. Use a different path or delete the existing file first.`,
);
} catch (err: any) {
// Re-throw our own error; swallow only "file not found"
if (err.message?.startsWith("Destination file already exists:"))
throw err;
// File doesn't exist — safe to proceed
}
}
await ops.writeFile(writePath, finalContent);
// If moved, delete original
if (move && writePath !== absolutePath) {
await ops.unlink(absolutePath);
}
if (aborted) return;
if (signal) signal.removeEventListener("abort", onAbort);
const diffResult = generateDiffString(
originalNormalized,
result.lines,
);
const resultText = move
? `Moved ${path} to ${move}`
: `Updated ${path}`;
const warningsBlock = result.warnings?.length
? `\nWarnings:\n${result.warnings.join("\n")}`
: "";
resolve({
content: [
{
type: "text",
text: `${resultText}${warningsBlock}`,
},
],
details: {
diff: diffResult.diff,
firstChangedLine:
result.firstChangedLine ?? diffResult.firstChangedLine,
},
});
} catch (error: any) {
if (signal) signal.removeEventListener("abort", onAbort);
if (!aborted) {
reject(error);
}
}
})();
});
},
};
}
/** Default hashline edit tool using process.cwd() */
export const hashlineEditTool = createHashlineEditTool(process.cwd());

View file

@ -1,255 +0,0 @@
/**
* Hashline read tool reads files with LINE#ID prefix on each line.
*
* Produces output like:
* 1#QQ:function hello() {
* 2#KX: return 42;
* 3#NW:}
*
* These tags are used by the hashline_edit tool to address lines precisely.
*/
import { constants } from "node:fs";
import { access as fsAccess, readFile as fsReadFile } from "node:fs/promises";
import { type Static, Type } from "@sinclair/typebox";
import type { AgentTool } from "@singularity-forge/agent-core";
import type { ImageContent, TextContent } from "@singularity-forge/ai";
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
import { formatHashLines } from "./hashline.js";
import { resolveReadPath } from "./path-utils.js";
import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
formatSize,
type TruncationResult,
truncateHead,
} from "./truncate.js";
const readSchema = Type.Object({
path: Type.String({
description: "Path to the file to read (relative or absolute)",
}),
offset: Type.Optional(
Type.Number({
description: "Line number to start reading from (1-indexed)",
}),
),
limit: Type.Optional(
Type.Number({ description: "Maximum number of lines to read" }),
),
});
export type HashlineReadToolInput = Static<typeof readSchema>;
export interface HashlineReadToolDetails {
truncation?: TruncationResult;
}
/**
* Pluggable operations for the hashline read tool.
*/
export interface HashlineReadOperations {
readFile: (absolutePath: string) => Promise<Buffer>;
access: (absolutePath: string) => Promise<void>;
detectImageMimeType?: (
absolutePath: string,
) => Promise<string | null | undefined>;
}
const defaultReadOperations: HashlineReadOperations = {
readFile: (path) => fsReadFile(path),
access: (path) => fsAccess(path, constants.R_OK),
detectImageMimeType: detectSupportedImageMimeTypeFromFile,
};
export interface HashlineReadToolOptions {
autoResizeImages?: boolean;
operations?: HashlineReadOperations;
}
export function createHashlineReadTool(
cwd: string,
options?: HashlineReadToolOptions,
): AgentTool<typeof readSchema> {
const autoResizeImages = options?.autoResizeImages ?? true;
const ops = options?.operations ?? defaultReadOperations;
return {
name: "Read",
label: "Read",
description: `Read a file with LINE#ID hash anchors on each line. These anchors are used by hashline_edit for precise edits. Output format: LINENUM#HASH:CONTENT. Supports text files and images. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB. Use offset/limit for large files.`,
parameters: readSchema,
execute: async (
_toolCallId: string,
{
path,
offset,
limit,
}: { path: string; offset?: number; limit?: number },
signal?: AbortSignal,
) => {
const absolutePath = resolveReadPath(path, cwd);
return new Promise<{
content: (TextContent | ImageContent)[];
details: HashlineReadToolDetails | undefined;
}>((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
(async () => {
try {
await ops.access(absolutePath);
if (aborted) return;
const mimeType = ops.detectImageMimeType
? await ops.detectImageMimeType(absolutePath)
: undefined;
let content: (TextContent | ImageContent)[];
let details: HashlineReadToolDetails | undefined;
if (mimeType) {
// Image handling (identical to standard read tool)
const buffer = await ops.readFile(absolutePath);
const base64 = buffer.toString("base64");
if (autoResizeImages) {
const resized = await resizeImage({
type: "image",
data: base64,
mimeType,
});
const dimensionNote = formatDimensionNote(resized);
let textNote = `Read image file [${resized.mimeType}]`;
if (dimensionNote) {
textNote += `\n${dimensionNote}`;
}
content = [
{ type: "text", text: textNote },
{
type: "image",
data: resized.data,
mimeType: resized.mimeType,
},
];
} else {
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
}
} else {
// Text file — format with hashline prefixes
const buffer = await ops.readFile(absolutePath);
const textContent = buffer.toString("utf-8");
const allLines = textContent.split("\n");
const totalFileLines = allLines.length;
let startLine = offset ? Math.max(0, offset - 1) : 0;
// Clamp offset to file bounds instead of throwing (#3007)
let offsetClamped = false;
if (startLine >= allLines.length) {
startLine = Math.max(0, allLines.length - 1);
offsetClamped = true;
}
const startLineDisplay = startLine + 1;
let selectedContent: string;
let userLimitedLines: number | undefined;
if (limit !== undefined) {
const endLine = Math.min(startLine + limit, allLines.length);
selectedContent = allLines.slice(startLine, endLine).join("\n");
userLimitedLines = endLine - startLine;
} else {
selectedContent = allLines.slice(startLine).join("\n");
}
// Apply truncation
const truncation = truncateHead(selectedContent);
let outputText: string;
if (truncation.firstLineExceedsLimit) {
const firstLineSize = formatSize(
Buffer.byteLength(allLines[startLine], "utf-8"),
);
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
details = { truncation };
} else if (truncation.truncated) {
const endLineDisplay =
startLineDisplay + truncation.outputLines - 1;
const nextOffset = endLineDisplay + 1;
// Format with hashline prefixes
outputText = formatHashLines(
truncation.content,
startLineDisplay,
);
if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
} else {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
}
details = { truncation };
} else if (
userLimitedLines !== undefined &&
startLine + userLimitedLines < allLines.length
) {
const remaining =
allLines.length - (startLine + userLimitedLines);
const nextOffset = startLine + userLimitedLines + 1;
outputText = formatHashLines(
truncation.content,
startLineDisplay,
);
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
} else {
outputText = formatHashLines(
truncation.content,
startLineDisplay,
);
}
// Prepend clamp notice so the agent knows offset was adjusted
if (offsetClamped) {
outputText = `[Offset ${offset} beyond end of file (${totalFileLines} lines). Clamped to line ${startLineDisplay}.]\n\n${outputText}`;
}
content = [{ type: "text", text: outputText }];
}
if (aborted) return;
if (signal) signal.removeEventListener("abort", onAbort);
resolve({ content, details });
} catch (error: any) {
if (signal) signal.removeEventListener("abort", onAbort);
if (!aborted) {
reject(error);
}
}
})();
});
},
};
}
/** Default hashline read tool using process.cwd() */
export const hashlineReadTool = createHashlineReadTool(process.cwd());

View file

@ -65,23 +65,6 @@ export {
stripNewLinePrefixes, stripNewLinePrefixes,
validateLineRef, validateLineRef,
} from "./hashline.js"; } from "./hashline.js";
export {
createHashlineEditTool,
type HashlineEditInput,
type HashlineEditItem,
type HashlineEditOperations,
type HashlineEditToolDetails,
type HashlineEditToolOptions,
hashlineEditTool,
} from "./hashline-edit.js";
export {
createHashlineReadTool,
type HashlineReadOperations,
type HashlineReadToolDetails,
type HashlineReadToolInput,
type HashlineReadToolOptions,
hashlineReadTool,
} from "./hashline-read.js";
export { export {
createLsTool, createLsTool,
type LsOperations, type LsOperations,
@ -130,8 +113,6 @@ import { type BashToolOptions, bashTool, createBashTool } from "./bash.js";
import { createEditTool, editTool } from "./edit.js"; import { createEditTool, editTool } from "./edit.js";
import { createFindTool, findTool } from "./find.js"; import { createFindTool, findTool } from "./find.js";
import { createGrepTool, grepTool } from "./grep.js"; import { createGrepTool, grepTool } from "./grep.js";
import { createHashlineEditTool, hashlineEditTool } from "./hashline-edit.js";
import { createHashlineReadTool, hashlineReadTool } from "./hashline-read.js";
import { createInsertAroundSymbolTool } from "./insert-around-symbol.js"; import { createInsertAroundSymbolTool } from "./insert-around-symbol.js";
import { createLsTool, lsTool } from "./ls.js"; import { createLsTool, lsTool } from "./ls.js";
import { createReadTool, type ReadToolOptions, readTool } from "./read.js"; import { createReadTool, type ReadToolOptions, readTool } from "./read.js";
@ -166,8 +147,6 @@ export const allTools = {
Glob: findTool, Glob: findTool,
LS: lsTool, LS: lsTool,
lsp: lspTool, lsp: lspTool,
hashline_edit: hashlineEditTool,
hashline_read: hashlineReadTool,
// Serena-style AST tools: tree-sitter-anchored, language-aware editing. // Serena-style AST tools: tree-sitter-anchored, language-aware editing.
// More durable than substring Edit / line-anchor hashline for code in // More durable than substring Edit / line-anchor hashline for code in
// languages with a tree-sitter grammar. // languages with a tree-sitter grammar.
@ -176,18 +155,6 @@ export const allTools = {
AstGrep: createAstGrepTool(process.cwd()), AstGrep: createAstGrepTool(process.cwd()),
}; };
// Hashline-mode coding tools — read with hash anchors, edit with hash references
export const hashlineCodingTools: Tool[] = [
hashlineReadTool,
grepTool,
findTool,
lsTool,
bashTool,
hashlineEditTool,
writeTool,
lspTool,
];
export type ToolName = keyof typeof allTools; export type ToolName = keyof typeof allTools;
export interface ToolsOptions { export interface ToolsOptions {
@ -244,30 +211,8 @@ export function createAllTools(
Glob: createFindTool(cwd), Glob: createFindTool(cwd),
LS: createLsTool(cwd), LS: createLsTool(cwd),
lsp: createLspTool(cwd), lsp: createLspTool(cwd),
hashline_edit: createHashlineEditTool(cwd),
hashline_read: createHashlineReadTool(cwd, options?.read),
ReplaceSymbol: createReplaceSymbolTool(cwd), ReplaceSymbol: createReplaceSymbolTool(cwd),
InsertAroundSymbol: createInsertAroundSymbolTool(cwd), InsertAroundSymbol: createInsertAroundSymbolTool(cwd),
AstGrep: createAstGrepTool(cwd), AstGrep: createAstGrepTool(cwd),
}; };
} }
/**
* Create hashline-mode coding tools configured for a specific working directory.
* Uses hashline read (LINE#ID prefixed output) and hashline edit (hash-anchor based edits).
*/
export function createHashlineCodingTools(
cwd: string,
options?: ToolsOptions,
): Tool[] {
return [
createHashlineReadTool(cwd, options?.read),
createGrepTool(cwd),
createFindTool(cwd),
createLsTool(cwd),
createBashTool(cwd, options?.bash),
createHashlineEditTool(cwd),
createWriteTool(cwd),
createLspTool(cwd),
];
}

View file

@ -6,6 +6,7 @@ import type { AgentTool } from "@singularity-forge/agent-core";
import type { ImageContent, TextContent } from "@singularity-forge/ai"; import type { ImageContent, TextContent } from "@singularity-forge/ai";
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js"; import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
import { formatHashLines } from "./hashline.js";
import { resolveReadPath } from "./path-utils.js"; import { resolveReadPath } from "./path-utils.js";
import { import {
DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES,
@ -27,6 +28,13 @@ const readSchema = Type.Object({
limit: Type.Optional( limit: Type.Optional(
Type.Number({ description: "Maximum number of lines to read" }), Type.Number({ description: "Maximum number of lines to read" }),
), ),
format: Type.Optional(
Type.Union([Type.Literal("plain"), Type.Literal("tagged")], {
default: "plain",
description:
'Output format. "plain" (default): raw file text. "tagged": prefix each line with LINE#HASH for use with Edit({match:"anchor"}).',
}),
),
}); });
export type ReadToolInput = Static<typeof readSchema>; export type ReadToolInput = Static<typeof readSchema>;
@ -166,7 +174,7 @@ export function createReadTool(
return { return {
name: "Read", name: "Read",
label: "Read", label: "Read",
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete. Use format="tagged" to get LINE#HASH prefixes on each line (for anchor-mode editing with Edit({match:"anchor"})).`,
parameters: readSchema, parameters: readSchema,
execute: async ( execute: async (
_toolCallId: string, _toolCallId: string,
@ -174,7 +182,8 @@ export function createReadTool(
path, path,
offset, offset,
limit, limit,
}: { path: string; offset?: number; limit?: number }, format,
}: { path: string; offset?: number; limit?: number; format?: "plain" | "tagged" },
signal?: AbortSignal, signal?: AbortSignal,
) => { ) => {
const absolutePath = resolveReadPath(path, cwd); const absolutePath = resolveReadPath(path, cwd);
@ -298,7 +307,11 @@ export function createReadTool(
effectiveStartLine + truncation.outputLines - 1; effectiveStartLine + truncation.outputLines - 1;
const nextOffset = endLineDisplay + 1; const nextOffset = endLineDisplay + 1;
outputText = truncation.content; const bodyText =
format === "tagged"
? formatHashLines(truncation.content, effectiveStartLine)
: truncation.content;
outputText = bodyText;
if (truncation.truncatedBy === "lines") { if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${effectiveStartLine}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; outputText += `\n\n[Showing lines ${effectiveStartLine}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
@ -315,10 +328,17 @@ export function createReadTool(
const remaining = const remaining =
totalFileLines - (effectiveStartLine + lines.length - 1); totalFileLines - (effectiveStartLine + lines.length - 1);
outputText = truncation.content; const bodyText =
format === "tagged"
? formatHashLines(truncation.content, effectiveStartLine)
: truncation.content;
outputText = bodyText;
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
} else { } else {
outputText = truncation.content; outputText =
format === "tagged"
? formatHashLines(truncation.content, effectiveStartLine)
: truncation.content;
} }
if (offsetClamped) { if (offsetClamped) {
@ -340,7 +360,11 @@ export function createReadTool(
const endLineDisplay = truncation.outputLines; const endLineDisplay = truncation.outputLines;
const nextOffset = endLineDisplay + 1; const nextOffset = endLineDisplay + 1;
outputText = truncation.content; const bodyText =
format === "tagged"
? formatHashLines(truncation.content, 1)
: truncation.content;
outputText = bodyText;
if (truncation.truncatedBy === "lines") { if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines 1-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; outputText += `\n\n[Showing lines 1-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
@ -349,7 +373,10 @@ export function createReadTool(
} }
_details = { truncation }; _details = { truncation };
} else { } else {
outputText = truncation.content; outputText =
format === "tagged"
? formatHashLines(truncation.content, 1)
: truncation.content;
} }
} }

View file

@ -324,9 +324,6 @@ export {
checkBashInterception, checkBashInterception,
codingTools, codingTools,
compileInterceptor, compileInterceptor,
createHashlineCodingTools,
createHashlineEditTool,
createHashlineReadTool,
DEFAULT_BASH_INTERCEPTOR_RULES, DEFAULT_BASH_INTERCEPTOR_RULES,
DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES, DEFAULT_MAX_LINES,
@ -348,16 +345,6 @@ export {
getAllToolCompatibility, getAllToolCompatibility,
getToolCompatibility, getToolCompatibility,
grepTool, grepTool,
type HashlineEditInput,
type HashlineEditToolDetails,
type HashlineEditToolOptions,
type HashlineReadToolDetails,
type HashlineReadToolInput,
type HashlineReadToolOptions,
hashlineCodingTools,
// Hashline edit mode tools
hashlineEditTool,
hashlineReadTool,
type LsOperations, type LsOperations,
type LsToolDetails, type LsToolDetails,
type LsToolInput, type LsToolInput,

View file

@ -202,8 +202,6 @@ export function summarizeToolArgs(
case "Edit": case "Edit":
case "edit": case "edit":
return filePath(); return filePath();
case "hashline_edit":
return filePath();
case "Bash": case "Bash":
case "bash": { case "bash": {
const cmd = String(input.command ?? ""); const cmd = String(input.command ?? "");

View file

@ -513,13 +513,6 @@ describe("summarizeToolArgs", () => {
); );
}); });
it("extracts path for hashline_edit", () => {
assert.equal(
summarizeToolArgs("hashline_edit", { path: "src/main.ts" }),
"src/main.ts",
);
});
it("extracts agent and task for subagent", () => { it("extracts agent and task for subagent", () => {
assert.equal( assert.equal(
summarizeToolArgs("subagent", { summarizeToolArgs("subagent", {