@openlines/opl-pi-sht 0.2.0 → 0.2.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.
- package/README.md +2 -2
- package/extensions/opl-init/README.md +11 -22
- package/extensions/opl-init/index.ts +219 -77
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -71,7 +71,7 @@ Copy mode overwrites matching destinations. Link mode skips existing destination
|
|
|
71
71
|
|
|
72
72
|
| Extension | Summary | Commands, tools, and configuration |
|
|
73
73
|
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
74
|
-
| [`opl-init`](extensions/opl-init/README.md) | Fingerprinted repository-guide generator.
|
|
74
|
+
| [`opl-init`](extensions/opl-init/README.md) | Fingerprinted repository-guide generator with out-of-band model refinement. | `/init`; no config. |
|
|
75
75
|
| [`opl-simplebench`](extensions/opl-simplebench/README.md) | Auditable provider-aware model benchmark with JSON artifacts and metrics. | `/simplebench`, `simplebench`; supports Ollama, OpenAI-compatible providers, and Bedrock; optional `opl-simplebench.json`. |
|
|
76
76
|
| [`opl-webaccess`](extensions/opl-webaccess/README.md) | Search plus readable URL/PDF retrieval with session recovery. | `web_search`, `fetch_content`, `get_search_content`; `opl-webaccess.json`. |
|
|
77
77
|
| [`opl-browser`](extensions/opl-browser/README.md) | Chromium automation via Playwright with structured extraction of rendered pages; single dispatcher tool replacing the chrome-devtools MCP. | `browser` (action-based); `opl-browser.json`. |
|
|
@@ -111,7 +111,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
|
|
|
111
111
|
|
|
112
112
|
### Move through work faster
|
|
113
113
|
|
|
114
|
-
- **`opl-init`** deterministically
|
|
114
|
+
- **`opl-init`** deterministically crawls and refines `AGENTS.md` with a single out-of-band model call (never a synthetic user message), writes it, and reloads context so the session runs on the new guide. A current fingerprint means zero model calls; mid-session invocations wait for the agent to settle instead of interrupting it. Needs pi >= 0.86.0.
|
|
115
115
|

