pi-sessions 0.3.1 → 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-handoff/extract.ts +15 -14
- package/extensions/session-handoff.ts +2 -1
- package/extensions/session-search/extract.ts +218 -50
- package/extensions/session-search/hooks.ts +253 -103
- package/extensions/session-search/reindex.ts +48 -50
- package/extensions/shared/session-index/common.ts +6 -3
- package/extensions/shared/session-index/lineage.ts +72 -4
- package/extensions/shared/session-index/schema.ts +27 -23
- package/extensions/shared/session-index/search.ts +2 -2
- package/extensions/shared/session-index/sqlite.ts +98 -0
- package/extensions/shared/session-index/store.ts +125 -33
- package/package.json +3 -4
|
@@ -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,70 +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
|
-
}
|
|
34
|
+
db.transaction(() => {
|
|
35
|
+
dropIndexTables(db);
|
|
36
|
+
initializeSchema(db);
|
|
37
|
+
}).immediate();
|
|
40
38
|
|
|
41
|
-
renameSync(tempIndexPath, finalIndexPath);
|
|
42
|
-
return { sessionCount, chunkCount, indexPath: finalIndexPath };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function indexSessionFiles(
|
|
46
|
-
db: SessionIndexDatabase,
|
|
47
|
-
sessionFiles: string[],
|
|
48
|
-
): { sessionCount: number; chunkCount: number } {
|
|
49
|
-
return db.transaction((files: string[]) => {
|
|
50
39
|
let sessionCount = 0;
|
|
51
40
|
let chunkCount = 0;
|
|
52
|
-
|
|
53
|
-
for (const sessionFile of files) {
|
|
41
|
+
for (const sessionFile of sessionFiles) {
|
|
54
42
|
const extracted = extractSessionRecord(sessionFile);
|
|
55
43
|
if (!extracted) {
|
|
56
44
|
continue;
|
|
57
45
|
}
|
|
58
46
|
|
|
59
|
-
|
|
47
|
+
db.transaction(() => indexSession(db, extracted)).immediate();
|
|
60
48
|
sessionCount += 1;
|
|
61
|
-
chunkCount +=
|
|
62
|
-
insertSessionFileTouches(db, extracted.sessionId, extracted.fileTouches);
|
|
49
|
+
chunkCount += extracted.chunks.length;
|
|
63
50
|
}
|
|
64
51
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
})(sessionFiles);
|
|
71
|
-
}
|
|
52
|
+
db.transaction(() => {
|
|
53
|
+
rebuildSessionLineageRelations(db);
|
|
54
|
+
setMetadata(db, "indexed_at", new Date().toISOString());
|
|
55
|
+
setMetadata(db, "session_source", "SessionManager.listAll()");
|
|
56
|
+
}).immediate();
|
|
72
57
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
chunks: SearchTextChunk[],
|
|
77
|
-
): number {
|
|
78
|
-
for (const chunk of chunks) {
|
|
79
|
-
insertTextChunk(db, { sessionId, ...chunk });
|
|
58
|
+
return { sessionCount, chunkCount, indexPath };
|
|
59
|
+
} finally {
|
|
60
|
+
db.close();
|
|
80
61
|
}
|
|
62
|
+
}
|
|
81
63
|
|
|
82
|
-
|
|
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
|
+
`);
|
|
83
75
|
}
|
|
84
76
|
|
|
85
|
-
function
|
|
86
|
-
db
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
});
|
|
92
90
|
}
|
|
93
91
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type Database from "better-sqlite3";
|
|
2
1
|
import { Type } from "typebox";
|
|
3
2
|
import type { FileTouchOp, FileTouchSource, PathScope } from "../../session-search/normalize.js";
|
|
4
3
|
import { safeParseTypeBoxJson } from "../typebox.js";
|
|
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 {
|
|
@@ -119,7 +122,7 @@ export interface SessionIndexStatus {
|
|
|
119
122
|
lastFullReindexAt?: string | undefined;
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
export type SessionIndexDatabase =
|
|
125
|
+
export type SessionIndexDatabase = SqliteDatabase;
|
|
123
126
|
|
|
124
127
|
export const NULLABLE_STRING_SCHEMA = Type.Union([Type.String(), Type.Null()]);
|
|
125
128
|
export const SESSION_ORIGIN_SCHEMA = Type.Union([
|