@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 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. | `/init [--refine]`; no config. |
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 writes a fingerprinted `AGENTS.md` so any agent starts already oriented in a repo. It refreshes only when the tree changes, overwriting a stale guide outright, and plain `/init` never sends a synthetic user message; `/init --refine` is the opt-in single request for a model-expanded guide, meant for session start or just before a push, not mid-task.
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
  ![init](images/ss-init.png)
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`: crawl the current repository and write `AGENTS.md` when its fingerprint is missing or stale. A stale guide is **overwritten** wholesale, hand-edited prose included; version control is the recovery path.
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
- - Crawls deterministically to depth 3, ignores generated/dependency directories, caps directory listings at 40 entries, and caps the rendered tree at 300 lines with explicit omission markers.
15
- - Re-walks declared `pnpm-workspace.yaml` and Cargo workspace members with their own depth-3 budget.
16
- - Reads known manifests to include package scripts when present.
17
- - Writes the deterministic fingerprinted `AGENTS.md` directly. It sends no synthetic user message on its own; `/init --refine` is the single, explicit request for a model-refined guide.
18
- - Skips the write when the final `<!-- opl-init:fp <fingerprint> -->` marker is current.
19
- - Excludes `AGENTS.md` itself from the Git status fingerprint, preventing the act of updating the guide from making it immediately stale.
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
- ## Session timing, reiterations, and re-injections
20
+ ## Requirements
22
21
 
23
- `/init` is an initialization command: run it at the **start** of a session, before the work begins, and again at the **very end** to fold the session's changes into the guide before pushing to the remote. Both are quiet moments, and `--refine` needs a quiet moment.
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, and deterministic guide writing. Git repositories fingerprint `HEAD` plus relevant `git status --porcelain=v1 --untracked-files=all` output, excluding `AGENTS.md` itself; non-Git repositories use sorted path, size, and mtime tuples. `/init` writes its guide locally and leaves the Pi transcript alone; `--refine` adds one small user message that points the model at the written file instead of re-sending the crawl.
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 MAX_MANIFEST_BYTES = 2048;
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
- const head = execFileSync("git", ["rev-parse", "HEAD"], {
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
- const extension = extname(entry) || entry;
132
- result.extCounts.set(extension, (result.extCounts.get(extension) ?? 0) + 1);
133
-
134
- if (MANIFESTS.has(entry)) {
135
- try {
136
- result.manifests.push({
137
- path: relative(root, path),
138
- content: readFileSync(path, "utf8").slice(0, MAX_MANIFEST_BYTES),
139
- });
140
- } catch {
141
- // Skip unreadable manifests.
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
- function packageScripts(content: string): string | null {
283
- try {
284
- const packageJson = JSON.parse(content);
285
- if (!packageJson.scripts || !Object.keys(packageJson.scripts).length) return null;
286
- return Object.entries(packageJson.scripts)
287
- .map(([name, command]) => ` ${name}: ${command}`)
288
- .join("\n");
289
- } catch {
290
- return null;
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
- * Opt-in refinement request for `/init --refine`. Deliberately small: the crawl inventory is
305
- * already in the guide, so the model reads the file instead of receiving a second copy of the
306
- * tree. This is the only injected user turn in the extension, and it never fires on its own.
307
- */
308
- function refinePrompt(agentsPath: string, marker: string): string {
309
- return [
310
- `Refine the repository guide at ${agentsPath}, which /init generated from a repository crawl.`,
311
- "Read the guide first, then open only the specific files you need to confirm repository-specific facts. Do not re-crawl the tree, spawn subagents, or create task lists.",
312
- "Rewrite the guide in place with the write tool, concise and evidence-based (usually 250-700 words):",
313
- '- Prefer these sections when evidence supports them: "## What this is", "## Commands", "## Architecture", "## Configuration and installation", "## Testing and operational quirks", "## Key files". Omit unsupported sections instead of inventing them, and state meaningful absences such as no build, lint, or typecheck command.',
314
- "- Keep the crawl's factual inventory (commands, file types, workspace members) and avoid generic contribution, Git, or pull-request advice.",
315
- "- Do not modify, create, or delete any other file.",
316
- `- Make the very last line exactly: ${marker}`,
317
- "A response containing Markdown without writing the file is a failure.",
318
- ].join("\n");
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
- // Named export for fixture tests (tests/opl-init-crawl.test.mjs).
322
- export { crawl };
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 the repository and write an AGENTS.md guide (--refine also asks the model to expand it)",
327
- handler: async (args, ctx) => {
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 currentFp = fingerprint(root);
332
- const exists = existsSync(agentsPath);
333
-
334
- let storedFp: string | null = null;
335
- if (exists) {
457
+ const storedFp = () => {
336
458
  try {
337
- storedFp = readFileSync(agentsPath, "utf8").match(FP_MARKER)?.[1] ?? null;
459
+ return readFileSync(agentsPath, "utf8").match(FP_MARKER)?.[1] ?? null;
338
460
  } catch {
339
- storedFp = null;
461
+ return null;
340
462
  }
341
- }
463
+ };
342
464
 
343
- const marker = `<!-- opl-init:fp ${currentFp} -->`;
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
- } else {
348
- // Regenerating replaces the whole file, including hand-edited prose; the repository's
349
- // version control is the recovery path.
350
- const crawlResult = crawl(root);
351
- try {
352
- writeFileSync(agentsPath, buildGuide(root, crawlResult, marker), "utf8");
353
- ctx.ui.notify(exists ? "AGENTS.md overwritten from the repository crawl." : "AGENTS.md created.", "info");
354
- } catch (error: any) {
355
- ctx.ui.notify(`Could not write AGENTS.md: ${error?.message || error}`, "error");
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
- // Queue, never steer: Pi throws "Agent is already processing" for an injection with no
361
- // deliverAs while a turn is streaming, and steering would hijack the task in flight.
362
- if (refine) pi.sendUserMessage(refinePrompt(agentsPath, marker), { deliverAs: "followUp" });
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.0",
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",