@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.
- package/README.md +92 -82
- package/configs/opl-browser.json.sample +3 -2
- package/configs/opl-footer.json.sample +3 -2
- package/configs/opl-guardian.json.sample +8 -2
- package/configs/opl-modes.json.sample +5 -0
- package/configs/opl-webaccess.json.sample +7 -1
- package/extensions/opl-browser/README.md +81 -55
- package/extensions/opl-browser/browser.ts +244 -50
- package/extensions/opl-browser/config.ts +15 -7
- package/extensions/opl-browser/index.ts +5 -4
- package/extensions/opl-browser/package.json +4 -4
- package/extensions/opl-browser/validate.ts +313 -60
- package/extensions/opl-configurator/README.md +40 -0
- package/extensions/opl-configurator/configurator.ts +148 -0
- package/extensions/opl-configurator/index.ts +12 -0
- package/extensions/opl-ctxtrim/README.md +45 -14
- package/extensions/opl-ctxtrim/index.ts +33 -13
- package/extensions/opl-footer/README.md +32 -7
- package/extensions/opl-footer/config.ts +11 -10
- package/extensions/opl-footer/configure.ts +157 -115
- package/extensions/opl-footer/git-status.ts +58 -1
- package/extensions/opl-footer/icons.ts +3 -0
- package/extensions/opl-footer/index.ts +287 -109
- package/extensions/opl-footer/segments/caveman.ts +9 -1
- package/extensions/opl-footer/segments/chat-mode.ts +8 -1
- package/extensions/opl-footer/segments/compactions.ts +13 -0
- package/extensions/opl-footer/segments/context.ts +6 -5
- package/extensions/opl-footer/segments/index.ts +2 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
- package/extensions/opl-footer/segments/path.ts +1 -1
- package/extensions/opl-footer/segments/plan-mode.ts +8 -1
- package/extensions/opl-footer/theme.ts +5 -2
- package/extensions/opl-footer/types.ts +4 -0
- package/extensions/opl-guardian/README.md +17 -5
- package/extensions/opl-guardian/config.ts +72 -6
- package/extensions/opl-guardian/guardian.ts +42 -10
- package/extensions/opl-guardian/index.ts +52 -17
- package/extensions/opl-guardian/policies.ts +52 -16
- package/extensions/opl-init/README.md +12 -4
- package/extensions/opl-init/index.ts +228 -27
- package/extensions/opl-input/README.md +17 -3
- package/extensions/opl-input/config.ts +59 -5
- package/extensions/opl-input/configure.ts +144 -0
- package/extensions/opl-input/index.ts +38 -30
- package/extensions/opl-input/mode-style.ts +6 -2
- package/extensions/opl-modes/README.md +16 -4
- package/extensions/opl-modes/config.ts +62 -8
- package/extensions/opl-modes/index.ts +48 -17
- package/extensions/opl-modes/state.ts +50 -2
- package/extensions/opl-modes/types.ts +6 -0
- package/extensions/opl-modes/utils.ts +18 -8
- package/extensions/opl-questionnaire/index.ts +10 -4
- package/extensions/opl-questionnaire/types.ts +4 -0
- package/extensions/opl-simplebench/README.md +21 -15
- package/extensions/opl-simplebench/artifact.ts +7 -4
- package/extensions/opl-simplebench/benchmark.ts +96 -35
- package/extensions/opl-simplebench/coding.ts +9 -4
- package/extensions/opl-simplebench/index.ts +19 -19
- package/extensions/opl-simplebench/llama-server.ts +3 -2
- package/extensions/opl-simplebench/report.ts +0 -6
- package/extensions/opl-simplebench/research.ts +5 -4
- package/extensions/opl-simplebench/scoring.ts +4 -2
- package/extensions/opl-simplebench/util/config.ts +15 -8
- package/extensions/opl-simplebench/util/exec-env.ts +36 -0
- package/extensions/opl-simplebench/util/format.ts +2 -2
- package/extensions/opl-simplebench/util/providers.ts +23 -194
- package/extensions/opl-todo/README.md +5 -5
- package/extensions/opl-todo/config.ts +7 -2
- package/extensions/opl-todo/index.ts +50 -9
- package/extensions/opl-webaccess/README.md +31 -5
- package/extensions/opl-webaccess/config.ts +36 -7
- package/extensions/opl-webaccess/extract.ts +37 -47
- package/extensions/opl-webaccess/http.ts +259 -0
- package/extensions/opl-webaccess/index.ts +12 -6
- package/extensions/opl-webaccess/package.json +4 -9
- package/extensions/opl-webaccess/pdf.ts +4 -4
- package/extensions/opl-webaccess/search.ts +19 -8
- package/extensions/opl-webaccess/storage.ts +134 -2
- package/extensions/opl-webaccess/types.ts +4 -0
- package/extensions/opl-webaccess/utils.ts +221 -33
- package/images/ss-init.png +0 -0
- package/images/ss-input-footer.png +0 -0
- package/images/ss-input-pet.png +0 -0
- package/images/ss-mode-custom.png +0 -0
- package/images/ss-questionnaire0.png +0 -0
- package/images/ss-questionnaire1.png +0 -0
- package/images/ss-simplebench.png +0 -0
- package/images/ss-todo.png +0 -0
- 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
|
|
18
|
-
- Fingerprints dirty tracked + untracked
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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]
|
|
396
|
+
const membersBlock = section?.[1]?.match(/members\s*=\s*\[([^\]]*)\]/s);
|
|
268
397
|
if (membersBlock) {
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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** —
|
|
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
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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(
|
|
90
|
+
const raw = readFileSync(configPath(), "utf8");
|
|
37
91
|
return JSON.parse(raw) as ChatInputUserConfig;
|
|
38
92
|
} catch {
|
|
39
93
|
return {};
|