@oh-my-pi/pi-coding-agent 14.5.13 → 14.6.0

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 (105) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +7 -7
  3. package/src/autoresearch/command-resume.md +5 -8
  4. package/src/autoresearch/git.ts +41 -51
  5. package/src/autoresearch/helpers.ts +43 -359
  6. package/src/autoresearch/index.ts +281 -273
  7. package/src/autoresearch/prompt-setup.md +43 -0
  8. package/src/autoresearch/prompt.md +52 -193
  9. package/src/autoresearch/resume-message.md +2 -8
  10. package/src/autoresearch/state.ts +59 -166
  11. package/src/autoresearch/storage.ts +687 -0
  12. package/src/autoresearch/tools/init-experiment.ts +201 -290
  13. package/src/autoresearch/tools/log-experiment.ts +304 -517
  14. package/src/autoresearch/tools/run-experiment.ts +117 -296
  15. package/src/autoresearch/tools/update-notes.ts +116 -0
  16. package/src/autoresearch/types.ts +16 -66
  17. package/src/commit/pipeline.ts +4 -3
  18. package/src/config/settings-schema.ts +1 -1
  19. package/src/config/settings.ts +20 -1
  20. package/src/config.ts +9 -6
  21. package/src/cursor.ts +1 -1
  22. package/src/edit/index.ts +9 -31
  23. package/src/edit/line-hash.ts +70 -43
  24. package/src/edit/modes/hashline.lark +26 -0
  25. package/src/edit/modes/hashline.ts +898 -1099
  26. package/src/edit/modes/patch.ts +0 -7
  27. package/src/edit/modes/replace.ts +0 -4
  28. package/src/edit/renderer.ts +22 -20
  29. package/src/edit/streaming.ts +8 -28
  30. package/src/eval/eval.lark +24 -30
  31. package/src/eval/js/context-manager.ts +5 -162
  32. package/src/eval/js/prelude.txt +0 -12
  33. package/src/eval/parse.ts +129 -129
  34. package/src/eval/py/kernel.ts +4 -4
  35. package/src/eval/py/prelude.py +1 -219
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +2 -2
  38. package/src/internal-urls/docs-index.generated.ts +1 -1
  39. package/src/main.ts +10 -0
  40. package/src/mcp/manager.ts +22 -0
  41. package/src/modes/components/session-observer-overlay.ts +5 -2
  42. package/src/modes/components/status-line/segments.ts +1 -1
  43. package/src/modes/components/status-line.ts +3 -5
  44. package/src/modes/components/tree-selector.ts +4 -5
  45. package/src/modes/components/welcome.ts +11 -1
  46. package/src/modes/controllers/command-controller.ts +2 -6
  47. package/src/modes/controllers/event-controller.ts +1 -2
  48. package/src/modes/controllers/extension-ui-controller.ts +3 -15
  49. package/src/modes/controllers/input-controller.ts +0 -1
  50. package/src/modes/controllers/selector-controller.ts +1 -1
  51. package/src/modes/interactive-mode.ts +5 -7
  52. package/src/modes/rpc/rpc-client.ts +9 -0
  53. package/src/modes/rpc/rpc-mode.ts +6 -0
  54. package/src/modes/rpc/rpc-types.ts +9 -0
  55. package/src/prompts/system/system-prompt.md +14 -38
  56. package/src/prompts/tools/ast-edit.md +8 -8
  57. package/src/prompts/tools/ast-grep.md +10 -10
  58. package/src/prompts/tools/eval.md +13 -31
  59. package/src/prompts/tools/find.md +2 -1
  60. package/src/prompts/tools/hashline.md +66 -57
  61. package/src/prompts/tools/search.md +2 -2
  62. package/src/sdk.ts +19 -4
  63. package/src/session/agent-session.ts +110 -4
  64. package/src/session/session-manager.ts +17 -13
  65. package/src/task/agents.ts +4 -5
  66. package/src/tools/archive-reader.ts +9 -3
  67. package/src/tools/ast-edit.ts +141 -44
  68. package/src/tools/ast-grep.ts +112 -36
  69. package/src/tools/browser/readable.ts +11 -6
  70. package/src/tools/browser/tab-supervisor.ts +2 -2
  71. package/src/tools/browser.ts +5 -3
  72. package/src/tools/eval.ts +2 -53
  73. package/src/tools/find.ts +16 -15
  74. package/src/tools/image-gen.ts +2 -2
  75. package/src/tools/path-utils.ts +36 -196
  76. package/src/tools/search.ts +56 -35
  77. package/src/tools/write.ts +8 -1
  78. package/src/utils/edit-mode.ts +2 -11
  79. package/src/utils/file-display-mode.ts +1 -1
  80. package/src/utils/git.ts +17 -0
  81. package/src/utils/session-color.ts +0 -12
  82. package/src/utils/title-generator.ts +22 -38
  83. package/src/web/scrapers/crossref.ts +3 -3
  84. package/src/web/scrapers/devto.ts +1 -1
  85. package/src/web/scrapers/discourse.ts +5 -5
  86. package/src/web/scrapers/firefox-addons.ts +1 -1
  87. package/src/web/scrapers/flathub.ts +2 -2
  88. package/src/web/scrapers/gitlab.ts +1 -1
  89. package/src/web/scrapers/go-pkg.ts +2 -2
  90. package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
  91. package/src/web/scrapers/mastodon.ts +9 -9
  92. package/src/web/scrapers/mdn.ts +11 -7
  93. package/src/web/scrapers/pub-dev.ts +1 -1
  94. package/src/web/scrapers/rawg.ts +3 -3
  95. package/src/web/scrapers/readthedocs.ts +1 -1
  96. package/src/web/scrapers/spdx.ts +1 -1
  97. package/src/web/scrapers/stackoverflow.ts +2 -2
  98. package/src/web/scrapers/types.ts +53 -39
  99. package/src/web/scrapers/w3c.ts +1 -1
  100. package/src/web/search/providers/gemini.ts +2 -2
  101. package/src/autoresearch/apply-contract-to-state.ts +0 -24
  102. package/src/autoresearch/contract.ts +0 -288
  103. package/src/edit/modes/atom.lark +0 -29
  104. package/src/edit/modes/atom.ts +0 -1773
  105. package/src/prompts/tools/atom.md +0 -150
