@henryqw/pi-session-recall 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -31,6 +31,8 @@ Query syntax: FTS5 over a trigram index — multi-word = AND by default, `OR` fo
31
31
 
32
32
  Hits inside the current session's live context are suppressed; compacted-away or inactive-branch history stays discoverable. Forked sessions collapse into their parent when both match.
33
33
 
34
+ If the lazy index sync before browse/discovery cannot fully enumerate the session tree, or throws entirely, results are still served from the current index — potentially partially updated and stale: files discovered before the failure may already reflect their new content, while rows for files the walk never reached remain stale — and carry a top-level `syncWarning`: `{kind:"incomplete-walk"}` for a partial walk (indexed-but-unseen paths are never purged in that case), or `{kind:"sync-failed", error}` with the capped failure message. The warning is omitted once a sync completes.
35
+
34
36
  ## Deliberate exclusions
35
37
 
36
38
  Session directories whose encoded path starts with `--tmp-` or `--private-tmp-` (sessions run from `/tmp` or `/private/tmp`) are never indexed. Session files over 32 MiB are excluded from indexing and hydration: discovery cannot newly find them; READ/SCROLL return an explicit size error, while a stale discovery hit retained from before the file grew is returned as metadata with empty messages and that error.
@@ -4,7 +4,7 @@
4
4
  * Pure functions over file paths; no SQLite access.
5
5
  */
6
6
  /// <reference types="node" />
7
- import { MAX_SESSION_FILE_BYTES, readBoundedSnapshot } from "./search-core.ts";
7
+ import { readTranscriptEntries, type TranscriptEntry } from "./transcript.ts";
8
8
  import type { WindowMessage } from "./types.ts";
9
9
 
10
10
  export interface WindowResult {
@@ -36,34 +36,37 @@ interface Entry {
36
36
  };
37
37
  }
38
38
 
