@openlines/opl-pi-sht 0.1.21 → 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 +8 -4
- package/configs/opl-modes.json.sample +24 -28
- package/extensions/opl-browser/browser.ts +4 -4
- package/extensions/opl-footer/README.md +9 -2
- package/extensions/opl-footer/git-status.ts +42 -0
- package/extensions/opl-footer/index.ts +26 -8
- package/extensions/opl-footer/segments/cost.ts +2 -2
- package/extensions/opl-footer/segments/helpers.ts +0 -2
- package/extensions/opl-footer/segments/mode-switcher.ts +4 -2
- package/extensions/opl-footer/theme.ts +24 -5
- package/extensions/opl-init/README.md +14 -11
- package/extensions/opl-init/index.ts +204 -148
- package/extensions/opl-input/README.md +5 -3
- package/extensions/opl-input/index.ts +14 -7
- package/extensions/opl-input/mode-style.ts +1 -1
- package/extensions/opl-input/utils.ts +16 -5
- package/extensions/opl-modes/README.md +19 -13
- package/extensions/opl-modes/config.ts +64 -16
- package/extensions/opl-modes/index.ts +156 -70
- package/extensions/opl-modes/state.ts +40 -18
- package/extensions/opl-modes/types.ts +2 -0
- package/extensions/opl-modes/utils.ts +123 -12
- package/extensions/opl-simplebench/benchmark.ts +24 -7
- package/extensions/opl-simplebench/index.ts +8 -5
- package/extensions/opl-simplebench/research.ts +4 -1
- package/extensions/opl-simplebench/util/providers.ts +6 -1
- package/extensions/opl-todo/index.ts +2 -2
- package/package.json +3 -3
|
@@ -20,56 +20,68 @@ const MANIFESTS = new Set([
|
|
|
20
20
|
const MAX_DEPTH = 3;
|
|
21
21
|
const MAX_MEMBER_DEPTH = 3;
|
|
22
22
|
const MAX_TREE_LINES = 300;
|
|
23
|
-
const
|
|
23
|
+
const MAX_MANIFEST_PARSE_BYTES = 262144;
|
|
24
24
|
const MAX_DIR_ENTRIES = 40;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// AGENTS.md is deliberately absent — it is this command's output.
|
|
29
|
-
const RULE_SOURCES = [
|
|
30
|
-
".cursorrules",
|
|
31
|
-
".cursor/rules",
|
|
32
|
-
".github/copilot-instructions.md",
|
|
33
|
-
"CLAUDE.md",
|
|
34
|
-
"CLAUDE.local.md",
|
|
35
|
-
".claude/rules",
|
|
36
|
-
".windsurfrules",
|
|
37
|
-
".windsurf/rules",
|
|
38
|
-
".clinerules",
|
|
39
|
-
".devin/rules",
|
|
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;
|
|
41
28
|
|
|
42
29
|
type Crawl = {
|
|
43
30
|
tree: string[];
|
|
44
31
|
extCounts: Map<string, number>;
|
|
45
32
|
manifests: { path: string; content: string }[];
|
|
46
33
|
workspaceMembers: string[];
|
|
34
|
+
seen: Set<string>;
|
|
47
35
|
};
|
|
48
36
|
|
|
49
37
|
// Marker the model appends to AGENTS.md so future runs can detect staleness exactly.
|
|
50
38
|
const FP_MARKER = /<!-- opl-init:fp (\S+) -->/;
|
|
51
39
|
|
|
52
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.
|
|
53
45
|
// Returns null for non-git directories (caller falls back to fingerprintFallback).
|
|
54
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 {
|
|
55
65
|
try {
|
|
56
|
-
|
|
66
|
+
return execFileSync("git", args, {
|
|
57
67
|
cwd: root,
|
|
58
68
|
encoding: "utf8",
|
|
59
69
|
stdio: ["ignore", "pipe", "ignore"],
|
|
70
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
60
71
|
});
|
|
61
|
-
const status = execFileSync(
|
|
62
|
-
"git",
|
|
63
|
-
["status", "--porcelain=v1", "--untracked-files=all"],
|
|
64
|
-
{ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
65
|
-
);
|
|
66
|
-
const relevantStatus = status.split("\n").filter(line => !line.endsWith("AGENTS.md")).join("\n");
|
|
67
|
-
return createHash("sha256").update(head).update(relevantStatus).digest("hex").slice(0, 16);
|
|
68
72
|
} catch {
|
|
69
73
|
return null;
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
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
|
+
|
|
73
85
|
// ponytail: stat-only (path|size|mtime), not content hashing. Catches add/delete/
|
|
74
86
|
// rename but shares the touch-without-change blind spot; only used for non-git dirs.
|
|
75
87
|
function fingerprintFallback(root: string): string {
|
|
@@ -101,7 +113,7 @@ function fingerprintFallback(root: string): string {
|
|
|
101
113
|
|
|
102
114
|
walk(root);
|
|
103
115
|
parts.sort();
|
|
104
|
-
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);
|
|
105
117
|
}
|
|
106
118
|
|
|
107
119
|
function fingerprint(root: string): string {
|
|
@@ -114,6 +126,7 @@ function crawl(root: string): Crawl {
|
|
|
114
126
|
extCounts: new Map(),
|
|
115
127
|
manifests: [],
|
|
116
128
|
workspaceMembers: [],
|
|
129
|
+
seen: new Set(),
|
|
117
130
|
};
|
|
118
131
|
|
|
119
132
|
function walk(dir: string, depth: number, prefix: string, maxDepth: number) {
|
|
@@ -144,17 +157,23 @@ function crawl(root: string): Crawl {
|
|
|
144
157
|
}
|
|
145
158
|
|
|
146
159
|
files.push(entry);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
}
|
|
158
177
|
}
|
|
159
178
|
}
|
|
160
179
|
}
|
|
@@ -295,157 +314,194 @@ function result_tree_paths(root: string): string[] {
|
|
|
295
314
|
return paths;
|
|
296
315
|
}
|
|
297
316
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
return null;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function findRuleSources(root: string): { path: string; detail: string }[] {
|
|
311
|
-
const found: { path: string; detail: string }[] = [];
|
|
312
|
-
for (const name of RULE_SOURCES) {
|
|
313
|
-
const path = join(root, name);
|
|
314
|
-
let stats;
|
|
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;
|
|
315
325
|
try {
|
|
316
|
-
|
|
326
|
+
parsed = JSON.parse(manifest.content);
|
|
317
327
|
} catch {
|
|
318
328
|
continue;
|
|
319
329
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
326
|
-
continue;
|
|
327
|
-
}
|
|
328
|
-
found.push({ path: `${name}/ (${entries.length} file${entries.length === 1 ? "" : "s"})`, detail: entries.join(", ") });
|
|
329
|
-
} else {
|
|
330
|
-
try {
|
|
331
|
-
found.push({ path: name, detail: readFileSync(path, "utf8").slice(0, MAX_RULE_SOURCE_BYTES) });
|
|
332
|
-
} catch {
|
|
333
|
-
found.push({ path: name, detail: "(unreadable)" });
|
|
334
|
-
}
|
|
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}`);
|
|
335
336
|
}
|
|
336
337
|
}
|
|
337
|
-
return
|
|
338
|
+
return lines.length ? lines.join("\n") : null;
|
|
338
339
|
}
|
|
339
340
|
|
|
340
|
-
function
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
`## Repository crawl for: ${root}`,
|
|
347
|
-
`### Directory tree (depth ${MAX_DEPTH}; declared workspace members get their own depth-${MAX_MEMBER_DEPTH} walk; per-directory entries capped at ${MAX_DIR_ENTRIES})\n\`\`\`\n${crawlResult.tree.join("\n")}\n\`\`\``,
|
|
348
|
-
`### File counts by extension\n${extensions || "(none)"}`,
|
|
349
|
-
];
|
|
350
|
-
|
|
351
|
-
if (crawlResult.workspaceMembers.length) {
|
|
352
|
-
sections.push(`### Declared workspace members (expanded above)\n${crawlResult.workspaceMembers.join(", ")}`);
|
|
353
|
-
}
|
|
341
|
+
function buildGuide(root: string, crawlResult: Crawl, marker: string): string {
|
|
342
|
+
const topLevel = crawlResult.tree.filter(line => !line.startsWith(" ")).slice(0, 30).join(", ");
|
|
343
|
+
const scripts = aggregateScripts(crawlResult.manifests);
|
|
344
|
+
const commandBlock = scripts ? "Package scripts:\n```\n" + scripts + "\n```" : "No package scripts were detected by the repository crawl.";
|
|
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`;
|
|
346
|
+
}
|
|
354
347
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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;
|
|
365
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");
|
|
366
383
|
}
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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;
|
|
373
392
|
}
|
|
393
|
+
if (!push(`=== ${rel} ===\n${head}`)) break;
|
|
374
394
|
}
|
|
375
|
-
|
|
376
|
-
return sections.join("\n\n");
|
|
395
|
+
return parts.join("\n");
|
|
377
396
|
}
|
|
378
397
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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`;
|
|
386
418
|
}
|
|
387
419
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
Produce a concise, useful Markdown guide, usually 250-700 words:
|
|
394
|
-
- Start with a project-specific title such as "# Repository Guide" or "# <project>".
|
|
395
|
-
- Prefer these sections when supported by evidence: "## What this is", "## Commands", "## Architecture", "## Configuration and installation", "## Testing and operational quirks", and "## Key files".
|
|
396
|
-
- State meaningful absences when known, such as no build, lint, or typecheck command.
|
|
397
|
-
- Capture non-obvious constraints, state, deployment, security, or test-order invariants when they materially affect contributors.
|
|
398
|
-
- Avoid generic contribution, Git, or pull-request advice unless the repository supplies specific facts for it.
|
|
399
|
-
- Omit unsupported sections rather than inventing details.
|
|
400
|
-
- If existing agent rule sources are listed below, incorporate their relevant project-specific content into the guide. Do not copy them verbatim and do not treat them as authoritative over evidence from this repository.
|
|
401
|
-
- IMPORTANT: when you write or update AGENTS.md, make the very last line exactly the fingerprint marker given below (verbatim, no changes). This lets future runs detect staleness.
|
|
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;
|
|
402
425
|
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
}
|
|
405
447
|
|
|
406
|
-
// Named
|
|
407
|
-
export { crawl };
|
|
448
|
+
// Named exports for fixture tests (tests/opl-init-*.test.mjs).
|
|
449
|
+
export { crawl, fingerprint, evidencePacket, finalizeRefinedGuide, refineGuide };
|
|
408
450
|
|
|
409
451
|
export default function (pi: ExtensionAPI) {
|
|
410
452
|
pi.registerCommand("init", {
|
|
411
|
-
description: "[opl-init] Crawl
|
|
453
|
+
description: "[opl-init] Crawl + refine AGENTS.md out-of-band; zero model calls when current",
|
|
412
454
|
handler: async (_args, ctx) => {
|
|
413
455
|
const root = ctx.cwd;
|
|
414
456
|
const agentsPath = join(root, "AGENTS.md");
|
|
415
|
-
const
|
|
416
|
-
const exists = existsSync(agentsPath);
|
|
417
|
-
|
|
418
|
-
let storedFp: string | null = null;
|
|
419
|
-
if (exists) {
|
|
457
|
+
const storedFp = () => {
|
|
420
458
|
try {
|
|
421
|
-
|
|
459
|
+
return readFileSync(agentsPath, "utf8").match(FP_MARKER)?.[1] ?? null;
|
|
422
460
|
} catch {
|
|
423
|
-
|
|
461
|
+
return null;
|
|
424
462
|
}
|
|
425
|
-
}
|
|
463
|
+
};
|
|
426
464
|
|
|
427
|
-
|
|
465
|
+
let currentFp = fingerprint(root);
|
|
466
|
+
if (existsSync(agentsPath) && storedFp() === currentFp) {
|
|
428
467
|
ctx.ui.notify("AGENTS.md is current; /init will not modify it.", "info");
|
|
429
468
|
return;
|
|
430
469
|
}
|
|
431
470
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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");
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
437
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.
|
|
438
485
|
const crawlResult = crawl(root);
|
|
486
|
+
const baseline = buildGuide(root, crawlResult, marker);
|
|
487
|
+
const refined = await refineGuide(ctx, evidencePacket(root, baseline, crawlResult), marker);
|
|
488
|
+
|
|
439
489
|
try {
|
|
440
|
-
writeFileSync(agentsPath,
|
|
441
|
-
ctx.ui.notify("AGENTS.md baseline written; the agent will refine it from the crawl.", "info");
|
|
490
|
+
writeFileSync(agentsPath, refined ?? baseline, "utf8");
|
|
442
491
|
} catch (error: any) {
|
|
443
|
-
ctx.ui.notify(`Could not write AGENTS.md
|
|
492
|
+
ctx.ui.notify(`Could not write AGENTS.md: ${error?.message || error}`, "error");
|
|
493
|
+
return;
|
|
444
494
|
}
|
|
445
495
|
|
|
446
|
-
|
|
447
|
-
|
|
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",
|
|
448
501
|
);
|
|
502
|
+
// Terminal: pi docs say code after ctx.reload() runs in the old frame.
|
|
503
|
+
await ctx.reload();
|
|
504
|
+
return;
|
|
449
505
|
},
|
|
450
506
|
});
|
|
451
507
|
}
|
|
@@ -15,11 +15,12 @@ No commands, flags, or shortcuts. It replaces the standard editor at session sta
|
|
|
15
15
|
- **Scroll-aware borders**: Editor scroll indicators are embedded in the top or bottom border when the input has more content than fits.
|
|
16
16
|
- **Slash-menu placement**: The slash menu is rendered below the input with configurable gap and indentation.
|
|
17
17
|
- **Responsive rendering**: Narrow terminals fall back to the native editor rendering; the companion is hidden below 40 columns.
|
|
18
|
+
- **Shared idle heartbeat**: this component owns the bundle's only render timer. With the companion disabled it slows to one repaint per second instead of ten, which still advances the footer's elapsed/time-per-second cells while Pi is idle.
|
|
18
19
|
- **Hex and theme colors**: Color settings accept Pi theme tokens or six-digit hex colors.
|
|
19
20
|
|
|
20
21
|
## Mode styling precedence
|
|
21
22
|
|
|
22
|
-
Exactly one style applies at a time, in this order: Bash (`!` input) > active `opl-modes` appearance > hardcoded mode fallback. The compiled fallbacks are normal: `❯`/`accent`/`border`; chat: `»`/`
|
|
23
|
+
Exactly one style applies at a time, in this order: Bash (`!` input) > active `opl-modes` appearance > hardcoded mode fallback. The compiled fallbacks are normal: `❯`/`accent`/`border`; chat: `»`/`borderAccent`; and plan/execute: `⏸`/`customMessageLabel`.
|
|
23
24
|
|
|
24
25
|
## Configuration
|
|
25
26
|
|
|
@@ -54,7 +55,7 @@ Create `~/.pi/agent/configs/opl-input.json` or copy [`configs/opl-input.json.sam
|
|
|
54
55
|
|
|
55
56
|
| Field | Type | Default | Description |
|
|
56
57
|
|---|---|---:|---|
|
|
57
|
-
| `companion.enabled` | boolean | `false` | Show the animated companion above the input. |
|
|
58
|
+
| `companion.enabled` | boolean | `false` | Show the animated companion above the input. The idle repaint that drives it runs every 100 ms when enabled and every 1 s when disabled (the footer's time-based cells ride the same tick). |
|
|
58
59
|
| `companion.color` | color | `"accent"` | Companion color. |
|
|
59
60
|
| `companion.type` | string | unset | Select a named entry from `companion.types`; built-in `dog` also has a fallback shape (`"cat"` uses the default ears). |
|
|
60
61
|
| `companion.ears` | string | cat ears | Directly override the companion's top line; wins over `type`. |
|
|
@@ -74,9 +75,10 @@ Every color option accepts either:
|
|
|
74
75
|
- Syntax: `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation`
|
|
75
76
|
- Thinking borders: `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `thinkingMax`
|
|
76
77
|
- Special: `bashMode`
|
|
77
|
-
- **A
|
|
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
79
|
|
|
79
80
|
Invalid theme tokens fall back to the theme's `border` token; invalid hex renders uncolored rather than crashing.
|
|
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.
|
|
80
82
|
|
|
81
83
|
## Architecture
|
|
82
84
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { CustomEditor, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { TUI, EditorTheme } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
5
|
import { CONFIG, COMPANION_PADDING, MIN_WIDTH_FOR_COMPANION } from "./config.js";
|
|
6
6
|
import { resolveModeStyle, type ModeAppearance } from "./mode-style.js";
|
|
7
|
-
import { applyColor, CompanionAnimator, startRenderTimer } from "./utils.js";
|
|
7
|
+
import { applyColor, CompanionAnimator, COMPANION_TICK_MS, IDLE_REPAINT_MS, startRenderTimer } from "./utils.js";
|
|
8
8
|
|
|
9
9
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
10
10
|
const ANSI_RE = /\x1b\[[0-9;]*m|\x1b\[0?m/g;
|
|
@@ -43,10 +43,15 @@ class ChatInput extends CustomEditor {
|
|
|
43
43
|
this.inputTheme = theme;
|
|
44
44
|
|
|
45
45
|
// Animate companion even when idle — tick drives state machine
|
|
46
|
-
this
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
// With the companion hidden this component still owns the only idle repaint in the
|
|
47
|
+
// bundle (footer time-based cells read it), so the tick slows down instead of stopping.
|
|
48
|
+
this.stopCompanionTimer = startRenderTimer(
|
|
49
|
+
() => {
|
|
50
|
+
this.animator.tick(Date.now());
|
|
51
|
+
this.tui.requestRender();
|
|
52
|
+
},
|
|
53
|
+
CONFIG.COMPANION_ENABLED ? COMPANION_TICK_MS : IDLE_REPAINT_MS,
|
|
54
|
+
);
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
dispose(): void {
|
|
@@ -74,7 +79,9 @@ class ChatInput extends CustomEditor {
|
|
|
74
79
|
const style = resolveModeStyle({ bash: isBash, mode: mode.mode, appearance: mode.appearance });
|
|
75
80
|
const border = (s: string) => applyColor(this.uiTheme, style.borderColor, s);
|
|
76
81
|
const accent = (s: string) => applyColor(this.uiTheme, style.prefixColor, s);
|
|
77
|
-
|
|
82
|
+
// The layout reserves exactly one cell for the prefix (continuation lines pad with " "),
|
|
83
|
+
// so a wider configured prefix would push the box border past `width`.
|
|
84
|
+
const prefix = truncateToWidth(style.prefix, 1);
|
|
78
85
|
|
|
79
86
|
if (CONFIG.BOXED_VIEW) {
|
|
80
87
|
return this.renderBoxed(stock, contentWidth, width, border, accent, prefix);
|
|
@@ -21,7 +21,7 @@ export interface ResolvedModeStyle {
|
|
|
21
21
|
|
|
22
22
|
const MODE_DEFAULTS: Record<string, Required<ModeAppearance>> = {
|
|
23
23
|
off: { prefix: "❯", prefixColor: "accent", borderColor: "border" },
|
|
24
|
-
chat: { prefix: "»", prefixColor: "
|
|
24
|
+
chat: { prefix: "»", prefixColor: "borderAccent", borderColor: "borderAccent" },
|
|
25
25
|
plan: { prefix: "⏸", prefixColor: "customMessageLabel", borderColor: "customMessageLabel" },
|
|
26
26
|
execute: { prefix: "⏸", prefixColor: "customMessageLabel", borderColor: "customMessageLabel" },
|
|
27
27
|
};
|
|
@@ -48,7 +48,9 @@ function isHexColor(color: string): boolean {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
function hexToAnsi(hex: string): string {
|
|
51
|
-
|
|
51
|
+
// Accept the #abc shorthand so a three-digit value renders the same as #aabbcc.
|
|
52
|
+
let h = hex.replace("#", "");
|
|
53
|
+
if (/^[0-9a-fA-F]{3}$/.test(h)) h = h.split("").map((c) => c + c).join("");
|
|
52
54
|
if (!/^[0-9a-fA-F]{6}$/.test(h)) return "";
|
|
53
55
|
const r = parseInt(h.slice(0, 2), 16);
|
|
54
56
|
const g = parseInt(h.slice(2, 4), 16);
|
|
@@ -56,14 +58,23 @@ function hexToAnsi(hex: string): string {
|
|
|
56
58
|
return `\x1b[38;2;${r};${g};${b}m`;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
/** Animation cadence while the companion is visible. */
|
|
62
|
+
export const COMPANION_TICK_MS = 100;
|
|
63
|
+
/** Idle repaint cadence with the companion off: keeps time-based footer cells advancing without 10 repaints/second. */
|
|
64
|
+
export const IDLE_REPAINT_MS = 1000;
|
|
65
|
+
|
|
66
|
+
export function startRenderTimer(render: () => void, periodMs: number = COMPANION_TICK_MS): () => void {
|
|
67
|
+
const timer = setInterval(render, periodMs);
|
|
61
68
|
return () => clearInterval(timer);
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
export function applyColor(theme: Theme, color: string, text: string): string {
|
|
65
72
|
if (isHexColor(color)) {
|
|
66
|
-
|
|
73
|
+
// A malformed hex yields "": render the text with no escape rather than an empty
|
|
74
|
+
// color plus a stray reset.
|
|
75
|
+
const ansi = hexToAnsi(color);
|
|
76
|
+
if (!ansi) return text;
|
|
77
|
+
return `${ansi}${text}\x1b[0m`;
|
|
67
78
|
}
|
|
68
79
|
try {
|
|
69
80
|
return theme.fg(color as ThemeColor, text);
|
|
@@ -86,7 +97,7 @@ export interface CompanionState {
|
|
|
86
97
|
|
|
87
98
|
type Phase = "face" | "ears" | "full" | "none";
|
|
88
99
|
|
|
89
|
-
const TICK_MS =
|
|
100
|
+
const TICK_MS = COMPANION_TICK_MS; // companion animation frame budget
|
|
90
101
|
const R = (min: number, max: number) => min + Math.random() * (max - min);
|
|
91
102
|
|
|
92
103
|
export class CompanionAnimator {
|