|
|
116
116
|
- **`opl-browser`** gives full Chromium automation (navigate, snapshot, extract rendered-page markdown, interact, screenshot, console/network capture, evaluate) through a single tool, with handle+preview output for large results — navigation is http(s)-only and screenshots stay in the project directory.
|
|
117
117
|
- **`opl-webaccess`** adds provider-backed search plus readable URL and PDF extraction, with session recovery of earlier results, an http(s)-only fetch, a 10 MB response cap, and a 30s timeout.
|
|
@@ -4,34 +4,23 @@ Generates or refreshes a repository-specific `AGENTS.md` guide through `/init`.
|
|
|
4
4
|
|
|
5
5
|
## Commands, flags, and shortcuts
|
|
6
6
|
|
|
7
|
-
- `/init`:
|
|
8
|
-
- `/init --refine`: same write, then one injected user turn asking the model to expand the guide into a fuller, evidence-based version. Opt-in only: plain `/init` never touches the transcript.
|
|
7
|
+
- `/init`: the only interface; there are no flags and unknown args are ignored. If the guide's fingerprint is current it stops and says so - zero model calls. If it is missing or stale: deterministic crawl, one out-of-band model refinement, extension-validated write, then `ctx.reload()` so the running session picks the new guide up.
|
|
9
8
|
- No shortcuts.
|
|
10
9
|
|
|
11
10
|
## Extension features
|
|
12
11
|
|
|
13
|
-
- Produces a deterministic repository guide with package commands and a file-type inventory.
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
12
|
+
- Produces a deterministic repository guide with package commands and a file-type inventory, then refines it with **one** `ctx.modelRegistry.streamSimple()` call on the current model (pi >= 0.86.0). The refinement never enters the conversation: no synthetic user message, no turn in the transcript, no `write` tool needed, and read-only/chat/plan modes refine identically.
|
|
13
|
+
- The model only sees a bounded evidence packet (the baseline guide, every `package.json`'s full scripts block, and the first ~2 KB of each root/workspace `README`/`CLAUDE.md`, hard 24 KB budget with an explicit truncation line). The crawl is the only explorer.
|
|
14
|
+
- The extension owns the fingerprint marker: model output is stripped of fences and any `opl-init:fp` comment, and the exact marker is appended as the final line. A model can never corrupt or fake a "current" guide.
|
|
15
|
+
- Refine failure (no model, no auth, provider error, empty output) writes the deterministic baseline and notifies `refine failed` - `/init` only fails to write if the write itself fails.
|
|
16
|
+
- Mid-session `/init` waits for the agent to settle (`ctx.waitForIdle()`), recomputes the fingerprint, and only then crawls. It never steers or interrupts the task in flight.
|
|
17
|
+
- Crawls to depth 3, ignores generated/dependency directories, caps directory listings at 40 entries, caps the rendered tree at 300 lines with explicit omission markers, and re-walks declared `pnpm-workspace.yaml`/Cargo members with their own depth-3 budget.
|
|
18
|
+
- Fingerprints dirty tracked + untracked file **contents** (plus HEAD and a generator schema version), excluding the root `AGENTS.md` itself, so writing the guide never makes it immediately stale and re-editing an already-modified file is never read as "current".
|
|
20
19
|
|
|
21
|
-
##
|
|
20
|
+
## Requirements
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
What a `--refine` injection does to a live session:
|
|
26
|
-
|
|
27
|
-
- It is a **user-role turn**. It is persisted in the session and replayed as context on every later turn and on resume, so two `--refine` runs in one session leave two instruction blocks behind and the transcript keeps paying for both.
|
|
28
|
-
- It **always triggers a turn**. With an idle agent the refinement starts immediately. With an agent already streaming it is queued as a follow-up (`deliverAs: "followUp"`), so the guide is rewritten *after* the current turn finishes — later than you asked, and possibly after you have moved on to something else. An injection with no `deliverAs` makes Pi throw `Agent is already processing` and the request is lost.
|
|
29
|
-
- It **cannot be fulfilled in a read-only mode**. Chat and plan modes carry no `write` tool, so the model can only describe the guide it was asked to write: the turn is spent and `AGENTS.md` stays at the deterministic baseline. Run `--refine` in normal (OFF) mode.
|
|
30
|
-
- In **execute mode it ends the plan**. `opl-modes` leaves execute mode when a turn completes without `plan_complete`, so the refinement turn finishing drops the session to OFF mid-plan.
|
|
31
|
-
- A refined guide is **not sticky**. The fingerprint is `HEAD` plus `git status`, so the next commit or dirty file makes the guide stale, and the next plain `/init` overwrites the refined prose with the baseline. Refine last, then commit `AGENTS.md`; if you refine and keep working, expect to refine once more at the end of the session.
|
|
32
|
-
- The refinement's own write does **not** invalidate the marker: `AGENTS.md` is filtered out of the status fingerprint, so a successful `--refine` leaves `/init` reporting "current".
|
|
33
|
-
- There is **no recursion and no automatic re-injection**. The message is sent with `expandPromptTemplates: false`, so a `/init` appearing inside it is not re-dispatched, and the extension registers no event handlers: every injection comes from you typing `--refine`.
|
|
22
|
+
Refinement needs pi 0.86.0 or newer (`ctx.modelRegistry.streamSimple`, `ctx.reload`). Run `pi update` first if `/init` reports a refine failure on a machine with configured auth.
|
|
34
23
|
|
|
35
24
|
## Architecture
|
|
36
25
|
|
|
37
|
-
`index.ts` owns crawling, fingerprinting,
|
|
26
|
+
`index.ts` owns crawling, fingerprinting, the evidence packet, the model call, output validation, and writing. Git repositories fingerprint `schema version + HEAD + sha256 of every dirty tracked and untracked file` (root `AGENTS.md` excluded); non-git repositories use sorted path/size/mtime tuples. `/init` writes the file itself and reloads the session context; the main agent is never enlisted.
|
|
@@ -20,40 +20,68 @@ const MANIFESTS = new Set([
|
|
|
20
20
|
const MAX_DEPTH = 3;
|
|
21
21
|
const MAX_MEMBER_DEPTH = 3;
|
|
22
22
|
const MAX_TREE_LINES = 300;
|
|
23
|
-
const
|
|
23
|
+
const MAX_MANIFEST_PARSE_BYTES = 262144;
|
|
24
24
|
const MAX_DIR_ENTRIES = 40;
|
|
25
|
+
// Bumping this constant is how generator upgrades invalidate every
|
|
26
|
+
// previously-written guide: it is a fingerprint input.
|
|
27
|
+
const GUIDE_SCHEMA_VERSION = 1;
|
|
25
28
|
|
|
26
29
|
type Crawl = {
|
|
27
30
|
tree: string[];
|
|
28
31
|
extCounts: Map<string, number>;
|
|
29
32
|
manifests: { path: string; content: string }[];
|
|
30
33
|
workspaceMembers: string[];
|
|
34
|
+
seen: Set<string>;
|
|
31
35
|
};
|
|
32
36
|
|
|
33
37
|
// Marker the model appends to AGENTS.md so future runs can detect staleness exactly.
|
|
34
38
|
const FP_MARKER = /<!-- opl-init:fp (\S+) -->/;
|
|
35
39
|
|
|
36
40
|
// Exact snapshot of git-tracked + untracked state. Respects .gitignore for free.
|
|
41
|
+
// Content-based: HEAD + per-path content hashes of every dirty tracked file
|
|
42
|
+
// (vs HEAD, so staged changes count) and every untracked file. Only the root
|
|
43
|
+
// AGENTS.md is excluded, so writing the guide does not make it stale;
|
|
44
|
+
// subdirectory AGENTS.md files are repository facts.
|
|
37
45
|
// Returns null for non-git directories (caller falls back to fingerprintFallback).
|
|
38
46
|
function fingerprintGit(root: string): string | null {
|
|
47
|
+
const head = gitOutput(root, ["rev-parse", "HEAD"]);
|
|
48
|
+
if (head === null) return null;
|
|
49
|
+
const hash = createHash("sha256").update(`schema:${GUIDE_SCHEMA_VERSION}\0head:${head}\0`);
|
|
50
|
+
// --relative keeps diff paths cwd-relative and cwd-scoped, matching
|
|
51
|
+
// ls-files --others and the crawl; without it a subdirectory session would
|
|
52
|
+
// resolve every dirty path to a nonexistent file and hash "DELETED".
|
|
53
|
+
for (const path of (gitOutput(root, ["diff", "HEAD", "--name-only", "-z", "--relative"]) ?? "").split("\0")) {
|
|
54
|
+
if (!path || path === "AGENTS.md") continue;
|
|
55
|
+
hash.update(`${path}\0`).update(hashFile(join(root, path))).update("\0");
|
|
56
|
+
}
|
|
57
|
+
for (const path of (gitOutput(root, ["ls-files", "--others", "--exclude-standard", "-z"]) ?? "").split("\0")) {
|
|
58
|
+
if (!path || path === "AGENTS.md") continue;
|
|
59
|
+
hash.update(`${path}\0`).update(hashFile(join(root, path))).update("\0");
|
|
60
|
+
}
|
|
61
|
+
return hash.digest("hex").slice(0, 16);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function gitOutput(root: string, args: string[]): string | null {
|
|
39
65
|
try {
|
|
40
|
-
|
|
66
|
+
return execFileSync("git", args, {
|
|
41
67
|
cwd: root,
|
|
42
68
|
encoding: "utf8",
|
|
43
69
|
stdio: ["ignore", "pipe", "ignore"],
|
|
70
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
44
71
|
});
|
|
45
|
-
const status = execFileSync(
|
|
46
|
-
"git",
|
|
47
|
-
["status", "--porcelain=v1", "--untracked-files=all"],
|
|
48
|
-
{ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
49
|
-
);
|
|
50
|
-
const relevantStatus = status.split("\n").filter(line => !line.endsWith("AGENTS.md")).join("\n");
|
|
51
|
-
return createHash("sha256").update(head).update(relevantStatus).digest("hex").slice(0, 16);
|
|
52
72
|
} catch {
|
|
53
73
|
return null;
|
|
54
74
|
}
|
|
55
75
|
}
|
|
56
76
|
|
|
77
|
+
function hashFile(path: string): string {
|
|
78
|
+
try {
|
|
79
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
80
|
+
} catch {
|
|
81
|
+
return "DELETED";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
57
85
|
// ponytail: stat-only (path|size|mtime), not content hashing. Catches add/delete/
|
|
58
86
|
// rename but shares the touch-without-change blind spot; only used for non-git dirs.
|
|
59
87
|
function fingerprintFallback(root: string): string {
|
|
@@ -85,7 +113,7 @@ function fingerprintFallback(root: string): string {
|
|
|
85
113
|
|
|
86
114
|
walk(root);
|
|
87
115
|
parts.sort();
|
|
88
|
-
return createHash("sha256").update(parts.join("\n")).digest("hex").slice(0, 16);
|
|
116
|
+
return createHash("sha256").update(`schema:${GUIDE_SCHEMA_VERSION}\n`).update(parts.join("\n")).digest("hex").slice(0, 16);
|
|
89
117
|
}
|
|
90
118
|
|
|
91
119
|
function fingerprint(root: string): string {
|
|
@@ -98,6 +126,7 @@ function crawl(root: string): Crawl {
|
|
|
98
126
|
extCounts: new Map(),
|
|
99
127
|
manifests: [],
|
|
100
128
|
workspaceMembers: [],
|
|
129
|
+
seen: new Set(),
|
|
101
130
|
};
|
|
102
131
|
|
|
103
132
|
function walk(dir: string, depth: number, prefix: string, maxDepth: number) {
|
|
@@ -128,17 +157,23 @@ function crawl(root: string): Crawl {
|
|
|
128
157
|
}
|
|
129
158
|
|
|
130
159
|
files.push(entry);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
160
|
+
// Dedupe by absolute path: declared-member re-walks overlap the root
|
|
161
|
+
// walk, and the inventory must not double-count. Tree lines still
|
|
162
|
+
// accept shallow overlap for completeness.
|
|
163
|
+
if (!result.seen.has(path)) {
|
|
164
|
+
result.seen.add(path);
|
|
165
|
+
const extension = extname(entry) || entry;
|
|
166
|
+
result.extCounts.set(extension, (result.extCounts.get(extension) ?? 0) + 1);
|
|
167
|
+
|
|
168
|
+
if (MANIFESTS.has(entry)) {
|
|
169
|
+
try {
|
|
170
|
+
result.manifests.push({
|
|
171
|
+
path: relative(root, path),
|
|
172
|
+
content: readFileSync(path, "utf8").slice(0, MAX_MANIFEST_PARSE_BYTES),
|
|
173
|
+
});
|
|
174
|
+
} catch {
|
|
175
|
+
// Skip unreadable manifests.
|
|
176
|
+
}
|
|
142
177
|
}
|
|
143
178
|
}
|
|
144
179
|
}
|
|
@@ -279,87 +314,194 @@ function result_tree_paths(root: string): string[] {
|
|
|
279
314
|
return paths;
|
|
280
315
|
}
|
|
281
316
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
317
|
+
// Root-first, per-package-labeled script lines, bounded at 30 entries total.
|
|
318
|
+
function aggregateScripts(manifests: { path: string; content: string }[]): string | null {
|
|
319
|
+
const lines: string[] = [];
|
|
320
|
+
const sorted = [...manifests].sort((a, b) =>
|
|
321
|
+
a.path === "package.json" ? -1 : b.path === "package.json" ? 1 : a.path.localeCompare(b.path));
|
|
322
|
+
for (const manifest of sorted) {
|
|
323
|
+
if (!manifest.path.endsWith("package.json") || lines.length >= 30) continue;
|
|
324
|
+
let parsed;
|
|
325
|
+
try {
|
|
326
|
+
parsed = JSON.parse(manifest.content);
|
|
327
|
+
} catch {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const scripts = parsed?.scripts;
|
|
331
|
+
if (!scripts || typeof scripts !== "object") continue;
|
|
332
|
+
const label = manifest.path === "package.json" ? "" : `${manifest.path.replace(/\/package\.json$/, "")}/ `;
|
|
333
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
334
|
+
if (lines.length >= 30) break;
|
|
335
|
+
lines.push(` ${label}${name}: ${command}`);
|
|
336
|
+
}
|
|
291
337
|
}
|
|
338
|
+
return lines.length ? lines.join("\n") : null;
|
|
292
339
|
}
|
|
293
340
|
|
|
294
341
|
function buildGuide(root: string, crawlResult: Crawl, marker: string): string {
|
|
295
342
|
const topLevel = crawlResult.tree.filter(line => !line.startsWith(" ")).slice(0, 30).join(", ");
|
|
296
|
-
const scripts = crawlResult.manifests
|
|
297
|
-
.map(manifest => manifest.path.endsWith("package.json") ? packageScripts(manifest.content) : null)
|
|
298
|
-
.find(Boolean);
|
|
343
|
+
const scripts = aggregateScripts(crawlResult.manifests);
|
|
299
344
|
const commandBlock = scripts ? "Package scripts:\n```\n" + scripts + "\n```" : "No package scripts were detected by the repository crawl.";
|
|
300
345
|
return `# Repository Guide\n\n## What this is\n\nRepository at \`${root}\`. Use the repository files as the source of truth; the top-level inventory includes ${topLevel || "(not available)"}.\n\n## Commands\n\n${commandBlock}\n\n## Repository inventory\n\n- File types: ${[...crawlResult.extCounts.entries()].map(([extension, count]) => `${extension} (${count})`).join(", ") || "none detected"}.\n- Inspect specific files before changing behavior; this guide is a starting point, not a substitute for reading the code.\n\n## Agent workflow\n\nKeep changes focused on the requested behavior, preserve existing interfaces, and run the narrowest relevant test before the full suite. Keep secrets and generated output out of tracked configuration.\n${marker}\n`;
|
|
301
346
|
}
|
|
302
347
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
348
|
+
const MAX_EVIDENCE_BYTES = 24576;
|
|
349
|
+
const MAX_README_HEAD_BYTES = 2048;
|
|
350
|
+
|
|
351
|
+
// Evidence the model cannot fetch itself: the deterministic stand-in for the
|
|
352
|
+
// tools the refine call does not have, under a hard byte budget. The spec's
|
|
353
|
+
// three parts: baseline guide, full per-package scripts blocks, readme heads.
|
|
354
|
+
function evidencePacket(
|
|
355
|
+
root: string,
|
|
356
|
+
baseline: string,
|
|
357
|
+
crawlResult: { manifests: { path: string; content: string }[]; workspaceMembers: string[] },
|
|
358
|
+
): string {
|
|
359
|
+
const parts = [baseline];
|
|
360
|
+
let used = Buffer.byteLength(baseline, "utf8");
|
|
361
|
+
const push = (block: string): boolean => {
|
|
362
|
+
const bytes = Buffer.byteLength(block, "utf8") + 1;
|
|
363
|
+
if (used + bytes > MAX_EVIDENCE_BYTES) {
|
|
364
|
+
parts.push("(evidence truncated)");
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
used += bytes;
|
|
368
|
+
parts.push(block);
|
|
369
|
+
return true;
|
|
370
|
+
};
|
|
371
|
+
for (const manifest of crawlResult.manifests) {
|
|
372
|
+
if (!manifest.path.endsWith("package.json")) continue;
|
|
373
|
+
let parsed;
|
|
374
|
+
try {
|
|
375
|
+
parsed = JSON.parse(manifest.content);
|
|
376
|
+
} catch {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const scripts = parsed?.scripts;
|
|
380
|
+
if (!scripts || typeof scripts !== "object") continue;
|
|
381
|
+
const block = `=== scripts: ${manifest.path} ===\n${Object.entries(scripts).map(([name, command]) => `${name}: ${command}`).join("\n")}`;
|
|
382
|
+
if (!push(block)) return parts.join("\n");
|
|
383
|
+
}
|
|
384
|
+
const candidates = ["README.md", "readme.md", "README", "CLAUDE.md"];
|
|
385
|
+
const paths = [...candidates, ...crawlResult.workspaceMembers.flatMap((m) => candidates.map((c) => `${m}/${c}`))];
|
|
386
|
+
for (const rel of paths) {
|
|
387
|
+
let head: string;
|
|
388
|
+
try {
|
|
389
|
+
head = readFileSync(join(root, rel), "utf8").slice(0, MAX_README_HEAD_BYTES);
|
|
390
|
+
} catch {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (!push(`=== ${rel} ===\n${head}`)) break;
|
|
394
|
+
}
|
|
395
|
+
return parts.join("\n");
|
|
319
396
|
}
|
|
320
397
|
|
|
321
|
-
|
|
322
|
-
|
|
398
|
+
const REFINE_SYSTEM_PROMPT = [
|
|
399
|
+
"You are rewriting a repository guide for an AI coding agent.",
|
|
400
|
+
"Input: a draft guide generated from a deterministic repository crawl, plus bounded file excerpts.",
|
|
401
|
+
"Return ONLY the final Markdown document: no preamble, no code fence around the document, no HTML comments.",
|
|
402
|
+
"Do not invent facts unsupported by the input; keep the factual inventory (commands, file types, workspace members).",
|
|
403
|
+
'Prefer these sections when evidence supports them: "## What this is", "## Commands", "## Architecture", "## Configuration and installation", "## Testing and operational quirks", "## Key files". Omit unsupported sections.',
|
|
404
|
+
"Usually 250-700 words. Avoid generic contribution, Git, or pull-request advice.",
|
|
405
|
+
].join("\n");
|
|
406
|
+
|
|
407
|
+
// The model never owns the marker line: every opl-init:fp comment is stripped
|
|
408
|
+
// and the extension's exact marker is appended as the final line.
|
|
409
|
+
function finalizeRefinedGuide(text: string, marker: string): string {
|
|
410
|
+
let body = text.trim();
|
|
411
|
+
// Strip a wrapper fence only when it opens *and* closes; a guide ending in
|
|
412
|
+
// a legitimate code block keeps its closing fence.
|
|
413
|
+
if (/^```[a-zA-Z]*\s*\n/.test(body)) {
|
|
414
|
+
body = body.replace(/^```[a-zA-Z]*\s*\n/, "").replace(/\n```\s*$/, "");
|
|
415
|
+
}
|
|
416
|
+
body = body.replace(/<!-- opl-init:fp \S+ -->/g, "").trimEnd();
|
|
417
|
+
return `${body}\n${marker}\n`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// One out-of-band completion on the current model. No tools, no session
|
|
421
|
+
// message: any failure returns null and the caller writes the baseline.
|
|
422
|
+
// Bounded so a stalled provider cannot leave /init hanging with no UI signal
|
|
423
|
+
// (the old injected turn was Esc-cancellable; this replaces that escape hatch).
|
|
424
|
+
const REFINE_TIMEOUT_MS = 120000;
|
|
425
|
+
|
|
426
|
+
async function refineGuide(ctx: any, evidence: string, marker: string): Promise<string | null> {
|
|
427
|
+
const model = ctx.model;
|
|
428
|
+
if (!model || typeof ctx.modelRegistry?.streamSimple !== "function") return null;
|
|
429
|
+
ctx.ui?.notify?.("opl-init: refining the guide with the current model...", "info");
|
|
430
|
+
try {
|
|
431
|
+
const stream = ctx.modelRegistry.streamSimple(
|
|
432
|
+
model,
|
|
433
|
+
{ systemPrompt: REFINE_SYSTEM_PROMPT, messages: [{ role: "user", content: evidence, timestamp: Date.now() }], tools: [] },
|
|
434
|
+
{ reasoning: false, signal: AbortSignal.timeout(REFINE_TIMEOUT_MS) },
|
|
435
|
+
);
|
|
436
|
+
const result = await stream.result();
|
|
437
|
+
if (!result || result.stopReason === "error" || result.stopReason === "aborted") return null;
|
|
438
|
+
const text = (result.content ?? [])
|
|
439
|
+
.filter((part: any) => part.type === "text")
|
|
440
|
+
.map((part: any) => part.text)
|
|
441
|
+
.join("\n");
|
|
442
|
+
return text.trim() ? finalizeRefinedGuide(text, marker) : null;
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Named exports for fixture tests (tests/opl-init-*.test.mjs).
|
|
449
|
+
export { crawl, fingerprint, evidencePacket, finalizeRefinedGuide, refineGuide };
|
|
323
450
|
|
|
324
451
|
export default function (pi: ExtensionAPI) {
|
|
325
452
|
pi.registerCommand("init", {
|
|
326
|
-
description: "[opl-init] Crawl
|
|
327
|
-
handler: async (
|
|
328
|
-
const refine = /(?:^|\s)--refine(?:\s|$)/.test(args.trim());
|
|
453
|
+
description: "[opl-init] Crawl + refine AGENTS.md out-of-band; zero model calls when current",
|
|
454
|
+
handler: async (_args, ctx) => {
|
|
329
455
|
const root = ctx.cwd;
|
|
330
456
|
const agentsPath = join(root, "AGENTS.md");
|
|
331
|
-
const
|
|
332
|
-
const exists = existsSync(agentsPath);
|
|
333
|
-
|
|
334
|
-
let storedFp: string | null = null;
|
|
335
|
-
if (exists) {
|
|
457
|
+
const storedFp = () => {
|
|
336
458
|
try {
|
|
337
|
-
|
|
459
|
+
return readFileSync(agentsPath, "utf8").match(FP_MARKER)?.[1] ?? null;
|
|
338
460
|
} catch {
|
|
339
|
-
|
|
461
|
+
return null;
|
|
340
462
|
}
|
|
341
|
-
}
|
|
463
|
+
};
|
|
342
464
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (exists && storedFp === currentFp) {
|
|
465
|
+
let currentFp = fingerprint(root);
|
|
466
|
+
if (existsSync(agentsPath) && storedFp() === currentFp) {
|
|
346
467
|
ctx.ui.notify("AGENTS.md is current; /init will not modify it.", "info");
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!ctx.isIdle()) {
|
|
472
|
+
ctx.ui.notify("opl-init: waiting for the current agent run to settle before writing AGENTS.md.", "info");
|
|
473
|
+
await ctx.waitForIdle();
|
|
474
|
+
// The settling run may have moved the tree; recompute before crawling.
|
|
475
|
+
currentFp = fingerprint(root);
|
|
476
|
+
if (existsSync(agentsPath) && storedFp() === currentFp) {
|
|
477
|
+
ctx.ui.notify("AGENTS.md is current; /init will not modify it.", "info");
|
|
356
478
|
return;
|
|
357
479
|
}
|
|
358
480
|
}
|
|
359
481
|
|
|
360
|
-
|
|
361
|
-
//
|
|
362
|
-
|
|
482
|
+
const marker = `<!-- opl-init:fp ${currentFp} -->`;
|
|
483
|
+
// Regenerating replaces the whole file, including hand-edited prose; the
|
|
484
|
+
// repository's version control is the recovery path.
|
|
485
|
+
const crawlResult = crawl(root);
|
|
486
|
+
const baseline = buildGuide(root, crawlResult, marker);
|
|
487
|
+
const refined = await refineGuide(ctx, evidencePacket(root, baseline, crawlResult), marker);
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
writeFileSync(agentsPath, refined ?? baseline, "utf8");
|
|
491
|
+
} catch (error: any) {
|
|
492
|
+
ctx.ui.notify(`Could not write AGENTS.md: ${error?.message || error}`, "error");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
ctx.ui.notify(
|
|
497
|
+
refined
|
|
498
|
+
? "AGENTS.md written from the repository crawl (model-refined)."
|
|
499
|
+
: "refine failed; wrote deterministic AGENTS.md from the repository crawl.",
|
|
500
|
+
"info",
|
|
501
|
+
);
|
|
502
|
+
// Terminal: pi docs say code after ctx.reload() runs in the old frame.
|
|
503
|
+
await ctx.reload();
|
|
504
|
+
return;
|
|
363
505
|
},
|
|
364
506
|
});
|
|
365
507
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlines/opl-pi-sht",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context, safe modes, fingerprinted /init, model benchmarking, web access, and browser automation.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"test": "npm run test:opl-browser && npm run test:opl-footer && npm run test:opl-init && npm run test:opl-input && npm run test:opl-modes && npm run test:opl-questionnaire && npm run test:opl-todo && npm run test:opl-webaccess && npm run test:opl-simplebench && npm run test:opl-ctxtrim",
|
|
44
44
|
"test:opl-browser": "bun test tests/opl-browser-helpers.test.mjs && OPL_EXTENSION=opl-browser bun test tests/extension-smoke.test.mjs",
|
|
45
45
|
"test:opl-footer": "bun test tests/opl-footer-helpers.test.mjs && OPL_EXTENSION=opl-footer bun test tests/extension-smoke.test.mjs",
|
|
46
|
-
"test:opl-init": "bun test tests/opl-init-crawl.test.mjs && OPL_EXTENSION=opl-init bun test tests/extension-smoke.test.mjs",
|
|
46
|
+
"test:opl-init": "bun test tests/opl-init-crawl.test.mjs tests/opl-init-fingerprint.test.mjs tests/opl-init-packet.test.mjs && OPL_EXTENSION=opl-init bun test tests/extension-smoke.test.mjs",
|
|
47
47
|
"test:opl-input": "bun test tests/opl-input-style.test.mjs && OPL_EXTENSION=opl-input bun test tests/extension-smoke.test.mjs",
|
|
48
48
|
"test:opl-modes": "bun test tests/opl-modes-helpers.test.mjs tests/opl-modes-lifecycle.test.mjs && OPL_EXTENSION=opl-modes bun test tests/extension-smoke.test.mjs",
|
|
49
49
|
"test:opl-questionnaire": "OPL_EXTENSION=opl-questionnaire bun test tests/extension-smoke.test.mjs",
|