@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.
@@ -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.1",
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",
@@ -18,6 +18,7 @@
18
18
  "LICENSE",
19
19
  "extensions",
20
20
  "skills",
21
+ "docs",
21
22
  "README.md"
22
23
  ],
23
24
  "scripts": {
@@ -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**