@@ -0,0 +1,116 @@
1
+ import { Text } from "@oh-my-pi/pi-tui";
2
+ import { Type } from "@sinclair/typebox";
3
+ import type { ToolDefinition } from "../../extensibility/extensions";
4
+ import type { Theme } from "../../modes/theme/theme";
5
+ import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
6
+ import * as git from "../../utils/git";
7
+ import { buildExperimentState } from "../state";
8
+ import { openAutoresearchStorageIfExists } from "../storage";
9
+ import type { AutoresearchToolFactoryOptions } from "../types";
10
+
11
+ const updateNotesSchema = Type.Object({
12
+ body: Type.String({
13
+ description: "Replacement markdown body for the active autoresearch session's notes (your durable playbook).",
14
+ }),
15
+ append_idea: Type.Optional(
16
+ Type.String({
17
+ description:
18
+ "When set, append this string as a new bullet under an Ideas section instead of replacing the body. `body` is ignored.",
19
+ }),
20
+ ),
21
+ });
22
+
23
+ interface UpdateNotesDetails {
24
+ notes: string;
25
+ }
26
+
27
+ export function createUpdateNotesTool(
28
+ options: AutoresearchToolFactoryOptions,
29
+ ): ToolDefinition<typeof updateNotesSchema, UpdateNotesDetails> {
30
+ return {
31
+ name: "update_notes",
32
+ label: "Update Notes",
33
+ description:
34
+ "Persist the durable autoresearch playbook (goal, scope notes, hypotheses, ideas backlog) on the active session. Pass `body` to replace the entire notes blob, or `append_idea` to append a single bullet under an `## Ideas` section.",
35
+ parameters: updateNotesSchema,
36
+ defaultInactive: true,
37
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
38
+ const storage = await openAutoresearchStorageIfExists(ctx.cwd);
39
+ const currentBranch = (await git.branch.current(ctx.cwd)) ?? null;
40
+ const session = storage?.getActiveSessionForBranch(currentBranch) ?? null;
41
+ if (!storage || !session) {
42
+ return {
43
+ content: [
44
+ {
45
+ type: "text",
46
+ text: "Error: no active autoresearch session for the current branch. Call init_experiment first.",
47
+ },
48
+ ],
49
+ };
50
+ }
51
+
52
+ const nextNotes =
53
+ params.append_idea !== undefined && params.append_idea.trim().length > 0
54
+ ? appendIdea(session.notes, params.append_idea.trim())
55
+ : params.body;
56
+
57
+ storage.updateSession(session.id, { notes: nextNotes });
58
+ const refreshed = storage.getSessionById(session.id);
59
+ const loggedRuns = storage.listLoggedRuns(session.id);
60
+ const runtime = options.getRuntime(ctx);
61
+ if (refreshed) {
62
+ runtime.state = buildExperimentState(refreshed, loggedRuns);
63
+ }
64
+ options.dashboard.updateWidget(ctx, runtime);
65
+
66
+ return {
67
+ content: [
68
+ {
69
+ type: "text",
70
+ text:
71
+ params.append_idea !== undefined
72
+ ? `Appended idea (${nextNotes.length} chars total).`
73
+ : `Notes updated (${nextNotes.length} chars).`,
74
+ },
75
+ ],
76
+ details: { notes: nextNotes },
77
+ };
78
+ },
79
+ renderCall(args, _options, theme): Text {
80
+ const preview = args.append_idea ?? args.body.slice(0, 100);
81
+ return new Text(
82
+ `${theme.fg("toolTitle", theme.bold("update_notes"))} ${theme.fg("muted", truncateToWidth(replaceTabs(preview), 100))}`,
83
+ 0,
84
+ 0,
85
+ );
86
+ },
87
+ renderResult(result, _options, theme: Theme): Text {
88
+ const text = replaceTabs(result.content.find(part => part.type === "text")?.text ?? "");
89
+ return new Text(theme.fg("muted", text), 0, 0);
90
+ },
91
+ };
92
+ }
93
+
94
+ const IDEAS_HEADING = "## Ideas";
95
+
96
+ function appendIdea(currentNotes: string, idea: string): string {
97
+ const trimmed = currentNotes.trimEnd();
98
+ if (trimmed.length === 0) {
99
+ return `${IDEAS_HEADING}\n- ${idea}\n`;
100
+ }
101
+ if (trimmed.includes(IDEAS_HEADING)) {
102
+ const lines = trimmed.split("\n");
103
+ const ideasIndex = lines.findIndex(line => line.trim() === IDEAS_HEADING);
104
+ // find end of ideas section (next heading or end of file)
105
+ let insertAt = lines.length;
106
+ for (let i = ideasIndex + 1; i < lines.length; i += 1) {
107
+ if (/^#{1,6}\s/.test(lines[i] ?? "")) {
108
+ insertAt = i;
109
+ break;
110
+ }
111
+ }
112
+ lines.splice(insertAt, 0, `- ${idea}`);
113
+ return `${lines.join("\n")}\n`;
114
+ }
115
+ return `${trimmed}\n\n${IDEAS_HEADING}\n- ${idea}\n`;
116
+ }
@@ -21,21 +21,6 @@ export interface MetricDef {
21
21
  unit: string;
22
22
  }
