@henryqw/pi-session-recall 0.1.4 → 0.1.6
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 +8 -16
- package/extensions/hydrate.ts +30 -27
- package/extensions/search-core.ts +10 -62
- package/extensions/session-recall.ts +20 -7
- package/extensions/transcript.ts +112 -0
- package/extensions/types.ts +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,14 @@ 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
|
+
|
|
36
|
+
## State
|
|
37
|
+
|
|
38
|
+
| Path | Purpose |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `~/.pi/agent/config/pi-session-recall/index.db` | Derived SQLite search index, maintained by the extension. |
|
|
41
|
+
|
|
34
42
|
## Deliberate exclusions
|
|
35
43
|
|
|
36
44
|
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.
|
|
@@ -38,19 +46,3 @@ Session directories whose encoded path starts with `--tmp-` or `--private-tmp-`
|
|
|
38
46
|
## Storage & privacy
|
|
39
47
|
|
|
40
48
|
The SQLite index lives at `~/.pi/agent/config/pi-session-recall/index.db`. It is derived state: delete it and it rebuilds from your session files. Everything stays local — transcripts are read in place and nothing leaves the machine beyond what tool results already show the model.
|
|
41
|
-
|
|
42
|
-
## Remove
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
pi remove npm:@henryqw/pi-session-recall
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Delete `~/.pi/agent/config/pi-session-recall/` to reclaim index disk space.
|
|
49
|
-
|
|
50
|
-
## Development
|
|
51
|
-
|
|
52
|
-
```bash
|
|
53
|
-
npm test --workspace @henryqw/pi-session-recall
|
|
54
|
-
npm run typecheck --workspace @henryqw/pi-session-recall
|
|
55
|
-
npm run pack:check --workspace @henryqw/pi-session-recall
|
|
56
|
-
```
|
package/extensions/hydrate.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Pure functions over file paths; no SQLite access.
|
|
5
5
|
*/
|
|
6
6
|
/// <reference types="node" />
|
|
7
|
-
import {
|
|
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
|
-
/**
|
|
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
|
|
42
|
-
// with the index engine; the fd pins the inode so a
|
|
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
|
|
47
|
-
|
|
48
|
-
if (
|
|
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
|
|
310
|
-
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (!
|
|
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:
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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> = {
|
|
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
|
+
}
|
package/extensions/types.ts
CHANGED
|
@@ -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