omp-vcc 0.1.12 → 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 +174 -81
- 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 +782 -140
- package/package.json +77 -1
- package/scripts/smoke.ts +8 -0
- package/types.d.ts +10 -2
|
@@ -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
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
|
|
3
|
+
/** Minimal persisted-session entry shape used by the global index. */
|
|
4
|
+
export interface PersistedSessionEntry {
|
|
5
|
+
type?: unknown;
|
|
6
|
+
id?: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A fail-closed mapping from unique message entry IDs to global positions. */
|
|
10
|
+
export interface GlobalIndex {
|
|
11
|
+
/** Number of persisted entries whose type is exactly "message". */
|
|
12
|
+
messageCount: number;
|
|
13
|
+
/** Unique, non-empty string message IDs mapped to 1-based global positions. */
|
|
14
|
+
indexById: Map<string, number>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build global message positions from persisted session entries.
|
|
19
|
+
*
|
|
20
|
+
* Every message entry consumes a position, including entries without an ID.
|
|
21
|
+
* A repeated ID is removed permanently: malformed branches must not resolve
|
|
22
|
+
* to either occurrence. Missing and non-string IDs never enter the map.
|
|
23
|
+
*/
|
|
24
|
+
export const buildGlobalIndex = (
|
|
25
|
+
entries: Iterable<PersistedSessionEntry>,
|
|
26
|
+
): GlobalIndex => {
|
|
27
|
+
const indexById = new Map<string, number>();
|
|
28
|
+
const seenIds = new Set<string>();
|
|
29
|
+
let messageCount = 0;
|
|
30
|
+
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
if (entry?.type !== "message") continue;
|
|
33
|
+
messageCount++;
|
|
34
|
+
|
|
35
|
+
const id = entry.id;
|
|
36
|
+
if (typeof id !== "string" || id.length === 0 || seenIds.has(id)) {
|
|
37
|
+
if (typeof id === "string" && id.length > 0) indexById.delete(id);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
seenIds.add(id);
|
|
42
|
+
indexById.set(id, messageCount);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { messageCount, indexById };
|
|
46
|
+
};
|
|
@@ -1,44 +1,139 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
|
-
import {
|
|
2
|
+
import { statSync } from "fs";
|
|
3
3
|
import type { Message } from "@oh-my-pi/pi-ai";
|
|
4
|
-
import { renderMessage, type RenderedEntry } from "./render-entries";
|
|
4
|
+
import { renderMessage, type RenderedEntry } from "./render-entries.ts";
|
|
5
|
+
import { scanSessionEntries } from "./session-lines.ts";
|
|
5
6
|
|
|
6
7
|
export interface LoadedMessages {
|
|
7
8
|
rendered: RenderedEntry[];
|
|
8
9
|
rawMessages: Message[];
|
|
10
|
+
/** Entry IDs aligned with rendered/rawMessages; missing IDs use an empty sentinel. */
|
|
11
|
+
entryIds: string[];
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
export interface LoadMessagesDebugEvent {
|
|
15
|
+
kind: "session-parse-errors";
|
|
16
|
+
sessionFile: string;
|
|
17
|
+
parseErrors: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type LoadMessagesDebugSink = (event: LoadMessagesDebugEvent) => void;
|
|
21
|
+
|
|
22
|
+
const CACHE_TTL_MS = 2_000;
|
|
23
|
+
const CACHE_CAPACITY = 3;
|
|
24
|
+
|
|
25
|
+
interface CacheEntry {
|
|
26
|
+
createdAt: number;
|
|
27
|
+
mtimeMs: number;
|
|
28
|
+
value: LoadedMessages;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const cache = new Map<string, CacheEntry>();
|
|
32
|
+
|
|
33
|
+
const cacheKey = (
|
|
34
|
+
sessionFile: string,
|
|
35
|
+
full: boolean,
|
|
36
|
+
allowedEntryIds?: Set<string>,
|
|
37
|
+
): string => {
|
|
38
|
+
const lineage = allowedEntryIds
|
|
39
|
+
? JSON.stringify(Array.from(allowedEntryIds).sort())
|
|
40
|
+
: "*";
|
|
41
|
+
return JSON.stringify([sessionFile, full, lineage]);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const cached = (
|
|
45
|
+
key: string,
|
|
46
|
+
now: number,
|
|
47
|
+
mtimeMs: number,
|
|
48
|
+
): LoadedMessages | undefined => {
|
|
49
|
+
const hit = cache.get(key);
|
|
50
|
+
if (!hit) return undefined;
|
|
51
|
+
if (now - hit.createdAt >= CACHE_TTL_MS || hit.mtimeMs !== mtimeMs) {
|
|
52
|
+
cache.delete(key);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Refresh insertion order for the three-entry LRU.
|
|
57
|
+
cache.delete(key);
|
|
58
|
+
cache.set(key, hit);
|
|
59
|
+
return hit.value;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const remember = (key: string, value: LoadedMessages, mtimeMs: number): void => {
|
|
63
|
+
cache.delete(key);
|
|
64
|
+
cache.set(key, { createdAt: Date.now(), mtimeMs, value });
|
|
65
|
+
if (cache.size > CACHE_CAPACITY) {
|
|
66
|
+
const oldest = cache.keys().next().value;
|
|
67
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const emitParseDiagnostic = (
|
|
72
|
+
sink: LoadMessagesDebugSink | undefined,
|
|
73
|
+
sessionFile: string,
|
|
74
|
+
parseErrors: number,
|
|
75
|
+
): void => {
|
|
76
|
+
if (!sink || parseErrors === 0) return;
|
|
77
|
+
try {
|
|
78
|
+
sink({
|
|
79
|
+
kind: "session-parse-errors",
|
|
80
|
+
sessionFile: sessionFile.slice(0, 1_024),
|
|
81
|
+
parseErrors,
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
// Diagnostics must never affect session loading.
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
11
88
|
export const loadAllMessages = (
|
|
12
89
|
sessionFile: string,
|
|
13
90
|
full: boolean,
|
|
14
91
|
allowedEntryIds?: Set<string>,
|
|
92
|
+
debugSink?: LoadMessagesDebugSink,
|
|
15
93
|
): LoadedMessages => {
|
|
16
|
-
|
|
94
|
+
const key = cacheKey(sessionFile, full, allowedEntryIds);
|
|
95
|
+
let mtimeMs: number | undefined;
|
|
17
96
|
try {
|
|
18
|
-
|
|
97
|
+
mtimeMs = statSync(sessionFile).mtimeMs;
|
|
19
98
|
} catch {
|
|
20
|
-
|
|
99
|
+
// The scanner below preserves the existing empty-result behavior for
|
|
100
|
+
// missing/unreadable files while distinguishing ENOENT for its own callers.
|
|
21
101
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
102
|
+
|
|
103
|
+
if (mtimeMs !== undefined) {
|
|
104
|
+
const hit = cached(key, Date.now(), mtimeMs);
|
|
105
|
+
if (hit) return hit;
|
|
26
106
|
}
|
|
107
|
+
|
|
27
108
|
const rendered: RenderedEntry[] = [];
|
|
28
109
|
const rawMessages: Message[] = [];
|
|
29
|
-
|
|
110
|
+
const entryIds: string[] = [];
|
|
30
111
|
let messageIndex = 0;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
112
|
+
let parseErrors = 0;
|
|
113
|
+
let missing = false;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const scan = scanSessionEntries<any>(sessionFile, (entry) => {
|
|
117
|
+
if (entry?.type !== "message") return;
|
|
118
|
+
|
|
119
|
+
if (entry.message) {
|
|
120
|
+
const allowed = !allowedEntryIds || allowedEntryIds.has(entry.id);
|
|
121
|
+
if (allowed) {
|
|
122
|
+
rendered.push(renderMessage(entry.message, messageIndex, full));
|
|
123
|
+
rawMessages.push(entry.message);
|
|
124
|
+
entryIds.push(typeof entry.id === "string" ? entry.id : "");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
messageIndex++;
|
|
128
|
+
});
|
|
129
|
+
missing = scan.missing;
|
|
130
|
+
parseErrors = scan.parseErrors;
|
|
131
|
+
} catch {
|
|
132
|
+
return { rendered: [], rawMessages: [], entryIds: [] };
|
|
41
133
|
}
|
|
42
134
|
|
|
43
|
-
|
|
135
|
+
emitParseDiagnostic(debugSink, sessionFile, parseErrors);
|
|
136
|
+
const value = { rendered, rawMessages, entryIds };
|
|
137
|
+
if (!missing && mtimeMs !== undefined) remember(key, value, mtimeMs);
|
|
138
|
+
return value;
|
|
44
139
|
};
|
|
@@ -4,26 +4,26 @@ import type { NormalizedBlock } from "../types";
|
|
|
4
4
|
import { textOf } from "./content";
|
|
5
5
|
import { sanitize } from "./sanitize";
|
|
6
6
|
|
|
7
|
-
const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
|
|
7
|
+
const normalizeOne = (msg: Message, msgIndex: number, sourceIndex: number | undefined): NormalizedBlock[] => {
|
|
8
8
|
if (msg.role === "user") {
|
|
9
9
|
const blocks: NormalizedBlock[] = [];
|
|
10
10
|
const text = sanitize(textOf(msg.content));
|
|
11
|
-
if (text) blocks.push({ kind: "user", text, sourceIndex
|
|
11
|
+
if (text) blocks.push({ kind: "user", text, sourceIndex });
|
|
12
12
|
if (msg.content && typeof msg.content !== "string") {
|
|
13
13
|
for (const part of msg.content) {
|
|
14
14
|
if (part.type === "image") {
|
|
15
|
-
blocks.push({ kind: "user", text: `[image: ${part.mimeType}]`, sourceIndex
|
|
15
|
+
blocks.push({ kind: "user", text: `[image: ${part.mimeType}]`, sourceIndex });
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex
|
|
19
|
+
return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex }];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
if (msg.role === "bashExecution") {
|
|
23
23
|
const cmd = (msg as any).command ?? "";
|
|
24
24
|
const out = (msg as any).output ?? "";
|
|
25
25
|
const exit = (msg as any).exitCode;
|
|
26
|
-
return [{ kind: "bash", command: cmd, output: out, exitCode: exit, sourceIndex
|
|
26
|
+
return [{ kind: "bash", command: cmd, output: out, exitCode: exit, sourceIndex }];
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
if (msg.role === "toolResult") {
|
|
@@ -31,29 +31,29 @@ const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
|
|
|
31
31
|
kind: "tool_result",
|
|
32
32
|
name: msg.toolName,
|
|
33
33
|
text: sanitize(textOf(msg.content)),
|
|
34
|
-
sourceIndex
|
|
34
|
+
sourceIndex,
|
|
35
35
|
}];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
if (msg.role === "assistant") {
|
|
39
39
|
if (!msg.content) return [];
|
|
40
40
|
if (typeof msg.content === "string") {
|
|
41
|
-
return [{ kind: "assistant", text: sanitize(msg.content), sourceIndex
|
|
41
|
+
return [{ kind: "assistant", text: sanitize(msg.content), sourceIndex }];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
const blocks: NormalizedBlock[] = [];
|
|
45
45
|
for (const part of msg.content) {
|
|
46
46
|
if (part.type === "text") {
|
|
47
|
-
blocks.push({ kind: "assistant", text: sanitize(part.text), sourceIndex
|
|
47
|
+
blocks.push({ kind: "assistant", text: sanitize(part.text), sourceIndex });
|
|
48
48
|
} else if (part.type === "thinking") {
|
|
49
49
|
const thinkingText = sanitize(part.text ?? part.thinking ?? "");
|
|
50
|
-
if (thinkingText) blocks.push({ kind: "thinking", text: thinkingText, sourceIndex
|
|
50
|
+
if (thinkingText) blocks.push({ kind: "thinking", text: thinkingText, sourceIndex });
|
|
51
51
|
} else if (part.type === "toolCall") {
|
|
52
52
|
blocks.push({
|
|
53
53
|
kind: "tool_call",
|
|
54
54
|
name: part.name,
|
|
55
55
|
args: part.arguments,
|
|
56
|
-
sourceIndex
|
|
56
|
+
sourceIndex,
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -65,14 +65,14 @@ const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
|
|
|
65
65
|
// user or assistant turn. branchSummary entries carry no content here.
|
|
66
66
|
if (msg.role === "custom") {
|
|
67
67
|
const text = sanitize(textOf(msg.content));
|
|
68
|
-
if (text) return [{ kind: "custom", text, sourceIndex
|
|
68
|
+
if (text) return [{ kind: "custom", text, sourceIndex }];
|
|
69
69
|
return [];
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
return [];
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
-
export const normalize = (messages: Message[]): NormalizedBlock[] =>
|
|
76
|
-
messages.flatMap((msg, i) => normalizeOne(msg, i));
|
|
75
|
+
export const normalize = (messages: Message[], sourceIndices?: Array<number | undefined>): NormalizedBlock[] =>
|
|
76
|
+
messages.flatMap((msg, i) => normalizeOne(msg, i, sourceIndices ? sourceIndices[i] : i));
|
|
77
77
|
|
|
78
78
|
|