@henryqw/pi-session-recall 2.0.0 → 2.1.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.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Find decisions and context in past Pi sessions through a local FTS5 index.
4
4
 
5
5
  Saved transcripts are not injected on every turn. The active tool registration still adds standing prompt cost through its schema, descriptions, and guideline. Returned content enters active model context.
6
6
 
7
- The bundled `pi-session-pattern-miner` skill finds repeated work that may deserve automation.
7
+ The bundled `pi-session-pattern-miner` skill prepares one bounded sample. The model then finds repeated work that may deserve automation.
8
8
 
9
9
  ## Install
10
10
 
@@ -22,7 +22,19 @@ Start discovery with a distinctive query:
22
22
 
23
23
  `session_search` returns ranked sessions. The top result includes nearby messages and session bookends.
24
24
 
25
- Use IDs from that result to ask for more context:
25
+ Prepare a repository-scoped pattern-mining sample with one call:
26
+
27
+ ```json
28
+ {
29
+ "operation": "prepare-pattern-miner",
30
+ "scope": "repository",
31
+ "limit": 10
32
+ }
33
+ ```
34
+
35
+ Use `scope:"all"` for cross-repository work. It still returns the corpus when repository inventory is unavailable.
36
+
37
+ Use IDs from a discovery result to ask for more context:
26
38
 
27
39
  ```json
28
40
  {
@@ -36,13 +48,14 @@ The follow-up returns up to ten messages before and after that anchor on the sel
36
48
 
37
49
  | Surface | Type | Purpose |
38
50
  | --- | --- | --- |
39
- | `session_search` | tool | Search past sessions or inspect one. |
51
+ | `session_search` | tool | Search, inspect, or prepare a bounded mining corpus from past sessions. |
40
52
  | `pi-session-pattern-miner` | skill | Find repeated work and choose the smallest useful automation. |
41
53
 
42
54
  BM25 is a text-ranking method. Hydrated results include messages read from saved session files.
43
55
 
44
56
  | Mode | Call | Result |
45
57
  | --- | --- | --- |
58
+ | Pattern preparation | `operation:"prepare-pattern-miner"` + `scope:"repository"` or `scope:"all"` | Up to ten recent lineage-unique sessions plus repository inventory. The default limit is 10. Repository scope includes exact and descendant `cwd` values, filters before the limit, and excludes the current session file. |
46
59
  | Discovery | `query` | BM25-ranked top sessions. Adaptive retrieval uses user and assistant text for windows, bookends, anchors, and counts. It omits tool-result messages and sets `toolResultsOmitted:true` when it removes one. Lower hits still include their indexed anchor. Use `detail:"full"` to hydrate every hit with tool-result messages included. |
47
60
  | Scroll | `sessionId` + `aroundMessageId` | Raw message roles, including tool results, within ±`window` ([1,20]) of the anchor. Re-anchor on the last or first message ID to scroll. Across forks, pass the previous response's `branchTip`; `aroundMessageId` only centers the window and must lie on that branch. |
48
61
  | Read | `sessionId` | Raw message roles, including tool results, from the session. Large sessions return head 20 + tail 10. Oversized content is bounded to 50k characters and marked with `contentTruncated`. |
@@ -52,7 +65,36 @@ In the interactive TUI, the collapsed tool block shows the last five visual line
52
65
 
53
66
  ### Skills
54
67
 
55
- Run `/skill:pi-session-pattern-miner` to find repeated workflows in past sessions. It requires evidence from two independent sessions and checks for existing automation. It prefers a fixed script when model judgment is not needed.
68
+ Run `/skill:pi-session-pattern-miner` to find repeated workflows in past sessions. The skill makes one preparation call before interpretation.
69
+
70
+ It treats one lineage as one source. It requires two independent examples before recommending automation. A requested topic gets a focused confirmation search even when the prepared sample does not contain it.
71
+
72
+ After clustering, the skill always checks current candidate-relevant package manifests, scripts, skills, and instructions. It abstains if it cannot check them safely.
73
+
74
+ ### Repository inventory
75
+
76
+ Repository inventory contains discovery hints. It is never proof that a file owns a workflow. It does not verify the current worktree.
77
+
78
+ Package scripts, executable paths, and instruction paths come from stage-0 entries in the Git index. Package content comes from indexed blobs. Git reads the objects locally in one check batch and one content batch. Lazy object fetching and replacement refs are disabled. Inventory never opens working-tree package paths.
79
+
80
+ Executables need index mode `100755`. Instructions need a recognized name and a regular-file index mode.
81
+
82
+ Skills come from Pi's effective command registry. Their canonical source paths must stay inside the repository.
83
+
84
+ Available inventory includes this provenance:
85
+
86
+ ```json
87
+ {
88
+ "packageScripts": "git-index",
89
+ "executableScripts": "git-index",
90
+ "agentInstructions": "git-index",
91
+ "skills": "pi-effective-registry"
92
+ }
93
+ ```
94
+
95
+ It also sets `worktreeVerified:false`. Staged adds, changes, and deletes affect the snapshot. Unstaged changes, deletions, mode changes, symlinks, and untracked files do not.
96
+
97
+ Inspect the current candidate files before assigning ownership. Abstain if targeted current-file checks cannot be done safely.
56
98
 
57
99
  ## Flow
58
100
 
@@ -70,12 +112,16 @@ Search makes no model calls.
70
112
 
71
113
  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.
72
114
 
73
- Before browse or discovery, the extension lazily syncs the index from the session tree.
115
+ Before browse, discovery, or pattern preparation, the extension lazily syncs the index from the session tree.
116
+
117
+ Pattern preparation reports `sync.walkComplete`, `sync.backlogRemaining`, and `sync.complete`. `sync.complete` is true only after a complete walk with no backlog.
74
118
 
75
119
  ### Retrieval safety
76
120
 
77
121
  Adaptive discovery leaves tool-result messages out of returned context. Use `detail:"full"`, READ, or SCROLL when you explicitly need them.
78
122
 
123
+ Pattern preparation returns only non-empty user and assistant text. It never returns thinking blocks or tool-result content. Each session keeps citation and lineage metadata even when its hydration fails.
124
+
79
125
  Historical tool output may contain secrets or other sensitive data. Raw retrieval places that output in active model context.
80
126
 
81
127
  ## State and storage
@@ -91,7 +137,7 @@ The index and transcript reads stay local. Transcripts are read in place. Return
91
137
  Pin the previous release:
92
138
 
93
139
  ```bash
