pi-sessions 0.3.2 → 0.4.1

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.
@@ -1,18 +1,21 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
- import {
3
- type AssistantMessage,
4
- completeSimple,
5
- type Message,
6
- type SimpleStreamOptions,
7
- type TextContent,
8
- type ThinkingContent,
9
- type Tool,
10
- type ToolCall,
2
+ import type {
3
+ Api,
4
+ AssistantMessage,
5
+ Model,
6
+ TextContent,
7
+ ThinkingContent,
8
+ ToolCall,
11
9
  } from "@earendil-works/pi-ai";
12
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
13
11
  import {
14
12
  buildSessionContext,
15
13
  convertToLlm,
14
+ createAgentSession,
15
+ DefaultResourceLoader,
16
+ defineTool,
17
+ getAgentDir,
18
+ SessionManager,
16
19
  serializeConversation,
17
20
  } from "@earendil-works/pi-coding-agent";
18
21
  import { type Static, Type } from "typebox";
@@ -56,12 +59,7 @@ const HANDOFF_EXTRACTION_PARAMETERS = Type.Object({
56
59
  ),
57
60
  });
58
61
 
59
- const HANDOFF_EXTRACTION_TOOL: Tool<typeof HANDOFF_EXTRACTION_PARAMETERS> = {
60
- name: "create_handoff_context",
61
- description: "Extract the structured handoff context for the next session.",
62
- parameters: HANDOFF_EXTRACTION_PARAMETERS,
63
- };
64
-
62
+ type HandoffExtractionArgs = Static<typeof HANDOFF_EXTRACTION_PARAMETERS>;
65
63
  type RequiredHandoffExtractionArgs = Static<typeof REQUIRED_HANDOFF_EXTRACTION_PARAMETERS>;
66
64
 
