pi-sessions 0.3.2 → 0.4.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.
@@ -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;