94
- pi install npm:@henryqw/pi-session-recall@1.0.3
140
+ pi install npm:@henryqw/pi-session-recall@2.0.0
95
141
  ```
96
142
 
97
143
  No index migration or cleanup is needed.
@@ -109,3 +155,19 @@ Session directories whose encoded path starts with `--tmp-` or `--private-tmp-`
109
155
  Session files over 32 MiB are excluded from indexing and hydration. Discovery cannot newly find them.
110
156
 
111
157
  READ and SCROLL return an explicit size error. A stale discovery hit from before a file grew returns metadata with empty messages and that error.
158
+
159
+ Pattern preparation runs one sync pass. A positive backlog or incomplete walk limits the sample and sets `sync.complete:false`. A total sync or required repository-inventory failure returns an explicit tool error.
160
+
161
+ Repository scope fails outside Git or when repository inventory fails. All scope still returns its corpus in both cases.
162
+
163
+ Outside Git, all scope sets `inventory.available:false` with `reason:"not-a-git-repository"`. On a repository inventory error, it uses `reason:"inventory-failed"`. Cancellation always aborts the call instead of returning unavailable inventory.
164
+
165
+ Preparation rejects `query`, session cursors, `window`, or `detail` in the same call. It also rejects `scope` without the operation.
166
+
167
+ Preparation output stays within 50,000 serialized characters. Inventory uses at most 10,000 characters. Its `omittedCounts` report only omitted collection entries, and `inventory.truncated` reports those omissions.
168
+
169
+ Inventory fails if the Git root output exceeds 4 KiB or the raw index listing exceeds 8 MiB. It also fails on malformed index data, invalid UTF-8, conflict entries, unsupported package modes, Git errors, unexpected Git stderr, or bounded stream overflow.
170
+
171
+ Inventory accepts at most 512 package manifests. Each indexed manifest can be at most 1 MiB, and their declared sizes can total at most 16 MiB. One bounded batch checks all blob sizes before one bounded batch reads their content. Blob order, type, size, UTF-8, and exact output framing are checked before manifest data is parsed.
172
+
173
+ Session and top-level `contentTruncated` report transcript budget trimming only. Session `truncated` reports omitted middle messages.
@@ -25,6 +25,13 @@ export interface ReadResult {
25
25
  messages: WindowMessage[];
26
26
  totalMessages: number;
27
27
  truncated: boolean;
28
+ /** Resolved leaf branch, or null when no selected message can be hydrated. */
29
+ branchTip: string | null;
30
+ }
31
+
32
+ export interface ReadOptions {
33
+ /** Preparation view: retain only non-empty user/assistant text. */
34
+ userAssistantTextOnly?: boolean;
28
35
  }
29
36
 
30
37
  interface Entry {
@@ -249,18 +256,24 @@ export function readSession(
249
256
  sessionPath: string,
250
257
  head = 20,
251
258
  tail = 10,
259
+ opts?: ReadOptions,
252
260
  ): ReadResult {
253
261
  const entries = parseSessionEntries(sessionPath);
254
262
  const entriesById = new Map(entries.map((e) => [e.id, e]));
255
263
  const leaf = leafId(entriesById, entries);
256
- if (!leaf) return { messages: [], totalMessages: 0, truncated: false };
257
- const msgs = branchMessages(entriesById, leaf);
264
+ if (!leaf) return { messages: [], totalMessages: 0, truncated: false, branchTip: null };
265
+ const rawMessages = branchMessages(entriesById, leaf);
266
+ const msgs = opts?.userAssistantTextOnly
267
+ ? rawMessages.filter((m) => (m.role === "user" || m.role === "assistant") && m.content.trim().length > 0)
268
+ : rawMessages;
269
+ const branchTip = msgs.length > 0 ? leaf : null;
258
270
  if (msgs.length > head + tail) {
259
271
  return {
260
272
  messages: [...msgs.slice(0, head), ...msgs.slice(-tail)],
261
273
  totalMessages: msgs.length,
262
274
  truncated: true,
275
+ branchTip,
263
276
  };
264
277
  }
265
- return { messages: msgs, totalMessages: msgs.length, truncated: false };
278
+ return { messages: msgs, totalMessages: msgs.length, truncated: false, branchTip };
266
279
  }
@@ -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);
@@ -9,15 +9,18 @@ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
9
9
  import { Type } from "typebox";
10
10
  import { realpathSync } from "node:fs";
11
11
  import { join, sep } from "node:path";
12
- import { getSessionRows, searchIndex, syncSessions } from "./search-core.ts";
12
+ import { getPreparationRows, getSessionRows, searchIndex, syncSessions } from "./search-core.ts";
13
13
  import { getWindow, readSession } from "./hydrate.ts";
14
14
  import { MAX_QUERY_CHARS } from "./query.ts";
15
- import type { WindowMessage } from "./types.ts";
15
+ import { inventoryRepository, type RepositoryInventory } from "./repository-inventory.ts";
16
+ import type { PreparationSessionRow, WindowMessage } from "./types.ts";
16
17
 
17
18
  const dbPath = () => join(extensionConfigDir("pi-session-recall"), "index.db");
18
19
  const sessionsDir = () => join(getAgentDir(), "sessions");
19
20
 
20
21
  const OUTPUT_CHAR_BUDGET = 50_000;
22
+ const INVENTORY_CHAR_BUDGET = 10_000;
23
+ const ERROR_MESSAGE_CHARS = 512;
21
24
 
22
25
  function clamp(n: number | undefined, min: number, max: number, dflt: number): number {
23
26
  if (typeof n !== "number" || !Number.isFinite(n)) return dflt;
@@ -78,7 +81,185 @@ function boundContent(
78
81
  return cap === null ? build(null) : { ...build(cap), contentTruncated: true };
79
82
  }
80
83
 
84
+ type InventoryCollection = "packageScripts" | "executableScripts" | "skills" | "agentInstructions";
85
+ const INVENTORY_COLLECTIONS: InventoryCollection[] = ["packageScripts", "executableScripts", "skills", "agentInstructions"];
86
+
87
+ function inventoryShape(
88
+ source: RepositoryInventory,
89
+ kept: Record<InventoryCollection, unknown[]>,
90
+ ): Record<string, unknown> {
91
+ const omittedCounts = {
92
+ packageScripts: source.packageScripts.length - kept.packageScripts.length,
93
+ executableScripts: source.executableScripts.length - kept.executableScripts.length,
94
+ skills: source.skills.length - kept.skills.length,
95
+ agentInstructions: source.agentInstructions.length - kept.agentInstructions.length,
96
+ };
97
+ return {
98
+ available: source.available,
99
+ ...(source.reason ? { reason: source.reason } : {}),
100
+ ...(source.provenance ? { provenance: source.provenance } : {}),
101
+ worktreeVerified: source.worktreeVerified,
102
+ packageScripts: kept.packageScripts,
103
+ executableScripts: kept.executableScripts,
104
+ skills: kept.skills,
105
+ agentInstructions: kept.agentInstructions,
106
+ truncated: Object.values(omittedCounts).some((count) => count > 0),
107
+ omittedCounts,
108
+ };
109
+ }
110
+
111
+ /** Keep stable prefixes from every inventory collection within one bounded,
112
+ * round-robin allocation so a large first collection cannot starve the rest. */
113
+ function boundInventory(source: RepositoryInventory, maxChars: number): Record<string, unknown> {
114
+ const all: Record<InventoryCollection, unknown[]> = {
115
+ packageScripts: source.packageScripts,
116
+ executableScripts: source.executableScripts,
117
+ skills: source.skills,
118
+ agentInstructions: source.agentInstructions,
119
+ };
120
+ const full = inventoryShape(source, all);
121
+ if (JSON.stringify(full).length <= maxChars) return full;
122
+
123
+ const kept: Record<InventoryCollection, unknown[]> = {
124
+ packageScripts: [],
125
+ executableScripts: [],
126
+ skills: [],
127
+ agentInstructions: [],
128
+ };
129
+ const blocked = new Set<InventoryCollection>();
130
+ for (;;) {
131
+ let advanced = false;
132
+ for (const key of INVENTORY_COLLECTIONS) {
133
+ if (blocked.has(key) || kept[key].length >= all[key].length) continue;
134
+ const candidate = { ...kept, [key]: [...kept[key], all[key][kept[key].length]] };
135
+ if (JSON.stringify(inventoryShape(source, candidate)).length <= maxChars) {
136
+ kept[key] = candidate[key];
137
+ advanced = true;
138
+ } else {
139
+ blocked.add(key);
140
+ }
141
+ }
142
+ if (!advanced) break;
143
+ }
144
+ return inventoryShape(source, kept);
145
+ }
146
+
147
+ interface PreparedSession {
148
+ metadata: Record<string, unknown>;
149
+ messages: WindowMessage[];
150
+ }
151
+
152
+ function hydrationError(error: unknown): { kind: "missing" | "oversized" | "unreadable"; message: string } {
153
+ const message = (error instanceof Error ? error.message : String(error)).slice(0, ERROR_MESSAGE_CHARS);
154
+ const code = (error as NodeJS.ErrnoException)?.code;
155
+ return {
156
+ kind: code === "ENOENT" ? "missing" : message.includes("exceeds 32 MiB snapshot limit") ? "oversized" : "unreadable",
157
+ message,
158
+ };
159
+ }
160
+
161
+ function hydratePreparationSession(row: PreparationSessionRow): PreparedSession {
162
+ const indexed = {
163
+ path: row.path,
164
+ cwd: row.cwd,
165
+ name: row.name ?? null,
166
+ startedAt: row.startedAt ?? null,
167
+ lineageId: row.lineageId,
168
+ };
169
+ try {
170
+ const hydrated = readSession(row.path, 20, 10, { userAssistantTextOnly: true });
171
+ return {
172
+ metadata: {
173
+ ...indexed,
174
+ branchTip: hydrated.branchTip,
175
+ totalMessages: hydrated.totalMessages,
176
+ truncated: hydrated.truncated,
177
+ contentTruncated: false,
178
+ messages: [],
179
+ },
180
+ messages: hydrated.messages,
181
+ };
182
+ } catch (error) {
183
+ return {
184
+ metadata: {
185
+ ...indexed,
186
+ branchTip: null,
187
+ totalMessages: null,
188
+ truncated: false,
189
+ contentTruncated: false,
190
+ messages: [],
191
+ error: hydrationError(error),
192
+ },
193
+ messages: [],
194
+ };
195
+ }
196
+ }
197
+
198
+ function allocatePreparationMessages(session: PreparedSession, budget: number): Record<string, unknown> {
199
+ if (session.messages.length === 0) return session.metadata;
200
+ if (JSON.stringify(session.messages).length - 2 <= budget) {
201
+ return { ...session.metadata, messages: session.messages };
202
+ }
203
+ const maxLen = Math.max(...session.messages.map((message) => message.content.length), 0);
204
+ const cap = maxFittingCap(maxLen, budget + 2, (value) => truncateContent(session.messages, value));
205
+ return {
206
+ ...session.metadata,
207
+ contentTruncated: true,
208
+ messages: cap === null ? [] : truncateContent(session.messages, cap),
209
+ };
210
+ }
211
+
212
+ function buildPreparationResult(
213
+ kind: "repository" | "all",
214
+ requestedLimit: number,
215
+ gitRoot: string | undefined,
216
+ syncResult: ReturnType<typeof syncSessions>,
217
+ rows: PreparationSessionRow[],
218
+ repositoryInventory: RepositoryInventory,
219
+ ): Record<string, unknown> {
220
+ const sync = {
221
+ walkComplete: syncResult.walkComplete,
222
+ backlogRemaining: syncResult.backlogRemaining,
223
+ complete: syncResult.walkComplete && syncResult.backlogRemaining === 0,
224
+ };
225
+ const sessions = rows.map(hydratePreparationSession);
226
+ const emptyCollections: Record<InventoryCollection, unknown[]> = {
227
+ packageScripts: [],
228
+ executableScripts: [],
229
+ skills: [],
230
+ agentInstructions: [],
231
+ };
232
+ const minimumInventory = inventoryShape(repositoryInventory, emptyCollections);
233
+ const build = (
234
+ preparedSessions: Record<string, unknown>[],
235
+ inventory: Record<string, unknown>,
236
+ contentTruncated: boolean,
237
+ ) => ({
238
+ mode: "prepare-pattern-miner",
239
+ scope: { kind, gitRoot: gitRoot ?? null, requestedLimit, sampledCount: preparedSessions.length },
240
+ sync,
241
+ sessions: preparedSessions,
242
+ inventory,
243
+ contentTruncated,
244
+ });
245
+
246
+ const metadataOnlyLength = JSON.stringify(build(sessions.map((session) => session.metadata), minimumInventory, false)).length;
247
+ if (metadataOnlyLength > OUTPUT_CHAR_BUDGET) throw new Error("Pattern-miner preparation metadata exceeds output budget.");
248
+ const inventoryBudget = Math.min(
249
+ INVENTORY_CHAR_BUDGET,
250
+ JSON.stringify(minimumInventory).length + OUTPUT_CHAR_BUDGET - metadataOnlyLength,
251
+ );
252
+ const inventory = boundInventory(repositoryInventory, inventoryBudget);
253
+ const baseLength = JSON.stringify(build(sessions.map((session) => session.metadata), inventory, false)).length;
254
+ const perSessionBudget = sessions.length === 0 ? 0 : Math.floor((OUTPUT_CHAR_BUDGET - baseLength) / sessions.length);
255
+ const allocated = sessions.map((session) => allocatePreparationMessages(session, perSessionBudget));
256
+ const contentTruncated = allocated.some((session) => session.contentTruncated === true);
257
+ return build(allocated, inventory, contentTruncated);
258
+ }
259
+
81
260
  interface ToolParams {
261
+ operation?: "prepare-pattern-miner";
262
+ scope?: "repository" | "all";
82
263
  query?: string;
83
264
  sessionId?: string;
84
265
  aroundMessageId?: string;
@@ -90,6 +271,7 @@ interface ToolParams {
90
271
 
91
272
  const DESCRIPTION = `Search past Pi sessions locally with FTS5; returns stored messages.
