@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 +68 -6
- package/extensions/hydrate.ts +16 -3
- package/extensions/repository-inventory.ts +515 -0
- package/extensions/search-core.ts +71 -2
- package/extensions/session-recall.ts +220 -4
- package/extensions/types.ts +6 -0
- package/package.json +1 -1
- package/skills/pi-session-pattern-miner/SKILL.md +58 -16
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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@
|
|
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.
|
package/extensions/hydrate.ts
CHANGED
|
@@ -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
|
|
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
|
-
// ---
|
|
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
|
|
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]
|
|
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,
|
|
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),
|
package/extensions/types.ts
CHANGED
|
@@ -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
|
@@ -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
|
|
8
|
+
Find repeated work in past Pi sessions. Turn the best-supported pattern into the smallest useful automation.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Use `session_search` for session history. Do not scan Pi session files directly.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
## Prepare once
|
|
13
13
|
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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** —
|
|
33
|
-
3. **Script plus thin skill** —
|
|
34
|
-
4. **Skill only** —
|
|
35
|
-
5. **Product change** —
|
|
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
|
-
|
|
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
|
|
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**
|