39
- /** Parse JSONL lines; malformed lines are skipped. Header line (no id) skipped. */
39
+ /** Project a boundary entry onto the shape hydration consumes. Entries without
40
+ * a validated id pair are dropped (they carry no hydratable position); the
41
+ * raw data must be a JSON object for any projection beyond id/parentId to
42
+ * exist at all. */
43
+ function toEntry(t: TranscriptEntry): Entry | null {
44
+ if (t.id === undefined) return null;
45
+ if (t.data === null || typeof t.data !== "object") return null;
46
+ const rec = t.data as Record<string, unknown>;
47
+ const message = rec.message !== null && typeof rec.message === "object"
48
+ ? (rec.message as Entry["message"])
49
+ : undefined;
50
+ return {
51
+ id: t.id,
52
+ parentId: t.parentId ?? null,
53
+ type: typeof rec.type === "string" ? rec.type : "",
54
+ timestamp: typeof rec.timestamp === "string" ? rec.timestamp : undefined,
55
+ message,
56
+ };
57
+ }
58
+
59
+ /** Parse JSONL lines; malformed lines are skipped and duplicate/invalid ids are
60
+ * handled by the shared transcript boundary. Entries stream out of the
61
+ * boundary's generator and only successfully projected ones are pushed. */
40
62
  function parseSessionEntries(sessionPath: string): Entry[] {
41
- // O_NONBLOCK + descriptor validation + fixed-size snapshot read are shared
42
- // with the index engine; the fd pins the inode so a concurrent append after
43
- // fstat waits until the next hydration call.
44
- const raw = readBoundedSnapshot(sessionPath, MAX_SESSION_FILE_BYTES);
63
+ // The bounded snapshot read (O_NONBLOCK + descriptor validation + fixed-size
64
+ // read) is shared with the index engine; the fd pins the inode so a
65
+ // concurrent append after fstat waits until the next hydration call.
45
66
  const entries: Entry[] = [];
46
- const seenIds = new Set<string>();
47
- for (const line of raw.split("\n")) {
48
- if (!line.trim()) continue;
49
- let obj: unknown;
50
- try {
51
- obj = JSON.parse(line);
52
- } catch {
53
- continue; // skip malformed line
54
- }
55
- const e = obj as Entry;
56
- if (
57
- e &&
58
- typeof e.id === "string" &&
59
- e.id.length > 0 &&
60
- e.id.length <= 256 &&
61
- !seenIds.has(e.id) &&
62
- (e.parentId == null || (typeof e.parentId === "string" && e.parentId.length <= 256))
63
- ) {
64
- seenIds.add(e.id);
65
- entries.push(e);
66
- }
67
+ for (const t of readTranscriptEntries(sessionPath)) {
68
+ const projected = toEntry(t);
69
+ if (projected !== null) entries.push(projected);
67
70
  }
68
71
  return entries;
69
72
  }
@@ -8,11 +8,9 @@ import { DatabaseSync } from "node:sqlite";
8
8
  import type { SQLInputValue, SQLOutputValue } from "node:sqlite";
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
+ import { MAX_SESSION_FILE_BYTES, readTranscriptEntries } from "./transcript.ts";
11
12
  import type { SearchHit, SessionRow, SyncResult } from "./types.ts";
12
13
  export const DEFAULT_SYNC_CAP = 50;
13
- /** Hard byte ceiling per session file: larger files are skipped (and retried
14
- * behind fresh work) instead of being read whole into memory. */
15
- export const MAX_SESSION_FILE_BYTES = 32 * 1024 * 1024;
16
14
  /** Hard ceiling for the internal/test `opts.cap` work bound of syncSessions. */
17
15
  const MAX_SYNC_CAP = DEFAULT_SYNC_CAP * 10;
18
16
  export const MAX_QUERY_CHARS = 512;
@@ -260,42 +258,7 @@ function extractText(content: unknown): string {
260
258
  return parts.join("\n").trim();
261
259
  }
262
260
 
263
- /** Open the path once, validate that exact descriptor (type + size), and read
264
- * only the validated snapshot from it. A concurrent append/replacement between
265
- * the walk's stat and this open cannot grow the allocation or the read beyond
266
- * maxBytes: the fd pins the inode, and fstat on that fd fixes both bounds.
267
- * Deliberately not fs.readFileSync(fd) — that re-reads to EOF unbounded.
268
- * Shared with hydration: callers pass their own byte ceiling (both use
269
- * MAX_SESSION_FILE_BYTES in production). */
270
- export function readBoundedSnapshot(filePath: string, maxBytes: number): string {
271
- // O_NONBLOCK keeps a writerless FIFO (regular .jsonl swapped mid-walk) from
272
- // blocking this open before fstat rejects it; O_NOFOLLOW (absent on Windows)
273
- // rejects a symlink swapped in after the walk instead of following it.
274
- const fd = fs.openSync(
275
- filePath,
276
- fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0),
277
- );
278
- try {
279
- const st = fs.fstatSync(fd);
280
- if (!st.isFile()) throw new Error(`session path is not a regular file: ${filePath}`);
281
- if (st.size > maxBytes) {
282
- throw new Error(`session file exceeds ${Math.round(maxBytes / (1024 * 1024))} MiB snapshot limit: ${filePath}`);
283
- }
284
- const buf = Buffer.allocUnsafe(st.size);
285
- let read = 0;
286
- while (read < buf.length) {
287
- const n = fs.readSync(fd, buf, read, buf.length - read, read);
288
- if (n === 0) break; // truncated concurrently after fstat: index what was there
289
- read += n;
290
- }
291
- return buf.toString("utf-8", 0, read);
292
- } finally {
293
- fs.closeSync(fd);
294
- }
295
- }
296
-
297
261
  function parseSessionFile(filePath: string, maxBytes: number): ParsedFile {
298
- const seenEntryIds = new Set<string>();
299
262
  const parsed: ParsedFile = {
300
263
  cwd: null,
301
264
  name: null,
@@ -306,23 +269,8 @@ function parseSessionFile(filePath: string, maxBytes: number): ParsedFile {
306
269
  };
307
270
  // Unreadable/oversized file must throw so the sync transaction rolls back
308
271
  // instead of wiping previously indexed rows and advancing the watermark over a hole.
309
- const content = readBoundedSnapshot(filePath, maxBytes);
310
- for (const line of content.split("\n")) {
311
- if (!line.trim()) continue;
312
- let entry: any;
313
- try {
314
- entry = JSON.parse(line);
315
- } catch {
316
- continue; // skip malformed lines
317
- }
318
- const hasValidId = typeof entry?.id === "string" && entry.id.length > 0 && entry.id.length <= 256
319
- && (entry.parentId == null || (typeof entry.parentId === "string" && entry.parentId.length <= 256));
320
- if (hasValidId) {
321
- // Hydration is first-wins across every entry type; reserve IDs at the
322
- // same boundary so discovery can never point at a different duplicate.
323
- if (seenEntryIds.has(entry.id)) continue;
324
- seenEntryIds.add(entry.id);
325
- }
272
+ for (const { data: entryRaw, id } of readTranscriptEntries(filePath, maxBytes)) {
273
+ const entry = entryRaw !== null && typeof entryRaw === "object" ? (entryRaw as Record<string, unknown>) : undefined;
326
274
  switch (entry?.type) {
327
275
  case "session":
328
276
  // Untrusted header strings are capped here so every consumer
@@ -335,17 +283,17 @@ function parseSessionFile(filePath: string, maxBytes: number): ParsedFile {
335
283
  if (typeof entry.name === "string") parsed.name = entry.name.slice(0, 500);
336
284
  break;
337
285
  case "message": {
338
- const role = entry.message?.role;
339
- if (role !== "user" && role !== "assistant") break;
340
- const full = extractText(entry.message.content);
341
- if (!full) break;
342
- if (!hasValidId) break;
286
+ const msg = entry.message as { role?: unknown; content?: unknown } | undefined;
287
+ const role = msg?.role;
288
+ if (!msg || (role !== "user" && role !== "assistant")) break;
289
+ const full = extractText(msg.content);
290
+ if (!full || id === undefined) break;
343
291
  if (role === "user" && parsed.preview === null) {
344
292
  parsed.preview = full.slice(0, 200);
345
293
  }
346
294
  const regions = truncateRegions(full);
347
295
  parsed.messages.push({
348
- entryId: entry.id,
296
+ entryId: id,
349
297
  role,
350
298
  timestamp: capStr(entry.timestamp, 128),
351
299
  head: regions ? regions.head : full,
@@ -549,7 +497,7 @@ export function syncSessions(
549
497
  const backlog = changed.length - filesProcessed + deletedRemaining;
550
498
  db.prepare("INSERT INTO meta(key, value) VALUES ('backlog', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(backlog));
551
499
 
552
- return { filesProcessed, messagesIndexed, backlogRemaining: backlog };
500
+ return { filesProcessed, messagesIndexed, backlogRemaining: backlog, walkComplete: walk.complete };
553
501
  } finally {
554
502
  db.close();
555
503
  }
@@ -208,17 +208,20 @@ export default function (pi: ExtensionAPI): void {
208
208
  return textResult(result);
209
209
  }
210
210
 
211
- // Lazy sync: drains any backlog the capped startup pass left.
211
+ // Lazy sync: drains any backlog the capped startup pass left. A partial
212
+ // or failed sync degrades to a warning; the stale index stays usable.
213
+ let syncWarning: { kind: "incomplete-walk" } | { kind: "sync-failed"; error: string } | undefined;
212
214
  try {
213
- syncSessions(sessionsDir(), dbPath());
214
- } catch {
215
- // Serve from the possibly stale index rather than failing.
215
+ const sync = syncSessions(sessionsDir(), dbPath());
216
+ if (!sync.walkComplete) syncWarning = { kind: "incomplete-walk" };
217
+ } catch (error) {
218
+ syncWarning = { kind: "sync-failed", error: (error instanceof Error ? error.message : String(error)).slice(0, 512) };
216
219
  }
217
220
 
218
221
  // --- BROWSE ---
219
222
  if (!params.query?.trim()) {
220
223
  const rows = getSessionRows(dbPath(), clamp(params.limit, 1, 10, 3));
221
- return textResult({ mode: "browse", sessions: rows });
224
+ return textResult({ mode: "browse", sessions: rows, ...(syncWarning ? { syncWarning } : {}) });
222
225
  }
223
226
 
224
227
  // --- DISCOVERY ---
@@ -249,7 +252,17 @@ export default function (pi: ExtensionAPI): void {
249
252
  const resultQuery = params.query!.trim().slice(0, MAX_QUERY_CHARS);
250
253
  // Reserve the complete response envelope and divide remaining space
251
254
  // across hits so the first hydrated result cannot starve later metadata.
252
- let used = JSON.stringify({ mode: "discovery", query: resultQuery, results: [], backlogRemaining }).length + Math.max(0, hits.length - 1);
255
+ // The same warning-bearing envelope is reused for the final result so the
256
+ // reservation matches what is returned (and textResult's trimming keeps
257
+ // top-level non-array keys like syncWarning).
258
+ const envelope: Record<string, unknown> = {
259
+ mode: "discovery",
260
+ query: resultQuery,
261
+ results: [],
262
+ backlogRemaining,
263
+ ...(syncWarning ? { syncWarning } : {}),
264
+ };
265
+ let used = JSON.stringify(envelope).length + Math.max(0, hits.length - 1);
253
266
  const results = hits.map((hit, index) => {
254
267
  const remaining = Math.floor((OUTPUT_CHAR_BUDGET - used) / (hits.length - index));
255
268
  const meta = {
@@ -339,7 +352,7 @@ export default function (pi: ExtensionAPI): void {
339
352
  }
340
353
  });
341
354
 
342
- const result: Record<string, unknown> = { mode: "discovery", query: resultQuery, results, backlogRemaining };
355
+ const result: Record<string, unknown> = { ...envelope, results };
343
356
  return textResult(result);
344
357
  } catch (error) {
345
358
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Neutral JSONL trust boundary for Pi session transcripts: one bounded,
3
+ * descriptor-validated snapshot per read, parsed into entries whose only
4
+ * trusted fields are the validated id/parentId pair. No pi runtime imports —
5
+ * shared by the index engine (search-core) and hydration (hydrate).
6
+ */
7
+ import fs from "node:fs";
8
+
9
+ /** Hard byte ceiling per session file: larger files are skipped (and retried
10
+ * behind fresh work) instead of being read whole into memory. */
11
+ export const MAX_SESSION_FILE_BYTES = 32 * 1024 * 1024;
12
+
13
+ /** Open the path once, validate that exact descriptor (type + size), and read
14
+ * only the validated snapshot from it. A concurrent append/replacement between
15
+ * the walk's stat and this open cannot grow the allocation or the read beyond
16
+ * maxBytes: the fd pins the inode, and fstat on that fd fixes both bounds.
17
+ * Deliberately not fs.readFileSync(fd) — that re-reads to EOF unbounded.
18
+ * Shared with hydration: callers pass their own byte ceiling (both use
19
+ * MAX_SESSION_FILE_BYTES in production). */
20
+ export function readBoundedSnapshot(filePath: string, maxBytes: number): string {
21
+ // O_NONBLOCK keeps a writerless FIFO (regular .jsonl swapped mid-walk) from
22
+ // blocking this open before fstat rejects it; O_NOFOLLOW (absent on Windows)
23
+ // rejects a symlink swapped in after the walk instead of following it.
24
+ const fd = fs.openSync(
25
+ filePath,
26
+ fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0),
27
+ );
28
+ try {
29
+ const st = fs.fstatSync(fd);
30
+ if (!st.isFile()) throw new Error(`session path is not a regular file: ${filePath}`);
31
+ if (st.size > maxBytes) {
32
+ throw new Error(`session file exceeds ${Math.round(maxBytes / (1024 * 1024))} MiB snapshot limit: ${filePath}`);
33
+ }
34
+ const buf = Buffer.allocUnsafe(st.size);
35
+ let read = 0;
36
+ while (read < buf.length) {
37
+ const n = fs.readSync(fd, buf, read, buf.length - read, read);
38
+ if (n === 0) break; // truncated concurrently after fstat: index what was there
39
+ read += n;
40
+ }
41
+ return buf.toString("utf-8", 0, read);
42
+ } finally {
43
+ fs.closeSync(fd);
44
+ }
45
+ }
46
+
47
+ /** One parseable JSONL line. `data` is the raw parsed JSON value — any JSON
48
+ * type, never assumed to be an object. `id`/`parentId` exist only when both
49
+ * passed validation at this boundary; consumers may rely on them without
50
+ * re-validating. */
51
+ export interface TranscriptEntry {
52
+ data: unknown;
53
+ /** Nonempty string ≤256 chars; absent when missing or invalid. */
54
+ id?: string;
55
+ /** null or string ≤256 chars; defined only when `id` is present. */
56
+ parentId?: string | null;
57
+ }
58
+
59
+ /** Validate the id pair exactly as both previous parsers did: a valid entry
60
+ * needs a usable id AND a nullish-or-valid parentId; anything else is treated
61
+ * as id-less data (still projected for headers, never hydrated). */
62
+ function validateIds(data: unknown): { id: string; parentId: string | null } | undefined {
63
+ const rec = data !== null && typeof data === "object" ? (data as Record<string, unknown>) : undefined;
64
+ const id = rec?.id;
65
+ if (typeof id !== "string" || id.length === 0 || id.length > 256) return undefined;
66
+ const parentId = rec?.parentId;
67
+ if (parentId == null) return { id, parentId: null };
68
+ if (typeof parentId === "string" && parentId.length <= 256) return { id, parentId };
69
+ return undefined;
70
+ }
71
+
72
+ /** Lazily yield one bounded snapshot of `filePath` as JSONL entries in file
73
+ * order — no materialized entry array, and lines are located by newline
74
+ * scan rather than splitting the whole snapshot into a string array.
75
+ * Blank and malformed JSON lines are skipped. The first occurrence of
76
+ * every valid id is reserved across all entry types; later duplicates are
77
+ * dropped entirely. Entries without a valid id pair are retained so index
78
+ * header/session_info projection can still use them — hydration projects to
79
+ * entries carrying a validated `id`. The snapshot is read once when iteration
80
+ * starts (fd-pinned descriptor validation still applies); consumers iterate
81
+ * immediately via for-of. */
82
+ export function* readTranscriptEntries(
83
+ filePath: string,
84
+ maxBytes: number = MAX_SESSION_FILE_BYTES,
85
+ ): Generator<TranscriptEntry> {
86
+ const content = readBoundedSnapshot(filePath, maxBytes);
87
+ const seenIds = new Set<string>();
88
+ let start = 0;
89
+ while (start < content.length) {
90
+ const nl = content.indexOf("\n", start);
91
+ const end = nl === -1 ? content.length : nl;
92
+ const line = content.slice(start, end);
93
+ start = end + 1;
94
+ if (!line.trim()) continue;
95
+ let data: unknown;
96
+ try {
97
+ data = JSON.parse(line);
98
+ } catch {
99
+ continue; // skip malformed lines
100
+ }
101
+ const ids = validateIds(data);
102
+ if (!ids) {
103
+ yield { data };
104
+ continue;
105
+ }
106
+ // Hydration is first-wins across every entry type; reserve IDs here so
107
+ // no consumer can ever point at a different duplicate.
108
+ if (seenIds.has(ids.id)) continue;
109
+ seenIds.add(ids.id);
110
+ yield { data, id: ids.id, parentId: ids.parentId };
111
+ }
112
+ }
@@ -42,4 +42,7 @@ export interface SyncResult {
42
42
  messagesIndexed: number;
43
43
  /** Changed files still unindexed after this pass, including failures. */
44
44
  backlogRemaining: number;
45
+ /** False when the filesystem walk could not fully enumerate the tree —
46
+ * indexed-but-unseen paths were NOT purged and results may be stale. */
47
+ walkComplete: boolean;
45
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-session-recall",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "FTS5 search over past Pi sessions: single tool, four arg-inferred modes (discovery/scroll/read/browse), zero LLM calls.",
5
5
  "keywords": [
6
6
  "pi-package",