67
65
  const REQUIRED_HANDOFF_EXTRACTION_PARAMETERS = Type.Object({
@@ -95,11 +93,7 @@ export async function generateHandoffDraft(
95
93
  throw new Error("No model is available for handoff.");
96
94
  }
97
95
 
98
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
99
- if (!auth.ok || !auth.apiKey) {
100
- throw new Error(`No API key is available for ${ctx.model.provider}/${ctx.model.id}.`);
101
- }
102
-
96
+ const model = ctx.model;
103
97
  const sessionContext = buildSessionContext(
104
98
  ctx.sessionManager.getEntries(),
105
99
  ctx.sessionManager.getLeafId(),
@@ -109,51 +103,18 @@ export async function generateHandoffDraft(
109
103
  }
110
104
 
111
105
  const conversationText = serializeConversation(convertToLlm(sessionContext.messages));
112
- const userMessage: Message = {
113
- role: "user",
114
- content: [
115
- {
116
- type: "text",
117
- text: buildExtractionPrompt(conversationText, goal),
118
- },
119
- ],
120
- timestamp: Date.now(),
121
- };
122
-
123
- const requestOptions: SimpleStreamOptions = {
124
- apiKey: auth.apiKey,
125
- ...(auth.headers ? { headers: auth.headers } : {}),
126
- ...(signal ? { signal } : {}),
127
- ...(ctx.model.reasoning && thinkingLevel && thinkingLevel !== "off"
128
- ? { reasoning: thinkingLevel }
129
- : {}),
130
- };
131
-
132
- const response = await completeSimple(
133
- ctx.model,
134
- {
135
- systemPrompt: HANDOFF_SYSTEM_PROMPT,
136
- messages: [userMessage],
137
- tools: [HANDOFF_EXTRACTION_TOOL],
138
- },
139
- requestOptions,
106
+ const handoffContext = await runHandoffExtractionAgent(
107
+ ctx,
108
+ model,
109
+ conversationText,
110
+ goal,
111
+ thinkingLevel,
112
+ signal,
140
113
  );
141
-
142
- if (response.stopReason === "aborted") {
114
+ if (!handoffContext) {
143
115
  return undefined;
144
116
  }
145
117
 
146
- if (response.stopReason === "error") {
147
- throw new Error(response.errorMessage ?? "Handoff generation failed.");
148
- }
149
-
150
- const extraction = extractHandoffContext(response, goal);
151
- if (!extraction.context) {
152
- throw new Error(extraction.error);
153
- }
154
-
155
- const handoffContext = extraction.context;
156
-
157
118
  const sessionId = ctx.sessionManager.getSessionId();
158
119
  const sessionPath = ctx.sessionManager.getSessionFile();
159
120
 
@@ -177,6 +138,84 @@ export function buildExtractionPrompt(conversationText: string, goal: string): s
177
138
  ].join("\n");
178
139
  }
179
140
 
141
+ async function runHandoffExtractionAgent(
142
+ ctx: ExtensionContext,
143
+ model: Model<Api>,
144
+ conversationText: string,
145
+ goal: string,
146
+ thinkingLevel: ThinkingLevel | undefined,
147
+ signal?: AbortSignal,
148
+ ): Promise<HandoffContext | undefined> {
149
+ let capturedArguments: HandoffExtractionArgs | undefined;
150
+ const createHandoffContextTool = defineTool({
151
+ name: "create_handoff_context",
152
+ label: "Create handoff context",
153
+ description: "Extract the structured handoff context for the next session.",
154
+ parameters: HANDOFF_EXTRACTION_PARAMETERS,
155
+ execute: async (_toolCallId, params) => {
156
+ capturedArguments = params;
157
+ return {
158
+ content: [{ type: "text", text: "Handoff context captured. Stopping." }],
159
+ details: {},
160
+ terminate: true,
161
+ };
162
+ },
163
+ });
164
+
165
+ const resourceLoader = new DefaultResourceLoader({
166
+ cwd: ctx.cwd,
167
+ agentDir: getAgentDir(),
168
+ noExtensions: true,
169
+ noPromptTemplates: true,
170
+ noSkills: true,
171
+ appendSystemPromptOverride: (base) => [...base, HANDOFF_SYSTEM_PROMPT],
172
+ });
173
+ await resourceLoader.reload();
174
+
175
+ const { session } = await createAgentSession({
176
+ cwd: ctx.cwd,
177
+ model,
178
+ modelRegistry: ctx.modelRegistry,
179
+ ...(thinkingLevel ? { thinkingLevel } : {}),
180
+ tools: ["read", "grep", "find", "ls", "create_handoff_context"],
181
+ customTools: [createHandoffContextTool],
182
+ resourceLoader,
183
+ sessionManager: SessionManager.inMemory(ctx.cwd),
184
+ });
185
+
186
+ const abortHandler = (): void => {
187
+ void session.abort();
188
+ };
189
+
190
+ try {
191
+ signal?.addEventListener("abort", abortHandler, { once: true });
192
+ if (signal?.aborted) {
193
+ await session.abort();
194
+ return undefined;
195
+ }
196
+
197
+ await session.prompt(buildExtractionPrompt(conversationText, goal));
198
+ } finally {
199
+ signal?.removeEventListener("abort", abortHandler);
200
+ session.dispose();
201
+ }
202
+
203
+ if (signal?.aborted) {
204
+ return undefined;
205
+ }
206
+
207
+ if (!capturedArguments) {
208
+ throw new Error("Handoff extraction did not return structured context.");
209
+ }
210
+
211
+ const extraction = extractHandoffContextFromArguments(capturedArguments, goal);
212
+ if (!extraction.context) {
213
+ throw new Error(extraction.error);
214
+ }
215
+
216
+ return extraction.context;
217
+ }
218
+
180
219
  export function assembleHandoffDraft(
181
220
  sessionId: string,
182
221
  sessionPath: string | undefined,
@@ -224,11 +263,18 @@ export function extractHandoffContext(
224
263
  return { error: "Handoff extraction did not return structured context." };
225
264
  }
226
265
 
266
+ return extractHandoffContextFromArguments(toolCall.arguments, goal);
267
+ }
268
+
269
+ function extractHandoffContextFromArguments(
270
+ argumentsValue: unknown,
271
+ goal: string,
272
+ ): { context: HandoffContext; error?: undefined } | { context?: undefined; error: string } {
227
273
  let requiredArguments: RequiredHandoffExtractionArgs;
228
274
  try {
229
275
  requiredArguments = parseTypeBoxValue(
230
276
  REQUIRED_HANDOFF_EXTRACTION_PARAMETERS,
231
- toolCall.arguments,
277
+ argumentsValue,
232
278
  "Invalid create_handoff_context arguments",
233
279
  );
234
280
  } catch {
@@ -241,9 +287,9 @@ export function extractHandoffContext(
241
287
  }
242
288
 
243
289
  const summary = normalizeText(requiredArguments.summary);
244
- const relevantFiles = normalizeStringArray(toolCall.arguments.relevantFiles, MAX_RELEVANT_FILES);
290
+ const relevantFiles = getRelevantFiles(argumentsValue);
245
291
  const nextTask = normalizeText(requiredArguments.nextTask) || goal.trim();
246
- const openQuestions = normalizeStringArray(toolCall.arguments.openQuestions, MAX_OPEN_QUESTIONS);
292
+ const openQuestions = getOpenQuestions(argumentsValue);
247
293
 
248
294
  if (!summary || !nextTask || !title) {
249
295
  return { error: "Handoff extraction did not return structured context." };
@@ -285,10 +331,30 @@ function normalizeStringArray(value: unknown, limit: number): string[] {
285
331
  return [...uniqueValues];
286
332
  }
287
333
 
334
+ function getRelevantFiles(argumentsValue: unknown): string[] {
335
+ if (!isRecord(argumentsValue)) {
336
+ return [];
337
+ }
338
+
339
+ return normalizeStringArray(argumentsValue.relevantFiles, MAX_RELEVANT_FILES);
340
+ }
341
+
342
+ function getOpenQuestions(argumentsValue: unknown): string[] {
343
+ if (!isRecord(argumentsValue)) {
344
+ return [];
345
+ }
346
+
347
+ return normalizeStringArray(argumentsValue.openQuestions, MAX_OPEN_QUESTIONS);
348
+ }
349
+
288
350
  function normalizeText(value: unknown): string {
289
351
  return typeof value === "string" ? value.trim() : "";
290
352
  }
291
353
 
354
+ function isRecord(value: unknown): value is Record<string, unknown> {
355
+ return typeof value === "object" && value !== null;
356
+ }
357
+
292
358
  function isCreateHandoffContextToolCall(
293
359
  content: TextContent | ThinkingContent | ToolCall,
294
360
  ): content is ToolCall {
@@ -1,4 +1,13 @@
1
- import { existsSync, readdirSync, readFileSync } from "node:fs";
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fstatSync,
5
+ openSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ readSync,
9
+ statSync,
10
+ } from "node:fs";
2
11
  import path from "node:path";
3
12
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
4
13
  import type { AssistantMessage, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
@@ -34,7 +43,7 @@ export interface SearchTextChunk {
34
43
  text: string;
35
44
  }
36
45
 
37
- interface DurableHandoffMetadataRecord {
46
+ export interface DurableHandoffMetadataRecord {
38
47
  entryId?: string | undefined;
39
48
  ts: string;
40
49
  metadata: HandoffSessionMetadata;
@@ -70,10 +79,39 @@ export interface ExtractedSessionRecord {
70
79
  sessionOrigin?: SessionOrigin | undefined;
71
80
  handoffGoal?: string | undefined;
72
81
  handoffNextTask?: string | undefined;
82
+ indexedFileSize: number;
83
+ indexedFileMtimeMs: number;
84
+ indexedFileAnchor: string;
73
85
  chunks: SearchTextChunk[];
74
86
  fileTouches: SessionFileTouch[];
75
87
  }
76
88
 
89
+ export interface SessionEntryScan {
90
+ chunks: SearchTextChunk[];
91
+ fileTouches: SessionFileTouch[];
92
+ messageCount: number;
93
+ entryCount: number;
94
+ maxEntryTs?: string | undefined;
95
+ firstUserPrompt?: string | undefined;
96
+ sessionName?: string | undefined;
97
+ handoffMetadata?: DurableHandoffMetadataRecord | undefined;
98
+ }
99
+
100
+ export interface SessionTailBaseline {
101
+ sessionId: string;
102
+ cwd: string;
103
+ startedAt: string;
104
+ indexedFileSize: number;
105
+ indexedFileAnchor: string;
106
+ }
107
+
108
+ export interface ExtractedSessionTail {
109
+ scan: SessionEntryScan;
110
+ indexedFileSize: number;
111
+ indexedFileMtimeMs: number;
112
+ indexedFileAnchor: string;
113
+ }
114
+
77
115
  export interface ParsedSessionFile {
78
116
  header: SessionHeader;
79
117
  entries: SessionEntry[];
@@ -82,6 +120,8 @@ export interface ParsedSessionFile {
82
120
 
83
121
  const TOOL_RESULT_TEXT_LIMIT = 500;
84
122
  const BASH_OUTPUT_TEXT_LIMIT = 500;
123
+ const FILE_ANCHOR_BYTES = 64;
124
+ const NEWLINE_BYTE = 0x0a;
85
125
  const SUMMARY_DETAILS_SCHEMA = Type.Object({
86
126
  readFiles: Type.Optional(Type.Array(Type.String())),
87
127
  modifiedFiles: Type.Optional(Type.Array(Type.String())),
@@ -115,30 +155,124 @@ export function listSessionFiles(sessionsDir: string): string[] {
115
155
  }
116
156
 
117
157
  export function extractSessionRecord(sessionPath: string): ExtractedSessionRecord | undefined {
118
- const parsed = parseSessionFile(sessionPath);
158
+ const indexedFileMtimeMs = Math.trunc(statSync(sessionPath).mtimeMs);
159
+ const buffer = readFileSync(sessionPath);
160
+ const slice = sliceCompleteJsonlPrefix(buffer);
161
+ const parsed = parseSessionContent(slice.content);
119
162
  if (!parsed) return undefined;
120
163
 
121
164
  const fallbackTs = parsed.header.timestamp;
165
+ const scan = scanSessionEntries(parsed.entries, fallbackTs, parsed.header.cwd);
166
+
167
+ const chunks: SearchTextChunk[] = [];
168
+ if (parsed.sessionName) {
169
+ chunks.push(createSessionNameChunk(parsed.sessionName, fallbackTs));
170
+ }
171
+ chunks.push(...scan.chunks);
172
+ appendDurableHandoffMetadataChunks(chunks, scan.handoffMetadata);
173
+
174
+ const parentSessionPath = normalizeParentSessionPath(parsed.header.parentSession);
175
+
176
+ return {
177
+ sessionId: parsed.header.id,
178
+ sessionPath,
179
+ sessionName: parsed.sessionName,
180
+ firstUserPrompt: trimmedText(scan.firstUserPrompt),
181
+ cwd: parsed.header.cwd,
182
+ repoRoots: deriveSessionRepoRoots(parsed.header.cwd, scan.fileTouches),
183
+ startedAt: fallbackTs,
184
+ modifiedAt: maxTimestamp(fallbackTs, scan.maxEntryTs),
185
+ messageCount: scan.messageCount,
186
+ entryCount: scan.entryCount,
187
+ parentSessionPath,
188
+ parentSessionId: parentSessionPath ? readSessionIdFromPath(parentSessionPath) : undefined,
189
+ sessionOrigin: inferSessionOrigin(parentSessionPath, scan.handoffMetadata?.metadata),
190
+ handoffGoal: scan.handoffMetadata?.metadata.goal,
191
+ handoffNextTask: scan.handoffMetadata?.metadata.nextTask,
192
+ indexedFileSize: slice.consumedBytes,
193
+ indexedFileMtimeMs,
194
+ indexedFileAnchor: buildFileAnchor(buffer, slice.consumedBytes),
195
+ chunks: chunks,
196
+ fileTouches: scan.fileTouches,
197
+ };
198
+ }
199
+
200
+ // Reads only the bytes appended since the last sync. The stored anchor (the
201
+ // final bytes of the previously indexed prefix) proves the prefix is unchanged;
202
+ // any in-place rewrite invalidates it and the caller falls back to a full
203
+ // re-extract. Session files are append-only in normal pi operation.
204
+ export function extractSessionTail(
205
+ sessionPath: string,
206
+ baseline: SessionTailBaseline,
207
+ ): ExtractedSessionTail | undefined {
208
+ const anchorBytes = Buffer.from(baseline.indexedFileAnchor, "base64");
209
+ const anchorStart = baseline.indexedFileSize - anchorBytes.length;
210
+ if (anchorBytes.length === 0 || anchorStart < 0) {
211
+ return undefined;
212
+ }
213
+
214
+ const indexedFileMtimeMs = Math.trunc(statSync(sessionPath).mtimeMs);
215
+ const fd = openSync(sessionPath, "r");
216
+ try {
217
+ const fileSize = fstatSync(fd).size;
218
+ if (fileSize < baseline.indexedFileSize) {
219
+ return undefined;
220
+ }
221
+
222
+ const window = Buffer.alloc(fileSize - anchorStart);
223
+ const bytesRead = readSync(fd, window, 0, window.length, anchorStart);
224
+ if (
225
+ bytesRead < anchorBytes.length ||
226
+ !window.subarray(0, anchorBytes.length).equals(anchorBytes)
227
+ ) {
228
+ return undefined;
229
+ }
230
+
231
+ const tailBuffer = window.subarray(anchorBytes.length, bytesRead);
232
+ const slice = sliceCompleteJsonlPrefix(tailBuffer);
233
+ const entries = parseSessionEntries(slice.content).filter(isSessionEntry);
234
+ const consumedWindow = Buffer.concat([
235
+ anchorBytes,
236
+ tailBuffer.subarray(0, slice.consumedBytes),
237
+ ]);
238
+
239
+ return {
240
+ scan: scanSessionEntries(entries, baseline.startedAt, baseline.cwd),
241
+ indexedFileSize: baseline.indexedFileSize + slice.consumedBytes,
242
+ indexedFileMtimeMs,
243
+ indexedFileAnchor: buildFileAnchor(consumedWindow, consumedWindow.length),
244
+ };
245
+ } finally {
246
+ closeSync(fd);
247
+ }
248
+ }
249
+
250
+ export function createSessionNameChunk(sessionName: string, ts: string): SearchTextChunk {
251
+ return {
252
+ entryType: "session_info",
253
+ ts,
254
+ sourceKind: "session_name",
255
+ text: sessionName,
256
+ };
257
+ }
258
+
259
+ export function scanSessionEntries(
260
+ entries: SessionEntry[],
261
+ fallbackTs: string,
262
+ cwd: string,
263
+ ): SessionEntryScan {
122
264
  const chunks: SearchTextChunk[] = [];
123
265
  const fileTouches: SessionFileTouch[] = [];
124
- let modifiedAt = fallbackTs;
125
266
  let messageCount = 0;
267
+ let maxEntryTs: string | undefined;
126
268
  let firstUserPrompt: string | undefined;
127
- let durableHandoffMetadata: DurableHandoffMetadataRecord | undefined;
269
+ let sessionName: string | undefined;
270
+ let handoffMetadata: DurableHandoffMetadataRecord | undefined;
128
271
 
129
- if (parsed.sessionName) {
130
- chunks.push({
131
- entryType: "session_info",
132
- ts: parsed.header.timestamp,
133
- sourceKind: "session_name",
134
- text: parsed.sessionName,
135
- });
136
- }
137
-
138
- for (const entry of parsed.entries) {
272
+ for (const entry of entries) {
139
273
  const entryTs = getEntryTimestamp(entry, fallbackTs);
140
- if (entryTs > modifiedAt) {
141
- modifiedAt = entryTs;
274
+ if (maxEntryTs === undefined || entryTs > maxEntryTs) {
275
+ maxEntryTs = entryTs;
142
276
  }
143
277
 
144
278
  switch (entry.type) {
@@ -150,15 +284,19 @@ export function extractSessionRecord(sessionPath: string): ExtractedSessionRecor
150
284
  firstUserPrompt = contentToText(message.content);
151
285
  }
152
286
  chunks.push(...extractMessageChunks(entry.id, entryTs, message));
153
- fileTouches.push(
154
- ...extractMessageFileTouches(entry.id, entryTs, message, parsed.header.cwd),
155
- );
287
+ fileTouches.push(...extractMessageFileTouches(entry.id, entryTs, message, cwd));
288
+ continue;
289
+ }
290
+ case "session_info": {
291
+ if (typeof entry.name === "string") {
292
+ sessionName = entry.name.trim();
293
+ }
156
294
  continue;
157
295
  }
158
296
  case "custom": {
159
297
  const nextHandoffMetadata = extractHandoffMetadata(entry, entryTs);
160
- if (nextHandoffMetadata && !durableHandoffMetadata) {
161
- durableHandoffMetadata = nextHandoffMetadata;
298
+ if (nextHandoffMetadata && !handoffMetadata) {
299
+ handoffMetadata = nextHandoffMetadata;
162
300
  }
163
301
  continue;
164
302
  }
@@ -179,7 +317,7 @@ export function extractSessionRecord(sessionPath: string): ExtractedSessionRecor
179
317
  entryTs,
180
318
  "branch_summary_details",
181
319
  entry.details,
182
- parsed.header.cwd,
320
+ cwd,
183
321
  ),
184
322
  );
185
323
  continue;
@@ -193,13 +331,7 @@ export function extractSessionRecord(sessionPath: string): ExtractedSessionRecor
193
331
  trimmedText(entry.summary),
194
332
  );
195
333
  fileTouches.push(
196
- ...extractDetailFileTouches(
197
- entry.id,
198
- entryTs,
199
- "compaction_details",
200
- entry.details,
201
- parsed.header.cwd,
202
- ),
334
+ ...extractDetailFileTouches(entry.id, entryTs, "compaction_details", entry.details, cwd),
203
335
  );
204
336
  continue;
205
337
  }
@@ -208,33 +340,69 @@ export function extractSessionRecord(sessionPath: string): ExtractedSessionRecor
208
340
  }
209
341
  }
210
342
 
211
- appendDurableHandoffMetadataChunks(chunks, durableHandoffMetadata);
212
-
213
- const parentSessionPath = normalizeParentSessionPath(parsed.header.parentSession);
214
-
215
343
  return {
216
- sessionId: parsed.header.id,
217
- sessionPath,
218
- sessionName: parsed.sessionName,
219
- firstUserPrompt: trimmedText(firstUserPrompt),
220
- cwd: parsed.header.cwd,
221
- repoRoots: deriveSessionRepoRoots(parsed.header.cwd, fileTouches),
222
- startedAt: parsed.header.timestamp,
223
- modifiedAt,
224
- messageCount,
225
- entryCount: parsed.entries.length,
226
- parentSessionPath,
227
- parentSessionId: parentSessionPath ? readSessionIdFromPath(parentSessionPath) : undefined,
228
- sessionOrigin: inferSessionOrigin(parentSessionPath, durableHandoffMetadata?.metadata),
229
- handoffGoal: durableHandoffMetadata?.metadata.goal,
230
- handoffNextTask: durableHandoffMetadata?.metadata.nextTask,
231
344
  chunks,
232
345
  fileTouches,
346
+ messageCount,
347
+ entryCount: entries.length,
348
+ maxEntryTs,
349
+ firstUserPrompt,
350
+ sessionName,
351
+ handoffMetadata,
233
352
  };
234
353
  }
235
354
 
355
+ function maxTimestamp(fallbackTs: string, entryTs: string | undefined): string {
356
+ return entryTs !== undefined && entryTs > fallbackTs ? entryTs : fallbackTs;
357
+ }
358
+
359
+ // Consumes only whole JSONL lines so the recorded byte offset always lands on
360
+ // an entry boundary. A final unterminated line is consumed only if it parses as
361
+ // complete JSON: a strict prefix of a JSON object can never parse, so a torn
362
+ // in-progress write is reliably excluded.
363
+ function sliceCompleteJsonlPrefix(buffer: Buffer): { content: string; consumedBytes: number } {
364
+ if (buffer.length === 0) {
365
+ return { content: "", consumedBytes: 0 };
366
+ }
367
+
368
+ if (buffer[buffer.length - 1] === NEWLINE_BYTE) {
369
+ return { content: buffer.toString("utf8"), consumedBytes: buffer.length };
370
+ }
371
+
372
+ const lastNewline = buffer.lastIndexOf(NEWLINE_BYTE);
373
+ const finalLine = buffer.subarray(lastNewline + 1).toString("utf8");
374
+ if (isCompleteJsonLine(finalLine)) {
375
+ return { content: buffer.toString("utf8"), consumedBytes: buffer.length };
376
+ }
377
+
378
+ const consumedBytes = lastNewline + 1;
379
+ return { content: buffer.subarray(0, consumedBytes).toString("utf8"), consumedBytes };
380
+ }
381
+
382
+ function isCompleteJsonLine(line: string): boolean {
383
+ const trimmed = line.trim();
384
+ if (!trimmed) {
385
+ return true;
386
+ }
387
+
388
+ try {
389
+ JSON.parse(trimmed);
390
+ return true;
391
+ } catch {
392
+ return false;
393
+ }
394
+ }
395
+
396
+ function buildFileAnchor(buffer: Buffer, end: number): string {
397
+ const start = Math.max(0, end - FILE_ANCHOR_BYTES);
398
+ return buffer.subarray(start, end).toString("base64");
399
+ }
400
+
236
401
  export function parseSessionFile(sessionPath: string): ParsedSessionFile | undefined {
237
- const raw = readFileSync(sessionPath, "utf8");
402
+ return parseSessionContent(readFileSync(sessionPath, "utf8"));
403
+ }
404
+
405
+ export function parseSessionContent(raw: string): ParsedSessionFile | undefined {
238
406
  const fileEntries = parseSessionEntries(raw);
239
407
  if (fileEntries.length === 0) {
240
408
  return undefined;