pi-readseek 0.1.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 (67) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +41 -0
  3. package/index.ts +142 -0
  4. package/package.json +73 -0
  5. package/prompts/edit.md +113 -0
  6. package/prompts/find.md +19 -0
  7. package/prompts/grep.md +26 -0
  8. package/prompts/ls.md +11 -0
  9. package/prompts/read.md +33 -0
  10. package/prompts/sg.md +25 -0
  11. package/prompts/write.md +46 -0
  12. package/src/binary-detect.ts +22 -0
  13. package/src/binary-resolution.ts +77 -0
  14. package/src/coerce-obvious-int.ts +39 -0
  15. package/src/context-application.ts +70 -0
  16. package/src/context-hygiene.ts +503 -0
  17. package/src/diff-data.ts +303 -0
  18. package/src/doom-loop-suggestions.ts +42 -0
  19. package/src/doom-loop.ts +216 -0
  20. package/src/edit-classify.ts +190 -0
  21. package/src/edit-diff.ts +354 -0
  22. package/src/edit-output.ts +107 -0
  23. package/src/edit-render-helpers.ts +141 -0
  24. package/src/edit-syntax-validate.ts +120 -0
  25. package/src/edit.ts +725 -0
  26. package/src/find-parsers.ts +89 -0
  27. package/src/find-stat.ts +36 -0
  28. package/src/find.ts +613 -0
  29. package/src/grep-budget.ts +79 -0
  30. package/src/grep-output.ts +197 -0
  31. package/src/grep-render-helpers.ts +77 -0
  32. package/src/grep-symbol-scope.ts +197 -0
  33. package/src/grep.ts +792 -0
  34. package/src/hashline.ts +747 -0
  35. package/src/ls.ts +293 -0
  36. package/src/map-cache.ts +152 -0
  37. package/src/path-utils.ts +24 -0
  38. package/src/pending-diff-preview.ts +269 -0
  39. package/src/persistent-map-cache.ts +251 -0
  40. package/src/read-local-bundle.ts +87 -0
  41. package/src/read-output.ts +212 -0
  42. package/src/read-render-helpers.ts +104 -0
  43. package/src/read.ts +748 -0
  44. package/src/readseek/constants.ts +21 -0
  45. package/src/readseek/enums.ts +38 -0
  46. package/src/readseek/formatter.ts +431 -0
  47. package/src/readseek/language-detect.ts +29 -0
  48. package/src/readseek/mapper.ts +69 -0
  49. package/src/readseek/parser-errors.ts +22 -0
  50. package/src/readseek/parser-loader.ts +83 -0
  51. package/src/readseek/symbol-error-format.ts +18 -0
  52. package/src/readseek/symbol-lookup.ts +294 -0
  53. package/src/readseek/types.ts +79 -0
  54. package/src/readseek-client.ts +343 -0
  55. package/src/readseek-error-codes.ts +54 -0
  56. package/src/readseek-settings.ts +287 -0
  57. package/src/readseek-value.ts +144 -0
  58. package/src/replace-symbol.ts +74 -0
  59. package/src/runtime.ts +3 -0
  60. package/src/sg-output.ts +88 -0
  61. package/src/sg.ts +308 -0
  62. package/src/syntax-validate-mode.ts +25 -0
  63. package/src/tool-prompt-metadata.ts +76 -0
  64. package/src/tui-diff-component.ts +86 -0
  65. package/src/tui-diff-renderer.ts +92 -0
  66. package/src/tui-render-utils.ts +129 -0
  67. package/src/write.ts +532 -0
