ds4-context-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ import type {
2
+ CanonicalBlock,
3
+ CanonicalMessage,
4
+ CanonicalRole,
5
+ ImageBlock,
6
+ } from "ds4-context-core/core/canonical-message";
7
+ import { estimateMessageTokens } from "ds4-context-core/core/token-estimator";
8
+
9
+ export interface MessageConversionInput {
10
+ sessionId: string;
11
+ entryId: string;
12
+ entryTimestamp?: string;
13
+ message: unknown;
14
+ }
15
+
16
+ function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return value !== null && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+
20
+ function stringValue(value: unknown): string | undefined {
21
+ return typeof value === "string" ? value : undefined;
22
+ }
23
+
24
+ function safeJson(value: unknown): string {
25
+ try {
26
+ return JSON.stringify(value) ?? "";
27
+ } catch {
28
+ return String(value);
29
+ }
30
+ }
31
+
32
+ function imageBlock(block: Record<string, unknown>): ImageBlock {
33
+ const source = isRecord(block.source) ? block.source : undefined;
34
+ return {
35
+ type: "image",
36
+ ...(stringValue(block.mimeType) || stringValue(source?.mediaType)
37
+ ? { mimeType: stringValue(block.mimeType) ?? stringValue(source?.mediaType) }
38
+ : {}),
39
+ ...(stringValue(block.data) || stringValue(source?.data)
40
+ ? { data: stringValue(block.data) ?? stringValue(source?.data) }
41
+ : {}),
42
+ };
43
+ }
44
+
45
+ function convertContentBlock(block: unknown): CanonicalBlock {
46
+ if (!isRecord(block)) return { type: "opaqueProvider", value: block };
47
+
48
+ if (block.type === "text" && typeof block.text === "string") {
49
+ return { type: "text", text: block.text };
50
+ }
51
+ if (block.type === "thinking" && typeof block.thinking === "string") {
52
+ return { type: "thinking", thinking: block.thinking };
53
+ }
54
+ if (block.type === "toolCall") {
55
+ return {
56
+ type: "toolCall",
57
+ id: stringValue(block.id) ?? "",
58
+ name: stringValue(block.name) ?? "",
59
+ arguments: block.arguments,
60
+ };
61
+ }
62
+ if (block.type === "image") return imageBlock(block);
63
+
64
+ return {
65
+ type: "opaqueProvider",
66
+ ...(typeof block.type === "string" ? { originalType: block.type } : {}),
67
+ value: block,
68
+ };
69
+ }
70
+
71
+ function contentBlocks(content: unknown): CanonicalBlock[] {
72
+ if (typeof content === "string") return [{ type: "text", text: content }];
73
+ if (!Array.isArray(content)) return content == null ? [] : [{ type: "opaqueProvider", value: content }];
74
+ return content.map(convertContentBlock);
75
+ }
76
+
77
+ function canonicalRole(originalRole: string | undefined): CanonicalRole {
78
+ switch (originalRole) {
79
+ case "system":
80
+ return "system";
81
+ case "user":
82
+ return "user";
83
+ case "assistant":
84
+ return "assistant";
85
+ case "toolResult":
86
+ case "bashExecution":
87
+ return "tool";
88
+ default:
89
+ return "custom";
90
+ }
91
+ }
92
+
93
+ function timestamp(message: Record<string, unknown>, entryTimestamp: string | undefined): number | undefined {
94
+ if (typeof message.timestamp === "number" && Number.isFinite(message.timestamp)) return message.timestamp;
95
+ if (!entryTimestamp) return undefined;
96
+ const parsed = Date.parse(entryTimestamp);
97
+ return Number.isNaN(parsed) ? undefined : parsed;
98
+ }
99
+
100
+ export function toCanonicalMessage(input: MessageConversionInput): CanonicalMessage {
101
+ const message = isRecord(input.message) ? input.message : {};
102
+ const originalRole = stringValue(message.role);
103
+ let blocks = contentBlocks(message.content);
104
+
105
+ if (originalRole === "toolResult") {
106
+ const text = blocks
107
+ .filter((block): block is Extract<CanonicalBlock, { type: "text" }> => block.type === "text")
108
+ .map((block) => block.text)
109
+ .join("\n");
110
+ const images = blocks.filter((block): block is ImageBlock => block.type === "image");
111
+ blocks = [
112
+ {
113
+ type: "toolResult",
114
+ toolCallId: stringValue(message.toolCallId) ?? "",
115
+ toolName: stringValue(message.toolName) ?? "",
116
+ content: text,
117
+ isError: message.isError === true,
118
+ },
119
+ ...images,
120
+ ];
121
+ } else if (originalRole === "bashExecution") {
122
+ blocks = [
123
+ { type: "text", text: `Command: ${stringValue(message.command) ?? ""}` },
124
+ { type: "text", text: stringValue(message.output) ?? "" },
125
+ ];
126
+ }
127
+
128
+ const createdAt = timestamp(message, input.entryTimestamp);
129
+ return {
130
+ id: `${input.sessionId}:${input.entryId}`,
131
+ sourceEntryId: input.entryId,
132
+ role: canonicalRole(originalRole),
133
+ blocks,
134
+ ...(createdAt !== undefined ? { createdAt } : {}),
135
+ ...(stringValue(message.provider) ? { provider: stringValue(message.provider) } : {}),
136
+ ...(stringValue(message.model) ? { model: stringValue(message.model) } : {}),
137
+ provenance: {
138
+ source: "pi-session",
139
+ sessionId: input.sessionId,
140
+ entryId: input.entryId,
141
+ ...(originalRole ? { originalRole } : {}),
142
+ },
143
+ tokenEstimate: estimateMessageTokens(message),
144
+ flags: {
145
+ atomic: originalRole === "toolResult" || blocks.some((block) => block.type === "toolCall"),
146
+ },
147
+ };
148
+ }
149
+
150
+ export function canonicalMessageSearchText(message: CanonicalMessage): string {
151
+ const parts: string[] = [];
152
+ for (const block of message.blocks) {
153
+ switch (block.type) {
154
+ case "text":
155
+ parts.push(block.text);
156
+ break;
157
+ case "toolCall":
158
+ parts.push(block.name, safeJson(block.arguments));
159
+ break;
160
+ case "toolResult":
161
+ parts.push(block.toolName, block.content);
162
+ break;
163
+ case "fileReference":
164
+ parts.push(block.path);
165
+ break;
166
+ case "artifactReference":
167
+ parts.push(block.artifactId);
168
+ break;
169
+ case "summaryReference":
170
+ parts.push(block.summaryId);
171
+ break;
172
+ // Thinking, image data, and opaque provider payloads are preserved but
173
+ // intentionally excluded from the lexical index.
174
+ case "thinking":
175
+ case "image":
176
+ case "opaqueProvider":
177
+ break;
178
+ }
179
+ }
180
+ return parts.filter(Boolean).join("\n");
181
+ }
@@ -0,0 +1,226 @@
1
+ import {
2
+ createAssistantMessageEventStream,
3
+ type Api,
4
+ type AssistantMessage,
5
+ type AssistantMessageEvent,
6
+ type AssistantMessageEventStream,
7
+ type Context,
8
+ type Model,
9
+ type SimpleStreamOptions,
10
+ } from "@earendil-works/pi-ai";
11
+ import { createGrammarToolInputProperties } from "@earendil-works/pi-ai/api/constrained-sampling";
12
+ import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
13
+ import { convertResponsesMessages } from "@earendil-works/pi-ai/api/openai-responses-shared";
14
+ import {
15
+ continuationItemHashes,
16
+ type NativeContinuationAttempt,
17
+ type PreparedNativeContinuation,
18
+ } from "ds4-context-core/continuation/native-continuation";
19
+
20
+ const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
21
+
22
+ export type OpenAIResponsesStreamDelegate = (
23
+ model: Model<Api>,
24
+ context: Context,
25
+ options?: SimpleStreamOptions,
26
+ ) => AssistantMessageEventStream;
27
+
28
+ export interface NativeContinuationStreamController {
29
+ prepare(
30
+ payload: unknown,
31
+ model: Model<Api>,
32
+ requestSessionId: string | undefined,
33
+ options: {
34
+ forceManagedReplay: boolean;
35
+ retryOfContinuation: boolean;
36
+ fallbackReason?: string;
37
+ },
38
+ ): PreparedNativeContinuation;
39
+ beginManagedReplayRetry(attempt: NativeContinuationAttempt): void;
40
+ complete(
41
+ attempt: NativeContinuationAttempt,
42
+ message: AssistantMessage,
43
+ responseItemHashes: readonly string[],
44
+ ): void;
45
+ fail(attempt: NativeContinuationAttempt, reason: string): void;
46
+ shouldRetryManagedReplay(): boolean;
47
+ }
48
+
49
+ interface AttemptRun {
50
+ stream: AssistantMessageEventStream;
51
+ attempt: () => NativeContinuationAttempt | undefined;
52
+ }
53
+
54
+ function terminalMessage(event: AssistantMessageEvent): AssistantMessage | undefined {
55
+ if (event.type === "done") return event.message;
56
+ if (event.type === "error") return event.error;
57
+ return undefined;
58
+ }
59
+
60
+ function retryableContinuationError(event: AssistantMessageEvent): boolean {
61
+ if (event.type !== "error") return false;
62
+ const message = event.error.errorMessage ?? "";
63
+ return /previous[ _-]?response|previous_response_id|response(?:\s+with)?\s+id[^\n]{0,80}(?:not found|expired|invalid|unknown)|conversation(?:\s+with)?\s+id[^\n]{0,80}(?:not found|expired|invalid|unknown)/iu.test(message);
64
+ }
65
+
66
+ function outputItemHashes(
67
+ model: Model<Api>,
68
+ context: Context,
69
+ message: AssistantMessage,
70
+ ): string[] {
71
+ try {
72
+ const supportsGrammar = Boolean(
73
+ model.compat
74
+ && "supportsOpenAIGrammarTools" in model.compat
75
+ && model.compat.supportsOpenAIGrammarTools === true,
76
+ );
77
+ const grammarToolInputProperties = createGrammarToolInputProperties(
78
+ context.tools,
79
+ supportsGrammar,
80
+ );
81
+ const items = convertResponsesMessages(
82
+ model,
83
+ { messages: [message] },
84
+ OPENAI_TOOL_CALL_PROVIDERS,
85
+ {
86
+ includeSystemPrompt: false,
87
+ grammarToolInputProperties,
88
+ },
89
+ ).filter((item) => item.type !== "function_call_output"
90
+ && item.type !== "custom_tool_call_output");
91
+ return continuationItemHashes(items);
92
+ } catch {
93
+ return [];
94
+ }
95
+ }
96
+
97
+ function syntheticError(model: Model<Api>, error: unknown): AssistantMessage {
98
+ return {
99
+ role: "assistant",
100
+ content: [],
101
+ api: model.api,
102
+ provider: model.provider,
103
+ model: model.id,
104
+ usage: {
105
+ input: 0,
106
+ output: 0,
107
+ cacheRead: 0,
108
+ cacheWrite: 0,
109
+ totalTokens: 0,
110
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
111
+ },
112
+ stopReason: "error",
113
+ errorMessage: error instanceof Error ? error.message : String(error),
114
+ timestamp: Date.now(),
115
+ };
116
+ }
117
+
118
+ export function createOpenAIResponsesContinuationStream(
119
+ controller: NativeContinuationStreamController,
120
+ delegate: OpenAIResponsesStreamDelegate = (model, context, options) =>
121
+ openAIResponsesApi().streamSimple(model, context, options),
122
+ ): OpenAIResponsesStreamDelegate {
123
+ return (model, context, options) => {
124
+ const output = createAssistantMessageEventStream();
125
+
126
+ const startAttempt = (
127
+ forceManagedReplay: boolean,
128
+ retryOfContinuation: boolean,
129
+ ): AttemptRun => {
130
+ let preparedAttempt: NativeContinuationAttempt | undefined;
131
+ const stream = delegate(model, context, {
132
+ ...options,
133
+ onPayload: async (payload, payloadModel) => {
134
+ const replacement = await options?.onPayload?.(payload, payloadModel);
135
+ const finalPayload = replacement === undefined ? payload : replacement;
136
+ const prepared = controller.prepare(
137
+ finalPayload,
138
+ model,
139
+ options?.sessionId,
140
+ {
141
+ forceManagedReplay,
142
+ retryOfContinuation,
143
+ ...(retryOfContinuation
144
+ ? { fallbackReason: "provider-rejected-continuation" }
145
+ : {}),
146
+ },
147
+ );
148
+ preparedAttempt = prepared.attempt;
149
+ return prepared.payload;
150
+ },
151
+ });
152
+ return { stream, attempt: () => preparedAttempt };
153
+ };
154
+
155
+ const consume = async (
156
+ run: AttemptRun,
157
+ allowRetry: boolean,
158
+ ): Promise<"complete" | "retry"> => {
159
+ let started = false;
160
+ for await (const event of run.stream) {
161
+ if (event.type === "start") started = true;
162
+ const attempt = run.attempt();
163
+ let retryEnabled = false;
164
+ try {
165
+ retryEnabled = controller.shouldRetryManagedReplay();
166
+ } catch {
167
+ retryEnabled = false;
168
+ }
169
+ if (event.type === "error"
170
+ && allowRetry
171
+ && !started
172
+ && attempt?.usedContinuation
173
+ && !options?.signal?.aborted
174
+ && retryEnabled
175
+ && retryableContinuationError(event)) {
176
+ try {
177
+ controller.beginManagedReplayRetry(attempt);
178
+ return "retry";
179
+ } catch {
180
+ // Continuation bookkeeping must not replace the provider's original error.
181
+ }
182
+ }
183
+
184
+ if (event.type === "done" && attempt) {
185
+ try {
186
+ controller.complete(attempt, event.message, outputItemHashes(model, context, event.message));
187
+ } catch {
188
+ // Provider output remains authoritative if optimization bookkeeping fails.
189
+ }
190
+ } else if (event.type === "error" && attempt) {
191
+ try {
192
+ controller.fail(attempt, started
193
+ ? "provider-stream-failed-after-start"
194
+ : "provider-request-failed");
195
+ } catch {
196
+ // Provider output remains authoritative if optimization bookkeeping fails.
197
+ }
198
+ }
199
+
200
+ output.push(event);
201
+ if (terminalMessage(event)) {
202
+ output.end();
203
+ return "complete";
204
+ }
205
+ }
206
+ throw new Error("OpenAI Responses provider stream ended without a terminal event");
207
+ };
208
+
209
+ void (async () => {
210
+ try {
211
+ const first = startAttempt(false, false);
212
+ const result = await consume(first, true);
213
+ if (result === "retry") {
214
+ const replay = startAttempt(true, true);
215
+ await consume(replay, false);
216
+ }
217
+ } catch (error) {
218
+ const message = syntheticError(model, error);
219
+ output.push({ type: "error", reason: "error", error: message });
220
+ output.end();
221
+ }
222
+ })();
223
+
224
+ return output;
225
+ };
226
+ }
@@ -0,0 +1,255 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import type { Logger } from "ds4-context-core/shared/logging";
3
+ import { silentLogger } from "ds4-context-core/shared/logging";
4
+ import {
5
+ AppendOnlyEntryChangedError,
6
+ type SessionIdentity,
7
+ type SessionIndexCheckpointInput,
8
+ type SessionIndexRepository,
9
+ type SessionIndexState,
10
+ } from "ds4-context-core/persistence/repositories/session-index-repository";
11
+ import type { PiSessionSnapshot } from "./session-reader.ts";
12
+ import { isPiSessionEntryRecord, toIndexedSessionEntry } from "./indexed-entry.ts";
13
+ import {
14
+ hashFileRange,
15
+ readJsonlRecords,
16
+ readSessionHeaderRecord,
17
+ type JsonlReadResult,
18
+ type SessionHeaderRecord,
19
+ } from "./session-jsonl.ts";
20
+
21
+ const SUPPORTED_SESSION_VERSION = 3;
22
+
23
+ export type SessionIndexMode = "noop" | "incremental" | "rebuild" | "skipped";
24
+
25
+ export interface SessionIndexResult {
26
+ mode: SessionIndexMode;
27
+ reason: string;
28
+ processedEntries: number;
29
+ insertedEntries: number;
30
+ totalEntries: number;
31
+ malformedLines: number;
32
+ durationMs: number;
33
+ }
34
+
35
+ export interface SessionIndexerOptions {
36
+ logger?: Logger;
37
+ now?: () => number;
38
+ monotonicNow?: () => number;
39
+ }
40
+
41
+ function validateHeader(header: SessionHeaderRecord, expectedSessionId: string): void {
42
+ if (header.value.id !== expectedSessionId) {
43
+ throw new Error(
44
+ `Session header id ${header.value.id} does not match Pi session id ${expectedSessionId}`,
45
+ );
46
+ }
47
+ const version = header.value.version;
48
+ if (typeof version === "number" && version > SUPPORTED_SESSION_VERSION) {
49
+ throw new Error(`Pi session format v${version} is newer than supported v${SUPPORTED_SESSION_VERSION}`);
50
+ }
51
+ }
52
+
53
+ function identity(session: PiSessionSnapshot, indexedAt: number): SessionIdentity {
54
+ if (!session.sessionFile) throw new Error("Cannot index an ephemeral Pi session");
55
+ return {
56
+ sessionId: session.sessionId,
57
+ sessionFile: session.sessionFile,
58
+ projectPath: session.projectPath,
59
+ indexedAt,
60
+ };
61
+ }
62
+
63
+ function checkpoint(
64
+ session: PiSessionSnapshot,
65
+ header: SessionHeaderRecord,
66
+ read: JsonlReadResult,
67
+ indexedAt: number,
68
+ previous?: SessionIndexState,
69
+ ): SessionIndexCheckpointInput {
70
+ const hasNewTerminatedLine = read.checkpointHash !== undefined;
71
+ return {
72
+ sessionId: session.sessionId,
73
+ sessionFile: session.sessionFile ?? "",
74
+ headerHash: header.rawHash,
75
+ fileSize: read.fileSize,
76
+ fileMtimeMs: read.fileMtimeMs,
77
+ checkpointOffset: hasNewTerminatedLine
78
+ ? read.safeCheckpointOffset
79
+ : previous?.checkpointOffset ?? read.safeCheckpointOffset,
80
+ checkpointHashStart: hasNewTerminatedLine
81
+ ? read.checkpointHashStart
82
+ : previous?.checkpointHashStart ?? read.checkpointHashStart,
83
+ ...(hasNewTerminatedLine
84
+ ? { checkpointHash: read.checkpointHash }
85
+ : previous?.checkpointHash
86
+ ? { checkpointHash: previous.checkpointHash }
87
+ : {}),
88
+ malformedLines: (previous?.malformedLines ?? 0) + read.malformedLines,
89
+ indexedAt,
90
+ };
91
+ }
92
+
93
+ export class PiSessionIndexer {
94
+ private readonly logger: Logger;
95
+ private readonly now: () => number;
96
+ private readonly monotonicNow: () => number;
97
+
98
+ constructor(
99
+ private readonly repository: SessionIndexRepository,
100
+ options: SessionIndexerOptions = {},
101
+ ) {
102
+ this.logger = options.logger ?? silentLogger;
103
+ this.now = options.now ?? Date.now;
104
+ this.monotonicNow = options.monotonicNow ?? (() => performance.now());
105
+ }
106
+
107
+ sync(session: PiSessionSnapshot, forceRebuild = false): SessionIndexResult {
108
+ const started = this.monotonicNow();
109
+ if (!session.sessionFile || !existsSync(session.sessionFile)) {
110
+ return this.result("skipped", "session file is not persisted yet", 0, 0, 0, 0, started);
111
+ }
112
+
113
+ const header = readSessionHeaderRecord(session.sessionFile);
114
+ validateHeader(header, session.sessionId);
115
+ const state = this.repository.getState(session.sessionId);
116
+ const stat = statSync(session.sessionFile);
117
+
118
+ const rebuildReason = this.rebuildReason(session, header, state, stat.size, stat.mtimeMs, forceRebuild);
119
+ if (rebuildReason) return this.rebuild(session, header, rebuildReason, started);
120
+
121
+ if (!state) return this.rebuild(session, header, "missing index checkpoint", started);
122
+ if (stat.size === state.fileSize && stat.mtimeMs === state.fileMtimeMs) {
123
+ return this.result(
124
+ "noop",
125
+ "session file unchanged",
126
+ 0,
127
+ 0,
128
+ state.indexedEntries,
129
+ state.malformedLines,
130
+ started,
131
+ );
132
+ }
133
+
134
+ const read = readJsonlRecords(session.sessionFile, state.checkpointOffset);
135
+ const indexedAt = this.now();
136
+ const entries = read.records
137
+ .filter((record) => record.value.type !== "session")
138
+ .map((record) => toIndexedSessionEntry(session.sessionId, record, indexedAt));
139
+
140
+ try {
141
+ const write = this.repository.append(
142
+ identity(session, indexedAt),
143
+ entries,
144
+ checkpoint(session, header, read, indexedAt, state),
145
+ );
146
+ const result = this.result(
147
+ "incremental",
148
+ "appended JSONL records",
149
+ entries.length,
150
+ write.inserted,
151
+ write.totalEntries,
152
+ state.malformedLines + read.malformedLines,
153
+ started,
154
+ );
155
+ this.logger.debug("session_index.incremental", { sessionId: session.sessionId, ...result });
156
+ return result;
157
+ } catch (error) {
158
+ if (error instanceof AppendOnlyEntryChangedError) {
159
+ return this.rebuild(session, header, `append-only validation failed for ${error.entryId}`, started);
160
+ }
161
+ throw error;
162
+ }
163
+ }
164
+
165
+ private rebuildReason(
166
+ session: PiSessionSnapshot,
167
+ header: SessionHeaderRecord,
168
+ state: SessionIndexState | undefined,
169
+ fileSize: number,
170
+ fileMtimeMs: number,
171
+ force: boolean,
172
+ ): string | undefined {
173
+ if (force) return "forced by user";
174
+ if (!state) return "initial index";
175
+ if (state.sessionFile !== session.sessionFile) return "session file path changed";
176
+ if (state.headerHash !== header.rawHash) return "session header changed";
177
+ if (fileSize < state.fileSize) return "session file was truncated";
178
+ if (fileSize === state.fileSize && fileMtimeMs !== state.fileMtimeMs) return "session file changed in place";
179
+ if (fileSize > state.fileSize && state.checkpointOffset < state.fileSize) {
180
+ return "session grew after an unterminated JSONL tail";
181
+ }
182
+ if (state.checkpointOffset > 0) {
183
+ if (!state.checkpointHash) return "index checkpoint hash is missing";
184
+ const currentHash = hashFileRange(
185
+ session.sessionFile ?? "",
186
+ state.checkpointHashStart,
187
+ state.checkpointOffset,
188
+ );
189
+ if (currentHash !== state.checkpointHash) return "index checkpoint no longer matches the JSONL file";
190
+ }
191
+ return undefined;
192
+ }
193
+
194
+ private rebuild(
195
+ session: PiSessionSnapshot,
196
+ header: SessionHeaderRecord,
197
+ reason: string,
198
+ started: number,
199
+ ): SessionIndexResult {
200
+ if (!session.sessionFile) throw new Error("Cannot rebuild an ephemeral Pi session index");
201
+ const read = readJsonlRecords(session.sessionFile, 0);
202
+ const first = read.records[0];
203
+ if (!first || first.value.type !== "session" || first.rawHash !== header.rawHash) {
204
+ throw new Error("Pi session header changed while rebuilding the index");
205
+ }
206
+
207
+ const indexedAt = this.now();
208
+ const seen = new Set<string>();
209
+ const entries = read.records.slice(1).map((record) => {
210
+ if (!isPiSessionEntryRecord(record.value)) {
211
+ throw new Error(`Invalid Pi session entry at byte ${record.startOffset}`);
212
+ }
213
+ if (seen.has(record.value.id)) throw new Error(`Duplicate Pi session entry id: ${record.value.id}`);
214
+ seen.add(record.value.id);
215
+ return toIndexedSessionEntry(session.sessionId, record, indexedAt);
216
+ });
217
+
218
+ const write = this.repository.rebuild(
219
+ identity(session, indexedAt),
220
+ entries,
221
+ checkpoint(session, header, read, indexedAt),
222
+ );
223
+ const result = this.result(
224
+ "rebuild",
225
+ reason,
226
+ entries.length,
227
+ write.inserted,
228
+ write.totalEntries,
229
+ read.malformedLines,
230
+ started,
231
+ );
232
+ this.logger.info("session_index.rebuilt", { sessionId: session.sessionId, ...result });
233
+ return result;
234
+ }
235
+
236
+ private result(
237
+ mode: SessionIndexMode,
238
+ reason: string,
239
+ processedEntries: number,
240
+ insertedEntries: number,
241
+ totalEntries: number,
242
+ malformedLines: number,
243
+ started: number,
244
+ ): SessionIndexResult {
245
+ return {
246
+ mode,
247
+ reason,
248
+ processedEntries,
249
+ insertedEntries,
250
+ totalEntries,
251
+ malformedLines,
252
+ durationMs: Math.max(0, this.monotonicNow() - started),
253
+ };
254
+ }
255
+ }