@openlines/opl-pi-sht 0.2.11 → 0.3.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.
Files changed (89) hide show
  1. package/README.md +92 -82
  2. package/configs/opl-browser.json.sample +3 -2
  3. package/configs/opl-footer.json.sample +3 -2
  4. package/configs/opl-guardian.json.sample +8 -2
  5. package/configs/opl-modes.json.sample +5 -0
  6. package/configs/opl-webaccess.json.sample +7 -1
  7. package/extensions/opl-browser/README.md +81 -55
  8. package/extensions/opl-browser/browser.ts +244 -50
  9. package/extensions/opl-browser/config.ts +15 -7
  10. package/extensions/opl-browser/index.ts +5 -4
  11. package/extensions/opl-browser/package.json +4 -4
  12. package/extensions/opl-browser/validate.ts +313 -60
  13. package/extensions/opl-configurator/README.md +40 -0
  14. package/extensions/opl-configurator/configurator.ts +148 -0
  15. package/extensions/opl-configurator/index.ts +12 -0
  16. package/extensions/opl-ctxtrim/README.md +45 -14
  17. package/extensions/opl-ctxtrim/index.ts +33 -13
  18. package/extensions/opl-footer/README.md +32 -7
  19. package/extensions/opl-footer/config.ts +11 -10
  20. package/extensions/opl-footer/configure.ts +157 -115
  21. package/extensions/opl-footer/git-status.ts +58 -1
  22. package/extensions/opl-footer/icons.ts +3 -0
  23. package/extensions/opl-footer/index.ts +287 -109
  24. package/extensions/opl-footer/segments/caveman.ts +9 -1
  25. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  26. package/extensions/opl-footer/segments/compactions.ts +13 -0
  27. package/extensions/opl-footer/segments/context.ts +6 -5
  28. package/extensions/opl-footer/segments/index.ts +2 -0
  29. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  30. package/extensions/opl-footer/segments/path.ts +1 -1
  31. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  32. package/extensions/opl-footer/theme.ts +5 -2
  33. package/extensions/opl-footer/types.ts +4 -0
  34. package/extensions/opl-guardian/README.md +17 -5
  35. package/extensions/opl-guardian/config.ts +72 -6
  36. package/extensions/opl-guardian/guardian.ts +42 -10
  37. package/extensions/opl-guardian/index.ts +52 -17
  38. package/extensions/opl-guardian/policies.ts +52 -16
  39. package/extensions/opl-init/README.md +12 -4
  40. package/extensions/opl-init/index.ts +228 -27
  41. package/extensions/opl-input/README.md +17 -3
  42. package/extensions/opl-input/config.ts +59 -5
  43. package/extensions/opl-input/configure.ts +144 -0
  44. package/extensions/opl-input/index.ts +38 -30
  45. package/extensions/opl-input/mode-style.ts +6 -2
  46. package/extensions/opl-modes/README.md +16 -4
  47. package/extensions/opl-modes/config.ts +62 -8
  48. package/extensions/opl-modes/index.ts +48 -17
  49. package/extensions/opl-modes/state.ts +50 -2
  50. package/extensions/opl-modes/types.ts +6 -0
  51. package/extensions/opl-modes/utils.ts +18 -8
  52. package/extensions/opl-questionnaire/index.ts +10 -4
  53. package/extensions/opl-questionnaire/types.ts +4 -0
  54. package/extensions/opl-simplebench/README.md +21 -15
  55. package/extensions/opl-simplebench/artifact.ts +7 -4
  56. package/extensions/opl-simplebench/benchmark.ts +96 -35
  57. package/extensions/opl-simplebench/coding.ts +9 -4
  58. package/extensions/opl-simplebench/index.ts +19 -19
  59. package/extensions/opl-simplebench/llama-server.ts +3 -2
  60. package/extensions/opl-simplebench/report.ts +0 -6
  61. package/extensions/opl-simplebench/research.ts +5 -4
  62. package/extensions/opl-simplebench/scoring.ts +4 -2
  63. package/extensions/opl-simplebench/util/config.ts +15 -8
  64. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  65. package/extensions/opl-simplebench/util/format.ts +2 -2
  66. package/extensions/opl-simplebench/util/providers.ts +23 -194
  67. package/extensions/opl-todo/README.md +5 -5
  68. package/extensions/opl-todo/config.ts +7 -2
  69. package/extensions/opl-todo/index.ts +50 -9
  70. package/extensions/opl-webaccess/README.md +31 -5
  71. package/extensions/opl-webaccess/config.ts +36 -7
  72. package/extensions/opl-webaccess/extract.ts +37 -47
  73. package/extensions/opl-webaccess/http.ts +259 -0
  74. package/extensions/opl-webaccess/index.ts +12 -6
  75. package/extensions/opl-webaccess/package.json +4 -9
  76. package/extensions/opl-webaccess/pdf.ts +4 -4
  77. package/extensions/opl-webaccess/search.ts +19 -8
  78. package/extensions/opl-webaccess/storage.ts +134 -2
  79. package/extensions/opl-webaccess/types.ts +4 -0
  80. package/extensions/opl-webaccess/utils.ts +221 -33
  81. package/images/ss-init.png +0 -0
  82. package/images/ss-input-footer.png +0 -0
  83. package/images/ss-input-pet.png +0 -0
  84. package/images/ss-mode-custom.png +0 -0
  85. package/images/ss-questionnaire0.png +0 -0
  86. package/images/ss-questionnaire1.png +0 -0
  87. package/images/ss-simplebench.png +0 -0
  88. package/images/ss-todo.png +0 -0
  89. package/package.json +20 -12
