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.
- package/extensions/session-search/extract.ts +218 -50
- package/extensions/session-search/hooks.ts +253 -103
- package/extensions/session-search/reindex.ts +48 -55
- package/extensions/shared/session-index/common.ts +4 -1
- package/extensions/shared/session-index/lineage.ts +70 -2
- package/extensions/shared/session-index/schema.ts +24 -11
- package/extensions/shared/session-index/search.ts +2 -2
- package/extensions/shared/session-index/sqlite.ts +45 -12
- package/extensions/shared/session-index/store.ts +125 -33
- package/package.json +1 -1
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
269
|
+
let sessionName: string | undefined;
|
|
270
|
+
let handoffMetadata: DurableHandoffMetadataRecord | undefined;
|
|
128
271
|
|
|
129
|
-
|
|
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 >
|
|
141
|
-
|
|
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
|
-
|
|
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 && !
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|