omp-vcc 0.1.13 → 0.1.14
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.
- package/extensions/main.ts +92 -29
- package/extensions/vcc-core/core/compaction-chain.ts +301 -0
- package/extensions/vcc-core/core/drill-down.ts +11 -4
- package/extensions/vcc-core/core/format-recall.ts +18 -2
- package/extensions/vcc-core/core/global-indices.ts +46 -0
- package/extensions/vcc-core/core/load-messages.ts +116 -21
- package/extensions/vcc-core/core/normalize.ts +13 -13
- package/extensions/vcc-core/core/recall-budget.ts +107 -0
- package/extensions/vcc-core/core/recall-scope.ts +16 -9
- package/extensions/vcc-core/core/search-entries.ts +201 -36
- package/extensions/vcc-core/core/session-lines.ts +81 -0
- package/extensions/vcc-core/core/settings.ts +239 -99
- package/extensions/vcc-core/core/summarize.ts +8 -2
- package/extensions/vcc-core/core/token-estimate.ts +55 -0
- package/extensions/vcc-core/core/tool-output-budget.ts +217 -0
- package/extensions/vcc-core/details.ts +35 -0
- package/extensions/vcc-core/hook.ts +677 -134
- package/package.json +77 -1
- package/types.d.ts +8 -0
package/extensions/main.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// pi-vcc port: sting8k/pi-vcc @0.7.0 — algorithmic, zero-LLM, brief transcript + 5 sections, token-budgeted
|
|
6
6
|
|
|
7
7
|
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
8
|
-
import { scaffoldSettings } from "./vcc-core/core/settings";
|
|
8
|
+
import { scaffoldSettings, loadSettings, loadSettingsWithPluginOverlay } from "./vcc-core/core/settings";
|
|
9
9
|
import { migrateStalePluginEntries } from "./vcc-core/core/migrate-stale";
|
|
10
10
|
import { loadAllMessages } from "./vcc-core/core/load-messages";
|
|
11
11
|
import {
|
|
@@ -26,8 +26,9 @@ import {
|
|
|
26
26
|
import { searchEntriesDetailed, getTouchedFiles } from "./vcc-core/core/search-entries";
|
|
27
27
|
import { formatRecallOutput, formatTouchedOutput } from "./vcc-core/core/format-recall";
|
|
28
28
|
import { getActiveLineageEntryIds } from "./vcc-core/core/lineage";
|
|
29
|
-
import { normalizeRecallScope, normalizeRecallMode, parseRecallScope } from "./vcc-core/core/recall-scope";
|
|
29
|
+
import { normalizeRecallScope, normalizeRecallMode, parseRecallScope, parseRecallMode } from "./vcc-core/core/recall-scope";
|
|
30
30
|
import { parseDrillDown, expandEntryFile, parseEntryRef, expandEntry } from "./vcc-core/core/drill-down";
|
|
31
|
+
import { capRecallBlocks, type RecallBudgetBlock } from "./vcc-core/core/recall-budget";
|
|
31
32
|
import { buildPiVccCustomInstructions, parseKeepAndPrompt } from "./vcc-core/core/compact-args";
|
|
32
33
|
|
|
33
34
|
// Build omp sentinel instructions; keep pi sentinel for backward compat in hook
|
|
@@ -39,14 +40,25 @@ const buildOmpCustomInstructions = (keepUserTurns: number | null): string => {
|
|
|
39
40
|
// Helper to parse recall command text: supports `query ... scope:all page:N`
|
|
40
41
|
const parseRecallCommandArgs = (
|
|
41
42
|
raw: string,
|
|
42
|
-
): { query: string; scope: "lineage" | "all"; page: number } => {
|
|
43
|
-
const
|
|
43
|
+
): { query: string; scope: "lineage" | "all"; mode: "hybrid" | "touched" | "file"; page: number } => {
|
|
44
|
+
const scoped = parseRecallScope(raw);
|
|
45
|
+
const parsed = parseRecallMode(scoped.text);
|
|
44
46
|
const pageMatch = parsed.text.match(/\bpage:(\d+)\b/i);
|
|
45
47
|
const page = pageMatch ? Math.max(1, Number.parseInt(pageMatch[1] ?? "1", 10)) : 1;
|
|
46
48
|
const query = parsed.text.replace(/\bpage:\d+\b/i, "").trim();
|
|
47
|
-
return { query, scope:
|
|
49
|
+
return { query, scope: scoped.scope, mode: normalizeRecallMode(parsed.mode), page };
|
|
48
50
|
};
|
|
49
51
|
|
|
52
|
+
const capModelRecall = (settings: { recallResponseMaxChars: number }, blocks: Array<RecallBudgetBlock | string>, blockKind = "entry"): string =>
|
|
53
|
+
capRecallBlocks(blocks, settings.recallResponseMaxChars, { blockKind });
|
|
54
|
+
const loadRecallMessages = (ctx: unknown, sessionFile: string, full: boolean, lineageEntryIds?: Set<string>) =>
|
|
55
|
+
loadAllMessages(sessionFile, full, lineageEntryIds, (event) => {
|
|
56
|
+
if (!loadSettings(ctx).debug) return;
|
|
57
|
+
try {
|
|
58
|
+
(ctx as any)?.ui?.notify?.(`omp-vcc: ${event.kind} (${event.parseErrors} malformed lines)`, "warning");
|
|
59
|
+
} catch {}
|
|
60
|
+
});
|
|
61
|
+
|
|
50
62
|
const DEFAULT_RECENT = 25;
|
|
51
63
|
const PAGE_SIZE = 5;
|
|
52
64
|
export default function (pi: ExtensionAPI): void {
|
|
@@ -63,14 +75,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
63
75
|
name: "vcc_recall",
|
|
64
76
|
label: "VCC Recall",
|
|
65
77
|
description:
|
|
66
|
-
"Recall earlier parts of the current session — decisions made, files touched, commands run, including anything dropped by compaction. Reach for this before telling the user you no longer have the context. Plain keywords work best; a regex pattern is also accepted. Results are paged (page); pass expand with entry indices to read full untruncated content. Use mode:'touched' to list files worked on in this session with their entry indices, and #N:path to drill into a file's content from an entry (#N:path:full for all lines). Note: apply_patch paths (inside the diff payload) and bash redirects do not appear in the touched index. Only the current session is searchable — earlier sessions are not.",
|
|
78
|
+
"Recall earlier parts of the current session — decisions made, files touched, commands run, including anything dropped by compaction. Reach for this before telling the user you no longer have the context. Plain keywords work best; a regex pattern is also accepted. Results are paged (page); pass expand with entry indices to read full untruncated content. Use mode:'touched' to list files worked on in this session with their entry indices, mode:'file' to search only file tool arguments, and #N:path to drill into a file's content from an entry (#N:path:full for all lines). Note: apply_patch paths (inside the diff payload) and bash redirects do not appear in the touched index. Only the current session is searchable — earlier sessions are not.",
|
|
67
79
|
approval: "read",
|
|
68
80
|
parameters: pi.zod.object({
|
|
69
81
|
query: pi.zod.string().optional().describe("What to recall, in plain keywords (e.g. 'redis cache decision'). Multi-word queries are ranked by relevance. A regex pattern also works."),
|
|
70
82
|
expand: pi.zod.array(pi.zod.number()).optional().describe("Entry indices to return full untruncated content for"),
|
|
71
83
|
page: pi.zod.number().optional().describe("Page number (1-based) for paginated search results. Default: 1."),
|
|
72
84
|
scope: pi.zod.enum(["lineage", "all", "active"]).optional().describe("Default 'lineage' covers the active conversation path. Use 'all' to also reach messages from other branches, such as turns that were edited or retried."),
|
|
73
|
-
mode: pi.zod.enum(["hybrid", "touched"]).optional().describe("What to show. hybrid (default) = normal search; touched = aggregated files-by-path
|
|
85
|
+
mode: pi.zod.enum(["hybrid", "touched", "file"]).optional().describe("What to show. hybrid (default) = normal search; touched = aggregated files-by-path; file = only file tool arguments."),
|
|
74
86
|
}),
|
|
75
87
|
async execute(_toolCallId: string, params: unknown, _signal: unknown, _onUpdate: unknown, ctx: unknown) {
|
|
76
88
|
const p = params as {
|
|
@@ -83,6 +95,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
83
95
|
const c = ctx as {
|
|
84
96
|
sessionManager?: { getSessionFile?: () => string | undefined; getBranch?: () => unknown[]; getEntries?: () => unknown[] };
|
|
85
97
|
};
|
|
98
|
+
const settings = await loadSettingsWithPluginOverlay(ctx);
|
|
86
99
|
const sessionFile = c.sessionManager?.getSessionFile?.();
|
|
87
100
|
if (!sessionFile) {
|
|
88
101
|
return {
|
|
@@ -95,12 +108,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
95
108
|
const lineageEntryIds = scope === "lineage" ? getActiveLineageEntryIds(c.sessionManager as unknown as { getBranch: () => { id?: string }[] }) : undefined;
|
|
96
109
|
|
|
97
110
|
const q = p.query?.trim();
|
|
111
|
+
const mode = normalizeRecallMode(p.mode);
|
|
112
|
+
const bounded = (text: string, id: string | number, kind = "entry"): string => capModelRecall(settings, [{ id, text }], kind);
|
|
98
113
|
|
|
99
114
|
const entryRef = q ? parseEntryRef(q) : null;
|
|
100
115
|
if (entryRef) {
|
|
101
116
|
const ref = entryRef;
|
|
102
117
|
if (lineageEntryIds) {
|
|
103
|
-
const { rendered } =
|
|
118
|
+
const { rendered } = loadRecallMessages(ctx, sessionFile, false, lineageEntryIds);
|
|
104
119
|
const exists = rendered.some((m) => m.index === ref.index);
|
|
105
120
|
if (!exists) {
|
|
106
121
|
return {
|
|
@@ -110,14 +125,31 @@ export default function (pi: ExtensionAPI): void {
|
|
|
110
125
|
}
|
|
111
126
|
}
|
|
112
127
|
const text = expandEntry(sessionFile, ref.index, ref.full, ref.offset, ref.limit);
|
|
113
|
-
return { content: [{ type: "text", text }], details: undefined };
|
|
128
|
+
return { content: [{ type: "text", text: bounded(text, `#${ref.index}`) }], details: undefined };
|
|
129
|
+
}
|
|
130
|
+
const textMatch = q?.match(/^#(\d+):text(?::(full|\d+(?::\d+)?))?$/);
|
|
131
|
+
if (textMatch) {
|
|
132
|
+
const index = Number.parseInt(textMatch[1] ?? "0", 10);
|
|
133
|
+
const suffix = textMatch[2];
|
|
134
|
+
const full = suffix === "full";
|
|
135
|
+
const parts = suffix && !full ? suffix.split(":") : [];
|
|
136
|
+
const offset = parts[0] !== undefined ? Number.parseInt(parts[0], 10) : undefined;
|
|
137
|
+
const limit = parts[1] !== undefined ? Number.parseInt(parts[1], 10) : undefined;
|
|
138
|
+
if (lineageEntryIds) {
|
|
139
|
+
const { rendered } = loadRecallMessages(ctx, sessionFile, false, lineageEntryIds);
|
|
140
|
+
if (!rendered.some((m) => m.index === index)) {
|
|
141
|
+
return { content: [{ type: "text", text: `Cannot expand indices outside active lineage: ${index}. Use scope:'all' to reach other branches.` }], details: undefined };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const text = expandEntry(sessionFile, index, full, offset, limit);
|
|
145
|
+
return { content: [{ type: "text", text: bounded(text, `#${index}:text`) }], details: undefined };
|
|
114
146
|
}
|
|
115
147
|
|
|
116
148
|
const drill = q ? parseDrillDown(q) : null;
|
|
117
149
|
if (drill) {
|
|
118
150
|
const parsed = drill;
|
|
119
151
|
if (lineageEntryIds) {
|
|
120
|
-
const { rendered } =
|
|
152
|
+
const { rendered } = loadRecallMessages(ctx, sessionFile, false, lineageEntryIds);
|
|
121
153
|
const exists = rendered.some((m) => m.index === parsed.index);
|
|
122
154
|
if (!exists) {
|
|
123
155
|
return {
|
|
@@ -127,20 +159,26 @@ export default function (pi: ExtensionAPI): void {
|
|
|
127
159
|
}
|
|
128
160
|
}
|
|
129
161
|
const text = expandEntryFile(sessionFile, parsed.index, parsed.pathPattern, parsed.full, parsed.offset, parsed.limit);
|
|
130
|
-
return { content: [{ type: "text", text }], details: undefined };
|
|
162
|
+
return { content: [{ type: "text", text: bounded(text, `#${parsed.index}:path`) }], details: undefined };
|
|
131
163
|
}
|
|
132
164
|
|
|
133
|
-
if (
|
|
134
|
-
const { rendered, rawMessages } =
|
|
165
|
+
if (mode === "touched") {
|
|
166
|
+
const { rendered, rawMessages } = loadRecallMessages(ctx, sessionFile, false, lineageEntryIds);
|
|
135
167
|
const touched = getTouchedFiles(rawMessages as unknown[], rendered);
|
|
136
168
|
const text = formatTouchedOutput(touched, p.page);
|
|
137
|
-
return { content: [{ type: "text", text }], details: undefined };
|
|
169
|
+
return { content: [{ type: "text", text: bounded(text, `page:${p.page ?? 1}`, "page") }], details: undefined };
|
|
170
|
+
}
|
|
171
|
+
if (mode === "file" && !q) {
|
|
172
|
+
const { rendered, rawMessages } = loadRecallMessages(ctx, sessionFile, false, lineageEntryIds);
|
|
173
|
+
const { hits } = searchEntriesDetailed(rendered, rawMessages as unknown[], undefined, { mode });
|
|
174
|
+
const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(hits);
|
|
175
|
+
return { content: [{ type: "text", text: capModelRecall(settings, [{ id: "file", text: output }], "file") }], details: undefined };
|
|
138
176
|
}
|
|
139
177
|
|
|
140
178
|
const expandSet = new Set(p.expand ?? []);
|
|
141
179
|
const hasExpand = expandSet.size > 0;
|
|
142
180
|
if (hasExpand) {
|
|
143
|
-
const { rendered: fullMsgs } =
|
|
181
|
+
const { rendered: fullMsgs } = loadRecallMessages(ctx, sessionFile, true, lineageEntryIds);
|
|
144
182
|
const requested = [...expandSet];
|
|
145
183
|
const byIndex = new Map(fullMsgs.map((m) => [m.index, m]));
|
|
146
184
|
const invalid = invalidExpandIndices(requested, new Set(byIndex.keys()));
|
|
@@ -151,13 +189,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
151
189
|
};
|
|
152
190
|
}
|
|
153
191
|
const expanded = requested.map((i) => byIndex.get(i)).filter((m): m is NonNullable<typeof m> => Boolean(m));
|
|
154
|
-
const
|
|
192
|
+
const blocks = expanded.map((entry) => ({ id: `#${entry.index}`, text: formatRecallOutput([entry]) }));
|
|
193
|
+
const output = (scope === "all" ? "Scope: all\n\n" : "") + capModelRecall(settings, blocks, "entry");
|
|
155
194
|
return { content: [{ type: "text", text: output }], details: undefined };
|
|
156
195
|
}
|
|
157
196
|
|
|
158
|
-
const { rendered: msgs, rawMessages } =
|
|
197
|
+
const { rendered: msgs, rawMessages } = loadRecallMessages(ctx, sessionFile, false, lineageEntryIds);
|
|
159
198
|
if (q) {
|
|
160
|
-
const { hits, totalBeforeCap, truncated } = searchEntriesDetailed(msgs, rawMessages as unknown[], q);
|
|
199
|
+
const { hits, totalBeforeCap, truncated } = searchEntriesDetailed(msgs, rawMessages as unknown[], q, { mode });
|
|
161
200
|
const page = Math.max(1, p.page ?? 1);
|
|
162
201
|
const totalPages = Math.ceil(hits.length / PAGE_SIZE);
|
|
163
202
|
const scopeSuffix = scope === "all" ? " (scope: all)" : "";
|
|
@@ -165,16 +204,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
165
204
|
if (hits.length > 0 && page > totalPages) {
|
|
166
205
|
const guidance = truncated ? `Use a page between 1 and ${totalPages}.` : `Use a page between 1 and ${totalPages}, or refine your query.`;
|
|
167
206
|
const text = `Page ${page} is outside the available range 1-${totalPages} (${hits.length} matches${scopeSuffix}${truncationNote}). ${guidance}`;
|
|
168
|
-
return { content: [{ type: "text", text }], details: undefined };
|
|
207
|
+
return { content: [{ type: "text", text: bounded(text, `page:${page}`, "page") }], details: undefined };
|
|
169
208
|
}
|
|
170
209
|
const start = (page - 1) * PAGE_SIZE;
|
|
171
210
|
const pageResults = hits.slice(start, start + PAGE_SIZE);
|
|
172
211
|
const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
|
|
173
212
|
const footer = page < totalPages ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---` : "";
|
|
174
213
|
const output = formatRecallOutput(pageResults, q, header, { truncated, totalBeforeCap }) + footer;
|
|
175
|
-
return { content: [{ type: "text", text: output }], details: undefined };
|
|
214
|
+
return { content: [{ type: "text", text: bounded(output, `page:${page}`, "page") }], details: undefined };
|
|
176
215
|
}
|
|
177
|
-
const
|
|
216
|
+
const recent = msgs.slice(-DEFAULT_RECENT);
|
|
217
|
+
const blocks = recent.map((entry) => ({ id: `#${entry.index}`, text: formatRecallOutput([entry]) }));
|
|
218
|
+
const output = (scope === "all" ? "Scope: all\n\n" : "") + capModelRecall(settings, blocks, "entry");
|
|
178
219
|
return { content: [{ type: "text", text: output }], details: undefined };
|
|
179
220
|
},
|
|
180
221
|
} as unknown as Parameters<ExtensionAPI["registerTool"]>[0]);
|
|
@@ -219,7 +260,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
219
260
|
settled = true;
|
|
220
261
|
const stats = getLastCompactionStats(pi);
|
|
221
262
|
if (stats) {
|
|
222
|
-
scheduleCompactionStatsNotify(c as unknown as { ui: { notify: (msg: string, level?: string) => void } }, stats);
|
|
263
|
+
scheduleCompactionStatsNotify(pi, c as unknown as { ui: { notify: (msg: string, level?: string) => void } }, stats);
|
|
223
264
|
} else {
|
|
224
265
|
try { c.ui.notify(fallbackToast, "info"); } catch {}
|
|
225
266
|
}
|
|
@@ -295,19 +336,30 @@ export default function (pi: ExtensionAPI): void {
|
|
|
295
336
|
try { c.ui.notify("No session file available.", "error"); } catch {}
|
|
296
337
|
return;
|
|
297
338
|
}
|
|
298
|
-
const { query, scope, page } = parseRecallCommandArgs(args);
|
|
339
|
+
const { query, scope, mode, page } = parseRecallCommandArgs(args);
|
|
299
340
|
const lineageEntryIds = scope === "lineage" ? getActiveLineageEntryIds(c.sessionManager as unknown as { getBranch: () => { id?: string }[] }) : undefined;
|
|
300
341
|
const piAny = pi as unknown as { sendMessage?: (msg: unknown, opts?: unknown) => void };
|
|
301
342
|
if (!query) {
|
|
302
|
-
const { rendered } =
|
|
343
|
+
const { rendered, rawMessages } = loadRecallMessages(ctx, sessionFile, false, lineageEntryIds);
|
|
344
|
+
if (mode === "file") {
|
|
345
|
+
const { hits } = searchEntriesDetailed(rendered, rawMessages as unknown[], undefined, { mode });
|
|
346
|
+
const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(hits);
|
|
347
|
+
try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (mode === "touched") {
|
|
351
|
+
const output = formatTouchedOutput(getTouchedFiles(rawMessages as unknown[], rendered), page);
|
|
352
|
+
try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
303
355
|
const recent = rendered.slice(-DEFAULT_RECENT);
|
|
304
356
|
const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(recent);
|
|
305
357
|
try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
306
358
|
try { c.ui.notify(`vcc_recall: ${recent.length} recent`, "info"); } catch {}
|
|
307
359
|
return;
|
|
308
360
|
}
|
|
309
|
-
const { rendered, rawMessages } =
|
|
310
|
-
const { hits, totalBeforeCap, truncated } = searchEntriesDetailed(rendered, rawMessages as unknown[], query);
|
|
361
|
+
const { rendered, rawMessages } = loadRecallMessages(ctx, sessionFile, false, lineageEntryIds);
|
|
362
|
+
const { hits, totalBeforeCap, truncated } = searchEntriesDetailed(rendered, rawMessages as unknown[], query, { mode });
|
|
311
363
|
const totalPages = Math.ceil(hits.length / PAGE_SIZE);
|
|
312
364
|
const scopeSuffix = scope === "all" ? " (scope: all)" : "";
|
|
313
365
|
const scopeArg = scope === "all" ? " scope:all" : "";
|
|
@@ -341,17 +393,28 @@ export default function (pi: ExtensionAPI): void {
|
|
|
341
393
|
try { c.ui.notify("No session file available.", "error"); } catch {}
|
|
342
394
|
return;
|
|
343
395
|
}
|
|
344
|
-
const { query, scope, page } = parseRecallCommandArgs(args);
|
|
396
|
+
const { query, scope, mode, page } = parseRecallCommandArgs(args);
|
|
345
397
|
const lineageEntryIds = scope === "lineage" ? getActiveLineageEntryIds(c.sessionManager as unknown as { getBranch: () => { id?: string }[] }) : undefined;
|
|
346
398
|
const piAny = pi as unknown as { sendMessage?: (msg: unknown, opts?: unknown) => void };
|
|
347
|
-
const { rendered, rawMessages } =
|
|
399
|
+
const { rendered, rawMessages } = loadRecallMessages(ctx, sessionFile, false, lineageEntryIds);
|
|
348
400
|
if (!query) {
|
|
401
|
+
if (mode === "file") {
|
|
402
|
+
const { hits } = searchEntriesDetailed(rendered, rawMessages as unknown[], undefined, { mode });
|
|
403
|
+
const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(hits);
|
|
404
|
+
try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (mode === "touched") {
|
|
408
|
+
const output = formatTouchedOutput(getTouchedFiles(rawMessages as unknown[], rendered), page);
|
|
409
|
+
try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
349
412
|
const recent = rendered.slice(-DEFAULT_RECENT);
|
|
350
413
|
const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(recent);
|
|
351
414
|
try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
352
415
|
return;
|
|
353
416
|
}
|
|
354
|
-
const { hits, totalBeforeCap, truncated } = searchEntriesDetailed(rendered, rawMessages as unknown[], query);
|
|
417
|
+
const { hits, totalBeforeCap, truncated } = searchEntriesDetailed(rendered, rawMessages as unknown[], query, { mode });
|
|
355
418
|
const totalPages = Math.ceil(hits.length / PAGE_SIZE);
|
|
356
419
|
const scopeSuffix = scope === "all" ? " (scope: all)" : "";
|
|
357
420
|
const scopeArg = scope === "all" ? " scope:all" : "";
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import type { PiVccAppendDetails, PiVccAppendSegment } from "../details";
|
|
3
|
+
import { estimateScriptAwareTokens } from "./token-estimate";
|
|
4
|
+
|
|
5
|
+
export type ChainEntry = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
export interface ActiveCompactionChain {
|
|
8
|
+
details: PiVccAppendDetails[];
|
|
9
|
+
segments: PiVccAppendSegment[];
|
|
10
|
+
trailingSummary: string;
|
|
11
|
+
fallbackSummary?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CollectActiveSegmentsOptions {
|
|
15
|
+
/** The complete summary returned to the host for the latest compaction. */
|
|
16
|
+
fallbackSummary?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CoverageForMessagesInput {
|
|
20
|
+
selectedIds: Array<string | undefined>;
|
|
21
|
+
firstKeptEntryId: string;
|
|
22
|
+
sourceMessageCount?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AppendDetailsInput {
|
|
26
|
+
segment: Omit<PiVccAppendSegment, "sequence">;
|
|
27
|
+
chainStart: boolean;
|
|
28
|
+
trailingSummary: string;
|
|
29
|
+
sections: string[];
|
|
30
|
+
sourceMessageCount: number;
|
|
31
|
+
previousSummaryUsed: boolean;
|
|
32
|
+
previous?: ActiveCompactionChain | null;
|
|
33
|
+
retainedToolOutputProjection?: PiVccAppendDetails["retainedToolOutputProjection"];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CompactionThresholds {
|
|
37
|
+
contextWindow?: number;
|
|
38
|
+
chainThreshold: number;
|
|
39
|
+
contextThreshold?: number;
|
|
40
|
+
minimumSaving: number;
|
|
41
|
+
capacity?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CompactionDecisionInput {
|
|
45
|
+
manual?: boolean;
|
|
46
|
+
overflow?: boolean;
|
|
47
|
+
willRetry?: boolean;
|
|
48
|
+
pressure?: boolean;
|
|
49
|
+
chainTokens: number;
|
|
50
|
+
rebaseChainTokens?: number;
|
|
51
|
+
contextWindow?: number;
|
|
52
|
+
reserveTokens?: number;
|
|
53
|
+
fullContextTokens?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CompactionDecision {
|
|
57
|
+
chainStart: boolean;
|
|
58
|
+
mode: "append" | "rebase";
|
|
59
|
+
pressure: boolean;
|
|
60
|
+
capacityPressure: boolean;
|
|
61
|
+
chainThreshold: number;
|
|
62
|
+
contextThreshold?: number;
|
|
63
|
+
minimumSaving: number;
|
|
64
|
+
capacity?: number;
|
|
65
|
+
chainTokens: number;
|
|
66
|
+
rebaseChainTokens?: number;
|
|
67
|
+
/** Present only when the caller supplied a trusted full-context estimate. */
|
|
68
|
+
fullContextTokens?: number;
|
|
69
|
+
}
|
|
70
|
+
const nonEmptyString = (value: unknown): value is string => typeof value === "string" && value.length > 0;
|
|
71
|
+
|
|
72
|
+
const finitePositive = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
73
|
+
|
|
74
|
+
const finiteNonNegative = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
75
|
+
|
|
76
|
+
const uniqueEntryPositions = (entries: ChainEntry[]): Map<string, number> => {
|
|
77
|
+
const positions = new Map<string, number>();
|
|
78
|
+
for (let i = 0; i < entries.length; i++) {
|
|
79
|
+
const id = entries[i]?.id;
|
|
80
|
+
if (typeof id !== "string") continue;
|
|
81
|
+
if (positions.has(id)) positions.set(id, -1);
|
|
82
|
+
else positions.set(id, i);
|
|
83
|
+
}
|
|
84
|
+
return positions;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const isCompactionEntry = (entry: ChainEntry): boolean => entry.type === "compaction";
|
|
88
|
+
|
|
89
|
+
/** Runtime parser for the immutable v3 persisted details record. */
|
|
90
|
+
export const isPiVccAppendDetails = (value: unknown): value is PiVccAppendDetails => {
|
|
91
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
92
|
+
const candidate = value as Record<string, unknown>;
|
|
93
|
+
if (candidate.compactor !== "omp-vcc" || candidate.version !== 3 || candidate.summaryMode !== "append") return false;
|
|
94
|
+
if (typeof candidate.chainStart !== "boolean" || typeof candidate.trailingSummary !== "string") return false;
|
|
95
|
+
if (!nonEmptyString(candidate.trailingSummary)) return false;
|
|
96
|
+
if (!Array.isArray(candidate.sections) || !candidate.sections.every(nonEmptyString)) return false;
|
|
97
|
+
if (typeof candidate.sourceMessageCount !== "number" || !Number.isInteger(candidate.sourceMessageCount) || candidate.sourceMessageCount < 0) return false;
|
|
98
|
+
if (typeof candidate.previousSummaryUsed !== "boolean" || candidate.segment === null || typeof candidate.segment !== "object" || Array.isArray(candidate.segment)) return false;
|
|
99
|
+
const segment = candidate.segment as Record<string, unknown>;
|
|
100
|
+
if (typeof segment.sequence !== "number" || !Number.isInteger(segment.sequence) || segment.sequence < 1) return false;
|
|
101
|
+
if (!nonEmptyString(segment.summary) || !finiteNonNegative(segment.tokensBefore)) return false;
|
|
102
|
+
if (segment.coverage === null || typeof segment.coverage !== "object" || Array.isArray(segment.coverage)) return false;
|
|
103
|
+
const coverage = segment.coverage as Record<string, unknown>;
|
|
104
|
+
if (!nonEmptyString(coverage.firstCoveredEntryId) || !nonEmptyString(coverage.lastCoveredEntryId) || typeof coverage.firstKeptEntryId !== "string") return false;
|
|
105
|
+
if (typeof coverage.sourceMessageCount !== "number" || !Number.isInteger(coverage.sourceMessageCount) || coverage.sourceMessageCount < 1) return false;
|
|
106
|
+
if (coverage.includesLegacySummary !== undefined && typeof coverage.includesLegacySummary !== "boolean") return false;
|
|
107
|
+
if (coverage.rebasedFromCompactionId !== undefined && typeof coverage.rebasedFromCompactionId !== "string") return false;
|
|
108
|
+
if (candidate.retainedToolOutputProjection !== undefined) {
|
|
109
|
+
const projection = candidate.retainedToolOutputProjection;
|
|
110
|
+
if (projection === null || typeof projection !== "object" || Array.isArray(projection)) return false;
|
|
111
|
+
const record = projection as Record<string, unknown>;
|
|
112
|
+
if (record.version !== 1 || !finiteNonNegative(record.retainedTokens) || !finiteNonNegative(record.omittedTokens)) return false;
|
|
113
|
+
if (typeof record.pendingCount !== "number" || !Number.isInteger(record.pendingCount) || record.pendingCount < 0) return false;
|
|
114
|
+
if (!Array.isArray(record.omissions) || !record.omissions.every((value) => value !== null && typeof value === "object" && !Array.isArray(value) &&
|
|
115
|
+
nonEmptyString((value as Record<string, unknown>).entryId) && nonEmptyString((value as Record<string, unknown>).marker))) return false;
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const validCoverageForBranch = (coverage: PiVccAppendSegment["coverage"], positions: Map<string, number>): boolean => {
|
|
121
|
+
const first = positions.get(coverage.firstCoveredEntryId);
|
|
122
|
+
const last = positions.get(coverage.lastCoveredEntryId);
|
|
123
|
+
if (first === undefined || last === undefined || first < 0 || last < 0 || first > last) return false;
|
|
124
|
+
if (coverage.firstKeptEntryId.length === 0) return true;
|
|
125
|
+
const kept = positions.get(coverage.firstKeptEntryId);
|
|
126
|
+
return kept !== undefined && kept > last;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Collect the append chain ending at the latest compaction. A non-append
|
|
132
|
+
* compaction before the chain start is a legal legacy base; any malformed v3
|
|
133
|
+
* record, summary mismatch, branch mismatch, or sequence gap invalidates the
|
|
134
|
+
* entire chain.
|
|
135
|
+
*/
|
|
136
|
+
export const collectActiveSegments = (
|
|
137
|
+
branchEntries: ChainEntry[],
|
|
138
|
+
options: CollectActiveSegmentsOptions = {},
|
|
139
|
+
): ActiveCompactionChain | null => {
|
|
140
|
+
if (!Array.isArray(branchEntries)) return null;
|
|
141
|
+
const compactions = branchEntries.filter(isCompactionEntry);
|
|
142
|
+
if (compactions.length === 0) return null;
|
|
143
|
+
const collected: Array<{ details: PiVccAppendDetails; compaction: ChainEntry }> = [];
|
|
144
|
+
const positions = uniqueEntryPositions(branchEntries);
|
|
145
|
+
for (let i = compactions.length - 1; i >= 0; i--) {
|
|
146
|
+
const compaction = compactions[i];
|
|
147
|
+
const candidate = compaction.details;
|
|
148
|
+
if (!isPiVccAppendDetails(candidate)) {
|
|
149
|
+
if (collected.length === 0) return null;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
if (compaction.summary !== candidate.trailingSummary || compaction.firstKeptEntryId !== candidate.segment.coverage.firstKeptEntryId || !validCoverageForBranch(candidate.segment.coverage, positions)) return null;
|
|
153
|
+
collected.push({ details: candidate, compaction });
|
|
154
|
+
if (candidate.chainStart) break;
|
|
155
|
+
}
|
|
156
|
+
if (collected.length === 0 || !collected[collected.length - 1].details.chainStart) return null;
|
|
157
|
+
collected.reverse();
|
|
158
|
+
const details = collected.map((value) => value.details);
|
|
159
|
+
for (let i = 0; i < details.length; i++) {
|
|
160
|
+
if (details[i].segment.sequence !== i + 1 || (i > 0 && details[i].chainStart)) return null;
|
|
161
|
+
if (i > 0 && details[i - 1].segment.coverage.firstKeptEntryId.length > 0 &&
|
|
162
|
+
details[i].segment.coverage.firstCoveredEntryId !== details[i - 1].segment.coverage.firstKeptEntryId) return null;
|
|
163
|
+
}
|
|
164
|
+
const firstCoverage = details[0].segment.coverage;
|
|
165
|
+
if (firstCoverage.rebasedFromCompactionId !== undefined &&
|
|
166
|
+
!compactions.some((value) => value.id === firstCoverage.rebasedFromCompactionId)) return null;
|
|
167
|
+
const latest = details[details.length - 1];
|
|
168
|
+
if (options.fallbackSummary !== undefined && options.fallbackSummary !== latest.trailingSummary) return null;
|
|
169
|
+
return {
|
|
170
|
+
details,
|
|
171
|
+
segments: details.map((detail) => detail.segment),
|
|
172
|
+
trailingSummary: latest.trailingSummary,
|
|
173
|
+
fallbackSummary: latest.trailingSummary,
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** Derive immutable coverage from selected ids; an undefined id fails closed. */
|
|
178
|
+
export const coverageForMessages = (input: CoverageForMessagesInput): PiVccAppendSegment["coverage"] | null => {
|
|
179
|
+
const ids = input.selectedIds;
|
|
180
|
+
if (!Array.isArray(ids) || ids.length === 0 || ids.some((id) => !nonEmptyString(id))) return null;
|
|
181
|
+
const first = ids[0];
|
|
182
|
+
const last = ids[ids.length - 1];
|
|
183
|
+
if (!nonEmptyString(first) || !nonEmptyString(last)) return null;
|
|
184
|
+
const sourceMessageCount = input.sourceMessageCount ?? ids.length;
|
|
185
|
+
if (!Number.isInteger(sourceMessageCount) || sourceMessageCount < 1) return null;
|
|
186
|
+
return {
|
|
187
|
+
firstCoveredEntryId: first,
|
|
188
|
+
lastCoveredEntryId: last,
|
|
189
|
+
firstKeptEntryId: typeof input.firstKeptEntryId === "string" ? input.firstKeptEntryId : "",
|
|
190
|
+
sourceMessageCount,
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** Build a v3 details record and reject a missing/invalid prior chain. */
|
|
195
|
+
export const buildAppendOnlyDetails = (input: AppendDetailsInput): PiVccAppendDetails | null => {
|
|
196
|
+
if (!input || !input.segment || !nonEmptyString(input.segment.summary) || !nonEmptyString(input.trailingSummary)) return null;
|
|
197
|
+
if (!input.segment.coverage || !nonEmptyString(input.segment.coverage.firstCoveredEntryId) || !nonEmptyString(input.segment.coverage.lastCoveredEntryId)) return null;
|
|
198
|
+
if (typeof input.segment.coverage.firstKeptEntryId !== "string" || input.segment.coverage.sourceMessageCount < 1) return null;
|
|
199
|
+
if (!input.chainStart && (!input.previous || input.previous.segments.length === 0)) return null;
|
|
200
|
+
const priorDetails = input.previous?.details[input.previous.details.length - 1];
|
|
201
|
+
if (!input.chainStart && (!priorDetails || !isPiVccAppendDetails(priorDetails) || priorDetails.segment.sequence !== input.previous?.segments.length)) return null;
|
|
202
|
+
const prior = input.previous?.segments[input.previous.segments.length - 1];
|
|
203
|
+
const sequence = input.chainStart ? 1 : (prior?.sequence ?? 0) + 1;
|
|
204
|
+
if (!Number.isInteger(sequence) || sequence < 1) return null;
|
|
205
|
+
const details: PiVccAppendDetails = {
|
|
206
|
+
compactor: "omp-vcc",
|
|
207
|
+
version: 3,
|
|
208
|
+
summaryMode: "append",
|
|
209
|
+
chainStart: input.chainStart,
|
|
210
|
+
segment: { ...input.segment, sequence },
|
|
211
|
+
trailingSummary: input.trailingSummary,
|
|
212
|
+
sections: input.sections.slice(),
|
|
213
|
+
sourceMessageCount: input.sourceMessageCount,
|
|
214
|
+
previousSummaryUsed: input.previousSummaryUsed,
|
|
215
|
+
};
|
|
216
|
+
if (input.retainedToolOutputProjection !== undefined) details.retainedToolOutputProjection = input.retainedToolOutputProjection;
|
|
217
|
+
return isPiVccAppendDetails(details) ? details : null;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const estimateChainTokens = (chain: ActiveCompactionChain): number => {
|
|
221
|
+
let total = estimateScriptAwareTokens(chain.trailingSummary);
|
|
222
|
+
for (const segment of chain.segments) total += estimateScriptAwareTokens(segment.summary);
|
|
223
|
+
return total;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export const compactionThresholds = (contextWindow?: number, reserveTokens?: number): CompactionThresholds => {
|
|
227
|
+
if (!finitePositive(contextWindow)) {
|
|
228
|
+
return { chainThreshold: 34_000, minimumSaving: 24_000 };
|
|
229
|
+
}
|
|
230
|
+
const capacity = typeof reserveTokens === "number" && Number.isFinite(reserveTokens) && reserveTokens >= 0
|
|
231
|
+
? Math.max(0, contextWindow - reserveTokens)
|
|
232
|
+
: undefined;
|
|
233
|
+
return {
|
|
234
|
+
contextWindow,
|
|
235
|
+
chainThreshold: Math.floor(contextWindow / 8),
|
|
236
|
+
contextThreshold: Math.floor(contextWindow / 2),
|
|
237
|
+
minimumSaving: Math.max(1, Math.min(24_000, Math.floor(24_000 * contextWindow / 272_000))),
|
|
238
|
+
capacity,
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export const decideAppendMode = (input: CompactionDecisionInput): CompactionDecision => {
|
|
243
|
+
const thresholds = compactionThresholds(input.contextWindow, input.reserveTokens);
|
|
244
|
+
const capacityPressure = thresholds.capacity !== undefined && finiteNonNegative(input.fullContextTokens) && input.fullContextTokens > thresholds.capacity;
|
|
245
|
+
const pressure = input.pressure === true || input.chainTokens >= thresholds.chainThreshold;
|
|
246
|
+
const explicit = input.overflow === true || input.willRetry === true || capacityPressure;
|
|
247
|
+
const rebaseTokens = finiteNonNegative(input.rebaseChainTokens) ? input.rebaseChainTokens : undefined;
|
|
248
|
+
const saving = finiteNonNegative(input.fullContextTokens) && rebaseTokens !== undefined
|
|
249
|
+
? Math.max(0, input.fullContextTokens - rebaseTokens)
|
|
250
|
+
: undefined;
|
|
251
|
+
let mode: "append" | "rebase" = "append";
|
|
252
|
+
if (input.manual === true) mode = "rebase";
|
|
253
|
+
else if (explicit && rebaseTokens !== undefined) mode = rebaseTokens < input.chainTokens ? "rebase" : "append";
|
|
254
|
+
else if (pressure && saving !== undefined && saving >= thresholds.minimumSaving && (rebaseTokens === undefined || rebaseTokens < input.chainTokens)) mode = "rebase";
|
|
255
|
+
const decision: CompactionDecision = {
|
|
256
|
+
chainStart: input.manual === true && mode === "rebase",
|
|
257
|
+
mode,
|
|
258
|
+
pressure,
|
|
259
|
+
capacityPressure,
|
|
260
|
+
chainThreshold: thresholds.chainThreshold,
|
|
261
|
+
minimumSaving: thresholds.minimumSaving,
|
|
262
|
+
chainTokens: input.chainTokens,
|
|
263
|
+
rebaseChainTokens: rebaseTokens,
|
|
264
|
+
};
|
|
265
|
+
if (thresholds.contextThreshold !== undefined) decision.contextThreshold = thresholds.contextThreshold;
|
|
266
|
+
if (thresholds.contextWindow !== undefined) decision.contextWindow = thresholds.contextWindow;
|
|
267
|
+
if (thresholds.capacity !== undefined) decision.capacity = thresholds.capacity;
|
|
268
|
+
if (finiteNonNegative(input.fullContextTokens)) decision.fullContextTokens = input.fullContextTokens;
|
|
269
|
+
return decision;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export const APPEND_SEGMENT_CUSTOM_TYPE = "omp-vcc-append-segment";
|
|
273
|
+
export const APPEND_TRAILING_CUSTOM_TYPE = "omp-vcc-append-trailing";
|
|
274
|
+
|
|
275
|
+
export interface AppendContextProjectionInput {
|
|
276
|
+
messages: ChainEntry[];
|
|
277
|
+
chain: ActiveCompactionChain;
|
|
278
|
+
fallbackSummary: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Replace exactly one host fallback summary with hidden segment + trailing messages. */
|
|
282
|
+
export const projectAppendOnlyContext = (input: AppendContextProjectionInput): ChainEntry[] => {
|
|
283
|
+
if (!input || !Array.isArray(input.messages) || !input.chain || !nonEmptyString(input.fallbackSummary)) return input?.messages;
|
|
284
|
+
if (input.fallbackSummary !== input.chain.fallbackSummary && input.fallbackSummary !== input.chain.trailingSummary) return input.messages;
|
|
285
|
+
const matches: number[] = [];
|
|
286
|
+
for (let i = 0; i < input.messages.length; i++) {
|
|
287
|
+
const message = input.messages[i];
|
|
288
|
+
if ((message.role === "compactionSummary" || message.role === "branchSummary") && message.summary === input.fallbackSummary) matches.push(i);
|
|
289
|
+
}
|
|
290
|
+
if (matches.length !== 1) return input.messages;
|
|
291
|
+
const replacement: ChainEntry[] = input.chain.segments.map((segment) => ({
|
|
292
|
+
role: "custom",
|
|
293
|
+
customType: APPEND_SEGMENT_CUSTOM_TYPE,
|
|
294
|
+
display: false,
|
|
295
|
+
content: segment.summary,
|
|
296
|
+
}));
|
|
297
|
+
replacement.push({ role: "custom", customType: APPEND_TRAILING_CUSTOM_TYPE, display: false, content: input.chain.trailingSummary });
|
|
298
|
+
const result = input.messages.slice();
|
|
299
|
+
result.splice(matches[0], 1, ...replacement);
|
|
300
|
+
return result;
|
|
301
|
+
};
|
|
@@ -99,15 +99,22 @@ function formatToolCallContent(
|
|
|
99
99
|
const MAX_FULL_BYTES = 50 * 1024;
|
|
100
100
|
|
|
101
101
|
if (full) {
|
|
102
|
-
// Full content
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
// Full content is capped at 50 KiB of UTF-8 without splitting a code point.
|
|
103
|
+
const totalBytes = Buffer.byteLength(body, "utf8");
|
|
104
|
+
if (totalBytes > MAX_FULL_BYTES) {
|
|
105
|
+
const bytes = Buffer.from(body, "utf8");
|
|
106
|
+
let end = MAX_FULL_BYTES;
|
|
107
|
+
if (end < bytes.length && (bytes[end] & 0xc0) === 0x80) {
|
|
108
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--;
|
|
109
|
+
}
|
|
110
|
+
const truncated = bytes.subarray(0, end).toString("utf8");
|
|
111
|
+
const omittedBytes = bytes.length - end;
|
|
105
112
|
return `File: ${tc.path}
|
|
106
113
|
Tool: ${tc.name}
|
|
107
114
|
|
|
108
115
|
${truncated}
|
|
109
116
|
|
|
110
|
-
... (${
|
|
117
|
+
... (${omittedBytes} more bytes — file exceeds 50KB display limit. Use #${entryIndex}:${tc.path}:${previewLimit} for next page.)`;
|
|
111
118
|
}
|
|
112
119
|
return `File: ${tc.path}
|
|
113
120
|
Tool: ${tc.name}
|
|
@@ -94,14 +94,30 @@ export const formatRecallOutput = (
|
|
|
94
94
|
|
|
95
95
|
const lines = entries.map((e) => {
|
|
96
96
|
const fileSuffix = e.files?.length ? ` files:[${e.files.join(", ")}]` : "";
|
|
97
|
+
const fileDetails = e.fileMatches?.length
|
|
98
|
+
? `\n${e.fileMatches
|
|
99
|
+
.map((m) => {
|
|
100
|
+
const header = ` ${m.path} (${m.toolName}, ${m.lineCount} lines)`;
|
|
101
|
+
if (!m.snippet) return header;
|
|
102
|
+
const snippet = m.snippet
|
|
103
|
+
.split("\n")
|
|
104
|
+
.map((line) => ` ${line}`)
|
|
105
|
+
.join("\n");
|
|
106
|
+
return `${header}\n${snippet}`;
|
|
107
|
+
})
|
|
108
|
+
.join("\n")}`
|
|
109
|
+
: "";
|
|
97
110
|
const body = query && e.snippet ? e.snippet : e.summary;
|
|
98
|
-
|
|
111
|
+
const separator = fileDetails ? "\n" : " ";
|
|
112
|
+
return `#${e.index} [${e.role}]${fileSuffix}${fileDetails}${separator}${body}`;
|
|
99
113
|
});
|
|
100
114
|
|
|
101
115
|
const body = `${header}\n\n${lines.join("\n\n")}`;
|
|
102
116
|
// Every hit ref resolves: #N expands the full entry (see expandEntry in
|
|
103
117
|
// drill-down.ts). Surface the hint when results are capped or clipped.
|
|
104
|
-
const clipped = entries.some(
|
|
118
|
+
const clipped = entries.some(
|
|
119
|
+
(e) => e.snippet?.includes("...(") || e.fileMatches?.some((m) => m.snippet.includes("...(")),
|
|
120
|
+
);
|
|
105
121
|
if (opts?.truncated || clipped) {
|
|
106
122
|
return `${body}\n\n--- Use #N for full entry text ---`;
|
|
107
123
|
}
|