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,5 +1,4 @@
1
1
  import { existsSync, mkdirSync } from "node:fs";
2
- import path from "node:path";
3
2
  import { parseTypeBoxValue } from "../typebox.js";
4
3
  import {
5
4
  INDEX_SCHEMA_VERSION,
@@ -17,13 +16,6 @@ export function ensureIndexDir(dir: string): string {
17
16
  return dir;
18
17
  }
19
18
 
20
- export function createTempIndexPath(finalPath: string): string {
21
- const dir = path.dirname(finalPath);
22
- const baseName = path.basename(finalPath, path.extname(finalPath));
23
- ensureIndexDir(dir);
24
- return path.join(dir, `${baseName}.tmp-${process.pid}-${Date.now()}.sqlite`);
25
- }
26
-
27
19
  export function openIndexDatabase(
28
20
  dbPath: string,
29
21
  options?: { create?: boolean; timeoutMs?: number },
@@ -55,6 +47,9 @@ export function initializeSchema(db: SessionIndexDatabase): void {
55
47
  session_origin TEXT,
56
48
  handoff_goal TEXT,
57
49
  handoff_next_task TEXT,
50
+ indexed_file_size INTEGER,
51
+ indexed_file_mtime_ms INTEGER,
52
+ indexed_file_anchor TEXT,
58
53
  index_version INTEGER NOT NULL,
59
54
  indexed_at_ts TEXT NOT NULL,
60
55
  index_source TEXT NOT NULL
@@ -97,11 +92,29 @@ export function initializeSchema(db: SessionIndexDatabase): void {
97
92
  CREATE INDEX IF NOT EXISTS session_text_chunks_ts_idx ON session_text_chunks(ts DESC);
98
93
 
99
94
  CREATE VIRTUAL TABLE IF NOT EXISTS session_text_chunks_fts USING fts5(
100
- chunk_id UNINDEXED,
101
- session_id UNINDEXED,
102
- text
95
+ text,
96
+ content='session_text_chunks',
97
+ content_rowid='id'
103
98
  );
104
99
 
100
+ CREATE TRIGGER IF NOT EXISTS session_text_chunks_fts_ai
101
+ AFTER INSERT ON session_text_chunks BEGIN
102
+ INSERT INTO session_text_chunks_fts(rowid, text) VALUES (new.id, new.text);
103
+ END;
104
+
105
+ CREATE TRIGGER IF NOT EXISTS session_text_chunks_fts_ad
106
+ AFTER DELETE ON session_text_chunks BEGIN
107
+ INSERT INTO session_text_chunks_fts(session_text_chunks_fts, rowid, text)
108
+ VALUES ('delete', old.id, old.text);
109
+ END;
110
+
111
+ CREATE TRIGGER IF NOT EXISTS session_text_chunks_fts_au
112
+ AFTER UPDATE ON session_text_chunks BEGIN
113
+ INSERT INTO session_text_chunks_fts(session_text_chunks_fts, rowid, text)
114
+ VALUES ('delete', old.id, old.text);
115
+ INSERT INTO session_text_chunks_fts(rowid, text) VALUES (new.id, new.text);
116
+ END;
117
+
105
118
  CREATE TABLE IF NOT EXISTS session_file_touches (
106
119
  id INTEGER PRIMARY KEY AUTOINCREMENT,
107
120
  session_id TEXT NOT NULL,
@@ -325,12 +325,12 @@ function getTextMatchRows(db: SessionIndexDatabase, filters: SearchFilters): Sea
325
325
  s.session_origin as sessionOrigin,
326
326
  s.handoff_goal as handoffGoal,
327
327
  s.handoff_next_task as handoffNextTask,
328
- snippet(session_text_chunks_fts, 2, ?, ?, ?, 12) as snippet,
328
+ snippet(session_text_chunks_fts, 0, ?, ?, ?, 12) as snippet,
329
329
  bm25(session_text_chunks_fts) as rank,
330
330
  c.entry_id as entryId,
331
331
  c.source_kind as sourceKind
332
332
  FROM session_text_chunks_fts
333
- JOIN session_text_chunks c ON c.id = CAST(session_text_chunks_fts.chunk_id AS INTEGER)
333
+ JOIN session_text_chunks c ON c.id = session_text_chunks_fts.rowid
334
334
  JOIN sessions s ON s.session_id = c.session_id
335
335
  WHERE session_text_chunks_fts MATCH ?
336
336
  AND (? IS NULL OR s.modified_ts >= ?)
@@ -3,6 +3,8 @@ import { createRequire } from "node:module";
3
3
  const requireModule = createRequire(import.meta.url);
4
4
  const isBun = Boolean(process.versions.bun);
5
5
 
6
+ const DEFAULT_BUSY_TIMEOUT_MS = 5000;
7
+
6
8
  export type SqliteBindValue = string | number | bigint | null;
7
9
 
8
10
  export interface SqliteRunResult {
@@ -16,13 +18,17 @@ export interface SqliteStatement {
16
18
  run(...params: SqliteBindValue[]): SqliteRunResult;
17
19
  }
18
20
 
21
+ export interface SqliteTransaction<Args extends unknown[], Result> {
22
+ (...args: Args): Result;
23
+ immediate(...args: Args): Result;
24
+ }
25
+
19
26
  export interface SqliteDatabase {
20
- readonly inTransaction: boolean;
21
27
  prepare(sql: string): SqliteStatement;
22
28
  exec(sql: string): void;
23
29
  transaction<Args extends unknown[], Result>(
24
30
  fn: (...args: Args) => Result,
25
- ): (...args: Args) => Result;
31
+ ): SqliteTransaction<Args, Result>;
26
32
  close(): void;
27
33
  }
28
34
 
@@ -47,19 +53,46 @@ export function openSqlite(
47
53
  const Database = loadDatabaseConstructor();
48
54
  const db = isBun
49
55
  ? new Database(dbPath, { create: options.create, readwrite: true })
50
- : new Database(
51
- dbPath,
52
- options.timeoutMs === undefined
53
- ? { fileMustExist: !options.create }
54
- : { fileMustExist: !options.create, timeout: options.timeoutMs },
55
- );
56
+ : new Database(dbPath, { fileMustExist: !options.create });
56
57
 
58
+ // busy_timeout must be set before any other statement: the journal_mode
59
+ // pragma below is the connection's first lock acquisition (and may run WAL
60
+ // recovery), and immediate transactions rely on this timeout to queue behind
61
+ // concurrent writers instead of failing.
62
+ db.exec(`PRAGMA busy_timeout = ${options.timeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS}`);
57
63
  db.exec("PRAGMA journal_mode = WAL");
58
64
  db.exec("PRAGMA synchronous = NORMAL");
59
65
  db.exec("PRAGMA foreign_keys = ON");
60
- if (isBun && options.timeoutMs !== undefined) {
61
- db.exec(`PRAGMA busy_timeout = ${options.timeoutMs}`);
62
- }
63
66
 
64
- return db;
67
+ return withStatementCache(db);
68
+ }
69
+
70
+ // Neither driver memoizes prepare(), and the write paths prepare the same
71
+ // statements once per row. SQLite re-prepares cached statements transparently
72
+ // when the schema changes, so reusing them across DDL is safe.
73
+ function withStatementCache(db: SqliteDatabase): SqliteDatabase {
74
+ const statements = new Map<string, SqliteStatement>();
75
+
76
+ return {
77
+ prepare(sql) {
78
+ const cached = statements.get(sql);
79
+ if (cached) {
80
+ return cached;
81
+ }
82
+
83
+ const statement = db.prepare(sql);
84
+ statements.set(sql, statement);
85
+ return statement;
86
+ },
87
+ exec(sql) {
88
+ db.exec(sql);
89
+ },
90
+ transaction(fn) {
91
+ return db.transaction(fn);
92
+ },
93
+ close() {
94
+ statements.clear();
95
+ db.close();
96
+ },
97
+ };
65
98
  }
@@ -1,12 +1,38 @@
1
+ import { type Static, Type } from "typebox";
2
+ import { parseTypeBoxValue } from "../typebox.js";
1
3
  import {
2
4
  compactSessionId,
3
5
  INDEX_SCHEMA_VERSION,
6
+ NULLABLE_STRING_SCHEMA,
7
+ parseRepoRoots,
8
+ SESSION_ORIGIN_SCHEMA,
4
9
  type SessionFileTouchRow,
5
10
  type SessionIndexDatabase,
6
11
  type SessionRow,
7
12
  type SessionTextChunkRow,
8
13
  } from "./common.js";
9
14
 
15
+ const SESSION_ROW_QUERY_SCHEMA = Type.Object({
16
+ sessionId: Type.String(),
17
+ sessionPath: Type.String(),
18
+ sessionName: Type.String(),
19
+ firstUserPrompt: NULLABLE_STRING_SCHEMA,
20
+ cwd: Type.String(),
21
+ repoRootsJson: Type.String(),
22
+ startedAt: Type.String(),
23
+ modifiedAt: Type.String(),
24
+ messageCount: Type.Number(),
25
+ entryCount: Type.Number(),
26
+ parentSessionPath: NULLABLE_STRING_SCHEMA,
27
+ parentSessionId: NULLABLE_STRING_SCHEMA,
28
+ sessionOrigin: Type.Union([SESSION_ORIGIN_SCHEMA, Type.Null()]),
29
+ handoffGoal: NULLABLE_STRING_SCHEMA,
30
+ handoffNextTask: NULLABLE_STRING_SCHEMA,
31
+ indexedFileSize: Type.Union([Type.Number(), Type.Null()]),
32
+ indexedFileMtimeMs: Type.Union([Type.Number(), Type.Null()]),
33
+ indexedFileAnchor: NULLABLE_STRING_SCHEMA,
34
+ });
35
+
10
36
  function sessionRowBindings(row: SessionRow, indexSource: string) {
11
37
  return [
12
38
  row.sessionId,
@@ -24,6 +50,9 @@ function sessionRowBindings(row: SessionRow, indexSource: string) {
24
50
  row.sessionOrigin ?? null,
25
51
  row.handoffGoal ?? null,
26
52
  row.handoffNextTask ?? null,
53
+ row.indexedFileSize ?? null,
54
+ row.indexedFileMtimeMs ?? null,
55
+ row.indexedFileAnchor ?? null,
27
56
  INDEX_SCHEMA_VERSION,
28
57
  new Date().toISOString(),
29
58
  indexSource,
@@ -42,8 +71,9 @@ export function insertSession(
42
71
  created_ts, modified_ts, message_count, entry_count,
43
72
  parent_session_path, parent_session_id, session_origin,
44
73
  handoff_goal, handoff_next_task,
74
+ indexed_file_size, indexed_file_mtime_ms, indexed_file_anchor,
45
75
  index_version, indexed_at_ts, index_source
46
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
76
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
47
77
  `,
48
78
  ).run(...sessionRowBindings(row, indexSource));
49
79
  insertSessionIdChunk(db, row);
@@ -61,8 +91,9 @@ export function upsertSession(
61
91
  created_ts, modified_ts, message_count, entry_count,
62
92
  parent_session_path, parent_session_id, session_origin,
63
93
  handoff_goal, handoff_next_task,
94
+ indexed_file_size, indexed_file_mtime_ms, indexed_file_anchor,
64
95
  index_version, indexed_at_ts, index_source
65
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
96
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
66
97
  ON CONFLICT(session_id) DO UPDATE SET
67
98
  session_path = excluded.session_path,
68
99
  session_name = excluded.session_name,
@@ -78,6 +109,9 @@ export function upsertSession(
78
109
  session_origin = excluded.session_origin,
79
110
  handoff_goal = excluded.handoff_goal,
80
111
  handoff_next_task = excluded.handoff_next_task,
112
+ indexed_file_size = excluded.indexed_file_size,
113
+ indexed_file_mtime_ms = excluded.indexed_file_mtime_ms,
114
+ indexed_file_anchor = excluded.indexed_file_anchor,
81
115
  index_version = excluded.index_version,
82
116
  indexed_at_ts = excluded.indexed_at_ts,
83
117
  index_source = excluded.index_source
@@ -86,6 +120,72 @@ export function upsertSession(
86
120
  syncSessionIdChunk(db, row);
87
121
  }
88
122
 
123
+ export function getSessionRowByPath(
124
+ db: SessionIndexDatabase,
125
+ sessionPath: string,
126
+ ): SessionRow | undefined {
127
+ const row = db
128
+ .prepare(
129
+ `
130
+ SELECT
131
+ session_id as sessionId,
132
+ session_path as sessionPath,
133
+ session_name as sessionName,
134
+ first_user_prompt as firstUserPrompt,
135
+ cwd,
136
+ repo_roots_json as repoRootsJson,
137
+ created_ts as startedAt,
138
+ modified_ts as modifiedAt,
139
+ message_count as messageCount,
140
+ entry_count as entryCount,
141
+ parent_session_path as parentSessionPath,
142
+ parent_session_id as parentSessionId,
143
+ session_origin as sessionOrigin,
144
+ handoff_goal as handoffGoal,
145
+ handoff_next_task as handoffNextTask,
146
+ indexed_file_size as indexedFileSize,
147
+ indexed_file_mtime_ms as indexedFileMtimeMs,
148
+ indexed_file_anchor as indexedFileAnchor
149
+ FROM sessions
150
+ WHERE session_path = ?
151
+ ORDER BY indexed_at_ts DESC
152
+ LIMIT 1
153
+ `,
154
+ )
155
+ .get(sessionPath);
156
+
157
+ if (row === undefined || row === null) {
158
+ return undefined;
159
+ }
160
+
161
+ return buildSessionRow(
162
+ parseTypeBoxValue(SESSION_ROW_QUERY_SCHEMA, row, `Invalid session row for path ${sessionPath}`),
163
+ );
164
+ }
165
+
166
+ function buildSessionRow(row: Static<typeof SESSION_ROW_QUERY_SCHEMA>): SessionRow {
167
+ return {
168
+ sessionId: row.sessionId,
169
+ sessionPath: row.sessionPath,
170
+ sessionName: row.sessionName,
171
+ firstUserPrompt: row.firstUserPrompt ?? undefined,
172
+ cwd: row.cwd,
173
+ repoRoots: parseRepoRoots(row.repoRootsJson),
174
+ startedAt: row.startedAt,
175
+ modifiedAt: row.modifiedAt,
176
+ messageCount: row.messageCount,
177
+ entryCount: row.entryCount,
178
+ parentSessionPath: row.parentSessionPath ?? undefined,
179
+ parentSessionId: row.parentSessionId ?? undefined,
180
+ sessionOrigin: row.sessionOrigin ?? undefined,
181
+ handoffGoal: row.handoffGoal ?? undefined,
182
+ handoffNextTask: row.handoffNextTask ?? undefined,
183
+ indexedFileSize: row.indexedFileSize ?? undefined,
184
+ indexedFileMtimeMs: row.indexedFileMtimeMs ?? undefined,
185
+ indexedFileAnchor: row.indexedFileAnchor ?? undefined,
186
+ };
187
+ }
188
+
89
189
  function insertSessionIdChunk(db: SessionIndexDatabase, row: SessionRow): void {
90
190
  insertTextChunk(db, {
91
191
  sessionId: row.sessionId,
@@ -97,18 +197,7 @@ function insertSessionIdChunk(db: SessionIndexDatabase, row: SessionRow): void {
97
197
  }
98
198
 
99
199
  function syncSessionIdChunk(db: SessionIndexDatabase, row: SessionRow): void {
100
- db.prepare(
101
- `DELETE FROM session_text_chunks_fts
102
- WHERE chunk_id IN (
103
- SELECT CAST(id AS TEXT) FROM session_text_chunks
104
- WHERE session_id = ? AND source_kind = 'session_id'
105
- )`,
106
- ).run(row.sessionId);
107
-
108
- db.prepare(
109
- `DELETE FROM session_text_chunks WHERE session_id = ? AND source_kind = 'session_id'`,
110
- ).run(row.sessionId);
111
-
200
+ clearSessionChunksBySourceKind(db, row.sessionId, "session_id");
112
201
  insertSessionIdChunk(db, row);
113
202
  }
114
203
 
@@ -117,35 +206,38 @@ function buildSessionIdSearchText(sessionId: string): string {
117
206
  return compact === sessionId ? sessionId : `${sessionId} ${compact}`;
118
207
  }
119
208
 
209
+ export function clearSessionChunksBySourceKind(
210
+ db: SessionIndexDatabase,
211
+ sessionId: string,
212
+ sourceKind: string,
213
+ ): void {
214
+ db.prepare(`DELETE FROM session_text_chunks WHERE session_id = ? AND source_kind = ?`).run(
215
+ sessionId,
216
+ sourceKind,
217
+ );
218
+ }
219
+
120
220
  export function clearSessionIndexedData(db: SessionIndexDatabase, sessionId: string): void {
121
- db.prepare(`DELETE FROM session_text_chunks_fts WHERE session_id = ?`).run(sessionId);
122
221
  db.prepare(`DELETE FROM session_text_chunks WHERE session_id = ?`).run(sessionId);
123
222
  db.prepare(`DELETE FROM session_file_touches WHERE session_id = ?`).run(sessionId);
124
223
  }
125
224
 
126
225
  export function insertTextChunk(db: SessionIndexDatabase, row: SessionTextChunkRow): void {
127
- const result = db
128
- .prepare(
129
- `
226
+ db.prepare(
227
+ `
130
228
  INSERT INTO session_text_chunks(
131
229
  session_id, entry_id, entry_type, role, ts, source_kind, text
132
230
  ) VALUES (?, ?, ?, ?, ?, ?, ?)
133
231
  `,
134
- )
135
- .run(
136
- row.sessionId,
137
- row.entryId ?? null,
138
- row.entryType,
139
- row.role ?? null,
140
- row.ts,
141
- row.sourceKind,
142
- row.text,
143
- );
144
-
145
- const chunkId = Number(result.lastInsertRowid);
146
- db.prepare(
147
- `INSERT INTO session_text_chunks_fts(chunk_id, session_id, text) VALUES (?, ?, ?)`,
148
- ).run(chunkId, row.sessionId, row.text);
232
+ ).run(
233
+ row.sessionId,
234
+ row.entryId ?? null,
235
+ row.entryType,
236
+ row.role ?? null,
237
+ row.ts,
238
+ row.sourceKind,
239
+ row.text,
240
+ );
149
241
  }
150
242
 
151
243
  export function insertSessionFileTouch(db: SessionIndexDatabase, row: SessionFileTouchRow): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sessions",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Pi session search, ask, handoff, auto-titling, and indexing tools",
6
6
  "license": "MIT",