92
273
 
274
+ - \`operation: "prepare-pattern-miner"\` + \`scope\`: prepare one bounded corpus and repository inventory.
93
275
  - \`query\`: discover matches. Prefer distinctive identifiers or uncommon terms; multi-word queries are AND. Use \`OR\`/\`NOT\` for Boolean queries and quotes only when exact wording is known.
94
276
  - \`sessionId\` + \`aroundMessageId\`: scroll ±\`window\`; retain \`branchTip\` across forks.
95
277
  - \`sessionId\` alone: read; no args: browse recent sessions.
@@ -117,12 +299,14 @@ export default function (pi: ExtensionAPI): void {
117
299
  "Use session_search only when the user explicitly asks about past Pi sessions, historical decisions, or repeated work not available in the current conversation. Do not use it for current-session continuation or ordinary repository inspection.",
118
300
  ],
119
301
  parameters: Type.Object({
302
+ operation: Type.Optional(StringEnum(["prepare-pattern-miner"] as const)),
303
+ scope: Type.Optional(StringEnum(["repository", "all"] as const)),
120
304
  query: Type.Optional(Type.String({ description: "Search query (discovery). FTS5 syntax supported." })),
121
305
  sessionId: Type.Optional(Type.String({ description: "Absolute path of the session file." })),
122
306
  aroundMessageId: Type.Optional(Type.String({ description: "Anchor entry id for scroll mode — centers the window (with sessionId)." })),
123
307
  branchTip: Type.Optional(Type.String({ description: "Branch tip entry id from a previous response — selects which branch of a forked session to scroll; aroundMessageId must lie on it." })),
124
308
  window: Type.Optional(Type.Number({ description: "Scroll window radius, [1,20], default 5." })),
125
- limit: Type.Optional(Type.Number({ description: "Max results, [1,10], default 3." })),
309
+ limit: Type.Optional(Type.Number({ description: "Max results, [1,10]. Defaults to 10 for preparation and 3 otherwise." })),
126
310
  detail: Type.Optional(StringEnum(["adaptive", "full"] as const)),
127
311
  }),
128
312
  renderResult(result, { expanded }, theme) {
@@ -140,8 +324,39 @@ export default function (pi: ExtensionAPI): void {
140
324
  invalidate() {},
141
325
  };
142
326
  },
143
- async execute(_toolCallId, rawParams: ToolParams, _signal, _onUpdate, ctx) {
327
+ async execute(_toolCallId, rawParams: ToolParams, signal, _onUpdate, ctx) {
144
328
  try {
329
+ if (rawParams.operation !== undefined && rawParams.operation !== "prepare-pattern-miner") {
330
+ throw new Error("Unsupported session_search operation.");
331
+ }
332
+ if (rawParams.scope !== undefined && rawParams.operation === undefined) {
333
+ throw new Error("scope requires operation: prepare-pattern-miner.");
334
+ }
335
+ if (rawParams.operation === "prepare-pattern-miner") {
336
+ const incompatible = (["query", "sessionId", "aroundMessageId", "branchTip", "window", "detail"] as const)
337
+ .filter((key) => rawParams[key] !== undefined);
338
+ if (incompatible.length > 0) {
339
+ throw new Error(`prepare-pattern-miner does not accept: ${incompatible.join(", ")}.`);
340
+ }
341
+ if (rawParams.scope !== "repository" && rawParams.scope !== "all") {
342
+ throw new Error("prepare-pattern-miner requires scope: repository or all.");
343
+ }
344
+ const limit = clamp(rawParams.limit, 1, 10, 10);
345
+ const inventory = await inventoryRepository(
346
+ pi,
347
+ { cwd: ctx.cwd, signal },
348
+ rawParams.scope === "repository" ? "required" : "optional",
349
+ );
350
+ const sync = syncSessions(sessionsDir(), dbPath());
351
+ const currentSessionPath = ctx.sessionManager.getSessionFile() ?? undefined;
352
+ const rows = getPreparationRows(dbPath(), {
353
+ limit,
354
+ ...(rawParams.scope === "repository" ? { repositoryRoot: inventory.gitRoot! } : {}),
355
+ currentSessionPath,
356
+ });
357
+ return textResult(buildPreparationResult(rawParams.scope, limit, inventory.gitRoot, sync, rows, inventory));
358
+ }
359
+
145
360
  // LLMs sometimes send numeric ids/queries despite the string schema.
146
361
  const params: ToolParams = {
147
362
  query: rawParams.query != null ? String(rawParams.query) : undefined,
@@ -199,6 +414,7 @@ export default function (pi: ExtensionAPI): void {
199
414
  (cap) => ({
200
415
  mode: "read",
201
416
  sessionId,
417
+ branchTip: r.branchTip,
202
418
  totalMessages: r.totalMessages,
203
419
  truncated: r.truncated,
204
420
  messages: cap === null ? [] : truncateContent(r.messages, cap),
@@ -22,6 +22,12 @@ export interface SessionRow {
22
22
  preview?: string;
23
23
  }
24
24
 
25
+ /** Indexed metadata selected for pattern-miner preparation. */
26
+ export interface PreparationSessionRow extends SessionRow {
27
+ /** Stable one-hop fork identity used to avoid duplicate evidence. */
28
+ lineageId: string;
29
+ }
30
+
25
31
  /** One FTS discovery hit before hydration. */
26
32
  export interface SearchHit {
27
33
  path: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-session-recall",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Local FTS5 search over past Pi sessions plus a skill for turning recurring work into deterministic automation.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -5,22 +5,60 @@ description: Mine repeated work, corrections, and manual procedures from past Pi
5
5
 
6
6
  # Pi Session Pattern Miner
7
7
 
8
- Find repeated work in past Pi sessions and turn the best-supported pattern into an automation candidate. Use `session_search` for session history; do not scan Pi session files directly. The current model mines the evidence once; the resulting automation should remove model judgment from recurring execution wherever the workflow permits it.
8
+ Find repeated work in past Pi sessions. Turn the best-supported pattern into the smallest useful automation.
9
9
 
10
- ## Scope
10
+ Use `session_search` for session history. Do not scan Pi session files directly.
11
11
 
12
- Use the scope or topic in the request. Otherwise sample recent sessions. If the user specifies the current repository, use the current Git root and session `cwd` metadata to filter where possible; search distinctive repository or package names to find related sessions from other worktrees.
12
+ ## Prepare once
13
13
 
14
- State the sampled scope and its limits. `session_search` browse returns at most ten recent sessions, discovery is query-driven, and current live-context matches are suppressed. Its messages contain user/assistant text, not hidden thinking or tool output. Never claim exhaustive coverage or invent commands that are absent from the evidence.
14
+ Choose the requested scope. Use `repository` for the current Git repository. Use `all` for a cross-repository request or work outside Git.
15
+
16
+ Before interpretation, call `session_search` exactly once with:
17
+
18
+ ```json
19
+ {
20
+ "operation": "prepare-pattern-miner",
21
+ "scope": "repository",
22
+ "limit": 10
23
+ }
24
+ ```
25
+
26
+ Change only `scope` when needed. Do not run manual browse, read, scroll, or inventory rounds during preparation.
27
+
28
+ State the returned scope and sample count. If `sync.complete` is false, report the incomplete walk or positive backlog as a sample limitation.
29
+
30
+ The prepared corpus is recent and bounded. A truncated session can omit middle episodes. Never claim exhaustive coverage.
15
31
 
16
32
  ## Mine
17
33
 
18
- 1. Call `session_search` with only `limit: 10` to seed the sample.
19
- 2. Read each in-scope session by `sessionId`. For a truncated session, inspect only relevant gaps with `sessionId`, `aroundMessageId`, and `window: 20`; retain `branchTip` while scrolling a fork.
20
- 3. Extract candidate episodes: repeated user intents, corrections, manual procedures, avoidable retries, and agent-authored steps that recur. Ignore generic coding work such as inspect/edit/test unless the same concrete procedure repeats.
21
- 4. Cluster by underlying job, not wording. A pattern needs evidence from at least two independent sessions; do not count forks, retries, or continuations of one task as separate evidence.
22
- 5. Confirm each candidate with one or more distinctive `query` searches using `limit: 10` and `detail: "full"`. Record session path, date or name, relevant entry id, and a short paraphrase. Do not copy secrets or unnecessary transcript text.
23
- 6. Inspect the current repository's commands, package scripts, executable scripts, skills, and agent instructions before proposing anything. Reuse or repair an existing automation when it already owns the workflow.
34
+ The model performs these steps. Do not replace them with mechanical keyword rules.
35
+
36
+ 1. Identify semantic episodes, including repeated intents, corrections, retries, manual procedures, and recurring agent-authored steps.
37
+ 2. Ignore generic inspect, edit, and test work unless the same concrete procedure repeats.
38
+ 3. Cluster episodes by their underlying job, not their wording.
39
+ 4. Treat sessions with equal `lineageId` values as one source.
40
+ 5. Choose the owning automation surface from the prepared inventory and targeted repository evidence.
41
+ 6. Write the recommendation and implementation contract.
42
+
43
+ A user-supplied topic is already a candidate. Run a focused confirmation search even when that topic is absent from the prepared sample.
44
+
45
+ For other work, create a candidate from the prepared corpus before running optional searches. Do not search speculatively when no topic or candidate exists.
46
+
47
+ Confirm a candidate with one or more distinctive `query` calls. Use `limit: 10`. Record the session path, date or name, relevant entry ID, and a short paraphrase.
48
+
49
+ Do not copy secrets or unnecessary transcript text. Do not count forks, retries, or continuations of one task as independent evidence.
50
+
51
+ ## Verify ownership
52
+
53
+ After clustering, use the inventory only to find likely owners. It is not ownership proof and does not verify the current worktree.
54
+
55
+ Always inspect the current candidate-relevant package manifests, executable scripts, skill files, and instruction files before assigning ownership. Do this even when the inventory is available and complete.
56
+
57
+ Reuse or repair existing automation when it already owns the workflow. If targeted current-file verification cannot be performed safely, abstain.
58
+
59
+ ## Evidence gate
60
+
61
+ A recommendation requires two independent examples. This gate is mandatory.
24
62
 
25
63
  Stop with “not enough repeated evidence” when fewer than two independent sessions support a pattern. Do not manufacture a recommendation from one occurrence.
26
64
 
@@ -29,12 +67,14 @@ Stop with “not enough repeated evidence” when fewer than two independent ses
29
67
  Stop at the first option that fully handles the pattern:
30
68
 
31
69
  1. **Existing command or skill** — document, fix, or invoke it instead of adding another path.
32
- 2. **Script** — choose when inputs, decisions, outputs, and failures can be specified without model judgment. Prefer a repository command or standard-library script over a new dependency.
33
- 3. **Script plus thin skill** — choose when an agent must gather inputs or explain results, but the repeated operation itself can be deterministic. The skill must call the script rather than restate its algorithm.
34
- 4. **Skill only** — choose only when the reusable work inherently requires judgment, repository inspection, or user decisions.
35
- 5. **Product change** — choose when the root cause belongs in an extension, API, CI check, or other code rather than agent instructions.
70
+ 2. **Script** — use when inputs, decisions, outputs, and failures need no model judgment.
71
+ 3. **Script plus thin skill** — use when an agent gathers inputs, but a script performs the repeated operation.
72
+ 4. **Skill only** — use only when the reusable work inherently requires judgment, repository inspection, or user decisions.
73
+ 5. **Product change** — use when the root cause belongs in an extension, API, CI check, or other code.
36
74
 
37
- A deterministic candidate must define its trigger, inputs, outputs, side effects, failure behavior, and one runnable check. If those cannot be defined from session evidence plus the current codebase, recommend a focused investigation instead of automation.
75
+ Prefer a repository command or standard-library script. Add no dependency without evidence that existing tools cannot handle the job.
76
+
77
+ A deterministic candidate must define its trigger, inputs, outputs, side effects, failure behavior, and one runnable check. Recommend investigation when evidence cannot define these details.
38
78
 
39
79
  ## Report
40
80
 
@@ -43,7 +83,9 @@ Return at most three ranked patterns:
43
83
  | Pattern | Independent sessions | Repeated cost or failure | Existing coverage | Smallest automation |
44
84
  | --- | ---: | --- | --- | --- |
45
85
 
46
- For each pattern, cite the session paths and entry ids, explain why the proposed owning surface fits, and state what model work it removes. Then give the highest-confidence candidate a minimal implementation contract:
86
+ For each pattern, cite session paths and entry IDs. Explain why the owning surface fits and what model work it removes.
87
+
88
+ Give the highest-confidence candidate a minimal implementation contract:
47
89
 
48
90
  - **Trigger and inputs**
49
91
  - **Deterministic steps**