@oh-my-pi/pi-coding-agent 15.5.12 → 15.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/types/config/model-registry.d.ts +1 -1
  3. package/dist/types/config/models-config-schema.d.ts +2 -0
  4. package/dist/types/config/settings-schema.d.ts +1 -10
  5. package/dist/types/edit/file-snapshot-store.d.ts +19 -0
  6. package/dist/types/eval/__tests__/llm-bridge.test.d.ts +1 -0
  7. package/dist/types/eval/llm-bridge.d.ts +25 -0
  8. package/dist/types/export/html/template.generated.d.ts +1 -1
  9. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +15 -0
  10. package/dist/types/modes/theme/theme.d.ts +2 -1
  11. package/dist/types/session/agent-session.d.ts +2 -0
  12. package/dist/types/tools/index.d.ts +0 -1
  13. package/package.json +8 -8
  14. package/src/config/model-registry.ts +89 -5
  15. package/src/config/models-config-schema.ts +1 -1
  16. package/src/config/settings-schema.ts +1 -10
  17. package/src/edit/file-snapshot-store.ts +34 -0
  18. package/src/edit/hashline/diff.ts +3 -8
  19. package/src/edit/renderer.ts +1 -1
  20. package/src/eval/__tests__/llm-bridge.test.ts +297 -0
  21. package/src/eval/js/shared/prelude.txt +8 -0
  22. package/src/eval/js/tool-bridge.ts +4 -0
  23. package/src/eval/llm-bridge.ts +181 -0
  24. package/src/eval/py/prelude.py +52 -31
  25. package/src/export/html/template.generated.ts +1 -1
  26. package/src/export/html/template.js +0 -13
  27. package/src/extensibility/plugins/legacy-pi-compat.ts +60 -23
  28. package/src/internal-urls/docs-index.generated.ts +4 -5
  29. package/src/main.ts +4 -0
  30. package/src/modes/components/model-selector.ts +119 -22
  31. package/src/modes/components/status-line/presets.ts +1 -0
  32. package/src/modes/components/status-line/segments.ts +23 -0
  33. package/src/modes/interactive-mode.ts +22 -87
  34. package/src/modes/theme/theme.ts +7 -0
  35. package/src/prompts/tools/eval.md +2 -0
  36. package/src/session/agent-session.ts +19 -0
  37. package/src/session/session-manager.ts +47 -0
  38. package/src/tools/ast-edit.ts +1 -1
  39. package/src/tools/ast-grep.ts +6 -17
  40. package/src/tools/eval.ts +24 -48
  41. package/src/tools/index.ts +0 -4
  42. package/src/tools/read.ts +23 -33
  43. package/src/tools/renderers.ts +0 -2
  44. package/src/tools/search.ts +12 -21
  45. package/src/tools/write.ts +1 -3
  46. package/src/utils/file-mentions.ts +1 -3
  47. package/dist/types/tools/calculator.d.ts +0 -77
  48. package/src/prompts/tools/calculator.md +0 -10
  49. package/src/tools/calculator.ts +0 -541
@@ -702,6 +702,53 @@ export function buildSessionContext(
702
702
  }
703
703
  }
704
704
 
