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,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, type Stats, statSync } from "node:fs";
|
|
2
2
|
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
3
3
|
import {
|
|
4
4
|
isToolCallEventType,
|
|
@@ -6,25 +6,34 @@ import {
|
|
|
6
6
|
type ToolResultEvent,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import {
|
|
9
|
+
clearSessionChunksBySourceKind,
|
|
9
10
|
clearSessionIndexedData,
|
|
10
|
-
|
|
11
|
+
getMetadata,
|
|
11
12
|
getSessionById,
|
|
13
|
+
getSessionRowByPath,
|
|
12
14
|
INDEX_SCHEMA_VERSION,
|
|
13
15
|
insertSessionFileTouch,
|
|
14
16
|
insertTextChunk,
|
|
15
17
|
openIndexDatabase,
|
|
16
|
-
|
|
18
|
+
refreshSessionLineageRelationsFor,
|
|
19
|
+
type SessionIndexDatabase,
|
|
17
20
|
type SessionLineageRow,
|
|
18
21
|
type SessionOrigin,
|
|
22
|
+
type SessionRow,
|
|
19
23
|
setMetadata,
|
|
20
24
|
upsertSession,
|
|
21
25
|
} from "../shared/session-index/index.js";
|
|
22
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
createSessionNameChunk,
|
|
28
|
+
type ExtractedSessionRecord,
|
|
29
|
+
type ExtractedSessionTail,
|
|
30
|
+
extractSessionRecord,
|
|
31
|
+
extractSessionTail,
|
|
32
|
+
type SessionFileTouch,
|
|
33
|
+
} from "./extract.js";
|
|
34
|
+
import { deriveSessionRepoRoots } from "./normalize.js";
|
|
23
35
|
|
|
24
36
|
const TOOL_RESULT_TEXT_LIMIT = 500;
|
|
25
|
-
const HOOK_INDEX_WRITE_TIMEOUT_MS = 500;
|
|
26
|
-
const HOOK_INDEX_WRITE_MAX_ATTEMPTS = 4;
|
|
27
|
-
const HOOK_INDEX_WRITE_RETRY_DELAYS_MS = [150, 400, 1000];
|
|
28
37
|
|
|
29
38
|
type TrackedToolName = "read" | "edit" | "write";
|
|
30
39
|
|
|
@@ -99,24 +108,15 @@ export function createSessionHookController(options: { indexPath: string }): Ses
|
|
|
99
108
|
return syncAttachedSession(indexPath, state, "session_start");
|
|
100
109
|
},
|
|
101
110
|
async handleSessionSwitch(previousSessionFile, sessionFile, cwd, sessionOrigin) {
|
|
102
|
-
const previousSynced =
|
|
103
|
-
indexPath,
|
|
104
|
-
previousSessionFile,
|
|
105
|
-
"session_switch",
|
|
106
|
-
);
|
|
111
|
+
const previousSynced = syncSessionFile(indexPath, previousSessionFile, "session_switch");
|
|
107
112
|
attachSession(state, sessionFile, cwd);
|
|
108
|
-
const currentSynced =
|
|
109
|
-
indexPath,
|
|
110
|
-
state,
|
|
111
|
-
"session_switch",
|
|
112
|
-
sessionOrigin,
|
|
113
|
-
);
|
|
113
|
+
const currentSynced = syncAttachedSession(indexPath, state, "session_switch", sessionOrigin);
|
|
114
114
|
return previousSynced || currentSynced;
|
|
115
115
|
},
|
|
116
116
|
async handleSessionFork(previousSessionFile, sessionFile, cwd) {
|
|
117
|
-
const previousSynced =
|
|
117
|
+
const previousSynced = syncSessionFile(indexPath, previousSessionFile, "session_fork");
|
|
118
118
|
attachSession(state, sessionFile, cwd);
|
|
119
|
-
const currentSynced =
|
|
119
|
+
const currentSynced = syncAttachedSession(indexPath, state, "session_fork", "fork");
|
|
120
120
|
return previousSynced || currentSynced;
|
|
121
121
|
},
|
|
122
122
|
handleToolCall(event, sessionFile, cwd) {
|
|
@@ -144,7 +144,7 @@ export function createSessionHookController(options: { indexPath: string }): Ses
|
|
|
144
144
|
async handleTurnEnd(sessionFile, cwd) {
|
|
145
145
|
attachSession(state, sessionFile, cwd);
|
|
146
146
|
try {
|
|
147
|
-
return
|
|
147
|
+
return syncAttachedSession(indexPath, state, "turn_end");
|
|
148
148
|
} finally {
|
|
149
149
|
clearTurnState(state);
|
|
150
150
|
}
|
|
@@ -160,7 +160,7 @@ export function createSessionHookController(options: { indexPath: string }): Ses
|
|
|
160
160
|
async handleSessionShutdown(sessionFile, cwd) {
|
|
161
161
|
attachSession(state, sessionFile, cwd);
|
|
162
162
|
try {
|
|
163
|
-
return
|
|
163
|
+
return syncAttachedSession(indexPath, state, "session_shutdown");
|
|
164
164
|
} finally {
|
|
165
165
|
clearTurnState(state);
|
|
166
166
|
state.currentSessionFile = undefined;
|
|
@@ -182,73 +182,67 @@ function attachSession(
|
|
|
182
182
|
state.currentCwd = cwd;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
async function retryIndexWrite<T>(action: () => T): Promise<T> {
|
|
186
|
-
for (let attempt = 1; attempt <= HOOK_INDEX_WRITE_MAX_ATTEMPTS; attempt += 1) {
|
|
187
|
-
try {
|
|
188
|
-
return action();
|
|
189
|
-
} catch (error) {
|
|
190
|
-
if (!isRetryableSqliteLock(error) || attempt >= HOOK_INDEX_WRITE_MAX_ATTEMPTS) {
|
|
191
|
-
throw error;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
await sleep(getRetryDelayMs(attempt));
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return action();
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function getRetryDelayMs(attempt: number): number {
|
|
202
|
-
return (
|
|
203
|
-
HOOK_INDEX_WRITE_RETRY_DELAYS_MS[attempt - 1] ??
|
|
204
|
-
HOOK_INDEX_WRITE_RETRY_DELAYS_MS[HOOK_INDEX_WRITE_RETRY_DELAYS_MS.length - 1] ??
|
|
205
|
-
0
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function isRetryableSqliteLock(error: unknown): boolean {
|
|
210
|
-
if (!(error instanceof Error)) {
|
|
211
|
-
return false;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
|
|
215
|
-
const message = error.message.toLowerCase();
|
|
216
|
-
return (
|
|
217
|
-
code === "SQLITE_BUSY" ||
|
|
218
|
-
code === "SQLITE_LOCKED" ||
|
|
219
|
-
code?.startsWith("SQLITE_BUSY_") === true ||
|
|
220
|
-
code?.startsWith("SQLITE_LOCKED_") === true ||
|
|
221
|
-
message.includes("database is locked")
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function sleep(ms: number): Promise<void> {
|
|
226
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
227
|
-
}
|
|
228
|
-
|
|
229
185
|
function syncAttachedSession(
|
|
230
186
|
indexPath: string,
|
|
231
187
|
state: SessionHookState,
|
|
232
188
|
eventType: string,
|
|
233
189
|
sessionOrigin?: SessionOrigin,
|
|
234
|
-
):
|
|
190
|
+
): boolean {
|
|
235
191
|
return syncSessionFile(indexPath, state.currentSessionFile, eventType, state, sessionOrigin);
|
|
236
192
|
}
|
|
237
193
|
|
|
238
|
-
|
|
194
|
+
function syncSessionFile(
|
|
239
195
|
indexPath: string,
|
|
240
196
|
sessionFile: string | undefined,
|
|
241
197
|
eventType: string,
|
|
242
198
|
state?: SessionHookState,
|
|
243
199
|
sessionOrigin?: SessionOrigin,
|
|
244
|
-
):
|
|
245
|
-
if (!sessionFile || !existsSync(sessionFile)) {
|
|
200
|
+
): boolean {
|
|
201
|
+
if (!sessionFile || !existsSync(sessionFile) || !existsSync(indexPath)) {
|
|
246
202
|
return false;
|
|
247
203
|
}
|
|
248
204
|
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
205
|
+
const db = openIndexDatabase(indexPath, { create: false });
|
|
206
|
+
try {
|
|
207
|
+
if (readIndexSchemaVersion(db) !== INDEX_SCHEMA_VERSION) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const synced = syncSessionFileWithDb(db, sessionFile, eventType, sessionOrigin);
|
|
212
|
+
if (synced && state) {
|
|
213
|
+
state.lastFlushedSessionFile = sessionFile;
|
|
214
|
+
}
|
|
215
|
+
return synced;
|
|
216
|
+
} finally {
|
|
217
|
+
db.close();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface TailSyncBaseline extends SessionRow {
|
|
222
|
+
indexedFileSize: number;
|
|
223
|
+
indexedFileMtimeMs: number;
|
|
224
|
+
indexedFileAnchor: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function syncSessionFileWithDb(
|
|
228
|
+
db: SessionIndexDatabase,
|
|
229
|
+
sessionFile: string,
|
|
230
|
+
eventType: string,
|
|
231
|
+
sessionOrigin?: SessionOrigin,
|
|
232
|
+
): boolean {
|
|
233
|
+
const baseline = asTailSyncBaseline(getSessionRowByPath(db, sessionFile));
|
|
234
|
+
const stat = statSync(sessionFile);
|
|
235
|
+
|
|
236
|
+
if (baseline && isIndexCurrent(baseline, stat, sessionOrigin)) {
|
|
237
|
+
db.transaction(() => writeHookSyncMetadata(db, eventType)).immediate();
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (baseline && stat.size > baseline.indexedFileSize) {
|
|
242
|
+
const tail = extractSessionTail(sessionFile, baseline);
|
|
243
|
+
if (tail && applyTailSync(db, baseline, tail, eventType, sessionOrigin)) {
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
252
246
|
}
|
|
253
247
|
|
|
254
248
|
const extracted = extractSessionRecord(sessionFile);
|
|
@@ -256,42 +250,198 @@ async function syncSessionFile(
|
|
|
256
250
|
return false;
|
|
257
251
|
}
|
|
258
252
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
timeoutMs: HOOK_INDEX_WRITE_TIMEOUT_MS,
|
|
263
|
-
});
|
|
264
|
-
try {
|
|
265
|
-
db.transaction(() => {
|
|
266
|
-
const existingSession = getSessionById(db, extracted.sessionId);
|
|
267
|
-
const sessionRow = mergeSessionLineage(extracted, existingSession, sessionOrigin);
|
|
268
|
-
upsertSession(db, sessionRow, "hook");
|
|
269
|
-
if (shouldRefreshLineageRelations(existingSession, sessionRow)) {
|
|
270
|
-
rebuildSessionLineageRelations(db);
|
|
271
|
-
}
|
|
272
|
-
clearSessionIndexedData(db, extracted.sessionId);
|
|
253
|
+
applyFullSync(db, extracted, eventType, sessionOrigin);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
273
256
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
257
|
+
function asTailSyncBaseline(row: SessionRow | undefined): TailSyncBaseline | undefined {
|
|
258
|
+
if (
|
|
259
|
+
!row ||
|
|
260
|
+
row.indexedFileSize === undefined ||
|
|
261
|
+
row.indexedFileMtimeMs === undefined ||
|
|
262
|
+
row.indexedFileAnchor === undefined ||
|
|
263
|
+
row.indexedFileAnchor.length === 0
|
|
264
|
+
) {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
277
267
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
268
|
+
return {
|
|
269
|
+
...row,
|
|
270
|
+
indexedFileSize: row.indexedFileSize,
|
|
271
|
+
indexedFileMtimeMs: row.indexedFileMtimeMs,
|
|
272
|
+
indexedFileAnchor: row.indexedFileAnchor,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function isIndexCurrent(
|
|
277
|
+
baseline: TailSyncBaseline,
|
|
278
|
+
stat: Stats,
|
|
279
|
+
sessionOrigin?: SessionOrigin,
|
|
280
|
+
): boolean {
|
|
281
|
+
return (
|
|
282
|
+
stat.size === baseline.indexedFileSize &&
|
|
283
|
+
Math.trunc(stat.mtimeMs) === baseline.indexedFileMtimeMs &&
|
|
284
|
+
(sessionOrigin === undefined || sessionOrigin === baseline.sessionOrigin)
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// All write transactions are immediate: they read before they write, and a
|
|
289
|
+
// deferred transaction whose snapshot goes stale fails with SQLITE_BUSY on the
|
|
290
|
+
// read-to-write upgrade without ever invoking the busy handler. Immediate mode
|
|
291
|
+
// takes the write lock at BEGIN, where busy_timeout queues us behind concurrent
|
|
292
|
+
// writers from other pi processes.
|
|
293
|
+
function applyFullSync(
|
|
294
|
+
db: SessionIndexDatabase,
|
|
295
|
+
extracted: ExtractedSessionRecord,
|
|
296
|
+
eventType: string,
|
|
297
|
+
sessionOrigin?: SessionOrigin,
|
|
298
|
+
): void {
|
|
299
|
+
db.transaction(() => {
|
|
300
|
+
const existingSession = getSessionById(db, extracted.sessionId);
|
|
301
|
+
const sessionRow = mergeSessionLineage(extracted, existingSession, sessionOrigin);
|
|
302
|
+
clearSessionIndexedData(db, extracted.sessionId);
|
|
303
|
+
upsertSession(db, sessionRow, "hook");
|
|
304
|
+
if (shouldRefreshLineageRelations(existingSession, sessionRow)) {
|
|
305
|
+
refreshSessionLineageRelationsFor(db, [
|
|
306
|
+
extracted.sessionId,
|
|
307
|
+
existingSession?.parentSessionId,
|
|
308
|
+
sessionRow.parentSessionId,
|
|
309
|
+
]);
|
|
310
|
+
}
|
|
281
311
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
})();
|
|
285
|
-
} finally {
|
|
286
|
-
db.close();
|
|
312
|
+
for (const chunk of extracted.chunks) {
|
|
313
|
+
insertTextChunk(db, { sessionId: extracted.sessionId, ...chunk });
|
|
287
314
|
}
|
|
288
|
-
});
|
|
289
315
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
316
|
+
for (const fileTouch of extracted.fileTouches) {
|
|
317
|
+
insertSessionFileTouch(db, { sessionId: extracted.sessionId, ...fileTouch });
|
|
318
|
+
}
|
|
293
319
|
|
|
294
|
-
|
|
320
|
+
writeHookSyncMetadata(db, eventType);
|
|
321
|
+
}).immediate();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function applyTailSync(
|
|
325
|
+
db: SessionIndexDatabase,
|
|
326
|
+
baseline: TailSyncBaseline,
|
|
327
|
+
tail: ExtractedSessionTail,
|
|
328
|
+
eventType: string,
|
|
329
|
+
sessionOrigin?: SessionOrigin,
|
|
330
|
+
): boolean {
|
|
331
|
+
return db
|
|
332
|
+
.transaction((): boolean => {
|
|
333
|
+
// Another process may have advanced the index between our baseline read
|
|
334
|
+
// and this transaction; the tail deltas would then double-count.
|
|
335
|
+
const current = getSessionRowByPath(db, baseline.sessionPath);
|
|
336
|
+
if (
|
|
337
|
+
!current ||
|
|
338
|
+
current.sessionId !== baseline.sessionId ||
|
|
339
|
+
current.indexedFileSize !== baseline.indexedFileSize
|
|
340
|
+
) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const scan = tail.scan;
|
|
345
|
+
upsertSession(db, buildTailSessionRow(baseline, tail, sessionOrigin), "hook");
|
|
346
|
+
|
|
347
|
+
if (scan.sessionName !== undefined && scan.sessionName !== baseline.sessionName) {
|
|
348
|
+
clearSessionChunksBySourceKind(db, baseline.sessionId, "session_name");
|
|
349
|
+
if (scan.sessionName) {
|
|
350
|
+
insertTextChunk(db, {
|
|
351
|
+
sessionId: baseline.sessionId,
|
|
352
|
+
...createSessionNameChunk(scan.sessionName, baseline.startedAt),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!baseline.handoffGoal && scan.handoffMetadata) {
|
|
358
|
+
const { entryId, ts, metadata } = scan.handoffMetadata;
|
|
359
|
+
insertTextChunk(db, {
|
|
360
|
+
sessionId: baseline.sessionId,
|
|
361
|
+
entryId,
|
|
362
|
+
entryType: "custom",
|
|
363
|
+
ts,
|
|
364
|
+
sourceKind: "handoff_goal",
|
|
365
|
+
text: metadata.goal,
|
|
366
|
+
});
|
|
367
|
+
insertTextChunk(db, {
|
|
368
|
+
sessionId: baseline.sessionId,
|
|
369
|
+
entryId,
|
|
370
|
+
entryType: "custom",
|
|
371
|
+
ts,
|
|
372
|
+
sourceKind: "handoff_next_task",
|
|
373
|
+
text: metadata.nextTask,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (const chunk of scan.chunks) {
|
|
378
|
+
insertTextChunk(db, { sessionId: baseline.sessionId, ...chunk });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
for (const fileTouch of scan.fileTouches) {
|
|
382
|
+
insertSessionFileTouch(db, { sessionId: baseline.sessionId, ...fileTouch });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
writeHookSyncMetadata(db, eventType);
|
|
386
|
+
return true;
|
|
387
|
+
})
|
|
388
|
+
.immediate();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function buildTailSessionRow(
|
|
392
|
+
baseline: TailSyncBaseline,
|
|
393
|
+
tail: ExtractedSessionTail,
|
|
394
|
+
sessionOrigin?: SessionOrigin,
|
|
395
|
+
): SessionRow {
|
|
396
|
+
const scan = tail.scan;
|
|
397
|
+
const tailHandoffMetadata = baseline.handoffGoal ? undefined : scan.handoffMetadata?.metadata;
|
|
398
|
+
const tailOrigin =
|
|
399
|
+
baseline.parentSessionPath && tailHandoffMetadata?.origin === "handoff"
|
|
400
|
+
? ("handoff" as const)
|
|
401
|
+
: undefined;
|
|
402
|
+
const nextOrigin = resolveSessionOrigin(sessionOrigin, tailOrigin, baseline.sessionOrigin);
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
sessionId: baseline.sessionId,
|
|
406
|
+
sessionPath: baseline.sessionPath,
|
|
407
|
+
sessionName: scan.sessionName ?? baseline.sessionName,
|
|
408
|
+
firstUserPrompt: baseline.firstUserPrompt || (scan.firstUserPrompt ?? ""),
|
|
409
|
+
cwd: baseline.cwd,
|
|
410
|
+
repoRoots: mergeRepoRoots(baseline, scan.fileTouches),
|
|
411
|
+
startedAt: baseline.startedAt,
|
|
412
|
+
modifiedAt:
|
|
413
|
+
scan.maxEntryTs !== undefined && scan.maxEntryTs > baseline.modifiedAt
|
|
414
|
+
? scan.maxEntryTs
|
|
415
|
+
: baseline.modifiedAt,
|
|
416
|
+
messageCount: baseline.messageCount + scan.messageCount,
|
|
417
|
+
entryCount: baseline.entryCount + scan.entryCount,
|
|
418
|
+
parentSessionPath: baseline.parentSessionPath,
|
|
419
|
+
parentSessionId: baseline.parentSessionId,
|
|
420
|
+
sessionOrigin: baseline.parentSessionPath ? (nextOrigin ?? "unknown_child") : undefined,
|
|
421
|
+
handoffGoal: baseline.handoffGoal ?? tailHandoffMetadata?.goal,
|
|
422
|
+
handoffNextTask: baseline.handoffNextTask ?? tailHandoffMetadata?.nextTask,
|
|
423
|
+
indexedFileSize: tail.indexedFileSize,
|
|
424
|
+
indexedFileMtimeMs: tail.indexedFileMtimeMs,
|
|
425
|
+
indexedFileAnchor: tail.indexedFileAnchor,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function mergeRepoRoots(baseline: TailSyncBaseline, fileTouches: SessionFileTouch[]): string[] {
|
|
430
|
+
const merged = new Set([
|
|
431
|
+
...baseline.repoRoots,
|
|
432
|
+
...deriveSessionRepoRoots(baseline.cwd, fileTouches),
|
|
433
|
+
]);
|
|
434
|
+
return [...merged].sort();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function writeHookSyncMetadata(db: SessionIndexDatabase, eventType: string): void {
|
|
438
|
+
setMetadata(db, "hook_updated_at", new Date().toISOString());
|
|
439
|
+
setMetadata(db, "hook_last_event", eventType);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function readIndexSchemaVersion(db: SessionIndexDatabase): number | undefined {
|
|
443
|
+
const raw = getMetadata(db, "schema_version");
|
|
444
|
+
return raw === undefined ? undefined : Number(raw);
|
|
295
445
|
}
|
|
296
446
|
|
|
297
447
|
function mergeSessionLineage(
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from "node:path";
|
|
2
2
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
clearSessionIndexedData,
|
|
5
|
+
ensureIndexDir,
|
|
5
6
|
initializeSchema,
|
|
6
|
-
insertSession,
|
|
7
7
|
insertSessionFileTouch,
|
|
8
8
|
insertTextChunk,
|
|
9
9
|
openIndexDatabase,
|
|
10
10
|
rebuildSessionLineageRelations,
|
|
11
11
|
type SessionIndexDatabase,
|
|
12
12
|
setMetadata,
|
|
13
|
+
upsertSession,
|
|
13
14
|
} from "../shared/session-index/index.js";
|
|
14
|
-
import {
|
|
15
|
+
import { type ExtractedSessionRecord, extractSessionRecord } from "./extract.js";
|
|
15
16
|
|
|
16
17
|
export interface ReindexOptions {
|
|
17
18
|
indexPath: string;
|
|
@@ -24,75 +25,67 @@ export interface ReindexResult {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export async function rebuildSessionIndex(options: ReindexOptions): Promise<ReindexResult> {
|
|
27
|
-
const
|
|
28
|
-
|
|
28
|
+
const indexPath = options.indexPath;
|
|
29
|
+
ensureIndexDir(path.dirname(indexPath));
|
|
29
30
|
const sessionFiles = (await SessionManager.listAll()).map((session) => session.path);
|
|
30
31
|
|
|
31
|
-
const db = openIndexDatabase(
|
|
32
|
-
let sessionCount: number;
|
|
33
|
-
let chunkCount: number;
|
|
32
|
+
const db = openIndexDatabase(indexPath, { create: true });
|
|
34
33
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
40
|
-
} finally {
|
|
41
|
-
db.close();
|
|
42
|
-
}
|
|
34
|
+
db.transaction(() => {
|
|
35
|
+
dropIndexTables(db);
|
|
36
|
+
initializeSchema(db);
|
|
37
|
+
}).immediate();
|
|
43
38
|
|
|
44
|
-
renameSync(tempIndexPath, finalIndexPath);
|
|
45
|
-
rmSync(`${tempIndexPath}-wal`, { force: true });
|
|
46
|
-
rmSync(`${tempIndexPath}-shm`, { force: true });
|
|
47
|
-
return { sessionCount, chunkCount, indexPath: finalIndexPath };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function indexSessionFiles(
|
|
51
|
-
db: SessionIndexDatabase,
|
|
52
|
-
sessionFiles: string[],
|
|
53
|
-
): { sessionCount: number; chunkCount: number } {
|
|
54
|
-
return db.transaction((files: string[]) => {
|
|
55
39
|
let sessionCount = 0;
|
|
56
40
|
let chunkCount = 0;
|
|
57
|
-
|
|
58
|
-
for (const sessionFile of files) {
|
|
41
|
+
for (const sessionFile of sessionFiles) {
|
|
59
42
|
const extracted = extractSessionRecord(sessionFile);
|
|
60
43
|
if (!extracted) {
|
|
61
44
|
continue;
|
|
62
45
|
}
|
|
63
46
|
|
|
64
|
-
|
|
47
|
+
db.transaction(() => indexSession(db, extracted)).immediate();
|
|
65
48
|
sessionCount += 1;
|
|
66
|
-
chunkCount +=
|
|
67
|
-
insertSessionFileTouches(db, extracted.sessionId, extracted.fileTouches);
|
|
49
|
+
chunkCount += extracted.chunks.length;
|
|
68
50
|
}
|
|
69
51
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
})(sessionFiles);
|
|
76
|
-
}
|
|
52
|
+
db.transaction(() => {
|
|
53
|
+
rebuildSessionLineageRelations(db);
|
|
54
|
+
setMetadata(db, "indexed_at", new Date().toISOString());
|
|
55
|
+
setMetadata(db, "session_source", "SessionManager.listAll()");
|
|
56
|
+
}).immediate();
|
|
77
57
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
chunks: SearchTextChunk[],
|
|
82
|
-
): number {
|
|
83
|
-
for (const chunk of chunks) {
|
|
84
|
-
insertTextChunk(db, { sessionId, ...chunk });
|
|
58
|
+
return { sessionCount, chunkCount, indexPath };
|
|
59
|
+
} finally {
|
|
60
|
+
db.close();
|
|
85
61
|
}
|
|
62
|
+
}
|
|
86
63
|
|
|
87
|
-
|
|
64
|
+
function dropIndexTables(db: SessionIndexDatabase): void {
|
|
65
|
+
// Children before parents: foreign_keys is ON and DROP TABLE runs an
|
|
66
|
+
// implicit DELETE that parent-side constraints would reject.
|
|
67
|
+
db.exec(`
|
|
68
|
+
DROP TABLE IF EXISTS session_lineage_relations;
|
|
69
|
+
DROP TABLE IF EXISTS session_text_chunks;
|
|
70
|
+
DROP TABLE IF EXISTS session_file_touches;
|
|
71
|
+
DROP TABLE IF EXISTS session_text_chunks_fts;
|
|
72
|
+
DROP TABLE IF EXISTS sessions;
|
|
73
|
+
DROP TABLE IF EXISTS metadata;
|
|
74
|
+
`);
|
|
88
75
|
}
|
|
89
76
|
|
|
90
|
-
function
|
|
91
|
-
db
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
|
|
77
|
+
function indexSession(db: SessionIndexDatabase, extracted: ExtractedSessionRecord): void {
|
|
78
|
+
clearSessionIndexedData(db, extracted.sessionId);
|
|
79
|
+
upsertSession(db, extracted, "full_reindex");
|
|
80
|
+
|
|
81
|
+
for (const chunk of extracted.chunks) {
|
|
82
|
+
insertTextChunk(db, { sessionId: extracted.sessionId, ...chunk });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const fileTouch of extracted.fileTouches) {
|
|
86
|
+
insertSessionFileTouch(db, {
|
|
87
|
+
sessionId: extracted.sessionId,
|
|
88
|
+
...fileTouch,
|
|
89
|
+
});
|
|
97
90
|
}
|
|
98
91
|
}
|
|
@@ -3,7 +3,7 @@ import type { FileTouchOp, FileTouchSource, PathScope } from "../../session-sear
|
|
|
3
3
|
import { safeParseTypeBoxJson } from "../typebox.js";
|
|
4
4
|
import type { SqliteDatabase } from "./sqlite.js";
|
|
5
5
|
|
|
6
|
-
export const INDEX_SCHEMA_VERSION =
|
|
6
|
+
export const INDEX_SCHEMA_VERSION = 8;
|
|
7
7
|
|
|
8
8
|
export type SessionOrigin = "handoff" | "fork" | "unknown_child";
|
|
9
9
|
export type SessionLineageRelation =
|
|
@@ -30,6 +30,9 @@ export interface SessionRow {
|
|
|
30
30
|
sessionOrigin?: SessionOrigin | undefined;
|
|
31
31
|
handoffGoal?: string | undefined;
|
|
32
32
|
handoffNextTask?: string | undefined;
|
|
33
|
+
indexedFileSize?: number | undefined;
|
|
34
|
+
indexedFileMtimeMs?: number | undefined;
|
|
35
|
+
indexedFileAnchor?: string | undefined;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export interface SessionLineageRow {
|
|
@@ -185,9 +185,39 @@ export function getSiblingSessions(
|
|
|
185
185
|
return queryRelatedSessions(db, sessionId, ["sibling"]);
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
interface SessionGraph {
|
|
189
|
+
nodes: Map<string, SessionGraphNode>;
|
|
190
|
+
childrenByParent: Map<string, string[]>;
|
|
191
|
+
}
|
|
192
|
+
|
|
188
193
|
export function rebuildSessionLineageRelations(db: SessionIndexDatabase): void {
|
|
189
194
|
db.prepare(`DELETE FROM session_lineage_relations`).run();
|
|
190
195
|
|
|
196
|
+
const graph = buildSessionGraph(db);
|
|
197
|
+
insertLineageRelationRows(db, graph.nodes.keys(), graph);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Recomputes lineage for just the connected component(s) containing the seed
|
|
201
|
+
// sessions. Lineage relations only ever link sessions connected through parent
|
|
202
|
+
// edges, so the rest of the table is untouched.
|
|
203
|
+
export function refreshSessionLineageRelationsFor(
|
|
204
|
+
db: SessionIndexDatabase,
|
|
205
|
+
seedSessionIds: Array<string | undefined>,
|
|
206
|
+
): void {
|
|
207
|
+
const graph = buildSessionGraph(db);
|
|
208
|
+
const component = collectLineageComponent(seedSessionIds, graph);
|
|
209
|
+
if (component.size === 0) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const placeholders = [...component].map(() => "?").join(", ");
|
|
214
|
+
db.prepare(`DELETE FROM session_lineage_relations WHERE session_id IN (${placeholders})`).run(
|
|
215
|
+
...component,
|
|
216
|
+
);
|
|
217
|
+
insertLineageRelationRows(db, component, graph);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function buildSessionGraph(db: SessionIndexDatabase): SessionGraph {
|
|
191
221
|
const rows = parseTypeBoxRows(
|
|
192
222
|
SESSION_GRAPH_ROW_SCHEMA,
|
|
193
223
|
db
|
|
@@ -229,6 +259,40 @@ export function rebuildSessionLineageRelations(db: SessionIndexDatabase): void {
|
|
|
229
259
|
childrenByParent.set(node.resolvedParentSessionId, children);
|
|
230
260
|
}
|
|
231
261
|
|
|
262
|
+
return { nodes, childrenByParent };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function collectLineageComponent(
|
|
266
|
+
seedSessionIds: Array<string | undefined>,
|
|
267
|
+
graph: SessionGraph,
|
|
268
|
+
): Set<string> {
|
|
269
|
+
const component = new Set<string>();
|
|
270
|
+
const queue = seedSessionIds.filter(
|
|
271
|
+
(sessionId): sessionId is string => sessionId !== undefined && graph.nodes.has(sessionId),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
while (queue.length > 0) {
|
|
275
|
+
const sessionId = queue.pop();
|
|
276
|
+
if (!sessionId || component.has(sessionId)) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
component.add(sessionId);
|
|
281
|
+
const parentId = graph.nodes.get(sessionId)?.resolvedParentSessionId;
|
|
282
|
+
if (parentId) {
|
|
283
|
+
queue.push(parentId);
|
|
284
|
+
}
|
|
285
|
+
queue.push(...(graph.childrenByParent.get(sessionId) ?? []));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return component;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function insertLineageRelationRows(
|
|
292
|
+
db: SessionIndexDatabase,
|
|
293
|
+
sessionIds: Iterable<string>,
|
|
294
|
+
graph: SessionGraph,
|
|
295
|
+
): void {
|
|
232
296
|
const insertRelation = db.prepare(
|
|
233
297
|
`
|
|
234
298
|
INSERT INTO session_lineage_relations(session_id, related_session_id, relation, distance)
|
|
@@ -236,8 +300,12 @@ export function rebuildSessionLineageRelations(db: SessionIndexDatabase): void {
|
|
|
236
300
|
`,
|
|
237
301
|
);
|
|
238
302
|
|
|
239
|
-
for (const
|
|
240
|
-
const relations = collectMaterializedLineageRows(
|
|
303
|
+
for (const sessionId of sessionIds) {
|
|
304
|
+
const relations = collectMaterializedLineageRows(
|
|
305
|
+
sessionId,
|
|
306
|
+
graph.nodes,
|
|
307
|
+
graph.childrenByParent,
|
|
308
|
+
);
|
|
241
309
|
for (const relation of relations.values()) {
|
|
242
310
|
insertRelation.run(
|
|
243
311
|
relation.sessionId,
|