@henryqw/pi-session-recall 2.0.0 → 2.1.1

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.
@@ -0,0 +1,515 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ export const MAX_MANIFEST_BYTES = 1024 * 1024;
7
+ export const MAX_PACKAGE_MANIFESTS = 512;
8
+ export const MAX_TOTAL_MANIFEST_BYTES = 16 * 1024 * 1024;
9
+ export const REPOSITORY_UNAVAILABLE_REASON = "not-a-git-repository";
10
+ export const REPOSITORY_INVENTORY_FAILED_REASON = "inventory-failed";
11
+
12
+ const ROOT_STDOUT_BYTES = 4 * 1024;
13
+ const INDEX_STDOUT_BYTES = 8 * 1024 * 1024;
14
+ const GIT_STDERR_BYTES = 4 * 1024;
15
+ const BATCH_RECORD_BYTES = 128;
16
+ const BATCH_CHECK_STDOUT_BYTES = BATCH_RECORD_BYTES * MAX_PACKAGE_MANIFESTS;
17
+ const CONTENT_BATCH_STDOUT_BYTES = MAX_TOTAL_MANIFEST_BYTES + BATCH_RECORD_BYTES * MAX_PACKAGE_MANIFESTS;
18
+ const BATCH_STDIN_BYTES = (64 + 1) * MAX_PACKAGE_MANIFESTS;
19
+ const INSTRUCTION_NAMES = new Set(["AGENTS.md", "AGENTS.override.md", "CLAUDE.md"]);
20
+ const REGULAR_MODES = new Set(["100644", "100755"]);
21
+ const INDEX_MODES = new Set(["100644", "100755", "120000", "160000"]);
22
+ const PROVENANCE = {
23
+ packageScripts: "git-index",
24
+ executableScripts: "git-index",
25
+ agentInstructions: "git-index",
26
+ skills: "pi-effective-registry",
27
+ } as const;
28
+
29
+ export type RepositoryInventoryMode = "required" | "optional";
30
+
31
+ export interface PackageScript {
32
+ path: string;
33
+ name: string;
34
+ command: string;
35
+ }
36
+
37
+ export interface RepositorySkill {
38
+ name: string;
39
+ description: string;
40
+ sourcePath: string;
41
+ }
42
+
43
+ interface RepositoryInventoryCollections {
44
+ packageScripts: PackageScript[];
45
+ executableScripts: string[];
46
+ skills: RepositorySkill[];
47
+ agentInstructions: string[];
48
+ worktreeVerified: false;
49
+ }
50
+
51
+ export type RepositoryInventory = RepositoryInventoryCollections & ({
52
+ available: true;
53
+ gitRoot: string;
54
+ provenance: typeof PROVENANCE;
55
+ reason?: never;
56
+ } | {
57
+ available: false;
58
+ reason: typeof REPOSITORY_UNAVAILABLE_REASON | typeof REPOSITORY_INVENTORY_FAILED_REASON;
59
+ gitRoot?: never;
60
+ provenance?: never;
61
+ });
62
+
63
+ type InventoryPi = Pick<ExtensionAPI, "getCommands">;
64
+ type InventoryContext = Pick<ExtensionContext, "cwd" | "signal">;
65
+
66
+ interface GitResult {
67
+ stdout: Buffer;
68
+ code: number;
69
+ }
70
+
71
+ interface IndexEntry {
72
+ mode: string;
73
+ oid: string;
74
+ path: string;
75
+ }
76
+
77
+ function compare(left: string, right: string): number {
78
+ return left < right ? -1 : left > right ? 1 : 0;
79
+ }
80
+
81
+ function toPosixPath(value: string): string {
82
+ return value.split(path.sep).join("/");
83
+ }
84
+
85
+ function isWithin(root: string, target: string, allowRoot = true): boolean {
86
+ const relative = path.relative(root, target);
87
+ return (allowRoot && relative === "") ||
88
+ (relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
89
+ }
90
+
91
+ function boundedPath(value: string): string {
92
+ const bounded = value.length <= 240 ? value : `${value.slice(0, 239)}…`;
93
+ return JSON.stringify(bounded);
94
+ }
95
+
96
+ function malformedManifest(relativePath: string): Error {
97
+ return new Error(`Malformed repository manifest: ${boundedPath(relativePath)}`);
98
+ }
99
+
100
+ function unsupportedManifestMode(relativePath: string): Error {
101
+ return new Error(`Unsupported repository manifest mode: ${boundedPath(relativePath)}`);
102
+ }
103
+
104
+ function oversizedManifest(relativePath: string): Error {
105
+ return new Error(`Oversized repository manifest (1 MiB limit): ${boundedPath(relativePath)}`);
106
+ }
107
+
108
+ function throwIfAborted(signal: AbortSignal | undefined): void {
109
+ if (signal?.aborted) throw new Error("Repository inventory cancelled.");
110
+ }
111
+
112
+ /** Spawn one bounded process and never expose child output through failures. */
113
+ function runGit(
114
+ cwd: string,
115
+ args: string[],
116
+ signal: AbortSignal | undefined,
117
+ stdoutCap: number,
118
+ options: { allowNonzero?: boolean; allowStderrOnNonzero?: boolean; stdin?: Buffer } = {},
119
+ ): Promise<GitResult> {
120
+ throwIfAborted(signal);
121
+ if (options.stdin !== undefined && options.stdin.length > BATCH_STDIN_BYTES) {
122
+ throw new Error("Repository inventory Git stdin exceeded its limit.");
123
+ }
124
+ return new Promise((resolve, reject) => {
125
+ let child: ReturnType<typeof spawn>;
126
+ try {
127
+ child = spawn("git", args, {
128
+ cwd,
129
+ env: { ...process.env, GIT_NO_LAZY_FETCH: "1" },
130
+ shell: false,
131
+ stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
132
+ });
133
+ } catch {
134
+ reject(new Error("Repository inventory could not start Git."));
135
+ return;
136
+ }
137
+
138
+ const childStdout = child.stdout;
139
+ const childStderr = child.stderr;
140
+ const childStdin = child.stdin;
141
+ if (!childStdout || !childStderr || (options.stdin !== undefined && !childStdin)) {
142
+ child.kill("SIGKILL");
143
+ reject(new Error("Repository inventory could not start Git."));
144
+ return;
145
+ }
146
+ const stdout: Buffer[] = [];
147
+ let stdoutBytes = 0;
148
+ let stderrBytes = 0;
149
+ let failure: Error | undefined;
150
+ let settled = false;
151
+
152
+ const fail = (error: Error) => {
153
+ if (failure) return;
154
+ failure = error;
155
+ child.kill("SIGKILL");
156
+ };
157
+ const finish = (error?: Error, result?: GitResult) => {
158
+ if (settled) return;
159
+ settled = true;
160
+ signal?.removeEventListener("abort", onAbort);
161
+ if (error) reject(error);
162
+ else resolve(result!);
163
+ };
164
+ const onAbort = () => fail(new Error("Repository inventory cancelled."));
165
+
166
+ childStdout.on("data", (chunk: Buffer) => {
167
+ stdoutBytes += chunk.length;
168
+ if (stdoutBytes > stdoutCap) {
169
+ fail(new Error("Repository inventory Git stdout exceeded its limit."));
170
+ return;
171
+ }
172
+ stdout.push(chunk);
173
+ });
174
+ childStderr.on("data", (chunk: Buffer) => {
175
+ stderrBytes += chunk.length;
176
+ if (stderrBytes > GIT_STDERR_BYTES) {
177
+ fail(new Error("Repository inventory Git stderr exceeded its limit."));
178
+ } else if (!options.allowStderrOnNonzero) {
179
+ fail(new Error("Repository inventory Git wrote unexpected stderr."));
180
+ }
181
+ });
182
+ childStdin?.once("error", () => fail(new Error("Repository inventory could not write Git input.")));
183
+ child.once("error", () => finish(new Error("Repository inventory could not start Git.")));
184
+ child.once("close", (code) => {
185
+ if (failure) {
186
+ finish(failure);
187
+ return;
188
+ }
189
+ if (signal?.aborted) {
190
+ finish(new Error("Repository inventory cancelled."));
191
+ return;
192
+ }
193
+ if (code === null || (code !== 0 && !options.allowNonzero)) {
194
+ finish(new Error("Repository inventory Git command failed."));
195
+ return;
196
+ }
197
+ if (stderrBytes > 0 && !(code !== 0 && options.allowStderrOnNonzero)) {
198
+ finish(new Error("Repository inventory Git wrote unexpected stderr."));
199
+ return;
200
+ }
201
+ finish(undefined, { stdout: Buffer.concat(stdout, stdoutBytes), code });
202
+ });
203
+ signal?.addEventListener("abort", onAbort, { once: true });
204
+ if (signal?.aborted) onAbort();
205
+ if (options.stdin !== undefined && !failure) {
206
+ try {
207
+ childStdin!.end(options.stdin);
208
+ } catch {
209
+ fail(new Error("Repository inventory could not write Git input."));
210
+ }
211
+ }
212
+ });
213
+ }
214
+
215
+ function decodeUtf8(value: Buffer, error: () => Error): string {
216
+ let decoded: string;
217
+ try {
218
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(value);
219
+ } catch {
220
+ throw error();
221
+ }
222
+ if (decoded.includes("�")) throw error();
223
+ return decoded;
224
+ }
225
+
226
+ function decodeAscii(value: Buffer, error: () => Error): string {
227
+ if (value.some((byte) => byte > 0x7f)) throw error();
228
+ return value.toString("ascii");
229
+ }
230
+
231
+ function canonicalDirectory(value: string): string {
232
+ try {
233
+ const canonical = fs.realpathSync(value);
234
+ if (!fs.statSync(canonical).isDirectory()) throw new Error("not a directory");
235
+ return canonical;
236
+ } catch {
237
+ throw new Error("Repository inventory could not resolve the Git root.");
238
+ }
239
+ }
240
+
241
+ function gitRootFromOutput(stdout: Buffer): string {
242
+ const invalid = () => new Error("Repository inventory could not resolve the Git root.");
243
+ const decoded = decodeUtf8(stdout, invalid);
244
+ const root = decoded.endsWith("\r\n") ? decoded.slice(0, -2) : decoded.endsWith("\n") ? decoded.slice(0, -1) : decoded;
245
+ if (!root || root.includes("\r") || root.includes("\n") || (!path.isAbsolute(root) && !path.win32.isAbsolute(root))) {
246
+ throw invalid();
247
+ }
248
+ return canonicalDirectory(root);
249
+ }
250
+
251
+ async function resolveGitRoot(ctx: InventoryContext): Promise<string | undefined> {
252
+ const cwd = canonicalDirectory(ctx.cwd);
253
+ const result = await runGit(cwd, ["rev-parse", "--show-toplevel"], ctx.signal, ROOT_STDOUT_BYTES, {
254
+ allowNonzero: true,
255
+ allowStderrOnNonzero: true,
256
+ });
257
+ return result.code === 0 ? gitRootFromOutput(result.stdout) : undefined;
258
+ }
259
+
260
+ function looksLikePackageJson(value: string): boolean {
261
+ return value === "package.json" || value.endsWith("/package.json");
262
+ }
263
+
264
+ function normalizeRepositoryPath(value: string): string {
265
+ const segments = value.split("/");
266
+ if (
267
+ !value ||
268
+ value.includes("�") ||
269
+ value.includes("\\") ||
270
+ path.isAbsolute(value) ||
271
+ path.win32.isAbsolute(value) ||
272
+ segments.some((segment) => !segment || segment === "." || segment === "..")
273
+ ) {
274
+ throw new Error("Repository inventory received an invalid repository path.");
275
+ }
276
+ return value;
277
+ }
278
+
279
+ function parseIndex(stdout: Buffer): IndexEntry[] {
280
+ if (stdout.length === 0) return [];
281
+ if (stdout.at(-1) !== 0) throw new Error("Repository inventory received an invalid Git index listing.");
282
+ const entries: IndexEntry[] = [];
283
+ const paths = new Set<string>();
284
+ let start = 0;
285
+ while (start < stdout.length) {
286
+ const end = stdout.indexOf(0, start);
287
+ if (end < 0) throw new Error("Repository inventory received an invalid Git index listing.");
288
+ const record = stdout.subarray(start, end);
289
+ start = end + 1;
290
+ const tab = record.indexOf(0x09);
291
+ if (tab < 0) throw new Error("Repository inventory received an invalid Git index listing.");
292
+ const invalid = () => new Error("Repository inventory received an invalid Git index listing.");
293
+ const metadata = decodeAscii(record.subarray(0, tab), invalid);
294
+ const match = /^([0-7]{6}) ([0-9a-fA-F]{40}|[0-9a-fA-F]{64}) ([0-3])$/.exec(metadata);
295
+ if (!match) throw invalid();
296
+ const relativePath = normalizeRepositoryPath(decodeUtf8(record.subarray(tab + 1), invalid));
297
+ const [, mode, oid, stage] = match;
298
+ if (stage !== "0") throw new Error("Repository inventory does not accept conflicted Git index entries.");
299
+ if (paths.has(relativePath)) throw new Error("Repository inventory received duplicate Git index entries.");
300
+ paths.add(relativePath);
301
+ if (looksLikePackageJson(relativePath) && !REGULAR_MODES.has(mode)) throw unsupportedManifestMode(relativePath);
302
+ if (!INDEX_MODES.has(mode)) throw invalid();
303
+ entries.push({ mode, oid, path: relativePath });
304
+ }
305
+ return entries.sort((left, right) => compare(left.path, right.path));
306
+ }
307
+
308
+ function isRecord(value: unknown): value is Record<string, unknown> {
309
+ return value !== null && typeof value === "object" && !Array.isArray(value);
310
+ }
311
+
312
+ function scriptsFromManifest(source: string, relativePath: string): PackageScript[] {
313
+ let manifest: unknown;
314
+ try {
315
+ manifest = JSON.parse(source);
316
+ } catch {
317
+ throw malformedManifest(relativePath);
318
+ }
319
+ if (!isRecord(manifest)) throw malformedManifest(relativePath);
320
+ const scripts = manifest.scripts;
321
+ if (scripts === undefined) return [];
322
+ if (!isRecord(scripts)) throw malformedManifest(relativePath);
323
+ const result: PackageScript[] = [];
324
+ for (const [name, command] of Object.entries(scripts)) {
325
+ if (typeof command !== "string") throw malformedManifest(relativePath);
326
+ result.push({ path: relativePath, name, command });
327
+ }
328
+ return result.sort((left, right) => compare(left.name, right.name));
329
+ }
330
+
331
+ interface SizedIndexEntry extends IndexEntry {
332
+ size: number;
333
+ }
334
+
335
+ function invalidBatchOutput(): Error {
336
+ return new Error("Repository inventory received invalid Git batch output.");
337
+ }
338
+
339
+ function missingIndexedObject(relativePath: string): Error {
340
+ return new Error(`Repository inventory is missing an indexed Git object: ${boundedPath(relativePath)}`);
341
+ }
342
+
343
+ function decimalSize(value: string): number {
344
+ if (!/^(0|[1-9][0-9]*)$/.test(value)) throw invalidBatchOutput();
345
+ const size = BigInt(value);
346
+ if (size > BigInt(Number.MAX_SAFE_INTEGER)) throw invalidBatchOutput();
347
+ return Number(size);
348
+ }
349
+
350
+ function parseBatchCheck(stdout: Buffer, packages: IndexEntry[]): SizedIndexEntry[] {
351
+ if (stdout.at(-1) !== 0x0a) throw invalidBatchOutput();
352
+ const records = decodeAscii(stdout, invalidBatchOutput).slice(0, -1).split("\n");
353
+ if (records.length !== packages.length) throw invalidBatchOutput();
354
+
355
+ const sized: SizedIndexEntry[] = [];
356
+ let totalBytes = 0;
357
+ for (let index = 0; index < packages.length; index++) {
358
+ const entry = packages[index]!;
359
+ const record = records[index]!;
360
+ if (record === `${entry.oid} missing`) throw missingIndexedObject(entry.path);
361
+ const match = /^([0-9a-fA-F]{40}|[0-9a-fA-F]{64}) ([a-z]+) (0|[1-9][0-9]*)$/.exec(record);
362
+ if (!match) throw invalidBatchOutput();
363
+ const [, oid, type, rawSize] = match;
364
+ if (oid !== entry.oid) throw new Error("Repository inventory received Git objects out of order.");
365
+ if (type !== "blob") throw new Error("Repository inventory expected an indexed Git blob.");
366
+ const size = decimalSize(rawSize);
367
+ if (size > MAX_MANIFEST_BYTES) throw oversizedManifest(entry.path);
368
+ if (totalBytes > MAX_TOTAL_MANIFEST_BYTES - size) {
369
+ throw new Error("Repository inventory exceeds the 16 MiB total manifest limit.");
370
+ }
371
+ totalBytes += size;
372
+ sized.push({ ...entry, size });
373
+ }
374
+ return sized;
375
+ }
376
+
377
+ function parseContentBatch(stdout: Buffer, packages: SizedIndexEntry[]): PackageScript[] {
378
+ const scripts: PackageScript[] = [];
379
+ let offset = 0;
380
+ for (const entry of packages) {
381
+ const headerEnd = stdout.indexOf(0x0a, offset);
382
+ if (headerEnd < 0) throw invalidBatchOutput();
383
+ const header = decodeAscii(stdout.subarray(offset, headerEnd), invalidBatchOutput);
384
+ if (header === `${entry.oid} missing`) throw missingIndexedObject(entry.path);
385
+ const match = /^([0-9a-fA-F]{40}|[0-9a-fA-F]{64}) ([a-z]+) (0|[1-9][0-9]*)$/.exec(header);
386
+ if (!match) throw invalidBatchOutput();
387
+ const [, oid, type, rawSize] = match;
388
+ if (oid !== entry.oid) throw new Error("Repository inventory received Git objects out of order.");
389
+ if (type !== "blob") throw new Error("Repository inventory expected an indexed Git blob.");
390
+ const size = decimalSize(rawSize);
391
+ if (size !== entry.size) throw new Error("Repository inventory received an incorrect Git blob size.");
392
+
393
+ const contentStart = headerEnd + 1;
394
+ const contentEnd = contentStart + size;
395
+ if (contentEnd >= stdout.length || stdout[contentEnd] !== 0x0a) throw invalidBatchOutput();
396
+ const invalid = () => malformedManifest(entry.path);
397
+ scripts.push(...scriptsFromManifest(decodeUtf8(stdout.subarray(contentStart, contentEnd), invalid), entry.path));
398
+ offset = contentEnd + 1;
399
+ }
400
+ if (offset !== stdout.length) throw invalidBatchOutput();
401
+ return scripts;
402
+ }
403
+
404
+ async function packageScriptsFromIndex(
405
+ root: string,
406
+ entries: IndexEntry[],
407
+ signal: AbortSignal | undefined,
408
+ ): Promise<PackageScript[]> {
409
+ const packages = entries.filter((entry) => looksLikePackageJson(entry.path));
410
+ if (packages.length > MAX_PACKAGE_MANIFESTS) {
411
+ throw new Error("Repository inventory exceeds the 512 package manifest limit.");
412
+ }
413
+ if (packages.length === 0) return [];
414
+
415
+ const stdin = Buffer.from(packages.map((entry) => `${entry.oid}\n`).join(""), "ascii");
416
+ const checked = await runGit(
417
+ root,
418
+ ["--no-replace-objects", "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"],
419
+ signal,
420
+ BATCH_CHECK_STDOUT_BYTES,
421
+ { stdin },
422
+ );
423
+ const sized = parseBatchCheck(checked.stdout, packages);
424
+ const content = await runGit(
425
+ root,
426
+ ["--no-replace-objects", "cat-file", "--batch"],
427
+ signal,
428
+ CONTENT_BATCH_STDOUT_BYTES,
429
+ { stdin },
430
+ );
431
+ return parseContentBatch(content.stdout, sized);
432
+ }
433
+
434
+ function effectiveSkills(pi: InventoryPi, root: string): RepositorySkill[] {
435
+ let commands: ReturnType<InventoryPi["getCommands"]>;
436
+ try {
437
+ commands = pi.getCommands();
438
+ } catch {
439
+ throw new Error("Repository inventory could not read effective skills.");
440
+ }
441
+ const skills: RepositorySkill[] = [];
442
+ for (const command of commands) {
443
+ if (command.source !== "skill" || typeof command.name !== "string" || typeof command.sourceInfo?.path !== "string") continue;
444
+ let sourcePath: string;
445
+ try {
446
+ sourcePath = fs.realpathSync(command.sourceInfo.path);
447
+ } catch {
448
+ continue;
449
+ }
450
+ if (!isWithin(root, sourcePath, false)) continue;
451
+ skills.push({
452
+ name: command.name,
453
+ description: typeof command.description === "string" ? command.description : "",
454
+ sourcePath: toPosixPath(path.relative(root, sourcePath)),
455
+ });
456
+ }
457
+ return skills.sort((left, right) =>
458
+ compare(left.name, right.name) ||
459
+ compare(left.sourcePath, right.sourcePath) ||
460
+ compare(left.description, right.description),
461
+ );
462
+ }
463
+
464
+ function unavailableInventory(
465
+ reason: typeof REPOSITORY_UNAVAILABLE_REASON | typeof REPOSITORY_INVENTORY_FAILED_REASON,
466
+ ): RepositoryInventory {
467
+ return {
468
+ available: false,
469
+ reason,
470
+ packageScripts: [],
471
+ executableScripts: [],
472
+ skills: [],
473
+ agentInstructions: [],
474
+ worktreeVerified: false,
475
+ };
476
+ }
477
+
478
+ /** Return bounded discovery hints from the Git index and Pi's effective skill registry. */
479
+ export async function inventoryRepository(
480
+ pi: InventoryPi,
481
+ ctx: InventoryContext,
482
+ mode: RepositoryInventoryMode = "required",
483
+ ): Promise<RepositoryInventory> {
484
+ if (mode !== "required" && mode !== "optional") throw new Error("Invalid repository inventory mode.");
485
+ throwIfAborted(ctx.signal);
486
+ const gitRoot = await resolveGitRoot(ctx);
487
+ throwIfAborted(ctx.signal);
488
+ if (!gitRoot) {
489
+ if (mode === "optional") return unavailableInventory(REPOSITORY_UNAVAILABLE_REASON);
490
+ throw new Error("Repository inventory requires a Git repository.");
491
+ }
492
+
493
+ try {
494
+ const index = parseIndex((await runGit(gitRoot, ["--no-replace-objects", "ls-files", "--stage", "-z"], ctx.signal, INDEX_STDOUT_BYTES)).stdout);
495
+ const packageScripts = await packageScriptsFromIndex(gitRoot, index, ctx.signal);
496
+ const skills = effectiveSkills(pi, gitRoot);
497
+ throwIfAborted(ctx.signal);
498
+ return {
499
+ available: true,
500
+ gitRoot,
501
+ packageScripts,
502
+ executableScripts: index.filter((entry) => entry.mode === "100755").map((entry) => entry.path),
503
+ skills,
504
+ agentInstructions: index
505
+ .filter((entry) => REGULAR_MODES.has(entry.mode) && INSTRUCTION_NAMES.has(path.posix.basename(entry.path)))
506
+ .map((entry) => entry.path),
507
+ provenance: PROVENANCE,
508
+ worktreeVerified: false,
509
+ };
510
+ } catch (error) {
511
+ throwIfAborted(ctx.signal);
512
+ if (mode === "optional") return unavailableInventory(REPOSITORY_INVENTORY_FAILED_REASON);
513
+ throw error;
514
+ }
515
+ }
@@ -9,7 +9,7 @@ import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { buildFtsQueryPlan, buildLikeQueryPlan, foldCase, nearLike } from "./query.ts";
11
11
  import { MAX_SESSION_FILE_BYTES, readTranscriptEntries } from "./transcript.ts";
12
- import type { SearchHit, SessionRow, SyncResult } from "./types.ts";
12
+ import type { PreparationSessionRow, SearchHit, SessionRow, SyncResult } from "./types.ts";
13
13
  export const DEFAULT_SYNC_CAP = 50;
14
14
  /** Hard ceiling for the internal/test `opts.cap` work bound of syncSessions. */
15
15
  const MAX_SYNC_CAP = DEFAULT_SYNC_CAP * 10;
@@ -692,7 +692,76 @@ export function searchIndex(
692
692
  }
693
693
  }
