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.
@@ -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: capped at 50KB
103
- if (Buffer.byteLength(body, "utf8") > MAX_FULL_BYTES) {
104
- const truncated = body.slice(0, MAX_FULL_BYTES);
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
- ... (${Buffer.byteLength(body, "utf8") - MAX_FULL_BYTES} more bytes — file exceeds 50KB display limit. Use #${entryIndex}:${tc.path}:${previewLimit} for next page.)`;
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
- return `#${e.index} [${e.role}]${fileSuffix} ${body}`;
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((e) => e.snippet?.includes("...("));
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 { readFileSync } from "fs";
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
- let content: string;
94
+ const key = cacheKey(sessionFile, full, allowedEntryIds);
95
+ let mtimeMs: number | undefined;
17
96
  try {
18
- content = readFileSync(sessionFile, "utf-8");
97
+ mtimeMs = statSync(sessionFile).mtimeMs;
19
98
  } catch {
20
- return { rendered: [], rawMessages: [] };
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
- const entries: any[] = [];
23
- for (const line of content.split("\n")) {
24
- if (!line.trim()) continue;
25
- try { entries.push(JSON.parse(line)); } catch {}
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
- for (const e of entries) {
32
- const isMessage = e.type === "message" && e.message;
33
- if (!isMessage) continue;
34
-
35
- const allowed = !allowedEntryIds || allowedEntryIds.has(e.id);
36
- if (allowed) {
37
- rendered.push(renderMessage(e.message, messageIndex, full));
38
- rawMessages.push(e.message);
39
- }
40
- messageIndex++;
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
- return { rendered, rawMessages };
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: msgIndex });
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: msgIndex });
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: msgIndex }];
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: msgIndex }];
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: msgIndex,
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: msgIndex }];
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: msgIndex });
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: msgIndex });
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: msgIndex,
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: msgIndex }];
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