@@ -9,13 +9,19 @@ Generates or refreshes a repository-specific `AGENTS.md` guide through `/init`.
9
9
 
10
10
  ## Extension features
11
11
 
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.
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 at `reasoning: "minimal"` (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. `"minimal"` is the lowest thinking level pi-ai accepts; an earlier `{ reasoning: false }` read as *unset*, so the call silently paid the provider's default thinking level.
13
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
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
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
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".
17
+ - Crawls to depth 3, ignores generated/dependency directories, caps directory listings at 40 entries and 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. Workspace globs escape every metacharacter except `*` and `?`, because real member patterns carry them (`packages/app(one`, `libs/c++/*`); an unescaped one threw `SyntaxError` and aborted the entire crawl. A glob that compiles to nothing is skipped, reported in `Crawl.skippedGlobs`, and named in a tree line so the skip is visible in the guide.
18
+ - Fingerprints dirty tracked + untracked files (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". Content hashing is **budgeted** (`FP_LIMITS`: 2 MB per file, 32 MB total, 5,000 paths): past the per-file or total ceiling a file contributes `name|size|mtime` instead of its bytes, and past the path ceiling a path contributes its name plus `OVER_CAP`. Budget state is folded into the digest, so the fingerprint cannot oscillate. Without the ceilings a single 200 MB build artifact cost 143 ms and +200 MB of RSS on every `/init`.
19
+
20
+ ## User-owned operational notes
21
+
22
+ `/init` preserves an existing `## Operational notes` section verbatim, including nested `###` subsections, whitespace and line endings. The section ends at the next level-1 or level-2 Markdown heading outside a fenced code block, or at the end of the document. The final `opl-init:fp` marker belongs to the extension, not the section. The preserved section is placed after the regenerated guide and before that marker, whether model refinement succeeds or falls back to the deterministic crawl. Other hand-edited sections are replaced; if the protected heading is absent, generation is unchanged.
23
+
24
+ Use one section with that exact, case-sensitive title. Duplicate sections cancel the update, as does a detected file change during refinement; resolve duplicates or rerun `/init` after editing. This protects against `/init` updates only, not ordinary agent edits or other programs.
19
25
 
20
26
  ## Requirements
21
27
 
@@ -23,4 +29,6 @@ Refinement needs pi 0.86.0 or newer (`ctx.modelRegistry.streamSimple`, `ctx.relo
23
29
 
24
30
  ## Architecture
25
31
 
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.
32
+ Every `git` call goes through one guard. A repository is attacker-chosen data and can declare programs of its own (`[diff "x"] textconv`, `core.fsmonitor`, `core.hooksPath`, or a system/user config naming a helper) that git runs while answering what looks like a read-only query. The guard passes `--no-pager`, `-c core.fsmonitor=false`, `-c core.hooksPath=`, `-c protocol.ext.allow=never`, `-c credential.helper=`, `GIT_CONFIG_NOSYSTEM`, `GIT_ATTR_NOSYSTEM`, `GIT_TERMINAL_PROMPT=0` and `GIT_OPTIONAL_LOCKS=0`, plus `--no-ext-diff --no-textconv` on diff-family subcommands. Probes still ask for name-only or porcelain output, because that is all a fingerprint needs. The block is duplicated in `opl-footer/git-status.ts` - installs are per-directory, so the two cannot share a module - and `tests/net-guard-parity.test.mjs` fails if the copies drift.
33
+
34
+ `index.ts` owns crawling, fingerprinting, the evidence packet, the model call, output validation, and writing. Git repositories fingerprint `schema version + HEAD + a budgeted digest of dirty tracked and untracked files` (root `AGENTS.md` excluded, content replaced by `size|mtime` past the byte ceilings); non-git repositories use sorted path/size/mtime tuples. `gitOutput` caps captured output at 4 MB because every probe asks for path lists or a single value. `/init` writes the file itself and reloads the session context; the main agent is never enlisted.
@@ -31,6 +31,7 @@ type Crawl = {
31
31
  extCounts: Map<string, number>;
32
32
  manifests: { path: string; content: string }[];
33
33
  workspaceMembers: string[];
34
+ skippedGlobs: string[];
34
35
  seen: Set<string>;
35
36
  };
36
37
 
@@ -42,41 +43,147 @@ const FP_MARKER = /<!-- opl-init:fp (\S+) -->/;
42
43
  // (vs HEAD, so staged changes count) and every untracked file. Only the root
43
44
  // AGENTS.md is excluded, so writing the guide does not make it stale;
44
45
  // subdirectory AGENTS.md files are repository facts.
46
+ // /init computes the fingerprint synchronously on the command path, and a workspace can
47
+ // hold a dataset, model weights or a recording. Content-hashing everything cost 135 ms and
48
+ // 245 MB RSS for a single 200 MB file, so large files are represented by their metadata and
49
+ // the whole pass runs under a path and byte budget.
50
+ // Known blind spot, accepted deliberately: a rewrite that keeps size and mtime identical, or
51
+ // any change past the caps, is invisible until a change the fingerprint can see happens.
52
+ const FP_LIMITS = {
53
+ maxFileBytes: 2 * 1024 * 1024,
54
+ maxTotalBytes: 32 * 1024 * 1024,
55
+ maxPaths: 5000,
56
+ };
57
+
58
+ type FpLimits = typeof FP_LIMITS;
59
+ type FpBudget = { bytes: number; paths: number; capped: boolean };
60
+
45
61
  // Returns null for non-git directories (caller falls back to fingerprintFallback).
46
- function fingerprintGit(root: string): string | null {
62
+ function fingerprintGit(root: string, limits: FpLimits = FP_LIMITS): string | null {
47
63
  const head = gitOutput(root, ["rev-parse", "HEAD"]);
48
64
  if (head === null) return null;
49
65
  const hash = createHash("sha256").update(`schema:${GUIDE_SCHEMA_VERSION}\0head:${head}\0`);
66
+ const budget: FpBudget = { bytes: 0, paths: 0, capped: false };
50
67
  // --relative keeps diff paths cwd-relative and cwd-scoped, matching
51
68
  // ls-files --others and the crawl; without it a subdirectory session would
52
69
  // resolve every dirty path to a nonexistent file and hash "DELETED".
53
70
  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");
71
+ hashFingerprintPath(hash, root, path, budget, limits);
56
72
  }
57
73
  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");
74
+ hashFingerprintPath(hash, root, path, budget, limits);
60
75
  }
76
+ // Fold the budget state in so crossing a cap changes the digest once and then stays
77
+ // stable: an oscillating digest would make every /init run regenerate the guide.
78
+ hash.update(`budget:${budget.paths}:${budget.bytes}:${budget.capped ? 1 : 0}\0`);
61
79
  return hash.digest("hex").slice(0, 16);
62
80
  }
63
81
 
82
+ function hashFingerprintPath(
83
+ hash: ReturnType<typeof createHash>,
84
+ root: string,
85
+ path: string,
86
+ budget: FpBudget,
87
+ limits: FpLimits,
88
+ ): void {
89
+ if (!path || path === "AGENTS.md") return;
90
+ // The name is always hashed even past a cap, so adding or removing a file is still
91
+ // visible; only content stops being read.
92
+ hash.update(`${path}\0`).update(budget.paths >= limits.maxPaths ? "OVER_CAP\0" : hashFile(join(root, path), budget, limits)).update("\0");
93
+ budget.paths++;
94
+ if (budget.paths >= limits.maxPaths) budget.capped = true;
95
+ }
96
+
97
+ // BEGIN SHARED GIT GUARD
98
+ // A repository is attacker-chosen data: its local config, `.gitattributes` and system
99
+ // config can name programs that git runs while answering what looks like a read-only query.
100
+ // Measured on git 2.54: `git diff HEAD` executes a declared `[diff "x"] textconv` driver,
101
+ // while `--name-only`, `status --porcelain`, `ls-files` and `rev-parse` do not. Every call
102
+ // still goes through this guard so a future content-diff call cannot reintroduce the
103
+ // execution path. Duplicated per extension because install.sh installs one directory at a
104
+ // time; tests/net-guard-parity.test.mjs fails if the copies drift.
105
+ const SAFE_GIT_GLOBAL_ARGS = [
106
+ "--no-pager",
107
+ "-c",
108
+ "core.fsmonitor=false",
109
+ "-c",
110
+ "core.hooksPath=",
111
+ "-c",
112
+ "protocol.ext.allow=never",
113
+ "-c",
114
+ "credential.helper=",
115
+ ];
116
+
117
+ /** Refuse external content handlers. Diff-family subcommands only: plumbing rejects them. */
118
+ const SAFE_GIT_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv"];
119
+ const GIT_DIFF_COMMANDS = new Set(["diff", "log", "show", "stash", "range-diff", "format-patch"]);
120
+
121
+ const SAFE_GIT_ENV: Record<string, string> = {
122
+ // /etc/gitconfig and the user's own config can name helpers; a repo probe must not read them.
123
+ GIT_CONFIG_NOSYSTEM: "1",
124
+ GIT_ATTR_NOSYSTEM: "1",
125
+ GIT_PAGER: "cat",
126
+ // A credential prompt would hang the caller instead of failing fast.
127
+ GIT_TERMINAL_PROMPT: "0",
128
+ GIT_ASKPASS: "",
129
+ SSH_ASKPASS: "",
130
+ // Read-only plumbing must not create or wait on index.lock.
131
+ GIT_OPTIONAL_LOCKS: "0",
132
+ };
133
+
134
+ export function safeGitInvocation(args: string[]): string[] {
135
+ const [subcommand, ...rest] = args;
136
+ const diffFlags = subcommand && GIT_DIFF_COMMANDS.has(subcommand) ? SAFE_GIT_DIFF_FLAGS : [];
137
+ // A missing subcommand must not reach execFileSync/spawn as an undefined argv slot.
138
+ return subcommand
139
+ ? [...SAFE_GIT_GLOBAL_ARGS, subcommand, ...diffFlags, ...rest]
140
+ : [...SAFE_GIT_GLOBAL_ARGS, ...rest];
141
+ }
142
+
143
+ export function gitGuardEnv(base: Record<string, string | undefined> = process.env): Record<string, string> {
144
+ const env: Record<string, string> = {};
145
+ for (const [key, value] of Object.entries(base)) {
146
+ if (typeof value === "string") env[key] = value;
147
+ }
148
+ return { ...env, ...SAFE_GIT_ENV };
149
+ }
150
+ // END SHARED GIT GUARD
151
+
64
152
  function gitOutput(root: string, args: string[]): string | null {
153
+ // Name-only/porcelain plumbing only; the guard above refuses patch content.
65
154
  try {
66
- return execFileSync("git", args, {
155
+ return execFileSync("git", safeGitInvocation(args), {
67
156
  cwd: root,
157
+ env: gitGuardEnv(),
68
158
  encoding: "utf8",
69
159
  stdio: ["ignore", "pipe", "ignore"],
70
- maxBuffer: 64 * 1024 * 1024,
160
+ // Name-only and porcelain output is path lists: 4 MB is on the order of 100k paths.
161
+ // A repo that needs more makes execFileSync throw, which reads as "not a git
162
+ // directory" and falls through to the stat-only fingerprintFallback.
163
+ maxBuffer: 4 * 1024 * 1024,
71
164
  });
72
165
  } catch {
73
166
  return null;
74
167
  }
75
168
  }
76
169
 
77
- function hashFile(path: string): string {
170
+ function hashFile(path: string, budget: FpBudget, limits: FpLimits): string {
171
+ let stats;
172
+ try {
173
+ stats = statSync(path);
174
+ } catch {
175
+ return "DELETED";
176
+ }
177
+ // Above either threshold the entry is represented by metadata instead of bytes: a
178
+ // change to size or mtime is still detected, and the pass stays bounded.
179
+ if (!stats.isFile() || stats.size > limits.maxFileBytes || budget.bytes + stats.size > limits.maxTotalBytes) {
180
+ budget.capped = true;
181
+ return `meta:${stats.size}:${Math.round(stats.mtimeMs)}`;
182
+ }
78
183
  try {
79
- return createHash("sha256").update(readFileSync(path)).digest("hex");
184
+ const body = readFileSync(path);
185
+ budget.bytes += body.length;
186
+ return createHash("sha256").update(body).digest("hex");
80
187
  } catch {
81
188
  return "DELETED";
82
189
  }
@@ -126,6 +233,7 @@ function crawl(root: string): Crawl {
126
233
  extCounts: new Map(),
127
234
  manifests: [],
128
235
  workspaceMembers: [],
236
+ skippedGlobs: [],
129
237
  seen: new Set(),
130
238
  };
131
239
 
@@ -190,6 +298,7 @@ function crawl(root: string): Crawl {
190
298
  break;
191
299
  }
192
300
  const entry = all[i];
301
+ if (entry === undefined) continue;
193
302
  result.tree.push(`${prefix}${entry}`);
194
303
  if (entry.endsWith("/") && depth < maxDepth) {
195
304
  walk(join(dir, entry.slice(0, -1)), depth + 1, `${prefix} `, maxDepth);
@@ -202,7 +311,7 @@ function crawl(root: string): Crawl {
202
311
  // Manifest-first monorepo expansion: enumerate declared workspace members
203
312
  // with a fresh depth budget so packages/foo/src is not penalized by its
204
313
  // grouping prefix.
205
- for (const member of workspaceMembers(root)) {
314
+ for (const member of workspaceMembers(root, result.skippedGlobs)) {
206
315
  if (result.tree.length >= MAX_TREE_LINES) break;
207
316
  const abs = join(root, member);
208
317
  let stats;
@@ -225,23 +334,42 @@ function crawl(root: string): Crawl {
225
334
  if (result.tree.length >= MAX_TREE_LINES) {
226
335
  result.tree.push(`(tree truncated at ${MAX_TREE_LINES} entries)`);
227
336
  }
337
+ if (result.skippedGlobs.length > 0) {
338
+ result.tree.push(
339
+ `(skipped workspace glob(s) that could not be compiled: ${result.skippedGlobs.join(", ")})`,
340
+ );
341
+ }
228
342
  return result;
229
343
  }
230
344
 
231
345
  // ponytail: naive glob matcher supporting only what workspace files use in
232
- // practice: `**` across segments, `*` within a segment, optional trailing "/".
233
- function globToRegExp(pattern: string): RegExp {
346
+ // practice: `**` across segments, `*` within a segment, `?` for one character,
347
+ // optional trailing "/". Everything else is escaped, so a directory name that is
348
+ // itself regex syntax (`libs/c++/*`) matches literally instead of throwing.
349
+ // Returns null for a pattern that cannot be compiled: workspace files are
350
+ // user-authored, and one bad line must not abort /init.
351
+ function globToRegExp(pattern: string): RegExp | null {
234
352
  const cleaned = pattern.replace(/\/$/, "");
235
- const source = cleaned
236
- .split("/**/")
237
- .map((seg) => seg.replace(/[*]/g, "[^/]*"))
238
- .join("(?:/.*)?");
239
- return new RegExp(`^${source}$`);
353
+ if (cleaned === "") return null;
354
+ try {
355
+ const source = cleaned
356
+ .split("/**/")
357
+ .map((segment) =>
358
+ segment
359
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
360
+ .replace(/\*/g, "[^/]*")
361
+ .replace(/\?/g, "[^/]"),
362
+ )
363
+ .join("(?:/.*)?");
364
+ return new RegExp(`^${source}$`);
365
+ } catch {
366
+ return null;
367
+ }
240
368
  }
241
369
 
242
370
  // ponytail: line-based subset parsing of pnpm-workspace.yaml and Cargo.toml
243
371
  // [workspace] members. Full YAML/TOML parsing not warranted for glob lists.
244
- function workspaceMembers(root: string): string[] {
372
+ function workspaceMembers(root: string, skipped: string[]): string[] {
245
373
  const globs: string[] = [];
246
374
 
247
375
  try {
@@ -254,7 +382,8 @@ function workspaceMembers(root: string): string[] {
254
382
  inPackages = false;
255
383
  } else if (inPackages) {
256
384
  const match = line.match(/^\s*-\s*["']?([^"'#]+)/);
257
- if (match) globs.push(match[1].trim());
385
+ const pkg = match?.[1];
386
+ if (pkg) globs.push(pkg.trim());
258
387
  }
259
388
  }
260
389
  } catch {
@@ -264,10 +393,14 @@ function workspaceMembers(root: string): string[] {
264
393
  try {
265
394
  const toml = readFileSync(join(root, "Cargo.toml"), "utf8");
266
395
  const section = toml.match(/\[workspace\]([\s\S]*?)(?:\n\[|\s*$)/);
267
- const membersBlock = section?.[1].match(/members\s*=\s*\[([^\]]*)\]/s);
396
+ const membersBlock = section?.[1]?.match(/members\s*=\s*\[([^\]]*)\]/s);
268
397
  if (membersBlock) {
269
- for (const m of membersBlock[1].matchAll(/["']([^"']+)["']/g)) {
270
- globs.push(m[1]);
398
+ const memberList = membersBlock[1];
399
+ if (memberList) {
400
+ for (const m of memberList.matchAll(/["']([^"']+)["']/g)) {
401
+ const name = m[1];
402
+ if (name) globs.push(name);
403
+ }
271
404
  }
272
405
  }
273
406
  } catch {
@@ -277,6 +410,10 @@ function workspaceMembers(root: string): string[] {
277
410
  const members = new Set<string>();
278
411
  for (const glob of globs) {
279
412
  const re = globToRegExp(glob);
413
+ if (!re) {
414
+ if (!skipped.includes(glob)) skipped.push(glob);
415
+ continue;
416
+ }
280
417
  // Match against shallow candidate paths from the tree we already walked,
281
418
  // plus one extra readdir of likely parent dirs. Simple approach: test every
282
419
  // tree dir line's relative path.
@@ -431,7 +568,9 @@ async function refineGuide(ctx: any, evidence: string, marker: string): Promise<
431
568
  const stream = ctx.modelRegistry.streamSimple(
432
569
  model,
433
570
  { systemPrompt: REFINE_SYSTEM_PROMPT, messages: [{ role: "user", content: evidence, timestamp: Date.now() }], tools: [] },
434
- { reasoning: false, signal: AbortSignal.timeout(REFINE_TIMEOUT_MS) },
571
+ // ThinkingLevel has no "off": "minimal" is the cheapest level the adapters accept.
572
+ // `false` was silently dropped as "unset", so the refinement paid the provider default.
573
+ { reasoning: "minimal", signal: AbortSignal.timeout(REFINE_TIMEOUT_MS) },
435
574
  );
436
575
  const result = await stream.result();
437
576
  if (!result || result.stopReason === "error" || result.stopReason === "aborted") return null;
@@ -445,8 +584,50 @@ async function refineGuide(ctx: any, evidence: string, marker: string): Promise<
445
584
  }
446
585
  }
447
586
 
587
+ // Offsets retain the user's original line endings and whitespace. Only ATX
588
+ // headings outside fenced code blocks delimit the owned section.
589
+ function operationalSections(text: string): { start: number; end: number }[] {
590
+ const sections: { start: number; end: number }[] = [];
591
+ let active: { start: number; end: number } | undefined;
592
+ let fence: { char: string; length: number } | undefined;
593
+ for (const match of text.matchAll(/[^\n]*(?:\n|$)/g)) {
594
+ const line = match[0].replace(/\r?\n$/, "");
595
+ const offset = match.index;
596
+ const fenced = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
597
+ if (fence) {
598
+ if (fenced?.[1]?.[0] === fence.char && fenced[1].length >= fence.length && !fenced[2]?.trim()) fence = undefined;
599
+ continue;
600
+ }
601
+ if (fenced?.[1] && (fenced[1][0] !== "`" || !fenced[2]?.includes("`"))) {
602
+ fence = { char: fenced[1][0]!, length: fenced[1].length };
603
+ continue;
604
+ }
605
+ const heading = line.match(/^ {0,3}(#{1,2})(?:[ \t]+(.*)|[ \t]*)$/);
606
+ if (!heading) continue;
607
+ if (active) { active.end = offset; active = undefined; }
608
+ if (heading[1] === "##" && heading[2]?.replace(/[ \t]+#+[ \t]*$/, "").trim() === "Operational notes") {
609
+ active = { start: offset, end: text.length };
610
+ sections.push(active);
611
+ }
612
+ }
613
+ return sections;
614
+ }
615
+
616
+ function guideWithoutFinalMarker(text: string): string {
617
+ return text.replace(/(^|\n)<!-- opl-init:fp \S+ -->[ \t]*(?:\r?\n)?$/, "$1");
618
+ }
619
+
620
+ function mergeOperationalNotes(generated: string, owned: string, marker: string): string {
621
+ let body = guideWithoutFinalMarker(generated);
622
+ // Discard generated versions: the existing user section is authoritative.
623
+ for (const section of operationalSections(body).reverse()) {
624
+ body = body.slice(0, section.start) + body.slice(section.end);
625
+ }
626
+ return `${body.trimEnd()}\n\n${owned}${owned.endsWith("\n") ? "" : "\n"}${marker}\n`;
627
+ }
628
+
448
629
  // Named exports for fixture tests (tests/opl-init-*.test.mjs).
449
- export { crawl, fingerprint, evidencePacket, finalizeRefinedGuide, refineGuide };
630
+ export { crawl, fingerprint, fingerprintGit, FP_LIMITS, evidencePacket, finalizeRefinedGuide, refineGuide };
450
631
 
451
632
  export default function (pi: ExtensionAPI) {
452
633
  pi.registerCommand("init", {
@@ -480,14 +661,34 @@ export default function (pi: ExtensionAPI) {
480
661
  }
481
662
 
482
663
  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.
664
+ let original: string | null;
665
+ let owned: string | undefined;
666
+ try {
667
+ original = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null;
668
+ const body = guideWithoutFinalMarker(original ?? "");
669
+ const sections = operationalSections(body);
670
+ if (sections.length > 1) {
671
+ ctx.ui.notify("AGENTS.md has duplicate Operational notes sections; /init will not modify it.", "error");
672
+ return;
673
+ }
674
+ const section = sections[0];
675
+ if (section) owned = body.slice(section.start, section.end);
676
+ } catch (error: any) {
677
+ ctx.ui.notify(`Could not read AGENTS.md: ${error?.message || error}`, "error");
678
+ return;
679
+ }
485
680
  const crawlResult = crawl(root);
486
681
  const baseline = buildGuide(root, crawlResult, marker);
487
682
  const refined = await refineGuide(ctx, evidencePacket(root, baseline, crawlResult), marker);
488
683
 
489
684
  try {
490
- writeFileSync(agentsPath, refined ?? baseline, "utf8");
685
+ const latest = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null;
686
+ if (latest !== original) {
687
+ ctx.ui.notify("AGENTS.md changed during /init; update cancelled. Run /init again.", "error");
688
+ return;
689
+ }
690
+ const generated = refined ?? baseline;
691
+ writeFileSync(agentsPath, owned === undefined ? generated : mergeOperationalNotes(generated, owned, marker), "utf8");
491
692
  } catch (error: any) {
492
693
  ctx.ui.notify(`Could not write AGENTS.md: ${error?.message || error}`, "error");
493
694
  return;
@@ -4,7 +4,7 @@ Replaces Pi's default chat editor with a mode-aware custom editor. Native editor
4
4
 
5
5
  ## Commands, flags, and shortcuts
6
6
 
7
- No commands, flags, or shortcuts. It replaces the standard editor at session start.
7
+ No commands, flags, or shortcuts of its own. It replaces the standard editor at session start. Its settings are editable through the `/configurator` shell (Input tab, hotkey `i`); a save writes `opl-input.json` and reports that the change takes effect after `/reload`.
8
8
 
9
9
  ## Extension features
10
10
 
@@ -67,7 +67,7 @@ The companion requires at least 40 terminal columns and reserves three top-paddi
67
67
 
68
68
  Every color option accepts either:
69
69
 
70
- - **A Pi theme token** — one of the 45 tokens defined by Pi's `Theme`:
70
+ - **A Pi theme token** — a color name from Pi's theme schema (56 names at pi 0.87.0 and 1.1.0, 7 of them `*Bg` fills). The ones that matter here:
71
71
  - Core: `accent`, `border`, `borderAccent`, `borderMuted`, `success`, `error`, `warning`, `muted`, `dim`, `text`
72
72
  - Messages/content: `thinkingText`, `searchMatchText`, `userMessageText`, `customMessageText`, `customMessageLabel`
73
73
  - Tools: `toolTitle`, `toolOutput`, `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext`
@@ -75,7 +75,7 @@ Every color option accepts either:
75
75
  - Syntax: `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation`
76
76
  - Thinking borders: `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `thinkingMax`
77
77
  - Special: `bashMode`
78
- - **A hex color** — `"#c07898"`, or the three-digit shorthand `"#abc"` (expanded to `#aabbcc`; rendered as ANSI truecolor, downgraded automatically on 256-color terminals).
78
+ - **A hex color** — `"#c07898"`, or the three-digit shorthand `"#abc"` (expanded to `#aabbcc`). Hex values are emitted as ANSI truecolor (`38;2;r;g;b`); there is no 256-color downgrade in this extension, so on a 256-only terminal a hex color may render approximately or not at all, while a theme token renders correctly.
79
79
 
80
80
  Invalid theme tokens fall back to the theme's `border` token; invalid hex renders uncolored rather than crashing.
81
81
  - A mode `prefix` is clamped to one terminal cell (continuation lines reserve a single space), so a wide or multi-character `appearance.prefix` such as `👀` is truncated to one cell rather than pushing the box border past the editor width.
@@ -83,3 +83,17 @@ Invalid theme tokens fall back to the theme's `border` token; invalid hex render
83
83
  ## Architecture
84
84
 
85
85
  `index.ts` installs the editor integration and disposes companion render timers on editor replacement or session shutdown; `mode-style.ts` resolves Bash > published mode appearance > compiled fallback; `config.ts` loads editor and companion settings; and `utils.ts` handles color and rendering helpers. `opl-modes` is the sole publisher of active mode appearance through `globalThis.__agentMode`.
86
+
87
+ Two couplings are worth knowing before editing this extension:
88
+
89
+ - **pi-tui's text output.** Re-framing the editor means recognising its horizontal borders and
90
+ its scroll indicator, and pi-tui exposes neither as an API: they are the literal strings `─`
91
+ and `─── ↑ 3 more `. Those markers are collected in one place (`isSolidBorder`,
92
+ `scrollIndicatorText`, `isBorderLike` in `index.ts`) and
93
+ `tests/opl-input-pi-tui-markers.test.mjs` asserts the installed pi-tui still emits them. They
94
+ were byte-identical in pi-tui 0.87.0, 0.99.1 and 1.1.0; a failure there means the format
95
+ changed and the re-framing needs updating, not that the test is wrong.
96
+ - **The bundle's idle heartbeat.** This component owns the only render timer, so the footer's
97
+ time-based cells advance because of it. Disabling `opl-input` freezes that clock; with
98
+ `companion.enabled: false` the tick slows to one repaint per `IDLE_REPAINT_MS` rather than
99
+ stopping.
@@ -1,6 +1,6 @@
1
- import { readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  export const DEFAULT_CONFIG = {
6
6
  BOX_PAD_X: 1,
@@ -29,11 +29,65 @@ interface ChatInputUserConfig {
29
29
  };
30
30
  }
31
31
 
32
- const CONFIG_PATH = join(homedir(), ".pi", "agent", "configs", "opl-input.json");
32
+ /** Resolved per call so a custom `PI_AGENT_DIR` (pi's own agent dir) is honoured. */
33
+ export function configPath(): string {
34
+ return join(getAgentDir(), "configs", "opl-input.json");
35
+ }
36
+
37
+ /** Subset of fields the configurator tab is allowed to edit. */
38
+ export interface EditableInputConfig {
39
+ boxedView?: boolean;
40
+ companion?: {
41
+ enabled?: boolean;
42
+ type?: string;
43
+ };
44
+ }
45
+
46
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === "object" && value !== null && !Array.isArray(value);
48
+ }
49
+
50
+ /** Read the live config as a loose record ({} on absence or malformed), preserving unknown keys. */
51
+ export function readInputConfig(): Record<string, unknown> {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
54
+ return isPlainObject(parsed) ? parsed : {};
55
+ } catch {
56
+ return {};
57
+ }
58
+ }
59
+
60
+ /** Merge only the editable fields into the live file. Returns false when nothing changed. */
61
+ export function saveInputConfig(patch: EditableInputConfig): boolean {
62
+ const current = readInputConfig();
63
+ const next: Record<string, unknown> = { ...current };
64
+
65
+ if (typeof patch.boxedView === "boolean") next.boxedView = patch.boxedView;
66
+
67
+ if (patch.companion) {
68
+ const enabled = patch.companion.enabled;
69
+ const type = patch.companion.type;
70
+ const hasEnabled = typeof enabled === "boolean";
71
+ const hasType = typeof type === "string" && type.length > 0;
72
+ if (hasEnabled || hasType) {
73
+ const companion = isPlainObject(next.companion) ? { ...next.companion } : {};
74
+ if (hasEnabled) companion.enabled = enabled;
75
+ if (hasType) companion.type = type;
76
+ next.companion = companion;
77
+ }
78
+ }
79
+
80
+ if (JSON.stringify(next) === JSON.stringify(current)) return false;
81
+
82
+ const file = configPath();
83
+ mkdirSync(dirname(file), { recursive: true });
84
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n", "utf8");
85
+ return true;
86
+ }
33
87
 
34
88
  function loadUserConfig(): ChatInputUserConfig {
35
89
  try {
36
- const raw = readFileSync(CONFIG_PATH, "utf8");
90
+ const raw = readFileSync(configPath(), "utf8");
37
91
  return JSON.parse(raw) as ChatInputUserConfig;
38
92
  } catch {
39
93
  return {};