705
+ // Strip dangling tool_use blocks — a tool_use with no matching tool_result on the
706
+ // resolved leaf→root path — from ANY assistant turn, not just the trailing one.
707
+ // This happens whenever the leaf (or a branch point) lands such that an assistant
708
+ // turn's tool results are off the selected path: its result children live on a
709
+ // sibling branch, or it is the leaf itself (results are children below it). Left
710
+ // in place, `transformMessages` fabricates one synthetic "aborted"/"No result
711
+ // provided" result per dangling call plus a `<turn-aborted>` developer note, which
712
+ // render as phantom failed calls and re-inject the failed batch into the model's
713
+ // context — the rewind/restore loop.
714
+ //
715
+ // Stripping is necessary but not sufficient: a *modified* assistant turn that still
716
+ // carries signed `thinking`/`redacted_thinking` is rejected by Anthropic — "thinking
717
+ // blocks in the latest assistant message cannot be modified", and signed thinking
718
+ // replayed out of its original turn shape can also fail signature validation (this
719
+ // bites the handoff/branch-summary request). So when we rewrite a turn we also
720
+ // neutralize its protected reasoning: drop `redactedThinking` (encrypted, no
721
+ // plaintext to keep) and clear `thinking` signatures so the provider encoder
722
+ // downgrades them to plain text (verified accepted by the live API), preserving the
723
+ // visible reasoning while removing the immutability/invalid-signature hazard. Drop a
724
+ // turn left with no content. (Live turns never qualify: their results are persisted
725
+ // on the same path before any context rebuild.)
726
+ const pairedToolResultIds = new Set<string>();
727
+ for (const message of messages) {
728
+ if (message.role === "toolResult") pairedToolResultIds.add(message.toolCallId);
729
+ }
730
+ for (let i = messages.length - 1; i >= 0; i--) {
731
+ const message = messages[i];
732
+ if (message.role !== "assistant") continue;
733
+ const hasDangling = message.content.some(
734
+ block => block.type === "toolCall" && !pairedToolResultIds.has(block.id),
735
+ );
736
+ if (!hasDangling) continue;
737
+ const normalized = message.content
738
+ .filter(
739
+ block =>
740
+ !(block.type === "toolCall" && !pairedToolResultIds.has(block.id)) && block.type !== "redactedThinking",
741
+ )
742
+ .map(block =>
743
+ block.type === "thinking" && block.thinkingSignature ? { ...block, thinkingSignature: undefined } : block,
744
+ );
745
+ if (normalized.length === 0) {
746
+ messages.splice(i, 1);
747
+ } else {
748
+ messages[i] = { ...message, content: normalized };
749
+ }
750
+ }
751
+
705
752
  return {
706
753
  messages,
707
754
  thinkingLevel,
@@ -290,7 +290,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
290
290
  const absolutePath = path.resolve(this.session.cwd, relativePath);
291
291
  try {
292
292
  const fullText = normalizeToLF(await Bun.file(absolutePath).text());
293
- const tag = snapshotStore.recordContiguous(absolutePath, 1, fullText.split("\n"), { fullText });
293
+ const tag = snapshotStore.record(absolutePath, fullText);
294
294
  hashContexts.set(relativePath, { tag });
295
295
  } catch {
296
296
  // Best-effort: if a file disappears between ast-edit and rendering, emit plain line output.
@@ -1,5 +1,3 @@
1
- import { constants } from "node:fs";
2
- import { access } from "node:fs/promises";
3
1
  import * as path from "node:path";
4
2
  import { formatHashlineHeader } from "@oh-my-pi/hashline";
5
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
@@ -8,7 +6,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
8
6
  import { Text } from "@oh-my-pi/pi-tui";
9
7
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
10
8
  import * as z from "zod/v4";
11
- import { getFileSnapshotStore } from "../edit/file-snapshot-store";
9
+ import { recordFileSnapshot } from "../edit/file-snapshot-store";
12
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
11
  import type { Theme } from "../modes/theme/theme";
14
12
  import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
@@ -221,17 +219,14 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
221
219
  }
222
220
 
223
221
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
224
- const hashContexts = new Map<string, { absolutePath: string; tag?: string }>();
225
- const snapshotStore = useHashLines ? getFileSnapshotStore(this.session) : undefined;
222
+ const hashContexts = new Map<string, { tag: string }>();
226
223
  if (useHashLines) {
227
224
  for (const relativePath of fileList) {
228
225
  const absolutePath = path.resolve(this.session.cwd, relativePath);
229
- try {
230
- await access(absolutePath, constants.R_OK);
231
- hashContexts.set(relativePath, { absolutePath });
232
- } catch {
233
- // Best-effort: if a file disappears between ast-grep and rendering, emit plain line output.
234
- }
226
+ // Whole-file content tag: any anchor validates while the file is
227
+ // unchanged; over-cap / unreadable files get no tag (plain output).
228
+ const tag = await recordFileSnapshot(this.session, absolutePath);
229
+ if (tag) hashContexts.set(relativePath, { tag });
235
230
  }
236
231
  }
237
232
  const outputLines: string[] = [];
@@ -246,7 +241,6 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
246
241
  const endLine = match.startLine + lineCount - 1;
247
242
  return Math.max(width, String(match.startLine).length, String(endLine).length);
248
243
  }, 0);