694
694
 
695
- // --- Browse ---
695
+ // --- Preparation / browse ---
696
+
697
+ export interface PreparationRowOptions {
698
+ limit: number;
699
+ /** Canonical repository root. Omit to sample all indexed sessions. */
700
+ repositoryRoot?: string;
701
+ currentSessionPath?: string;
702
+ }
703
+
704
+ /** Select deterministic, repository-scoped pattern-miner candidates from the
705
+ * existing index. Scope, current-session exclusion, and one-hop lineage
706
+ * collapse all happen before the sample limit. */
707
+ export function getPreparationRows(
708
+ dbPath: string,
709
+ opts: PreparationRowOptions,
710
+ ): PreparationSessionRow[] {
711
+ const clauses: string[] = [];
712
+ const params: string[] = [];
713
+ if (opts.repositoryRoot !== undefined) {
714
+ const prefix = opts.repositoryRoot.endsWith(path.sep)
715
+ ? opts.repositoryRoot
716
+ : opts.repositoryRoot + path.sep;
717
+ clauses.push("(s.cwd = ? OR substr(s.cwd, 1, length(?)) = ?)");
718
+ params.push(opts.repositoryRoot, prefix, prefix);
719
+ }
720
+ if (opts.currentSessionPath !== undefined) {
721
+ clauses.push("s.path <> ?");
722
+ params.push(opts.currentSessionPath);
723
+ }
724
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
725
+ const db = openDb(dbPath);
726
+ try {
727
+ const rows = db.prepare(`
728
+ WITH eligible AS (
729
+ SELECT s.path, s.cwd, s.name, s.started_at, s.preview,
730
+ CASE
731
+ WHEN s.parent_session IS NULL THEN s.path
732
+ WHEN parent.path <> s.path AND parent.parent_session = s.path
733
+ THEN min(s.path, parent.path)
734
+ ELSE s.parent_session
735
+ END AS lineage_id
736
+ FROM sessions s
737
+ LEFT JOIN sessions parent ON parent.path = s.parent_session
738
+ ${where}
739
+ ), ranked AS (
740
+ SELECT *, ROW_NUMBER() OVER (
741
+ PARTITION BY lineage_id
742
+ ORDER BY CASE WHEN path = lineage_id THEN 0 ELSE 1 END,
743
+ started_at DESC, path
744
+ ) AS rn
745
+ FROM eligible
746
+ )
747
+ SELECT path, cwd, name, started_at, preview, lineage_id
748
+ FROM ranked
749
+ WHERE rn = 1
750
+ ORDER BY started_at DESC, path
751
+ LIMIT ?
752
+ `).all(...params, opts.limit) as any[];
753
+ return rows.map((r) => ({
754
+ path: r.path,
755
+ cwd: r.cwd ?? "",
756
+ name: r.name ?? undefined,
757
+ startedAt: r.started_at ?? undefined,
758
+ preview: r.preview ?? undefined,
759
+ lineageId: r.lineage_id,
760
+ }));
761
+ } finally {
762
+ db.close();
763
+ }
764
+ }
696
765
 
697
766
  export function getSessionRows(dbPath: string, limit: number): SessionRow[] {
698
767
  const db = openDb(dbPath);