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.
@@ -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
- getIndexStatus,
11
+ getMetadata,
11
12
  getSessionById,
13
+ getSessionRowByPath,
12
14
  INDEX_SCHEMA_VERSION,
13
15
  insertSessionFileTouch,
14
16
  insertTextChunk,
15
17
  openIndexDatabase,
16
- rebuildSessionLineageRelations,
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 { type ExtractedSessionRecord, extractSessionRecord } from "./extract.js";
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 = await syncSessionFile(
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 = await syncAttachedSession(
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 = await syncSessionFile(indexPath, previousSessionFile, "session_fork");
117
+ const previousSynced = syncSessionFile(indexPath, previousSessionFile, "session_fork");
118
118
  attachSession(state, sessionFile, cwd);
119
- const currentSynced = await syncAttachedSession(indexPath, state, "session_fork", "fork");
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 await syncAttachedSession(indexPath, state, "turn_end");
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 await syncAttachedSession(indexPath, state, "session_shutdown");
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
- ): Promise<boolean> {
190
+ ): boolean {
235
191
  return syncSessionFile(indexPath, state.currentSessionFile, eventType, state, sessionOrigin);
236
192
  }
237
193
 
238
- async function syncSessionFile(
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
- ): Promise<boolean> {
245
- if (!sessionFile || !existsSync(sessionFile)) {
200
+ ): boolean {
201
+ if (!sessionFile || !existsSync(sessionFile) || !existsSync(indexPath)) {
246
202
  return false;
247
203
  }
248
204
 
249
- const status = getIndexStatus(indexPath);
250
- if (!status.exists || status.schemaVersion !== INDEX_SCHEMA_VERSION) {
251
- return false;
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
- await retryIndexWrite(() => {
260
- const db = openIndexDatabase(indexPath, {
261
- create: false,
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
- for (const chunk of extracted.chunks) {
275
- insertTextChunk(db, { sessionId: extracted.sessionId, ...chunk });
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
- for (const fileTouch of extracted.fileTouches) {
279
- insertSessionFileTouch(db, { sessionId: extracted.sessionId, ...fileTouch });
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
- setMetadata(db, "hook_updated_at", new Date().toISOString());
283
- setMetadata(db, "hook_last_event", eventType);
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
- if (state) {
291
- state.lastFlushedSessionFile = sessionFile;
292
- }
316
+ for (const fileTouch of extracted.fileTouches) {
317
+ insertSessionFileTouch(db, { sessionId: extracted.sessionId, ...fileTouch });
318
+ }
293
319
 
294
- return true;
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 { renameSync } from "node:fs";
1
+ import path from "node:path";
2
2
  import { SessionManager } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
- createTempIndexPath,
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 { extractSessionRecord, type SearchTextChunk, type SessionFileTouch } from "./extract.js";
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 finalIndexPath = options.indexPath;
28
- const tempIndexPath = createTempIndexPath(finalIndexPath);
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(tempIndexPath, { create: true });
32
- let sessionCount: number;
33
- let chunkCount: number;
32
+ const db = openIndexDatabase(indexPath, { create: true });
34
33
  try {
35
- initializeSchema(db);
36
- ({ sessionCount, chunkCount } = indexSessionFiles(db, sessionFiles));
37
- } finally {
38
- db.close();
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
- insertSession(db, extracted, "full_reindex");
47
+ db.transaction(() => indexSession(db, extracted)).immediate();
60
48
  sessionCount += 1;
61
- chunkCount += insertSessionChunks(db, extracted.sessionId, extracted.chunks);
62
- insertSessionFileTouches(db, extracted.sessionId, extracted.fileTouches);
49
+ chunkCount += extracted.chunks.length;
63
50
  }
64
51
 
65
- rebuildSessionLineageRelations(db);
66
- setMetadata(db, "indexed_at", new Date().toISOString());
67
- setMetadata(db, "session_source", "SessionManager.listAll()");
68
-
69
- return { sessionCount, chunkCount };
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
- function insertSessionChunks(
74
- db: SessionIndexDatabase,
75
- sessionId: string,
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
- return chunks.length;
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 insertSessionFileTouches(
86
- db: SessionIndexDatabase,
87
- sessionId: string,
88
- fileTouches: SessionFileTouch[],
89
- ): void {
90
- for (const fileTouch of fileTouches) {
91
- insertSessionFileTouch(db, { sessionId, ...fileTouch });
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 = 7;
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 = Database.Database;
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([