@openlines/opl-pi-sht 0.1.14

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 (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/configs/opl-browser.json.sample +8 -0
  4. package/configs/opl-footer.json +48 -0
  5. package/configs/opl-footer.json.sample +48 -0
  6. package/configs/opl-input.json +22 -0
  7. package/configs/opl-input.json.sample +22 -0
  8. package/configs/opl-modes.json +291 -0
  9. package/configs/opl-modes.json.sample +285 -0
  10. package/configs/opl-simplebench.json +8 -0
  11. package/configs/opl-simplebench.json.sample +8 -0
  12. package/configs/opl-todo.json +12 -0
  13. package/configs/opl-todo.json.sample +12 -0
  14. package/configs/opl-webaccess.json +31 -0
  15. package/configs/opl-webaccess.json.sample +31 -0
  16. package/extensions/opl-browser/README.md +83 -0
  17. package/extensions/opl-browser/browser.ts +185 -0
  18. package/extensions/opl-browser/config.ts +37 -0
  19. package/extensions/opl-browser/index.ts +89 -0
  20. package/extensions/opl-browser/package-lock.json +60 -0
  21. package/extensions/opl-browser/package.json +10 -0
  22. package/extensions/opl-ctxtrim/README.md +78 -0
  23. package/extensions/opl-ctxtrim/index.ts +151 -0
  24. package/extensions/opl-footer/README.md +193 -0
  25. package/extensions/opl-footer/config.ts +91 -0
  26. package/extensions/opl-footer/git-status.ts +170 -0
  27. package/extensions/opl-footer/icons.ts +49 -0
  28. package/extensions/opl-footer/index.ts +368 -0
  29. package/extensions/opl-footer/segments/caveman.ts +25 -0
  30. package/extensions/opl-footer/segments/chat-mode.ts +24 -0
  31. package/extensions/opl-footer/segments/context.ts +92 -0
  32. package/extensions/opl-footer/segments/cost.ts +25 -0
  33. package/extensions/opl-footer/segments/git.ts +47 -0
  34. package/extensions/opl-footer/segments/helpers.ts +18 -0
  35. package/extensions/opl-footer/segments/index.ts +51 -0
  36. package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
  37. package/extensions/opl-footer/segments/model.ts +22 -0
  38. package/extensions/opl-footer/segments/path.ts +34 -0
  39. package/extensions/opl-footer/segments/pi.ts +11 -0
  40. package/extensions/opl-footer/segments/plan-mode.ts +24 -0
  41. package/extensions/opl-footer/segments/separator.ts +9 -0
  42. package/extensions/opl-footer/segments/session-stats.ts +82 -0
  43. package/extensions/opl-footer/segments/thinking.ts +51 -0
  44. package/extensions/opl-footer/segments/tokens.ts +66 -0
  45. package/extensions/opl-footer/theme.ts +113 -0
  46. package/extensions/opl-footer/types.ts +172 -0
  47. package/extensions/opl-init/README.md +23 -0
  48. package/extensions/opl-init/index.ts +451 -0
  49. package/extensions/opl-input/README.md +83 -0
  50. package/extensions/opl-input/config.ts +137 -0
  51. package/extensions/opl-input/index.ts +284 -0
  52. package/extensions/opl-input/mode-style.ts +38 -0
  53. package/extensions/opl-input/utils.ts +378 -0
  54. package/extensions/opl-modes/README.md +132 -0
  55. package/extensions/opl-modes/config.ts +473 -0
  56. package/extensions/opl-modes/index.ts +1081 -0
  57. package/extensions/opl-modes/menus.ts +60 -0
  58. package/extensions/opl-modes/state.ts +152 -0
  59. package/extensions/opl-modes/types.ts +164 -0
  60. package/extensions/opl-modes/utils.ts +138 -0
  61. package/extensions/opl-questionnaire/README.md +26 -0
  62. package/extensions/opl-questionnaire/index.ts +445 -0
  63. package/extensions/opl-questionnaire/types.ts +55 -0
  64. package/extensions/opl-simplebench/README.md +242 -0
  65. package/extensions/opl-simplebench/artifact.ts +41 -0
  66. package/extensions/opl-simplebench/benchmark.ts +873 -0
  67. package/extensions/opl-simplebench/coding.ts +282 -0
  68. package/extensions/opl-simplebench/index.ts +203 -0
  69. package/extensions/opl-simplebench/llama-server.ts +290 -0
  70. package/extensions/opl-simplebench/metrics.ts +73 -0
  71. package/extensions/opl-simplebench/report.ts +46 -0
  72. package/extensions/opl-simplebench/research.ts +200 -0
  73. package/extensions/opl-simplebench/scoring.ts +48 -0
  74. package/extensions/opl-simplebench/tests.ts +39 -0
  75. package/extensions/opl-simplebench/types.ts +6 -0
  76. package/extensions/opl-simplebench/util/config.ts +170 -0
  77. package/extensions/opl-simplebench/util/debug.ts +32 -0
  78. package/extensions/opl-simplebench/util/format.ts +402 -0
  79. package/extensions/opl-simplebench/util/providers.ts +841 -0
  80. package/extensions/opl-todo/README.md +43 -0
  81. package/extensions/opl-todo/config.ts +71 -0
  82. package/extensions/opl-todo/index.ts +415 -0
  83. package/extensions/opl-webaccess/README.md +47 -0
  84. package/extensions/opl-webaccess/config.ts +55 -0
  85. package/extensions/opl-webaccess/extract.ts +83 -0
  86. package/extensions/opl-webaccess/index.ts +232 -0
  87. package/extensions/opl-webaccess/package-lock.json +345 -0
  88. package/extensions/opl-webaccess/package.json +18 -0
  89. package/extensions/opl-webaccess/pdf.ts +8 -0
  90. package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
  91. package/extensions/opl-webaccess/providers/exa.ts +74 -0
  92. package/extensions/opl-webaccess/providers/gemini.ts +60 -0
  93. package/extensions/opl-webaccess/providers/searxng.ts +86 -0
  94. package/extensions/opl-webaccess/providers/tavily.ts +60 -0
  95. package/extensions/opl-webaccess/search.ts +36 -0
  96. package/extensions/opl-webaccess/storage.ts +51 -0
  97. package/extensions/opl-webaccess/turndown.d.ts +1 -0
  98. package/extensions/opl-webaccess/types.ts +26 -0
  99. package/extensions/opl-webaccess/utils.ts +28 -0
  100. package/package.json +55 -0
@@ -0,0 +1,451 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { execFileSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
+ import { extname, join, relative } from "node:path";
6
+
7
+ // ponytail: fixed ignore list instead of a full .gitignore parser.
8
+ const IGNORE = new Set([
9
+ ".git", "node_modules", "dist", "build", "target", ".next", "out",
10
+ "coverage", "__pycache__", ".venv", "venv", ".mypy_cache", ".pytest_cache",
11
+ ".idea", ".vscode", "vendor", ".turbo", ".cache",
12
+ ]);
13
+ const MANIFESTS = new Set([
14
+ "package.json", "Cargo.toml", "pyproject.toml", "go.mod", "pom.xml",
15
+ "build.gradle", "Gemfile",
16
+ // Monorepo structure declarations. turbo.json/nx.json are listed for context
17
+ // only; package locations come from the package manager's workspace config.
18
+ "pnpm-workspace.yaml", "turbo.json", "nx.json",
19
+ ]);
20
+ const MAX_DEPTH = 3;
21
+ const MAX_MEMBER_DEPTH = 3;
22
+ const MAX_TREE_LINES = 300;
23
+ const MAX_MANIFEST_BYTES = 2048;
24
+ const MAX_DIR_ENTRIES = 40;
25
+ const MAX_RULE_SOURCE_BYTES = 2048;
26
+
27
+ // Instruction sources other coding agents use (see research/harness-init-comparison.md).
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
+ ];
41
+
42
+ type Crawl = {
43
+ tree: string[];
44
+ extCounts: Map<string, number>;
45
+ manifests: { path: string; content: string }[];
46
+ workspaceMembers: string[];
47
+ };
48
+
49
+ // Marker the model appends to AGENTS.md so future runs can detect staleness exactly.
50
+ const FP_MARKER = /<!-- opl-init:fp (\S+) -->/;
51
+
52
+ // Exact snapshot of git-tracked + untracked state. Respects .gitignore for free.
53
+ // Returns null for non-git directories (caller falls back to fingerprintFallback).
54
+ function fingerprintGit(root: string): string | null {
55
+ try {
56
+ const head = execFileSync("git", ["rev-parse", "HEAD"], {
57
+ cwd: root,
58
+ encoding: "utf8",
59
+ stdio: ["ignore", "pipe", "ignore"],
60
+ });
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
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ // ponytail: stat-only (path|size|mtime), not content hashing. Catches add/delete/
74
+ // rename but shares the touch-without-change blind spot; only used for non-git dirs.
75
+ function fingerprintFallback(root: string): string {
76
+ const parts: string[] = [];
77
+
78
+ function walk(dir: string) {
79
+ let entries: string[];
80
+ try {
81
+ entries = readdirSync(dir);
82
+ } catch {
83
+ return;
84
+ }
85
+ for (const entry of entries) {
86
+ if (IGNORE.has(entry)) continue;
87
+ const path = join(dir, entry);
88
+ let stats;
89
+ try {
90
+ stats = statSync(path);
91
+ } catch {
92
+ continue;
93
+ }
94
+ if (stats.isDirectory()) {
95
+ walk(path);
96
+ } else if (entry !== "AGENTS.md") {
97
+ parts.push(`${relative(root, path)}|${stats.size}|${stats.mtimeMs}`);
98
+ }
99
+ }
100
+ }
101
+
102
+ walk(root);
103
+ parts.sort();
104
+ return createHash("sha256").update(parts.join("\n")).digest("hex").slice(0, 16);
105
+ }
106
+
107
+ function fingerprint(root: string): string {
108
+ return fingerprintGit(root) ?? fingerprintFallback(root);
109
+ }
110
+
111
+ function crawl(root: string): Crawl {
112
+ const result: Crawl = {
113
+ tree: [],
114
+ extCounts: new Map(),
115
+ manifests: [],
116
+ workspaceMembers: [],
117
+ };
118
+
119
+ function walk(dir: string, depth: number, prefix: string, maxDepth: number) {
120
+ if (result.tree.length >= MAX_TREE_LINES) return;
121
+
122
+ let entries: string[];
123
+ try {
124
+ entries = readdirSync(dir);
125
+ } catch {
126
+ return;
127
+ }
128
+
129
+ const dirs: string[] = [];
130
+ const files: string[] = [];
131
+ for (const entry of entries) {
132
+ if (IGNORE.has(entry)) continue;
133
+ const path = join(dir, entry);
134
+ let stats;
135
+ try {
136
+ stats = statSync(path);
137
+ } catch {
138
+ continue;
139
+ }
140
+
141
+ if (stats.isDirectory()) {
142
+ dirs.push(entry);
143
+ continue;
144
+ }
145
+
146
+ files.push(entry);
147
+ const extension = extname(entry) || entry;
148
+ result.extCounts.set(extension, (result.extCounts.get(extension) ?? 0) + 1);
149
+
150
+ if (MANIFESTS.has(entry)) {
151
+ try {
152
+ result.manifests.push({
153
+ path: relative(root, path),
154
+ content: readFileSync(path, "utf8").slice(0, MAX_MANIFEST_BYTES),
155
+ });
156
+ } catch {
157
+ // Skip unreadable manifests.
158
+ }
159
+ }
160
+ }
161
+
162
+ dirs.sort();
163
+ files.sort();
164
+ // ponytail: single combined per-directory cap; one omission line instead of
165
+ // a priority queue. Revisit if real repos starve important branches.
166
+ const all = [...dirs.map((d) => `${d}/`), ...files];
167
+ for (let i = 0; i < all.length; i++) {
168
+ if (result.tree.length >= MAX_TREE_LINES) break;
169
+ if (i >= MAX_DIR_ENTRIES) {
170
+ result.tree.push(`${prefix}... (${all.length - MAX_DIR_ENTRIES} more entries omitted)`);
171
+ break;
172
+ }
173
+ const entry = all[i];
174
+ result.tree.push(`${prefix}${entry}`);
175
+ if (entry.endsWith("/") && depth < maxDepth) {
176
+ walk(join(dir, entry.slice(0, -1)), depth + 1, `${prefix} `, maxDepth);
177
+ }
178
+ }
179
+ }
180
+
181
+ walk(root, 0, "", MAX_DEPTH);
182
+
183
+ // Manifest-first monorepo expansion: enumerate declared workspace members
184
+ // with a fresh depth budget so packages/foo/src is not penalized by its
185
+ // grouping prefix.
186
+ for (const member of workspaceMembers(root)) {
187
+ if (result.tree.length >= MAX_TREE_LINES) break;
188
+ const abs = join(root, member);
189
+ let stats;
190
+ try {
191
+ stats = statSync(abs);
192
+ } catch {
193
+ continue;
194
+ }
195
+ if (!stats.isDirectory()) continue;
196
+ // Always re-walk declared members with a full fresh budget: partial root-
197
+ // walk coverage must not truncate member internals. Shallow overlap with
198
+ // the root tree is accepted as the cost of completeness.
199
+ result.workspaceMembers.push(member);
200
+ result.tree.push(`${member}/`);
201
+ walk(abs, 0, " ", MAX_MEMBER_DEPTH);
202
+ }
203
+
204
+ // Explicit truncation signal — never leave the model guessing whether the
205
+ // tree is complete.
206
+ if (result.tree.length >= MAX_TREE_LINES) {
207
+ result.tree.push(`(tree truncated at ${MAX_TREE_LINES} entries)`);
208
+ }
209
+ return result;
210
+ }
211
+
212
+ // ponytail: naive glob matcher supporting only what workspace files use in
213
+ // practice: `**` across segments, `*` within a segment, optional trailing "/".
214
+ function globToRegExp(pattern: string): RegExp {
215
+ const cleaned = pattern.replace(/\/$/, "");
216
+ const source = cleaned
217
+ .split("/**/")
218
+ .map((seg) => seg.replace(/[*]/g, "[^/]*"))
219
+ .join("(?:/.*)?");
220
+ return new RegExp(`^${source}$`);
221
+ }
222
+
223
+ // ponytail: line-based subset parsing of pnpm-workspace.yaml and Cargo.toml
224
+ // [workspace] members. Full YAML/TOML parsing not warranted for glob lists.
225
+ function workspaceMembers(root: string): string[] {
226
+ const globs: string[] = [];
227
+
228
+ try {
229
+ const yaml = readFileSync(join(root, "pnpm-workspace.yaml"), "utf8");
230
+ let inPackages = false;
231
+ for (const line of yaml.split("\n")) {
232
+ if (/^packages:\s*$/.test(line)) {
233
+ inPackages = true;
234
+ } else if (/^\S/.test(line)) {
235
+ inPackages = false;
236
+ } else if (inPackages) {
237
+ const match = line.match(/^\s*-\s*["']?([^"'#]+)/);
238
+ if (match) globs.push(match[1].trim());
239
+ }
240
+ }
241
+ } catch {
242
+ // No pnpm-workspace.yaml.
243
+ }
244
+
245
+ try {
246
+ const toml = readFileSync(join(root, "Cargo.toml"), "utf8");
247
+ const section = toml.match(/\[workspace\]([\s\S]*?)(?:\n\[|\s*$)/);
248
+ const membersBlock = section?.[1].match(/members\s*=\s*\[([^\]]*)\]/s);
249
+ if (membersBlock) {
250
+ for (const m of membersBlock[1].matchAll(/["']([^"']+)["']/g)) {
251
+ globs.push(m[1]);
252
+ }
253
+ }
254
+ } catch {
255
+ // No Cargo.toml.
256
+ }
257
+
258
+ const members = new Set<string>();
259
+ for (const glob of globs) {
260
+ const re = globToRegExp(glob);
261
+ // Match against shallow candidate paths from the tree we already walked,
262
+ // plus one extra readdir of likely parent dirs. Simple approach: test every
263
+ // tree dir line's relative path.
264
+ for (const line of result_tree_paths(root)) {
265
+ if (re.test(line)) members.add(line);
266
+ }
267
+ }
268
+ return [...members].sort();
269
+ }
270
+
271
+ // Candidate relative paths for glob matching: all directory paths up to
272
+ // MAX_DEPTH derived from a fresh cheap listing (not the truncated tree).
273
+ function result_tree_paths(root: string): string[] {
274
+ const paths: string[] = [];
275
+ function walkList(dir: string, rel: string, depth: number) {
276
+ let entries: string[];
277
+ try {
278
+ entries = readdirSync(dir);
279
+ } catch {
280
+ return;
281
+ }
282
+ for (const entry of entries) {
283
+ if (IGNORE.has(entry)) continue;
284
+ try {
285
+ if (!statSync(join(dir, entry)).isDirectory()) continue;
286
+ } catch {
287
+ continue;
288
+ }
289
+ const childRel = rel ? `${rel}/${entry}` : entry;
290
+ paths.push(childRel);
291
+ if (depth < MAX_DEPTH + 1) walkList(join(dir, entry), childRel, depth + 1);
292
+ }
293
+ }
294
+ walkList(root, "", 0);
295
+ return paths;
296
+ }
297
+
298
+ function packageScripts(content: string): string | null {
299
+ try {
300
+ const packageJson = JSON.parse(content);
301
+ if (!packageJson.scripts || !Object.keys(packageJson.scripts).length) return null;
302
+ return Object.entries(packageJson.scripts)
303
+ .map(([name, command]) => ` ${name}: ${command}`)
304
+ .join("\n");
305
+ } catch {
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;
315
+ try {
316
+ stats = statSync(path);
317
+ } catch {
318
+ continue;
319
+ }
320
+ if (stats.isDirectory()) {
321
+ // ponytail: filenames only for rule dirs; model can read entries itself.
322
+ let entries: string[] = [];
323
+ try {
324
+ entries = readdirSync(path).slice(0, 20);
325
+ } catch {
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
+ }
335
+ }
336
+ }
337
+ return found;
338
+ }
339
+
340
+ function buildContext(root: string, crawlResult: Crawl): string {
341
+ const extensions = [...crawlResult.extCounts.entries()]
342
+ .sort((a, b) => b[1] - a[1])
343
+ .map(([extension, count]) => `${extension} (${count})`)
344
+ .join(", ");
345
+ const sections = [
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
+ }
354
+
355
+ if (crawlResult.manifests.length) {
356
+ // ponytail: send paths + package.json scripts only; the model can read a
357
+ // manifest itself if it needs the raw body. Keeps the prompt small and stable.
358
+ sections.push("### Manifests (read these files directly if you need more detail)");
359
+ for (const manifest of crawlResult.manifests) {
360
+ sections.push(`**${manifest.path}**`);
361
+ if (manifest.path.endsWith("package.json")) {
362
+ const scripts = packageScripts(manifest.content);
363
+ if (scripts) sections.push(`Scripts:\n\`\`\`\n${scripts}\n\`\`\``);
364
+ }
365
+ }
366
+ }
367
+
368
+ const ruleSources = findRuleSources(root);
369
+ if (ruleSources.length) {
370
+ sections.push("### Existing agent rule sources (from other coding agents)");
371
+ for (const source of ruleSources) {
372
+ sections.push(`**${source.path}**\n\`\`\`\n${source.detail}\n\`\`\``);
373
+ }
374
+ }
375
+
376
+ return sections.join("\n\n");
377
+ }
378
+
379
+ function fallbackGuide(root: string, crawlResult: Crawl, marker: string): string {
380
+ const topLevel = crawlResult.tree.filter(line => !line.startsWith(" ")).slice(0, 30).join(", ");
381
+ const scripts = crawlResult.manifests
382
+ .map(manifest => manifest.path.endsWith("package.json") ? packageScripts(manifest.content) : null)
383
+ .find(Boolean);
384
+ const commandBlock = scripts ? "Package scripts:\n```\n" + scripts + "\n```" : "No package scripts were detected by the repository crawl.";
385
+ 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- The full crawl is available in the init task context. Inspect specific files before changing behavior.\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`;
386
+ }
387
+
388
+ const PROMPT = `Generate or update AGENTS.md as an agent guide for this repository. This is an execution task, not a request for prose in chat. You MUST use the write tool to write the guide to the repository's AGENTS.md path; a response containing Markdown without writing the file is a failure. After writing, use the read tool to verify the file exists and ends with the exact fingerprint marker. Do not modify, refactor, or create any other file.
389
+ If AGENTS.md already exists and the repository crawl below says it is current, do not overwrite or modify it. If it is marked stale, update it. If it does not exist, create it.
390
+
391
+ The repository crawl below (directory tree, file counts, manifests, and any existing rule sources) is the primary source of truth. It was produced for you, so do not re-crawl, re-list, or re-scan the tree, and do not spawn subagents or task lists for this. Read a small number of specific files directly only when you must confirm a repository-specific fact the crawl does not already answer.
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.
402
+
403
+ ---
404
+ `;
405
+
406
+ // Named export for fixture tests (tests/opl-init-crawl.test.mjs).
407
+ export { crawl };
408
+
409
+ export default function (pi: ExtensionAPI) {
410
+ pi.registerCommand("init", {
411
+ description: "[opl-init] Crawl the repository and generate an AGENTS.md contributor guide",
412
+ handler: async (_args, ctx) => {
413
+ const root = ctx.cwd;
414
+ const agentsPath = join(root, "AGENTS.md");
415
+ const currentFp = fingerprint(root);
416
+ const exists = existsSync(agentsPath);
417
+
418
+ let storedFp: string | null = null;
419
+ if (exists) {
420
+ try {
421
+ storedFp = readFileSync(agentsPath, "utf8").match(FP_MARKER)?.[1] ?? null;
422
+ } catch {
423
+ storedFp = null;
424
+ }
425
+ }
426
+
427
+ if (exists && storedFp === currentFp) {
428
+ ctx.ui.notify("AGENTS.md is current; /init will not modify it.", "info");
429
+ return;
430
+ }
431
+
432
+ const status = !exists
433
+ ? "AGENTS.md status: missing; create it."
434
+ : storedFp === null
435
+ ? "AGENTS.md status: no fingerprint marker found; treat as stale and update it."
436
+ : "AGENTS.md status: stale (fingerprint mismatch); update it.";
437
+ const marker = `<!-- opl-init:fp ${currentFp} -->`;
438
+ const crawlResult = crawl(root);
439
+ try {
440
+ writeFileSync(agentsPath, fallbackGuide(root, crawlResult, marker), "utf8");
441
+ ctx.ui.notify("AGENTS.md baseline written; the agent will refine it from the crawl.", "info");
442
+ } catch (error: any) {
443
+ ctx.ui.notify(`Could not write AGENTS.md baseline: ${error?.message || error}`, "error");
444
+ }
445
+
446
+ pi.sendUserMessage(
447
+ `${PROMPT}\n${status}\nFingerprint marker to append as the last line: ${marker}\n\n${buildContext(root, crawlResult)}`,
448
+ );
449
+ },
450
+ });
451
+ }
@@ -0,0 +1,83 @@
1
+ # opl-input
2
+
3
+ Replaces Pi's default chat editor with a mode-aware custom editor. Native editor behavior such as cursor movement, history, autocomplete, paste, and slash-menu handling remains available.
4
+
5
+ ## Commands, flags, and shortcuts
6
+
7
+ No commands, flags, or shortcuts. It replaces the standard editor at session start.
8
+
9
+ ## Extension features
10
+
11
+ - **Boxed or unboxed input**: Full `┌─┐`/`│`/`└─┘` framing by default, or horizontal rules only with `boxedView: false`.
12
+ - **External configuration**: Settings are loaded from `~/.pi/agent/configs/opl-input.json`, not hard-coded into the extension.
13
+ - **Mode-aware styling**: Bash input has an input-local style; every active mode receives its border and prefix appearance from `opl-modes`.
14
+ - **Companion animation**: An optional animated three-line ASCII companion appears above the editor, with changing expressions, blinking, drifting, ear movement, and temporary hidden phases.
15
+ - **Scroll-aware borders**: Editor scroll indicators are embedded in the top or bottom border when the input has more content than fits.
16
+ - **Slash-menu placement**: The slash menu is rendered below the input with configurable gap and indentation.
17
+ - **Responsive rendering**: Narrow terminals fall back to the native editor rendering; the companion is hidden below 40 columns.
18
+ - **Hex and theme colors**: Color settings accept Pi theme tokens or six-digit hex colors.
19
+
20
+ ## Mode styling precedence
21
+
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: `»`/`chatModeBorder`; and plan/execute: `⏸`/`customMessageLabel`.
23
+
24
+ ## Configuration
25
+
26
+ Create `~/.pi/agent/configs/opl-input.json` or copy [`configs/opl-input.json.sample`](../../configs/opl-input.json.sample). The file is read once when the extension module loads; run `/reload` or restart Pi after changes.
27
+
28
+ ```json
29
+ {
30
+ "boxedView": false,
31
+ "boxPadX": 1,
32
+ "menuGap": 0,
33
+ "extraMenuIndent": 1,
34
+ "companion": {
35
+ "enabled": true,
36
+ "color": "accent",
37
+ "type": "dog",
38
+ "ears": " /\\_/\\ ",
39
+ "types": [{ "typeName": "dog", "top": " /),(\\ " }]
40
+ }
41
+ }
42
+ ```
43
+
44
+ ### Top-level options
45
+
46
+ | Option | Type | Default | Description |
47
+ |---|---|---:|---|
48
+ | `boxedView` | boolean | `true` | Full box when true; horizontal rules without side borders when false. |
49
+ | `boxPadX` | number | `1` | Horizontal padding inside the editor. |
50
+ | `menuGap` | number | `0` | Blank lines between the input bottom border and slash menu. |
51
+ | `extraMenuIndent` | number | `1` | Additional indentation for slash-menu lines. |
52
+
53
+ ### Companion
54
+
55
+ | Field | Type | Default | Description |
56
+ |---|---|---:|---|
57
+ | `companion.enabled` | boolean | `false` | Show the animated companion above the input. |
58
+ | `companion.color` | color | `"accent"` | Companion color. |
59
+ | `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
+ | `companion.ears` | string | cat ears | Directly override the companion's top line; wins over `type`. |
61
+ | `companion.types` | array | unset | Named `{ "typeName", "top" }` top-line definitions for use with `type`. |
62
+
63
+ The companion requires at least 40 terminal columns and reserves three top-padding lines when enabled. Its animation timing and probabilities are source constants in `config.ts`, not user configuration.
64
+
65
+ ### Color values
66
+
67
+ Every color option accepts either:
68
+
69
+ - **A Pi theme token** — one of the 45 tokens defined by Pi's `Theme`:
70
+ - Core: `accent`, `border`, `borderAccent`, `borderMuted`, `success`, `error`, `warning`, `muted`, `dim`, `text`
71
+ - Messages/content: `thinkingText`, `searchMatchText`, `userMessageText`, `customMessageText`, `customMessageLabel`
72
+ - Tools: `toolTitle`, `toolOutput`, `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext`
73
+ - Markdown: `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet`
74
+ - Syntax: `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation`
75
+ - Thinking borders: `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `thinkingMax`
76
+ - Special: `bashMode`
77
+ - **A six-digit hex color** — e.g. `"#c07898"` (rendered as ANSI truecolor, downgraded automatically on 256-color terminals).
78
+
79
+ Invalid theme tokens fall back to the theme's `border` token; invalid hex renders uncolored rather than crashing.
80
+
81
+ ## Architecture
82
+
83
+ `index.ts` installs the editor integration; `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`.
@@ -0,0 +1,137 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export const DEFAULT_CONFIG = {
6
+ BOX_PAD_X: 1,
7
+ MENU_GAP: 0,
8
+ EXTRA_MENU_INDENT: 1,
9
+ BOXED_VIEW: true,
10
+ COMPANION_ENABLED: false,
11
+ COMPANION_COLOR: "accent" as const,
12
+ COMPANION_TOP_PADDING: 3,
13
+ };
14
+
15
+ interface ChatInputUserConfig {
16
+ boxedView?: boolean;
17
+ boxPadX?: number;
18
+ menuGap?: number;
19
+ extraMenuIndent?: number;
20
+ companion?: {
21
+ enabled?: boolean;
22
+ color?: string;
23
+ type?: string;
24
+ ears?: string;
25
+ types?: Array<{
26
+ typeName: string;
27
+ top: string;
28
+ }>;
29
+ };
30
+ }
31
+
32
+ const CONFIG_PATH = join(homedir(), ".pi", "agent", "configs", "opl-input.json");
33
+
34
+ function loadUserConfig(): ChatInputUserConfig {
35
+ try {
36
+ const raw = readFileSync(CONFIG_PATH, "utf8");
37
+ return JSON.parse(raw) as ChatInputUserConfig;
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ const userConfig = loadUserConfig();
44
+
45
+ export const CONFIG = {
46
+ BOX_PAD_X: userConfig.boxPadX ?? DEFAULT_CONFIG.BOX_PAD_X,
47
+ MENU_GAP: userConfig.menuGap ?? DEFAULT_CONFIG.MENU_GAP,
48
+ EXTRA_MENU_INDENT: userConfig.extraMenuIndent ?? DEFAULT_CONFIG.EXTRA_MENU_INDENT,
49
+ BOXED_VIEW: userConfig.boxedView ?? DEFAULT_CONFIG.BOXED_VIEW,
50
+ COMPANION_ENABLED: userConfig.companion?.enabled ?? DEFAULT_CONFIG.COMPANION_ENABLED,
51
+ COMPANION_COLOR: (userConfig.companion?.color ?? DEFAULT_CONFIG.COMPANION_COLOR) as string,
52
+ COMPANION_TOP_PADDING: DEFAULT_CONFIG.COMPANION_TOP_PADDING,
53
+ COMPANION_EARS: resolveEars(userConfig.companion),
54
+ };
55
+
56
+ function resolveEars(companion?: ChatInputUserConfig["companion"]): string {
57
+ if (companion?.ears) return companion.ears;
58
+ if (companion?.type && companion?.types) {
59
+ const found = companion.types.find((t) => t.typeName === companion.type);
60
+ if (found) return found.top;
61
+ }
62
+ if (companion?.type === "dog") return " /)_(\\ ";
63
+ return " /\\_/\\ ";
64
+ }
65
+
66
+ // Non-user-configurable constants
67
+ export const COMPANION_PADDING = 3;
68
+ export const MIN_WIDTH_FOR_COMPANION = 40;
69
+
70
+ // ── animation timing (all in ms) ────────────────────────────────────
71
+ export const DIP_INTERVAL_MS = 4000;
72
+ export const RISE_INTERVAL_MS = 8000;
73
+ export const EARS_MIN_DURATION_MS = 2000;
74
+ export const EARS_MAX_DURATION_MS = 4000;
75
+ export const FULL_MIN_DURATION_MS = 3000;
76
+ export const FULL_MAX_DURATION_MS = 23000;
77
+ export const NONE_MIN_DURATION_MS = 800;
78
+ export const NONE_MAX_DURATION_MS = 2000;
79
+ export const FACE_MIN_DURATION_MS = 6000;
80
+ export const FACE_MAX_DURATION_MS = 36000;
81
+
82
+ // expression cycling
83
+ export const EXPR_MIN_DURATION_MS = 2000;
84
+ export const EXPR_MAX_DURATION_MS = 5500;
85
+ export const STARE_MIN_DURATION_MS = 8000;
86
+ export const STARE_MAX_DURATION_MS = 13000;
87
+ export const STARE_CHANCE = 0.15;
88
+ export const BLINK_MIN_DURATION_MS = 80;
89
+ export const BLINK_MAX_DURATION_MS = 330;
90
+
91
+ // expression transition: blink vs instant vs double-blink
92
+ export const EXPR_BLINK_CHANCE = 0.50;
93
+ export const EXPR_DOUBLE_BLINK_CHANCE = 0; // disabled
94
+ export const DOUBLE_BLINK_GAP_MIN_MS = 80;
95
+ export const DOUBLE_BLINK_GAP_MAX_MS = 160;
96
+
97
+ // wobble (ears phase)
98
+ export const WOBBLE_RANGE = 12;
99
+ export const WOBBLE_MIN_INTERVAL_MS = 200;
100
+ export const WOBBLE_MAX_INTERVAL_MS = 600;
101
+ export const DIR_STEPS_MIN = 2;
102
+ export const DIR_STEPS_MAX = 5;
103
+ export const EDGE_BIAS_STRENGTH = 0.45;
104
+ export const EDGE_PAUSE_MIN_MS = 300;
105
+ export const EDGE_PAUSE_MAX_MS = 800;
106
+
107
+ // face micro-drift
108
+ export const FACE_DRIFT_RANGE = 3;
109
+ export const FACE_DRIFT_MIN_INTERVAL_MS = 4000;
110
+ export const FACE_DRIFT_MAX_INTERVAL_MS = 10000;
111
+
112
+ // phase transitions
113
+ export const EARS_TO_NONE_CHANCE = 0.15;
114
+ export const EARS_TO_FULL_CHANCE = 0.425; // remainder = face
115
+ export const FULL_TO_EARS_CHANCE = 0.15;
116
+ export const FULL_TO_NONE_CHANCE = 0.10; // remainder = face
117
+
118
+ // transition frames
119
+ export const SLOW_TRANSITION_CHANCE = 0.2;
120
+ export const SLOW_TRANSITION_MULT_MIN = 2;
121
+ export const SLOW_TRANSITION_MULT_MAX = 3;
122
+
123
+ export const BLINK_ART: [string, string, string] = [" /\\_/\\ ", "( -.- )", " | | "];
124
+
125
+ const EARS = CONFIG.COMPANION_EARS;
126
+ export const COMPANION_ARTS: [string, string, string][] = [
127
+ [EARS, "( ⌒.⌒ )", " | | "], // happy
128
+ [EARS, "( o.o )", " | | "], // original
129
+ [EARS, "( ^.^ )", " | | "], // happy
130
+ [EARS, "( O.O )", " | | "], // awake
131
+ [EARS, "( o.- )", " | | "], // winking
132
+ [EARS, "( >.< )", " | | "], // closed
133
+ [EARS, "( o.O )", " | | "], // curious
134
+ [EARS, "( *.* )", " | | "], // sparkle
135
+ [EARS, "( ᴗ.ᴗ )", " | | "], // unimpressed
136
+ [EARS, "( ω.ω )", " | | "], // joyful
137
+ ];