@@ -0,0 +1,303 @@
1
+ export type DiffEntry =
2
+ | { kind: "context"; oldLine: number; newLine: number; text: string }
3
+ | { kind: "add"; newLine: number; text: string }
4
+ | { kind: "remove"; oldLine: number; text: string }
5
+ | { kind: "meta"; text: string };
6
+
7
+ export type DiffSpan =
8
+ | { kind: "equal"; text: string }
9
+ | { kind: "add"; text: string }
10
+ | { kind: "remove"; text: string };
11
+
12
+ export type InlineDiff = {
13
+ removeLineIndex: number;
14
+ addLineIndex: number;
15
+ removeSpans: DiffSpan[];
16
+ addSpans: DiffSpan[];
17
+ };
18
+
19
+ export type DiffBlockRange = {
20
+ kind: "add" | "remove";
21
+ startLine: number;
22
+ endLine: number;
23
+ };
24
+
25
+ export type DiffData = {
26
+ version: 1;
27
+ entries: DiffEntry[];
28
+ stats: { added: number; removed: number; context: number };
29
+ language?: string;
30
+ blockRanges?: DiffBlockRange[];
31
+ inlineDiffs?: InlineDiff[];
32
+ };
33
+
34
+ export type BuildDiffDataInput = {
35
+ path: string;
36
+ oldContent: string;
37
+ newContent: string;
38
+ diff: string;
39
+ blockRanges?: DiffBlockRange[];
40
+ };
41
+
42
+ export const MAX_INLINE_DIFF_LINE_LENGTH = 4096;
43
+ export const MAX_INLINE_DIFF_TOKENS = 512;
44
+ export const MAX_INLINE_DIFF_CELLS = 200_000;
45
+ export const MAX_INLINE_DIFF_PAIRS = 200;
46
+
47
+ const INLINE_SIMILARITY_THRESHOLD = 0.35;
48
+ const INLINE_TOKEN_PATTERN = /([A-Za-z_$][\w$]*|\d+|\s+|[^A-Za-z_$\w\s]+)/gu;
49
+
50
+ const LANGUAGE_BY_EXTENSION = new Map<string, string>([
51
+ [".ts", "typescript"],
52
+ [".tsx", "typescript"],
53
+ [".js", "javascript"],
54
+ [".jsx", "javascript"],
55
+ [".py", "python"],
56
+ [".rs", "rust"],
57
+ [".java", "java"],
58
+ ]);
59
+
60
+ function inferLanguage(path: string): string | undefined {
61
+ const extensionMatch = path.match(/\.[^.\/\\]+$/);
62
+ if (!extensionMatch) return undefined;
63
+ return LANGUAGE_BY_EXTENSION.get(extensionMatch[0].toLowerCase());
64
+ }
65
+
66
+ function parseFullDiffEntries(diff: string): DiffEntry[] {
67
+ const entries: DiffEntry[] = [];
68
+ let nextOldLine = 1;
69
+ let nextNewLine = 1;
70
+
71
+ for (const line of diff ? diff.split("\n") : []) {
72
+ const removeMatch = line.match(/^-\s*(\d+) (.*)$/);
73
+ if (removeMatch) {
74
+ const oldLine = Number(removeMatch[1]);
75
+ entries.push({ kind: "remove", oldLine, text: removeMatch[2] ?? "" });
76
+ nextOldLine = oldLine + 1;
77
+ continue;
78
+ }
79
+
80
+ const addMatch = line.match(/^\+\s*(\d+) (.*)$/);
81
+ if (addMatch) {
82
+ const newLine = Number(addMatch[1]);
83
+ entries.push({ kind: "add", newLine, text: addMatch[2] ?? "" });
84
+ nextNewLine = newLine + 1;
85
+ continue;
86
+ }
87
+
88
+ const contextMatch = line.match(/^ \s*(\d+) (.*)$/);
89
+ if (contextMatch) {
90
+ const oldLine = Number(contextMatch[1]);
91
+ const lineDelta = nextNewLine - nextOldLine;
92
+ const newLine = oldLine + lineDelta;
93
+ entries.push({ kind: "context", oldLine, newLine, text: contextMatch[2] ?? "" });
94
+ nextOldLine = oldLine + 1;
95
+ nextNewLine = newLine + 1;
96
+ continue;
97
+ }
98
+
99
+ entries.push({ kind: "meta", text: line });
100
+ }
101
+
102
+ void nextOldLine;
103
+ return entries;
104
+ }
105
+
106
+ function parseCompactDiffEntries(diff: string, oldContent: string, newContent: string): DiffEntry[] | undefined {
107
+ const oldLines = oldContent.split("\n");
108
+ const newLines = newContent.split("\n");
109
+
110
+ const compactDelete = diff.match(/^(\d+):[0-9a-f]{3}\|(.*) → \[deleted\]$/);
111
+ if (compactDelete) {
112
+ const oldLine = Number(compactDelete[1]);
113
+ const text = compactDelete[2] ?? "";
114
+ if (oldLines[oldLine - 1] === text) return [{ kind: "remove", oldLine, text }];
115
+ }
116
+
117
+ const compactPrefix = diff.match(/^(\d+):[0-9a-f]{3}\|/);
118
+ if (!compactPrefix) return undefined;
119
+
120
+ const oldLine = Number(compactPrefix[1]);
121
+ const oldTextStart = compactPrefix[0].length;
122
+ const separatorPattern = / → (\d+):[0-9a-f]{3}\|/g;
123
+ let separator: RegExpExecArray | null;
124
+ while ((separator = separatorPattern.exec(diff)) !== null) {
125
+ const newLine = Number(separator[1]);
126
+ const oldText = diff.slice(oldTextStart, separator.index);
127
+ const newText = diff.slice(separator.index + separator[0].length);
128
+ if (oldLines[oldLine - 1] === oldText && newLines[newLine - 1] === newText) {
129
+ return [
130
+ { kind: "remove", oldLine, text: oldText },
131
+ { kind: "add", newLine, text: newText },
132
+ ];
133
+ }
134
+ }
135
+
136
+ return undefined;
137
+ }
138
+
139
+ function buildStats(entries: DiffEntry[]): DiffData["stats"] {
140
+ return entries.reduce(
141
+ (stats, entry) => {
142
+ if (entry.kind === "add") stats.added++;
143
+ else if (entry.kind === "remove") stats.removed++;
144
+ else if (entry.kind === "context") stats.context++;
145
+ return stats;
146
+ },
147
+ { added: 0, removed: 0, context: 0 },
148
+ );
149
+ }
150
+
151
+ function tokenizeInlineDiff(text: string): string[] {
152
+ return text.match(INLINE_TOKEN_PATTERN) ?? (text ? [text] : []);
153
+ }
154
+
155
+ function longestCommonSubsequence(a: string[], b: string[]): Array<[number, number]> {
156
+ const rows = a.length + 1;
157
+ const cols = b.length + 1;
158
+ const table: number[][] = Array.from({ length: rows }, () => Array<number>(cols).fill(0));
159
+
160
+ for (let i = a.length - 1; i >= 0; i--) {
161
+ for (let j = b.length - 1; j >= 0; j--) {
162
+ table[i]![j] = a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!);
163
+ }
164
+ }
165
+
166
+ const pairs: Array<[number, number]> = [];
167
+ let i = 0;
168
+ let j = 0;
169
+ while (i < a.length && j < b.length) {
170
+ if (a[i] === b[j]) {
171
+ pairs.push([i, j]);
172
+ i++;
173
+ j++;
174
+ } else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
175
+ i++;
176
+ } else {
177
+ j++;
178
+ }
179
+ }
180
+
181
+ return pairs;
182
+ }
183
+
184
+ function pushMergedSpan(spans: DiffSpan[], span: DiffSpan): void {
185
+ if (!span.text) return;
186
+ const previous = spans[spans.length - 1];
187
+ if (previous?.kind === span.kind) {
188
+ previous.text += span.text;
189
+ return;
190
+ }
191
+ spans.push({ ...span });
192
+ }
193
+
194
+ function buildInlineSpans(removeText: string, addText: string): { removeSpans: DiffSpan[]; addSpans: DiffSpan[] } | undefined {
195
+ if (removeText.length > MAX_INLINE_DIFF_LINE_LENGTH || addText.length > MAX_INLINE_DIFF_LINE_LENGTH) return undefined;
196
+
197
+ const removeTokens = tokenizeInlineDiff(removeText);
198
+ const addTokens = tokenizeInlineDiff(addText);
199
+ if (!removeTokens.length || !addTokens.length) return undefined;
200
+ if (removeTokens.length > MAX_INLINE_DIFF_TOKENS || addTokens.length > MAX_INLINE_DIFF_TOKENS) return undefined;
201
+ if ((removeTokens.length + 1) * (addTokens.length + 1) > MAX_INLINE_DIFF_CELLS) return undefined;
202
+
203
+ const pairs = longestCommonSubsequence(removeTokens, addTokens);
204
+ const meaningfulRemoveTokenCount = removeTokens.filter((token) => token.trim().length > 0).length;
205
+ const meaningfulAddTokenCount = addTokens.filter((token) => token.trim().length > 0).length;
206
+ const meaningfulEqualTokenCount = pairs.filter(([removeIndex, addIndex]) => {
207
+ const removeToken = removeTokens[removeIndex] ?? "";
208
+ const addToken = addTokens[addIndex] ?? "";
209
+ return removeToken === addToken && removeToken.trim().length > 0 && addToken.trim().length > 0;
210
+ }).length;
211
+ const similarity = meaningfulEqualTokenCount / Math.max(meaningfulRemoveTokenCount, meaningfulAddTokenCount);
212
+ if (similarity < INLINE_SIMILARITY_THRESHOLD) return undefined;
213
+
214
+ const removeSpans: DiffSpan[] = [];
215
+ const addSpans: DiffSpan[] = [];
216
+ let removeCursor = 0;
217
+ let addCursor = 0;
218
+
219
+ for (const [removeIndex, addIndex] of pairs) {
220
+ if (removeCursor < removeIndex) {
221
+ pushMergedSpan(removeSpans, { kind: "remove", text: removeTokens.slice(removeCursor, removeIndex).join("") });
222
+ }
223
+ if (addCursor < addIndex) {
224
+ pushMergedSpan(addSpans, { kind: "add", text: addTokens.slice(addCursor, addIndex).join("") });
225
+ }
226
+ pushMergedSpan(removeSpans, { kind: "equal", text: removeTokens[removeIndex]! });
227
+ pushMergedSpan(addSpans, { kind: "equal", text: addTokens[addIndex]! });
228
+ removeCursor = removeIndex + 1;
229
+ addCursor = addIndex + 1;
230
+ }
231
+
232
+ if (removeCursor < removeTokens.length) {
233
+ pushMergedSpan(removeSpans, { kind: "remove", text: removeTokens.slice(removeCursor).join("") });
234
+ }
235
+ if (addCursor < addTokens.length) {
236
+ pushMergedSpan(addSpans, { kind: "add", text: addTokens.slice(addCursor).join("") });
237
+ }
238
+
239
+ if (!removeSpans.some((span) => span.kind === "remove") || !addSpans.some((span) => span.kind === "add")) return undefined;
240
+ return { removeSpans, addSpans };
241
+ }
242
+
243
+ function buildInlineDiffs(entries: DiffEntry[]): InlineDiff[] | undefined {
244
+ const inlineDiffs: InlineDiff[] = [];
245
+ let remainingPairs = MAX_INLINE_DIFF_PAIRS;
246
+
247
+ for (let index = 0; index < entries.length;) {
248
+ if (entries[index]?.kind !== "remove") {
249
+ index++;
250
+ continue;
251
+ }
252
+
253
+ const removeStart = index;
254
+ while (entries[index]?.kind === "remove") index++;
255
+ const addStart = index;
256
+ while (entries[index]?.kind === "add") index++;
257
+
258
+ const removeCount = addStart - removeStart;
259
+ const addCount = index - addStart;
260
+ if (removeCount === 0 || addCount === 0 || removeCount !== addCount) continue;
261
+
262
+ for (let offset = 0; offset < removeCount; offset++) {
263
+ if (remainingPairs <= 0) break;
264
+ remainingPairs--;
265
+
266
+ const removeIndex = removeStart + offset;
267
+ const addIndex = addStart + offset;
268
+ const removeEntry = entries[removeIndex];
269
+ const addEntry = entries[addIndex];
270
+ if (removeEntry?.kind !== "remove" || addEntry?.kind !== "add") continue;
271
+
272
+ const spans = buildInlineSpans(removeEntry.text, addEntry.text);
273
+ if (!spans) continue;
274
+
275
+ inlineDiffs.push({
276
+ removeLineIndex: removeIndex,
277
+ addLineIndex: addIndex,
278
+ removeSpans: spans.removeSpans,
279
+ addSpans: spans.addSpans,
280
+ });
281
+ }
282
+ }
283
+
284
+ return inlineDiffs.length ? inlineDiffs : undefined;
285
+ }
286
+
287
+ function finalizeDiffData(path: string, entries: DiffEntry[], blockRanges: DiffBlockRange[] | undefined): DiffData {
288
+ const language = inferLanguage(path);
289
+ const inlineDiffs = buildInlineDiffs(entries);
290
+ return {
291
+ version: 1,
292
+ entries,
293
+ stats: buildStats(entries),
294
+ ...(language ? { language } : {}),
295
+ ...(blockRanges?.length ? { blockRanges: [...blockRanges] } : {}),
296
+ ...(inlineDiffs ? { inlineDiffs } : {}),
297
+ };
298
+ }
299
+
300
+ export function buildDiffData(input: BuildDiffDataInput): DiffData {
301
+ const entries = parseCompactDiffEntries(input.diff, input.oldContent, input.newContent) ?? parseFullDiffEntries(input.diff);
302
+ return finalizeDiffData(input.path, entries, input.blockRanges);
303
+ }
@@ -0,0 +1,42 @@
1
+ // Static per-tool suggestion table used by the doom-loop warning formatter.
2
+ //
3
+ // UPKEEP: these strings are static and must be maintained alongside any
4
+ // tool-schema changes in src/read.ts, src/edit.ts, src/grep.ts, src/sg.ts,
5
+ // src/find.ts, and src/ls.ts. When a tool adds/renames a parameter, update
6
+ // the matching entry below so the suggestion remains accurate.
7
+
8
+ export const SUGGESTIONS: Record<string, readonly string[]> = {
9
+ grep: [
10
+ "try ignoreCase: true",
11
+ "try literal: true if pattern has special characters",
12
+ "try a narrower glob or path",
13
+ "switch to search for structural patterns",
14
+ "try summary: true to scope broader",
15
+ ],
16
+ read: [
17
+ "if searching for a symbol, use symbol: or map: true",
18
+ "if file is large, try offset + limit",
19
+ "if file keeps being read identically, the content may already be what you expect",
20
+ ],
21
+ edit: [
22
+ "if hash-mismatch keeps firing, re-read the file",
23
+ "if no-op keeps firing, your new_text equals current content",
24
+ "verify the anchor came from the most recent read/grep/search",
25
+ ],
26
+ search: [
27
+ 'check the lang parameter matches file type (e.g. lang: "tsx" for JSX)',
28
+ "simplify the pattern with $_ or $$$ wildcards",
29
+ "verify readseek is installed",
30
+ ],
31
+ find: [
32
+ "try a looser glob",
33
+ 'try type: "any"',
34
+ "try a different path",
35
+ ],
36
+ ls: [
37
+ "try a different path",
38
+ "remove the glob filter",
39
+ ],
40
+ };
41
+
42
+ export const GENERIC_SUGGESTION = "try a different approach — the repeating call is not making progress";
@@ -0,0 +1,216 @@
1
+ import { SUGGESTIONS, GENERIC_SUGGESTION } from "./doom-loop-suggestions.js";
2
+ export const MAX_RECENT_TOOL_CALLS = 24;
3
+
4
+ export interface RecordedToolCall {
5
+ toolCallId: string;
6
+ toolName: string;
7
+ input: Record<string, unknown>;
8
+ fingerprint: string;
9
+ }
10
+
11
+ export interface DoomLoopStep {
12
+ toolName: string;
13
+ input: Record<string, unknown>;
14
+ }
15
+
16
+ export type DoomLoopWarning =
17
+ | {
18
+ kind: "identical-tail";
19
+ toolName: string;
20
+ fingerprint: string;
21
+ }
22
+ | {
23
+ kind: "repeated-subsequence";
24
+ toolName: string;
25
+ fingerprint: string;
26
+ steps: DoomLoopStep[];
27
+ };
28
+
29
+ export interface DoomLoopState {
30
+ recentCalls: RecordedToolCall[];
31
+ pendingWarnings: Map<string, DoomLoopWarning>;
32
+ }
33
+
34
+ export function createDoomLoopState(): DoomLoopState {
35
+ return {
36
+ recentCalls: [],
37
+ pendingWarnings: new Map<string, DoomLoopWarning>(),
38
+ };
39
+ }
40
+
41
+ function stableStringify(value: unknown): string {
42
+ if (Array.isArray(value)) {
43
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
44
+ }
45
+
46
+ if (value && typeof value === "object") {
47
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b));
48
+ return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(",")}}`;
49
+ }
50
+
51
+ return JSON.stringify(value);
52
+ }
53
+
54
+ export function makeToolFingerprint(toolName: string, input: Record<string, unknown>): string {
55
+ return `${toolName}:${stableStringify(input)}`;
56
+ }
57
+
58
+ function sameFingerprints(left: RecordedToolCall[], right: RecordedToolCall[]): boolean {
59
+ return left.length === right.length && left.every((call, index) => call.fingerprint === right[index]?.fingerprint);
60
+ }
61
+
62
+ function hasIdenticalTail(calls: RecordedToolCall[]): boolean {
63
+ if (calls.length < 3) {
64
+ return false;
65
+ }
66
+
67
+ const last = calls[calls.length - 1]?.fingerprint;
68
+ return calls[calls.length - 2]?.fingerprint === last && calls[calls.length - 3]?.fingerprint === last;
69
+ }
70
+
71
+ function findRepeatedSubsequenceWindow(calls: RecordedToolCall[]): number | null {
72
+ const maxWindowSize = Math.floor(calls.length / 3);
73
+ for (let windowSize = 2; windowSize <= maxWindowSize; windowSize++) {
74
+ const newest = calls.slice(-windowSize);
75
+ const middle = calls.slice(-windowSize * 2, -windowSize);
76
+ const oldest = calls.slice(-windowSize * 3, -windowSize * 2);
77
+ if (sameFingerprints(newest, middle) && sameFingerprints(middle, oldest)) {
78
+ return windowSize;
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ export function recordToolCall(
85
+ state: DoomLoopState,
86
+ toolName: string,
87
+ toolCallId: string,
88
+ input: Record<string, unknown>,
89
+ ): void {
90
+ const fingerprint = makeToolFingerprint(toolName, input);
91
+ state.recentCalls.push({ toolCallId, toolName, input, fingerprint });
92
+
93
+ if (state.recentCalls.length > MAX_RECENT_TOOL_CALLS) {
94
+ state.recentCalls.splice(0, state.recentCalls.length - MAX_RECENT_TOOL_CALLS);
95
+ }
96
+
97
+ if (hasIdenticalTail(state.recentCalls)) {
98
+ state.pendingWarnings.set(toolCallId, {
99
+ kind: "identical-tail",
100
+ toolName,
101
+ fingerprint,
102
+ });
103
+ return;
104
+ }
105
+
106
+ const windowSize = findRepeatedSubsequenceWindow(state.recentCalls);
107
+ if (windowSize !== null) {
108
+ const newest = state.recentCalls.slice(-windowSize);
109
+ state.pendingWarnings.set(toolCallId, {
110
+ kind: "repeated-subsequence",
111
+ toolName,
112
+ fingerprint,
113
+ steps: newest.map((call) => ({ toolName: call.toolName, input: call.input })),
114
+ });
115
+ }
116
+ }
117
+
118
+ export function consumeDoomLoopWarning(
119
+ state: DoomLoopState,
120
+ toolCallId: string,
121
+ ): DoomLoopWarning | null {
122
+ const warning = state.pendingWarnings.get(toolCallId);
123
+ if (!warning) return null;
124
+ state.pendingWarnings.delete(toolCallId);
125
+ return warning;
126
+ }
127
+
128
+ const COMPACT_LINE_BUDGET = 80;
129
+ const STEP_PREFIX = " → ";
130
+
131
+ function truncate(value: string, max: number): string {
132
+ if (value.length <= max) return value;
133
+ if (max <= 1) return "…";
134
+ return `${value.slice(0, max - 1)}…`;
135
+ }
136
+
137
+ function renderCompactStep(toolName: string, input: Record<string, unknown>): string {
138
+ const keys = Object.keys(input).sort();
139
+ const salient = keys.slice(0, 2);
140
+ const base = `${STEP_PREFIX}${toolName}`;
141
+ if (salient.length === 0) return truncate(`${base} {}`, COMPACT_LINE_BUDGET);
142
+
143
+ let line = base;
144
+ for (const key of salient) {
145
+ const rendered = JSON.stringify(input[key]);
146
+ const part = ` ${key}=${rendered}`;
147
+ const candidate = line + part;
148
+ if (candidate.length > COMPACT_LINE_BUDGET) {
149
+ const remaining = COMPACT_LINE_BUDGET - (line + ` ${key}=`).length;
150
+ line = `${line} ${key}=${truncate(rendered, Math.max(1, remaining))}`;
151
+ return line;
152
+ }
153
+ line = candidate;
154
+ }
155
+ return truncate(line, COMPACT_LINE_BUDGET);
156
+ }
157
+
158
+ function parseFingerprintInput(fingerprint: string): Record<string, unknown> {
159
+ const colon = fingerprint.indexOf(":");
160
+ if (colon < 0) return {};
161
+ const json = fingerprint.slice(colon + 1);
162
+ try {
163
+ const parsed = JSON.parse(json);
164
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
165
+ } catch {
166
+ return {};
167
+ }
168
+ }
169
+
170
+ function suggestionsFor(toolName: string): string[] {
171
+ const entry = SUGGESTIONS[toolName];
172
+ if (entry && entry.length > 0) return [...entry];
173
+ return [GENERIC_SUGGESTION];
174
+ }
175
+
176
+ function renderSuggestionBullets(toolNames: string[]): string {
177
+ const seen = new Set<string>();
178
+ const lines: string[] = [];
179
+ for (const name of toolNames) {
180
+ if (seen.has(name)) continue;
181
+ seen.add(name);
182
+ const bullets = suggestionsFor(name);
183
+ lines.push(`For ${name}:`);
184
+ for (const bullet of bullets) {
185
+ lines.push(` • ${bullet}`);
186
+ }
187
+ }
188
+ return lines.join("\n");
189
+ }
190
+
191
+ export function formatDoomLoopMessage(warning: DoomLoopWarning): string {
192
+ if (warning.kind === "identical-tail") {
193
+ const input = parseFingerprintInput(warning.fingerprint);
194
+ const compact = renderCompactStep(warning.toolName, input);
195
+ const suggestions = renderSuggestionBullets([warning.toolName]);
196
+ return [
197
+ "⚠ REPEATED-CALL WARNING: This is the 3rd identical tool call.",
198
+ compact,
199
+ "",
200
+ "Continuing this pattern will not make progress. Suggestions:",
201
+ suggestions,
202
+ ].join("\n");
203
+ }
204
+
205
+ const stepLines = warning.steps.map((step) => renderCompactStep(step.toolName, step.input));
206
+ const suggestions = renderSuggestionBullets(warning.steps.map((step) => step.toolName));
207
+ return [
208
+ `⚠ ALTERNATING-CALL WARNING: You have called this sequence ${warning.steps.length > 0 ? "3 times" : ""}:`.trimEnd(),
209
+ ...stepLines,
210
+ "",
211
+ "Neither call is producing new information. Break the loop with a different approach.",
212
+ "",
213
+ suggestions,
214
+ ].join("\n");
215
+ }
216
+