23
23
 
24
- export interface AutoresearchBenchmarkContract {
25
- command: string | null;
26
- primaryMetric: string | null;
27
- metricUnit: string;
28
- direction: MetricDirection | null;
29
- secondaryMetrics: string[];
30
- }
31
-
32
- export interface AutoresearchContract {
33
- benchmark: AutoresearchBenchmarkContract;
34
- scopePaths: string[];
35
- offLimits: string[];
36
- constraints: string[];
37
- }
38
-
39
24
  export interface ExperimentResult {
40
25
  runNumber: number | null;
41
26
  commit: string;
@@ -47,6 +32,11 @@ export interface ExperimentResult {
47
32
  segment: number;
48
33
  confidence: number | null;
49
34
  asi?: ASIData;
35
+ modifiedPaths: string[];
36
+ scopeDeviations: string[];
37
+ justification: string | null;
38
+ flagged: boolean;
39
+ flaggedReason: string | null;
50
40
  }
51
41
 
52
42
  export interface ExperimentState {
@@ -57,13 +47,17 @@ export interface ExperimentState {
57
47
  metricUnit: string;
58
48
  secondaryMetrics: MetricDef[];
59
49
  name: string | null;
50
+ goal: string | null;
60
51
  currentSegment: number;
61
52
  maxExperiments: number | null;
62
53
  confidence: number | null;
63
- benchmarkCommand: string | null;
64
54
  scopePaths: string[];
65
55
  offLimits: string[];
66
56
  constraints: string[];
57
+ notes: string;
58
+ branch: string | null;
59
+ baselineCommit: string | null;
60
+ sessionId: number | null;
67
61
  }
68
62
 
69
63
  export interface RunExperimentProgressDetails {
@@ -78,7 +72,6 @@ export interface RunDetails {
78
72
  runNumber: number;
79
73
  runDirectory: string;
80
74
  benchmarkLogPath: string;
81
- checksLogPath?: string;
82
75
  command: string;
83
76
  exitCode: number | null;
84
77
  durationSeconds: number;
@@ -86,16 +79,13 @@ export interface RunDetails {
86
79
  crashed: boolean;
87
80
  timedOut: boolean;
88
81
  tailOutput: string;
89
- checksPass: boolean | null;
90
- checksTimedOut: boolean;
91
- checksOutput: string;
92
- checksDuration: number;
93
82
  parsedMetrics: NumericMetricMap | null;
94
83
  parsedPrimary: number | null;
95
84
  parsedAsi: ASIData | null;
96
85
  metricName: string;
97
86
  metricUnit: string;
98
87
  preRunDirtyPaths: string[];
88
+ abandonedPriorRun: number | null;
99
89
  truncation?: TruncationResult;
100
90
  fullOutputPath?: string;
101
91
  }
@@ -104,18 +94,12 @@ export interface LogDetails {
104
94
  experiment: ExperimentResult;
105
95
  state: ExperimentState;
106
96
  wallClockSeconds: number | null;
107
- }
108
-
109
- export interface ChecksResult {
110
- pass: boolean;
111
- output: string;
112
- duration: number;
97
+ scopeDeviations: string[];
98
+ justification: string | null;
99
+ flaggedRuns: Array<{ runId: number; reason: string }>;
113
100
  }
114
101
 
115
102
  export interface PendingRunSummary {
116
- checksDurationSeconds: number | null;
117
- checksPass: boolean | null;
118
- checksTimedOut: boolean;
119
103
  command: string;
120
104
  durationSeconds: number | null;
121
105
  parsedAsi: ASIData | null;
@@ -125,6 +109,8 @@ export interface PendingRunSummary {
125
109
  preRunDirtyPaths: string[];
126
110
  runDirectory: string;
127
111
  runNumber: number;
112
+ exitCode: number | null;
113
+ timedOut: boolean;
128
114
  }
129
115
 
130
116
  export interface RunningExperiment {
@@ -139,7 +125,6 @@ export interface AutoresearchRuntime {
139
125
  autoResumeArmed: boolean;
140
126
  dashboardExpanded: boolean;
141
127
  lastAutoResumePendingRunNumber: number | null;
142
- lastRunChecks: ChecksResult | null;
143
128
  lastRunDuration: number | null;
144
129
  lastRunAsi: ASIData | null;
145
130
  lastRunArtifactDir: string | null;
@@ -150,41 +135,6 @@ export interface AutoresearchRuntime {
150
135
  goal: string | null;
151
136
  }
152
137
 
153
- export interface AutoresearchConfig {
154
- maxIterations?: number;
155
- workingDir?: string;
156
- }
157
-
158
- export interface AutoresearchJsonConfigEntry {
159
- type: "config";
160
- name?: string;
161
- metricName?: string;
162
- metricUnit?: string;
163
- bestDirection?: MetricDirection;
164
- benchmarkCommand?: string;
165
- secondaryMetrics?: string[];
166
- scopePaths?: string[];
167
- offLimits?: string[];
168
- constraints?: string[];
169
- }
170
-
171
- export interface AutoresearchJsonRunEntry {
172
- run?: number;
173
- commit?: string;
174
- metric?: number;
175
- metrics?: NumericMetricMap;
176
- status?: ExperimentStatus;
177
- description?: string;
178
- timestamp?: number;
179
- confidence?: number | null;
180
- asi?: ASIData;
181
- }
182
-
183
- export interface ReconstructedExperimentData {
184
- hasLog: boolean;
185
- state: ExperimentState;
186
- }
187
-
188
138
  export interface AutoresearchControlEntryData {
189
139
  mode: "on" | "off" | "clear";
190
140
  goal?: string;
@@ -25,7 +25,8 @@ import type { CommitCommandArgs, ConventionalAnalysis } from "./types";
25
25
 
26
26
  const SUMMARY_MAX_CHARS = 72;
27
27
  const RECENT_COMMITS_COUNT = 8;
28
- const TYPES_DESCRIPTION = prompt.render(typesDescriptionPrompt);
28
+ let _typesDescription: string | undefined;
29
+ const TYPES_DESCRIPTION = (): string => (_typesDescription ??= prompt.render(typesDescriptionPrompt));
29
30
 
30
31
  /**
31
32
  * Execute the omp commit pipeline for staged changes.
@@ -176,7 +177,7 @@ async function generateAnalysis(input: {
176
177
  diff: input.diff,
177
178
  stat: input.stat,
178
179
  scopeCandidates: input.scopeCandidates,
179
- typesDescription: TYPES_DESCRIPTION,
180
+ typesDescription: TYPES_DESCRIPTION(),
180
181
  settings: {
181
182
  enabled: input.commitSettings.mapReduceEnabled,
182
183
  minFiles: input.commitSettings.mapReduceMinFiles,
@@ -193,7 +194,7 @@ async function generateAnalysis(input: {
193
194
  thinkingLevel: input.primaryThinkingLevel,
194
195
  contextFiles: input.contextFiles,
195
196
  userContext: input.userContext,
196
- typesDescription: TYPES_DESCRIPTION,
197
+ typesDescription: TYPES_DESCRIPTION(),
197
198
  recentCommits: input.recentCommits,
198
199
  scopeCandidates: input.scopeCandidates,
199
200
  stat: input.stat,
@@ -973,7 +973,7 @@ export const SETTINGS_SCHEMA = {
973
973
  ui: {
974
974
  tab: "editing",
975
975
  label: "Edit Mode",
976
- description: "Select the edit tool variant (replace, patch, hashline, atom, vim, or apply_patch)",
976
+ description: "Select the edit tool variant (replace, patch, hashline, vim, or apply_patch)",
977
977
  },
978
978
  },
979
979
 
@@ -326,7 +326,7 @@ export class Settings {
326
326
 
327
327
  /**
328
328
  * Get the edit variant for a specific model.
329
- * Returns "patch", "replace", "hashline", "atom", "vim", "apply_patch", or null (use global default).
329
+ * Returns "patch", "replace", "hashline", "vim", "apply_patch", or null (use global default).
330
330
  */
331
331
  getEditVariantForModel(model: string | undefined): EditMode | null {
332
332
  if (!model) return null;
@@ -533,6 +533,25 @@ export class Settings {
533
533
  delete isolationObj.enabled;
534
534
  }
535
535
 
536
+ // edit.mode: removed "atom" variant is now "hashline"
537
+ const editObj = raw.edit as Record<string, unknown> | undefined;
538
+ if (editObj) {
539
+ if (editObj.mode === "atom") {
540
+ editObj.mode = "hashline";
541
+ }
542
+ const modelVariants = editObj.modelVariants as Record<string, unknown> | undefined;
543
+ if (modelVariants && typeof modelVariants === "object" && !Array.isArray(modelVariants)) {
544
+ for (const [pattern, variant] of Object.entries(modelVariants)) {
545
+ if (variant === "atom") {
546
+ modelVariants[pattern] = "hashline";
547
+ }
548
+ }
549
+ }
550
+ }
551
+ if (raw["edit.mode"] === "atom") {
552
+ raw["edit.mode"] = "hashline";
553
+ }
554
+
536
555
  // statusLine: rename "plan_mode" segment to "mode"
537
556
  const statusLineObj = raw.statusLine as Record<string, unknown> | undefined;
538
557
  if (statusLineObj) {
package/src/config.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  } from "@oh-my-pi/pi-utils";
12
12
  import type { TSchema } from "@sinclair/typebox";
13
13
  import { Value } from "@sinclair/typebox/value";
14
- import { Ajv, type ErrorObject, type ValidateFunction } from "ajv";
14
+ import type { ErrorObject } from "ajv";
15
15
  import { JSONC, YAML } from "bun";
16
16
  import { expandTilde } from "./tools/path-utils";
17
17
 
@@ -143,7 +143,6 @@ export type LoadResult<T> =
143
143
  | { value: T; error?: undefined; status: "ok" }
144
144
  | { value?: null; error?: unknown; status: "not-found" };
145
145
 
146
- const ajv = new Ajv();
147
146
  export class ConfigFile<T> implements IConfigFile<T> {
148
147
  readonly #basePath: string;
149
148
  #cache?: LoadResult<T>;
@@ -221,13 +220,17 @@ export class ConfigFile<T> implements IConfigFile<T> {
221
220
  throw new Error(`Invalid config file path: ${this.#basePath}`);
222
221
  }
223
222
 
224
- const validate = ajv.compile(this.schema) as ValidateFunction<T>;
225
- if (!validate(parsed)) {
226
- const error = new ConfigError(this.id, validate.errors);
223
+ if (!Value.Check(this.schema, parsed)) {
224
+ const schemaErrors: ErrorObject[] = [];
225
+ for (const err of Value.Errors(this.schema, parsed)) {
226
+ schemaErrors.push({ instancePath: err.path, message: err.message } as ErrorObject);
227
+ if (schemaErrors.length >= 50) break;
228
+ }
229
+ const error = new ConfigError(this.id, schemaErrors);
227
230
  logger.warn("Failed to parse config file", { path: this.path(), error });
228
231
  return this.#storeCache({ error, status: "error" });
229
232
  }
230
- return this.#storeCache({ value: parsed, status: "ok" });
233
+ return this.#storeCache({ value: parsed as T, status: "ok" });
231
234
  } catch (error) {
232
235
  if (isEnoent(error)) {
233
236
  return this.#storeCache({ status: "not-found" });
package/src/cursor.ts CHANGED
@@ -180,7 +180,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
180
180
  const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
181
181
  const toolResultMessage = await executeTool(this.options, "search", toolCallId, {
182
182
  pattern: args.pattern,
183
- path: searchPath,
183
+ paths: [searchPath],
184
184
  i: args.caseInsensitive || undefined,
185
185
  });
186
186
  return toolResultMessage;
package/src/edit/index.ts CHANGED
@@ -9,7 +9,6 @@ import {
9
9
  writethroughNoop,
10
10
  } from "../lsp";
11
11
  import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
12
- import atomDescription from "../prompts/tools/atom.md" with { type: "text" };
13
12
  import hashlineDescription from "../prompts/tools/hashline.md" with { type: "text" };
14
13
  import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
15
14
  import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
@@ -17,17 +16,16 @@ import type { ToolSession } from "../tools";
17
16
  import { VimTool, vimSchema } from "../tools/vim";
18
17
  import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
19
18
  import type { VimToolDetails } from "../vim/types";
19
+ import { resolveLarkLidPlaceholders } from "./line-hash";
20
20
  import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
21
21
  import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
22
- import { type AtomParams, atomEditParamsSchema, executeAtomSingle } from "./modes/atom";
23
- import atomGrammar from "./modes/atom.lark" with { type: "text" };
24
22
  import {
25
23
  executeHashlineSingle,
26
24
  HashlineMismatchError,
27
25
  type HashlineParams,
28
- type HashlineToolEdit,
29
26
  hashlineEditParamsSchema,
30
27
  } from "./modes/hashline";
28
+ import hashlineGrammarTemplate from "./modes/hashline.lark" with { type: "text" };
31
29
  import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
32
30
  import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
33
31
  import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
@@ -36,8 +34,11 @@ export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/ed
36
34
  export * from "./apply-patch";
37
35
  export * from "./diff";
38
36
  export * from "./line-hash";
37
+
38
+ // Resolve the `$HASHFMT$` placeholder in the hashline Lark grammar.
39
+ const hashlineGrammar = resolveLarkLidPlaceholders(hashlineGrammarTemplate);
40
+
39
41
  export * from "./modes/apply-patch";
40
- export * from "./modes/atom";
41
42
  export * from "./modes/hashline";
42
43
  export * from "./modes/patch";
43
44
  export * from "./modes/replace";
@@ -49,12 +50,11 @@ type TInput =
49
50
  | typeof replaceEditSchema
50
51
  | typeof patchEditSchema
51
52
  | typeof hashlineEditParamsSchema
52
- | typeof atomEditParamsSchema
53
53
  | typeof vimSchema
54
54
  | typeof applyPatchSchema;
55
55
 
56
56
  type VimParams = Static<typeof vimSchema>;
57
- type EditParams = ReplaceParams | PatchParams | HashlineParams | AtomParams | VimParams | ApplyPatchParams;
57
+ type EditParams = ReplaceParams | PatchParams | HashlineParams | VimParams | ApplyPatchParams;
58
58
  type EditToolResultDetails = EditToolDetails | VimToolDetails;
59
59
 
60
60
  type EditModeDefinition = {
@@ -292,7 +292,7 @@ export class EditTool implements AgentTool<TInput> {
292
292
  */
293
293
  get customFormat(): { syntax: "lark"; definition: string } | undefined {
294
294
  if (this.mode === "apply_patch") return { syntax: "lark", definition: applyPatchGrammar };
295
- if (this.mode === "atom") return { syntax: "lark", definition: atomGrammar };
295
+ if (this.mode === "hashline") return { syntax: "lark", definition: hashlineGrammar };
296
296
  return undefined;
297
297
  }
298
298
 
@@ -390,30 +390,8 @@ export class EditTool implements AgentTool<TInput> {
390
390
  batchRequest: LspBatchRequest | undefined,
391
391
  _onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
392
392
  ) => {
393
- const { edits, path } = params as HashlineParams;
393
+ const { input, path } = params as HashlineParams & { path?: string };
394
394
  return executeHashlineSingle({
395
- session: tool.session,
396
- path,
397
- edits: edits as HashlineToolEdit[],
398
- signal,
399
- batchRequest,
400
- writethrough: tool.#writethrough,
401
- beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
402
- });
403
- },
404
- },
405
- atom: {
406
- description: () => prompt.render(atomDescription),
407
- parameters: atomEditParamsSchema,
408
- execute: (
409
- tool: EditTool,
410
- params: EditParams,
411
- signal: AbortSignal | undefined,
412
- batchRequest: LspBatchRequest | undefined,
413
- _onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
414
- ) => {
415
- const { input, path } = params as AtomParams & { path?: string };
416
- return executeAtomSingle({
417
395
  session: tool.session,
418
396
  input,
419
397
  path,
@@ -668,66 +668,93 @@ export const HASHLINE_BIGRAMS = [
668
668
  export const HASHLINE_BIGRAMS_COUNT = HASHLINE_BIGRAMS.length;
669
669
 
670
670
  /**
671
- * Regex source matching exactly one bigram from {@link HASHLINE_BIGRAMS}.
672
- * Used by hashline parsers keep in sync with the alphabet array above.
671
+ * Decoration prefix that may precede a `LINE+HASH` anchor in tool output:
672
+ * `>` (context line in grep), `+` (added line in diff), `-` (removed line),
673
+ * `*` (match line). Any combination, in any order, surrounded by optional
674
+ * whitespace. Output formatters emit at most one decoration per anchor; the
675
+ * regex stays liberal because anchor-ref parsers accept whatever the model
676
+ * echoes back.
673
677
  */
674
- export const HASHLINE_BIGRAM_RE_SRC = `(?:${HASHLINE_BIGRAMS.join("|")})`;
678
+ export const HASHLINE_ANCHOR_DECORATION_RE_SRC = `\\s*[>+\\-*]*\\s*`;
675
679
 
676
- export const HASHLINE_CONTENT_SEPARATOR = "|";
680
+ /**
681
+ * Capture-group regex source for a decorated `LINE+HASH` anchor. Group 1
682
+ * captures the line number (digits only); group 2 captures the hash. The
683
+ * source is intentionally unanchored — anchoring with `^` (or composing into a
684
+ * larger pattern) is the caller's responsibility.
685
+ */
686
+ export const HASHLINE_ANCHOR_RE_SRC = `${HASHLINE_ANCHOR_DECORATION_RE_SRC}(\\d+)([a-z]{2})`;
677
687
 
678
- const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
679
- const RE_STRUCTURAL_STRIP = /[\s{}]/g;
688
+ /**
689
+ * Bare `LINE+HASH` Lid (no decorations, no captures, no anchors). Use for
690
+ * embedding inside larger patterns where the line+hash unit appears as a
691
+ * literal (e.g. range bounds, alternation arms, op-line heuristics).
692
+ */
693
+ export const HASHLINE_LID_RE_SRC = `[1-9]\\d*[a-z]{2}`;
694
+
695
+ /**
696
+ * Capture-group form of {@link HASHLINE_LID_RE_SRC}: group 1 captures the
697
+ * line number, group 2 captures the hash.
698
+ */
699
+ export const HASHLINE_LID_CAPTURE_RE_SRC = `([1-9]\\d*)([a-z]{2})`;
700
+
701
+ /** Width of a hash in display characters. */
702
+ export const HASHLINE_HASH_WIDTH = 2;
703
+
704
+ /**
705
+ * Representative hash suffixes for use in user-facing error messages and
706
+ * prompt examples.
707
+ */
708
+ export const HASHLINE_HASH_EXAMPLES = ["sr", "ab", "th"] as const;
680
709
 
681
710
  /**
682
- * Bigram returned for lines that contain only whitespace and `{`/`}`.
683
- * Picks the English ordinal suffix for the line number (`1` `st`,
684
- * `2` → `nd`, `3` → `rd`, `11`/`12`/`13` → `th`, else `th`) so the
685
- * line digits + bigram BPE-merge into a single ordinal token (`1st`, `42nd`,
686
- * `100th`, …). Brace-only lines therefore cost one token for the whole
687
- * `LINE+ID` anchor instead of two.
711
+ * Format a comma-separated list of example anchors with an optional line-number
712
+ * prefix, quoted for inclusion in error messages: `"160sr", "160ab", "160th"`.
688
713
  */
689
- function structuralBigram(line: number): string {
690
- const mod100 = line % 100;
691
- if (mod100 >= 11 && mod100 <= 13) return "th";
692
- switch (line % 10) {
693
- case 1:
694
- return "st";
695
- case 2:
696
- return "nd";
697
- case 3:
698
- return "rd";
699
- default:
700
- return "th";
701
- }
714
+ export function describeAnchorExamples(linePrefix = ""): string {
715
+ return HASHLINE_HASH_EXAMPLES.map(e => `"${linePrefix}${e}"`).join(", ");
702
716
  }
703
717
 
704
718
  /**
705
- * Compute a short BPE-bigram hash of a single line.
719
+ * Sentinel token that the hashline Lark grammar uses for the hash
720
+ * regex source. Replaced at module-load time by {@link resolveLarkLidPlaceholders}
721
+ * so the grammar is re-derived from a single source of truth alongside its
722
+ * TypeScript consumers. Update the placeholder name here and in the grammar together.
723
+ */
724
+ export const LARK_LID_HASH_PLACEHOLDER = "$HASHFMT$";
725
+
726
+ /**
727
+ * Substitute the LID hash placeholder in a Lark grammar text with the
728
+ * `[a-z]{2}` hash regex source. Grammars that don't reference Lids pass
729
+ * through unchanged.
730
+ */
731
+ export function resolveLarkLidPlaceholders(grammar: string): string {
732
+ return grammar.replaceAll(LARK_LID_HASH_PLACEHOLDER, "[a-z]{2}");
733
+ }
734
+
735
+ export const HASHLINE_CONTENT_SEPARATOR = "|";
736
+
737
+ const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
738
+
739
+ /**
740
+ * Compute a 2-character hash of a single line via xxHash32 mod 647 over
741
+ * {@link HASHLINE_BIGRAMS}. Lines with no letter or digit (e.g. bare `}`,
742
+ * bare `{`) mix the line number into the seed so adjacent identical
743
+ * brace-only lines get distinct hashes; lines with significant content stay
744
+ * line-number-independent so a line is identifiable across small shifts.
706
745
  *
707
- * Uses xxHash32 on a trailing-whitespace-trimmed, CR-stripped line, mapped into
708
- * {@link HASHLINE_BIGRAMS} via modulo. Lines that contain only whitespace and
709
- * `{`/`}` collapse to an ordinal-suffix bigram (see {@link structuralBigram})
710
- * so brace-only structure shares one merged ordinal token. For other lines
711
- * containing no alphanumeric characters, the line number is mixed in to reduce hash collisions.
712
746
  * The line input should not include a trailing newline.
713
747
  */
714
748
  export function computeLineHash(idx: number, line: string): string {
715
749
  line = line.replace(/\r/g, "").trimEnd();
716
-
717
- if (line.replace(RE_STRUCTURAL_STRIP, "").length === 0) {
718
- return structuralBigram(idx);
719
- }
720
-
721
- let seed = 0;
722
- if (!RE_SIGNIFICANT.test(line)) {
723
- seed = idx;
724
- }
750
+ const seed = RE_SIGNIFICANT.test(line) ? 0 : idx;
725
751
  return HASHLINE_BIGRAMS[Bun.hash.xxHash32(line, seed) % HASHLINE_BIGRAMS_COUNT];
726
752
  }
727
753
 
728
754
  /**
729
755
  * Formats an anchor reference given a line number and its text.
730
- * Returns `LINE+ID` (e.g., `42nd`) — no separator between number and bigram.
756
+ * Returns `LINE+ID` (e.g., `42sr`) — no separator between
757
+ * number and hash.
731
758
  */
732
759
  export function formatLineHash(line: number, lines: string): string {
733
760
  return `${line}${computeLineHash(line, lines)}`;
@@ -735,7 +762,7 @@ export function formatLineHash(line: number, lines: string): string {
735
762
 
736
763
  /**
737
764
  * Formats a single line with a hashline anchor.
738
- * Returns `LINE+ID|TEXT` (e.g., `42nd|function hi() {\n2er| return;\n3in|}`)
765
+ * Returns `LINE+ID|TEXT` (e.g., `42sr|function hi() {`, `3ab|}`).
739
766
  */
740
767
  export function formatHashLine(lineNumber: number, line: string): string {
741
768
  return `${lineNumber}${computeLineHash(lineNumber, line)}${HASHLINE_CONTENT_SEPARATOR}${line}`;
@@ -754,7 +781,7 @@ export function formatHashLine(lineNumber: number, line: string): string {
754
781
  * @example
755
782
  * ```
756
783
  * formatHashLines("function hi() {\n return;\n}")
757
- * // "1th|function hi() {\n2er| return;\n3in|}"
784
+ * // "1bm|function hi() {\n2er| return;\n3ab|}"
758
785
  * ```
759
786
  */
760
787
  export function formatHashLines(text: string, startLine = 1): string {
@@ -0,0 +1,26 @@
1
+ %import common.LF
2
+
3
+ start: section+
4
+
5
+ section: file_header line_op*
6
+
7
+ file_header: "@" path LF
8
+
9
+ line_op: insert_before_op payload+
10
+ | insert_after_op payload+
11
+ | replace_op payload*
12
+ | delete_op
13
+ | blank
14
+
15
+ insert_before_op: "<" insert_target LF
16
+ insert_after_op: "+" insert_target LF
17
+ replace_op: "=" range LF
18
+ delete_op: "-" range LF
19
+ payload: "|" /[^\r\n]*/ LF
20
+
21
+ insert_target: LID | "EOF" | "BOF"
22
+ range: LID (".." LID)?
23
+
24
+ path: /(?:[^\s\r\n]+|"[^"\r\n]+"|'[^'\r\n]+')/
25
+ LID: /[1-9][0-9]*$HASHFMT$/
26
+ blank: LF