249
- const cacheEntries: Array<readonly [number, string]> = [];
250
244
  for (const match of fileMatches) {
251
245
  const matchLines = match.text.split("\n");
252
246
  for (let index = 0; index < matchLines.length; index++) {
@@ -257,7 +251,6 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
257
251
  formatMatchLine(lineNumber, line, isMatch, { useHashLines: hashContext !== undefined }),
258
252
  );
259
253
  displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
260
- cacheEntries.push([lineNumber, line] as const);
261
254
  }
262
255
  if (match.metaVariables && Object.keys(match.metaVariables).length > 0) {
263
256
  const serializedMeta = Object.entries(match.metaVariables)
@@ -269,10 +262,6 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
269
262
  }
270
263
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
271
264
  }
272
- if (hashContext && cacheEntries.length > 0) {
273
- const tag = snapshotStore?.recordSparse(hashContext.absolutePath, cacheEntries);
274
- if (tag) hashContext.tag = tag;
275
- }
276
265
  return { model: modelOut, display: displayOut };
277
266
  };
278
267
 
package/src/tools/eval.ts CHANGED
@@ -13,10 +13,19 @@ import { truncateToVisualLines } from "../modes/components/visual-truncate";
13
13
  import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
14
14
  import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
15
15
  import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
16
- import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
16
+ import { renderCodeCell } from "../tui";
17
17
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
18
18
  import { resolveEvalBackends, type ToolSession } from ".";
19
19
  import { truncateForPrompt } from "./approval";
20
+ import {
21
+ JSON_TREE_MAX_DEPTH_COLLAPSED,
22
+ JSON_TREE_MAX_DEPTH_EXPANDED,
23
+ JSON_TREE_MAX_LINES_COLLAPSED,
24
+ JSON_TREE_MAX_LINES_EXPANDED,
25
+ JSON_TREE_SCALAR_LEN_COLLAPSED,
26
+ JSON_TREE_SCALAR_LEN_EXPANDED,
27
+ renderJsonTreeLines,
28
+ } from "./json-tree";
20
29
  import {
21
30
  formatStyledTruncationWarning,
22
31
  resolveOutputMaxColumns,
@@ -61,15 +70,6 @@ export type EvalToolResult = {
61
70
 
62
71
  export type EvalProxyExecutor = (params: EvalToolParams, signal?: AbortSignal) => Promise<EvalToolResult>;
63
72
 
64
- function formatJsonScalar(value: unknown): string {
65
- if (value === null) return "null";
66
- if (value === undefined) return "undefined";
67
- if (typeof value === "string") return JSON.stringify(value);
68
- if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
69
- if (typeof value === "function") return "[function]";
70
- return "[object]";
71
- }
72
-
73
73
  /** Cap per `display()` value sent back to the model. */
74
74
  const MAX_DISPLAY_TEXT_BYTES = 8000;
75
75
 
@@ -102,41 +102,6 @@ function formatDisplayOutputsForText(outputs: EvalDisplayOutput[]): string {
102
102
  return chunks.join("\n\n");
103
103
  }
104
104
 
105
- function renderJsonTree(value: unknown, theme: Theme, expanded: boolean, maxDepth = expanded ? 6 : 2): string[] {
106
- const maxItems = expanded ? 20 : 5;
107
-
108
- const renderNode = (node: unknown, prefix: string, depth: number, isLast: boolean, label?: string): string[] => {
109
- const branch = getTreeBranch(isLast, theme);
110
- const displayLabel = label ? `${label}: ` : "";
111
-
112
- if (depth >= maxDepth || node === null || typeof node !== "object") {
113
- return [`${prefix}${branch} ${displayLabel}${formatJsonScalar(node)}`];
114
- }
115
-
116
- const isArray = Array.isArray(node);
117
- const entries = isArray
118
- ? node.map((val, index) => [String(index), val] as const)
119
- : Object.entries(node as object);
120
- const header = `${prefix}${branch} ${displayLabel}${isArray ? `Array(${entries.length})` : `Object(${entries.length})`}`;
121
- const lines = [header];
122
-
123
- const childPrefix = prefix + getTreeContinuePrefix(isLast, theme);
124
- const visible = entries.slice(0, maxItems);
125
- for (let i = 0; i < visible.length; i++) {
126
- const [key, val] = visible[i];
127
- const childLast = i === visible.length - 1 && (expanded || entries.length <= maxItems);
128
- lines.push(...renderNode(val, childPrefix, depth + 1, childLast, isArray ? `[${key}]` : key));
129
- }
130
- if (!expanded && entries.length > maxItems) {
131
- const moreBranch = theme.tree.last;
132
- lines.push(`${childPrefix}${moreBranch} ${entries.length - maxItems} more item(s)`);
133
- }
134
- return lines;
135
- };
136
-
137
- return renderNode(value, "", 0, true);
138
- }
139
-
140
105
  export interface EvalToolDescriptionOptions {
141
106
  py?: boolean;
142
107
  js?: boolean;
@@ -669,6 +634,7 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
669
634
  sh: "icon.package",
670
635
  env: "icon.package",
671
636
  batch: "icon.package",
637
+ llm: "icon.package",
672
638
  };
673
639
 
674
640
  const iconKey = opIcons[op] ?? "icon.file";
@@ -735,6 +701,11 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
735
701
  case "batch":
736
702
  parts.push(`${data.files} file${(data.files as number) !== 1 ? "s" : ""} processed`);
737
703
  break;
704
+ case "llm":
705
+ if (data.model) parts.push(String(data.model));
706
+ if (data.tier && data.tier !== data.model) parts.push(`(${data.tier})`);
707
+ parts.push(`${data.chars ?? 0} chars`);
708
+ break;
738
709
  case "wc":
739
710
  parts.push(`${data.lines}L ${data.words}W ${data.chars}C`);
740
711
  break;
@@ -950,10 +921,15 @@ export const evalToolRenderer = {
950
921
  const output = stripOutputNotice(rawOutput, details?.meta).trimEnd();
951
922
 
952
923
  const jsonOutputs = details?.jsonOutputs ?? [];
924
+ const treeExpanded = options.renderContext?.expanded ?? options.expanded;
925
+ const treeDepth = treeExpanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
926
+ const treeLineCap = treeExpanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
927
+ const treeScalarLen = treeExpanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
928
+ const labelOutputs = jsonOutputs.length > 1;
953
929
  const jsonLines = jsonOutputs.flatMap((value, index) => {
954
- const header = `JSON output ${index + 1}`;
955
- const treeLines = renderJsonTree(value, uiTheme, options.renderContext?.expanded ?? options.expanded);
956
- return [header, ...treeLines];
930
+ const tree = renderJsonTreeLines(value, uiTheme, treeDepth, treeLineCap, treeScalarLen);
931
+ const body = tree.truncated ? [...tree.lines, uiTheme.fg("dim", "…")] : tree.lines;
932
+ return labelOutputs ? [uiTheme.fg("dim", `display[${index + 1}]`), ...body] : body;
957
933
  });
958
934
 
959
935
  const timeoutSeconds = options.renderContext?.timeout;
@@ -28,7 +28,6 @@ import { AstEditTool } from "./ast-edit";
28
28
  import { AstGrepTool } from "./ast-grep";
29
29
  import { BashTool } from "./bash";
30
30
  import { BrowserTool } from "./browser";
31
- import { CalculatorTool } from "./calculator";
32
31
  import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
33
32
  import { DebugTool } from "./debug";
34
33
  import { EvalTool } from "./eval";
@@ -69,7 +68,6 @@ export * from "./ast-edit";
69
68
  export * from "./ast-grep";
70
69
  export * from "./bash";
71
70
  export * from "./browser";
72
- export * from "./calculator";
73
71
  export * from "./checkpoint";
74
72
  export * from "./debug";
75
73
  export * from "./eval";
@@ -286,7 +284,6 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
286
284
  ask: AskTool.createIf,
287
285
  debug: DebugTool.createIf,
288
286
  eval: s => new EvalTool(s),
289
- calc: s => new CalculatorTool(s),
290
287
  ssh: loadSshTool,
291
288
  github: GithubTool.createIf,
292
289
  find: s => new FindTool(s),
@@ -455,7 +452,6 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
455
452
  if (name === "web_search") return session.settings.get("web_search.enabled");
456
453
  // search_tool_bm25 is allowed when either legacy mcp.discoveryMode or new tools.discoveryMode is active.
457
454
  if (name === "search_tool_bm25") return discoveryActive;
458
- if (name === "calc") return session.settings.get("calc.enabled");
459
455
  if (name === "browser") return session.settings.get("browser.enabled");
460
456
  if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
461
457
  if (name === "irc") {
package/src/tools/read.ts CHANGED
@@ -9,7 +9,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
9
9
  import { Text } from "@oh-my-pi/pi-tui";
10
10
  import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
11
11
  import * as z from "zod/v4";
12
- import { getFileSnapshotStore } from "../edit/file-snapshot-store";
12
+ import { getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store";
13
13
  import { normalizeToLF } from "../edit/normalize";
14
14
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
15
15
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -130,9 +130,7 @@ function recordFullHashlineContext(
130
130
  ): HashlineHeaderContext | undefined {
131
131
  if (!absolutePath || !path.isAbsolute(absolutePath)) return undefined;
132
132
  const normalized = normalizeToLF(fullText);
133
- const tag = getFileSnapshotStore(session).recordContiguous(absolutePath, 1, normalized.split("\n"), {
134
- fullText: normalized,
135
- });
133
+ const tag = getFileSnapshotStore(session).record(absolutePath, normalized);
136
134
  return {
137
135
  header: formatHashlineHeader(displayPath, tag),
138
136
  tag,
@@ -1033,7 +1031,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1033
1031
 
1034
1032
  const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1035
1033
  const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1036
- const sparseSnapshotEntries: Array<readonly [number, string]> = [];
1037
1034
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
1038
1035
 
1039
1036
  const blocks: string[] = [];
@@ -1063,10 +1060,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1063
1060
  }
1064
1061
 
1065
1062
  const collectedLines = streamResult.lines;
1066
- // Column truncation is display-only. The snapshot (sparseSnapshotEntries)
1067
- // MUST hold on-disk content so later edits can verify line content against
1068
- // the live file. Stamping ellipsis-truncated lines into the snapshot makes
1069
- // every long-line file uneditable on the next edit attempt.
1063
+ // Column truncation is display-only; clone before stamping ellipsis so
1064
+ // the original on-disk lines stay intact for display reconstruction.
1070
1065
  let displayLines: string[] = collectedLines;
1071
1066
  if (!rawSelector && maxColumns > 0) {
1072
1067
  let cloned: string[] | undefined;
@@ -1080,19 +1075,16 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1080
1075
  }
1081
1076
  if (cloned) displayLines = cloned;
1082
1077
  }
1083
-
1084
- for (let index = 0; index < collectedLines.length; index++) {
1085
- sparseSnapshotEntries.push([range.startLine + index, collectedLines[index]]);
1086
- }
1087
-
1088
1078
  const blockText = displayLines.join("\n");
1089
1079
  blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1090
1080
  }
1091
1081
 
1092
1082
  let outputText = blocks.join("\n\n…\n\n");
1093
- if (shouldAddHashLines && sparseSnapshotEntries.length > 0 && outputText) {
1094
- const tag = getFileSnapshotStore(this.session).recordSparse(absolutePath, sparseSnapshotEntries);
1095
- outputText = `${formatHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
1083
+ if (shouldAddHashLines && outputText) {
1084
+ const tag = await recordFileSnapshot(this.session, absolutePath);
1085
+ if (tag) {
1086
+ outputText = `${formatHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
1087
+ }
1096
1088
  }
1097
1089
  if (notices.length > 0) {
1098
1090
  outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n");
@@ -1905,17 +1897,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1905
1897
  const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1906
1898
  let hashContext: HashlineHeaderContext | undefined;
1907
1899
  if (shouldAddHashLines && collectedLines.length > 0 && !firstLineExceedsLimit) {
1908
- const store = getFileSnapshotStore(this.session);
1909
- const tag =
1910
- offset === undefined && limit === undefined && !wasTruncated
1911
- ? (() => {
1912
- const normalized = normalizeToLF(collectedLines.join("\n"));
1913
- return store.recordContiguous(absolutePath, 1, normalized.split("\n"), {
1914
- fullText: normalized,
1915
- });
1916
- })()
1917
- : store.recordContiguous(absolutePath, startLineDisplay, collectedLines);
1918
- hashContext = hashlineHeaderContext(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag);
1900
+ // The tag is a content hash of the WHOLE file. A whole-file read
1901
+ // already holds every line in memory; a range read re-reads the
1902
+ // file (bounded by SNAPSHOT_MAX_BYTES) so the tag fingerprints the
1903
+ // full file and any anchor validates while the file is unchanged.
1904
+ const isWholeFile = offset === undefined && limit === undefined && !wasTruncated;
1905
+ const tag = isWholeFile
1906
+ ? getFileSnapshotStore(this.session).record(absolutePath, normalizeToLF(collectedLines.join("\n")))
1907
+ : await recordFileSnapshot(this.session, absolutePath);
1908
+ if (tag) {
1909
+ hashContext = hashlineHeaderContext(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag);
1910
+ }
1919
1911
  }
1920
1912
 
1921
1913
  let capturedDisplayContent: { text: string; startLine: number } | undefined;
@@ -2060,11 +2052,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2060
2052
  const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
2061
2053
 
2062
2054
  const rawText = region.lines.join("\n");
2063
- const hashContext = shouldAddHashLines
2064
- ? hashlineHeaderContext(
2065
- formatPathRelativeToCwd(entry.absolutePath, this.session.cwd),
2066
- getFileSnapshotStore(this.session).recordContiguous(entry.absolutePath, region.startLine, region.lines),
2067
- )
2055
+ const tag = shouldAddHashLines ? await recordFileSnapshot(this.session, entry.absolutePath) : undefined;
2056
+ const hashContext = tag
2057
+ ? hashlineHeaderContext(formatPathRelativeToCwd(entry.absolutePath, this.session.cwd), tag)
2068
2058
  : undefined;
2069
2059
  const formattedBody = formatTextWithMode(rawText, region.startLine, shouldAddHashLines, shouldAddLineNumbers);
2070
2060
  const formattedText = prependHashlineHeader(formattedBody, hashContext);
@@ -16,7 +16,6 @@ import { astEditToolRenderer } from "./ast-edit";
16
16
  import { astGrepToolRenderer } from "./ast-grep";
17
17
  import { bashToolRenderer } from "./bash";
18
18
  import { browserToolRenderer } from "./browser/render";
19
- import { calculatorToolRenderer } from "./calculator";
20
19
  import { debugToolRenderer } from "./debug";
21
20
  import { evalToolRenderer } from "./eval";
22
21
  import { findToolRenderer } from "./find";
@@ -54,7 +53,6 @@ export const toolRenderers: Record<string, ToolRenderer> = {
54
53
  recipe: recipeToolRenderer as ToolRenderer,
55
54
  debug: debugToolRenderer as ToolRenderer,
56
55
  eval: evalToolRenderer as ToolRenderer,
57
- calc: calculatorToolRenderer as ToolRenderer,
58
56
  edit: editToolRenderer as ToolRenderer,
59
57
  apply_patch: editToolRenderer as ToolRenderer,
60
58
  find: findToolRenderer as ToolRenderer,
@@ -1,5 +1,4 @@
1
- import { constants } from "node:fs";
2
- import { access, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
1
+ import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3
2
  import { tmpdir } from "node:os";
4
3
  import * as path from "node:path";
5
4
  import { formatHashlineHeader } from "@oh-my-pi/hashline";
@@ -9,7 +8,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
9
8
  import { Text } from "@oh-my-pi/pi-tui";
10
9
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
11
10
  import * as z from "zod/v4";
12
- import { getFileSnapshotStore } from "../edit/file-snapshot-store";
11
+ import { recordFileSnapshot } from "../edit/file-snapshot-store";
13
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
13
  import type { Theme } from "../modes/theme/theme";
15
14
  import searchDescription from "../prompts/tools/search.md" with { type: "text" };
@@ -610,19 +609,17 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
610
609
  matchesByFile.get(relativePath)!.push(match);
611
610
  }
612
611
  const displayLines: string[] = [];
613
- const hashContexts = new Map<string, { absolutePath: string; tag?: string }>();
614
- const snapshotStore = baseDisplayMode.hashLines ? getFileSnapshotStore(this.session) : undefined;
612
+ const hashContexts = new Map<string, { tag: string }>();
615
613
  if (baseDisplayMode.hashLines) {
616
614
  for (const relativePath of fileList) {
617
615
  if (archiveDisplaySet.has(relativePath)) continue;
618
616
  const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
619
617
  if (immutableSourcePaths.has(absoluteFilePath)) continue;
620
- try {
621
- await access(absoluteFilePath, constants.R_OK);
622
- hashContexts.set(relativePath, { absolutePath: absoluteFilePath });
623
- } catch {
624
- // Best-effort: if the file disappeared between grep and render, fall back to plain line output.
625
- }
618
+ // Mint a whole-file content tag so any anchor validates while the
619
+ // file is unchanged; over-cap / unreadable files get no tag (and
620
+ // therefore plain, non-editable line output).
621
+ const tag = await recordFileSnapshot(this.session, absoluteFilePath);
622
+ if (tag) hashContexts.set(relativePath, { tag });
626
623
  }
627
624
  }
628
625
  const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => {
@@ -641,40 +638,34 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
641
638
  }
642
639
  return nextWidth;
643
640
  }, 0);
644
- const cacheEntries: Array<readonly [number, string]> = [];
645
641
  let lastEmittedLine: number | undefined;
646
642
  const gutterPad = " ".repeat(lineNumberWidth + 1);
647
643
  for (const match of fileMatches) {
648
- const pushLine = (lineNumber: number, line: string, isMatch: boolean, recordable: boolean) => {
644
+ const pushLine = (lineNumber: number, line: string, isMatch: boolean) => {
649
645
  if (lastEmittedLine !== undefined && lineNumber > lastEmittedLine + 1) {
650
646
  modelOut.push("...");
651
647
  displayOut.push(`${gutterPad}│...`);
652
648
  }
653
649
  modelOut.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
654
650
  displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
655
- if (recordable) cacheEntries.push([lineNumber, line] as const);
656
651
  lastEmittedLine = lineNumber;
657
652
  };
658
653
  if (match.contextBefore) {
659
654
  for (const ctx of match.contextBefore) {
660
- pushLine(ctx.lineNumber, ctx.line, false, true);
655
+ pushLine(ctx.lineNumber, ctx.line, false);
661
656
  }
662
657
  }
663
- pushLine(match.lineNumber, match.line, true, !match.truncated);
658
+ pushLine(match.lineNumber, match.line, true);
664
659
  if (match.truncated) {
665
660
  linesTruncated = true;
666
661
  }
667
662
  if (match.contextAfter) {
668
663
  for (const ctx of match.contextAfter) {
669
- pushLine(ctx.lineNumber, ctx.line, false, true);
664
+ pushLine(ctx.lineNumber, ctx.line, false);
670
665
  }
671
666
  }
672
667
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
673
668
  }
674
- if (cacheEntries.length > 0 && hashContext) {
675
- const tag = snapshotStore?.recordSparse(hashContext.absolutePath, cacheEntries);
676
- if (tag) hashContext.tag = tag;
677
- }
678
669
  return { model: modelOut, display: displayOut };
679
670
  };
680
671
  if (isDirectory) {
@@ -130,9 +130,7 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
130
130
  function maybeWriteSnapshotHeader(session: ToolSession, absolutePath: string, content: string): string | undefined {
131
131
  if (!resolveFileDisplayMode(session).hashLines) return undefined;
132
132
  const normalized = normalizeToLF(content);
133
- const tag = getFileSnapshotStore(session).recordContiguous(absolutePath, 1, normalized.split("\n"), {
134
- fullText: normalized,
135
- });
133
+ const tag = getFileSnapshotStore(session).record(absolutePath, normalized);
136
134
  return formatHashlineHeader(formatPathRelativeToCwd(absolutePath, session.cwd), tag);
137
135
  }
138
136
 
@@ -359,9 +359,7 @@ export async function generateFileMentionMessages(
359
359
  const normalized = snapshotStore ? normalizeToLF(content) : content;
360
360
  let { output, lineCount } = buildTextOutput(normalized);
361
361
  if (snapshotStore) {
362
- const tag = snapshotStore.recordContiguous(absolutePath, 1, normalized.split("\n"), {
363
- fullText: normalized,
364
- });
362
+ const tag = snapshotStore.record(absolutePath, normalized);
365
363
  output = `${formatHashlineHeader(resolvedPath, tag)}\n${formatNumberedLines(output)}`;
366
364
  }
367
365
  files.push({ path: resolvedPath, content: output, lineCount });
@@ -1,77 +0,0 @@
1
- import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
- import type { Component } from "@oh-my-pi/pi-tui";
3
- import * as z from "zod/v4";
4
- import type { RenderResultOptions } from "../extensibility/custom-tools/types";
5
- import type { Theme } from "../modes/theme/theme";
6
- import type { ToolSession } from ".";
7
- declare const calculatorSchema: z.ZodObject<{
8
- calculations: z.ZodArray<z.ZodObject<{
9
- expression: z.ZodString;
10
- prefix: z.ZodString;
11
- suffix: z.ZodString;
12
- }, z.core.$strip>>;
13
- }, z.core.$strip>;
14
- export interface CalculatorToolDetails {
15
- results: Array<{
16
- expression: string;
17
- value: number;
18
- output: string;
19
- }>;
20
- }
21
- type CalculatorParams = z.infer<typeof calculatorSchema>;
22
- /**
23
- * Calculator tool for evaluating mathematical expressions.
24
- *
25
- * Supports decimal, hex (0x), binary (0b), octal (0o) literals,
26
- * standard arithmetic operators, and parentheses.
27
- */
28
- export declare class CalculatorTool implements AgentTool<typeof calculatorSchema, CalculatorToolDetails> {
29
- readonly name = "calc";
30
- readonly approval: "read";
31
- readonly label = "Calc";
32
- readonly summary = "Evaluate a mathematical expression";
33
- readonly loadMode = "discoverable";
34
- readonly description: string;
35
- readonly parameters: z.ZodObject<{
36
- calculations: z.ZodArray<z.ZodObject<{
37
- expression: z.ZodString;
38
- prefix: z.ZodString;
39
- suffix: z.ZodString;
40
- }, z.core.$strip>>;
41
- }, z.core.$strip>;
42
- readonly strict = true;
43
- constructor(_session: ToolSession);
44
- execute(_toolCallId: string, { calculations }: CalculatorParams, signal?: AbortSignal): Promise<AgentToolResult<CalculatorToolDetails>>;
45
- }
46
- interface CalculatorRenderArgs {
47
- calculations?: Array<{
48
- expression: string;
49
- prefix?: string;
50
- suffix?: string;
51
- }>;
52
- }
53
- /**
54
- * TUI renderer for calculator tool calls and results.
55
- * Handles both collapsed (preview) and expanded (full) display modes.
56
- */
57
- export declare const calculatorToolRenderer: {
58
- /**
59
- * Render the tool call header showing the first expression and count.
60
- * Format: "Calc <expression> (N calcs)"
61
- */
62
- renderCall(args: CalculatorRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component;
63
- /**
64
- * Render calculation results as a tree list.
65
- * Collapsed mode shows first N items with expand hint; expanded shows all.
66
- */
67
- renderResult(result: {
68
- content: Array<{
69
- type: string;
70
- text?: string;
71
- }>;
72
- details?: CalculatorToolDetails;
73
- isError?: boolean;
74
- }, options: RenderResultOptions, uiTheme: Theme, args?: CalculatorRenderArgs): Component;
75
- mergeCallAndResult: boolean;
76
- };
77
- export {};
@@ -1,10 +0,0 @@
1
- Performs basic calculations.
2
-
3
- <instruction>
4
- - Supports +, -, *, /, %, ** and parentheses
5
- - Supports decimal, hex (0x), binary (0b), and octal (0o) literals
6
- </instruction>
7
-
8
- <output>
9
- Returns each calculation result with its prefix and suffix applied.
10
- </output>