geneseed 3.13.0 → 3.15.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/CHANGELOG.md +93 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +23 -9
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +45 -10
- package/adapters/opencode/plugins/geneseed-notify.js +37 -19
- package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
- package/adapters/opencode/workflows/_runtime.js +16 -3
- package/bin/geneseed-cli.mjs +32 -34
- package/bin/geneseed-hook.mjs +28 -18
- package/docs/extending.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +28 -15
- package/js/build/args.mjs +375 -0
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +145 -560
- package/js/build/emit-claude.mjs +5 -6
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +21 -14
- package/js/build/render.mjs +6 -10
- package/js/build/source.mjs +9 -17
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/hosts/hooks-context.mjs +432 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +31 -25
- package/js/hosts/installs.mjs +38 -20
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/mcp.mjs +12 -48
- package/js/hosts/memory-files.mjs +190 -0
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +44 -36
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +375 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +4 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +9 -7
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +19 -22
- package/js/inspect/validate.mjs +50 -37
- package/js/lib/fs.mjs +12 -11
- package/js/lib/json.mjs +4 -4
- package/js/lib/paths.mjs +27 -33
- package/js/lib/proc.mjs +3 -3
- package/js/lib/prompt.mjs +132 -0
- package/js/lib/text.mjs +18 -4
- package/js/lib/udiff.mjs +3 -2
- package/js/loop/catalog.mjs +5 -3
- package/js/loop/cli.mjs +26 -12
- package/js/loop/graph.mjs +1 -1
- package/js/loop/registry.mjs +10 -15
- package/js/loop/score.mjs +1 -1
- package/js/loop/state.mjs +35 -11
- package/js/maintain/memory.mjs +6 -7
- package/js/maintain/migrate.mjs +22 -33
- package/js/maintain/setup.mjs +9 -131
- package/js/maintain/uninstall.mjs +57 -97
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +37 -43
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +109 -403
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +41 -725
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- package/js/web/docs.mjs +29 -72
- package/js/web/handler.mjs +51 -27
- package/js/web/jobs.mjs +24 -2
- package/js/web/routes.mjs +61 -6
- package/js/web/server.mjs +4 -4
- package/js/web/state.mjs +138 -0
- package/js/web/user-files.mjs +339 -0
- package/package.json +1 -1
- package/web/dist/assets/Activity-kpWxd70F.js +1 -0
- package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/Docs-BHyaggFy.js +5 -0
- package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
- package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
- package/web/dist/assets/Library-MBxadRzn.js +1 -0
- package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
- package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
- package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
- package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
- package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
- package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
- package/web/dist/assets/api-CxgfYu18.js +1 -0
- package/web/dist/assets/drawable-BbWQxe82.js +1 -0
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-ujKG1ZNf.js +2 -0
- package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
- package/web/dist/assets/sections-GalMS0Zv.js +1 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +5 -6
- package/web/src/pages/Laws.jsx +0 -1
- package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
- package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
- package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
- package/web/dist/assets/Docs-BzccCllG.js +0 -5
- package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
- package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
- package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
- package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
- package/web/dist/assets/Laws-BANxq_I_.js +0 -3
- package/web/dist/assets/Library-DW4x0fbh.js +0 -1
- package/web/dist/assets/Loops-DEBibbBP.js +0 -2
- package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
- package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
- package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
- package/web/dist/assets/activity-CObqyxkL.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-C_x5xncq.js +0 -1
- package/web/dist/assets/index-CMPgezFm.js +0 -2
- package/web/dist/assets/index-DMsT4Aa6.css +0 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/web/dist/assets/sections-Dg23Hel5.js +0 -1
- package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The memory store's file format — fact files with frontmatter, the `MEMORY.md` index, the
|
|
3
|
+
* per-agent lesson files — read and written without spawning anything.
|
|
4
|
+
*
|
|
5
|
+
* SPLIT FROM `learn` FOR THE CLI's SAKE. `learn` spawns the model CLI, and the web console
|
|
6
|
+
* (`js/web/api.mjs`, `js/web/actions.mjs`), `geneseed memory` (`js/maintain/memory.mjs`) and the
|
|
7
|
+
* loop catalogue (`js/loop/catalog.mjs`) imported the hook module only for `frontmatter` and
|
|
8
|
+
* `memoryDropIndex` — which dragged `node:child_process` into `bin/geneseed-cli.mjs`'s closure
|
|
9
|
+
* and made the hook module a row in `tests/unit/hook_cli.test.mjs`'s spawn allow-list for a
|
|
10
|
+
* spawn the CLI never makes. Everything here is fs-only; `hooks-learn.mjs` is the one spawner.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { readText, writeText } from '../lib/fs.mjs';
|
|
15
|
+
import { normcase } from '../lib/paths.mjs';
|
|
16
|
+
import { isFile, listDir, sortPaths, splitLines, splitWords } from './hooks-prims.mjs';
|
|
17
|
+
|
|
18
|
+
const FRONTMATTER_RE = /^\s*---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/;
|
|
19
|
+
const FILE_SEP_RE = /^---FILE---[^\S\n]*$/m;
|
|
20
|
+
// A memory `name:` becomes a FILENAME and the model writes it, so it is a plain slug or
|
|
21
|
+
// nothing — no separator, no `..`, no drive. Looser than `hooks-learn.mjs`'s AGENT_NAME_RE on
|
|
22
|
+
// purpose: a memory slug is a phrase (case and length vary), and a refused one is a fact
|
|
23
|
+
// silently lost.
|
|
24
|
+
const MEMORY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
|
|
25
|
+
const MAX_AGENT_BULLETS = 100;
|
|
26
|
+
|
|
27
|
+
/** `append_agent_lesson` — one dated bullet, capped at the newest MAX_AGENT_BULLETS. */
|
|
28
|
+
export function appendAgentLesson(memDir, agent, lesson) {
|
|
29
|
+
const d = path.join(memDir, 'agents');
|
|
30
|
+
mkdirSync(d, { recursive: true });
|
|
31
|
+
const f = path.join(d, `${agent}.md`);
|
|
32
|
+
let bullets = [];
|
|
33
|
+
if (existsSync(f)) bullets = splitLines(readText(f)).filter((l) => l.startsWith('- '));
|
|
34
|
+
const now = new Date();
|
|
35
|
+
const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-`
|
|
36
|
+
+ `${String(now.getDate()).padStart(2, '0')}`;
|
|
37
|
+
bullets.push(`- ${day}: ${splitWords(lesson).join(' ')}`);
|
|
38
|
+
bullets = bullets.slice(-MAX_AGENT_BULLETS);
|
|
39
|
+
writeText(f, `# ${agent} \u2014 lessons\n${bullets.join('\n')}\n`);
|
|
40
|
+
return f;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `_frontmatter` — leading YAML-ish frontmatter as a flat map, plus the body.
|
|
45
|
+
*
|
|
46
|
+
* Exported since P6b, whose `memoryItems` reads `name` and `description` off a fact file
|
|
47
|
+
* exactly as `_web_catalog._memory_items` does. That makes the web its SECOND owner, which
|
|
48
|
+
* this port treats as a coverage event rather than a tidiness one — the fields it produces
|
|
49
|
+
* are unreachable from a P6b cell (only the COUNT is consumed) and P6c's catalog cells are
|
|
50
|
+
* what gate them.
|
|
51
|
+
*/
|
|
52
|
+
export function frontmatter(md) {
|
|
53
|
+
const m = FRONTMATTER_RE.exec(md);
|
|
54
|
+
if (!m) return [new Map(), md];
|
|
55
|
+
const fm = new Map();
|
|
56
|
+
for (const line of splitLines(m[1])) {
|
|
57
|
+
const at = line.indexOf(':');
|
|
58
|
+
if (at >= 0) {
|
|
59
|
+
// `str.strip('"')` strips the character from BOTH ends, however many there are.
|
|
60
|
+
fm.set(line.slice(0, at).trim(), line.slice(at + 1).trim().replace(/^"+|"+$/g, ''));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [fm, m[2]];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const fmGet = (fm, key) => (fm.has(key) ? fm.get(key) : '');
|
|
67
|
+
|
|
68
|
+
/** `Path.stem` for a filename. */
|
|
69
|
+
const stem = (name) => path.basename(name, path.extname(name));
|
|
70
|
+
|
|
71
|
+
/** `mem_dir.glob("*.md")` — case-INSENSITIVE on Windows, as pathlib's glob is. */
|
|
72
|
+
function globMd(dir) {
|
|
73
|
+
return listDir(dir).filter((n) => normcase(n).endsWith('.md')
|
|
74
|
+
&& isFile(path.join(dir, n)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Stems that name the store's own files, never a fact — `MEMORY` is the index itself. */
|
|
78
|
+
const RESERVED_STEMS = new Set(['memory', 'readme']);
|
|
79
|
+
|
|
80
|
+
/** `_existing_slugs` — slugs already stored, so learn never re-emits a known fact. */
|
|
81
|
+
export function existingSlugs(memDir) {
|
|
82
|
+
return new Set(globMd(memDir).map(stem).filter((s) => !RESERVED_STEMS.has(s.toLowerCase())));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `_write_memories` — split the model output into files, write each NEW one, and append a
|
|
87
|
+
* pointer line to MEMORY.md. `existing` is MUTATED, so a reply carrying the same slug
|
|
88
|
+
* twice writes it once.
|
|
89
|
+
*
|
|
90
|
+
* A reserved stem is refused (`name: MEMORY` would overwrite the index), and names compare
|
|
91
|
+
* LOWERCASE: on Windows and macOS `User-Prefs.md` and `user-prefs.md` are one file, so a
|
|
92
|
+
* case variant of a stored slug would overwrite it.
|
|
93
|
+
*/
|
|
94
|
+
export function writeMemories(modelOutput, memDir, existing) {
|
|
95
|
+
const taken = new Set([...existing].map((s) => s.toLowerCase()));
|
|
96
|
+
const written = [];
|
|
97
|
+
const indexLines = [];
|
|
98
|
+
for (let chunk of modelOutput.split(FILE_SEP_RE)) {
|
|
99
|
+
chunk = chunk.trim();
|
|
100
|
+
if (!chunk || chunk.toUpperCase() === 'NOTHING') continue;
|
|
101
|
+
const [fm] = frontmatter(chunk);
|
|
102
|
+
const name = fmGet(fm, 'name').trim();
|
|
103
|
+
const key = name.toLowerCase();
|
|
104
|
+
if (!MEMORY_SLUG_RE.test(name) || RESERVED_STEMS.has(key) || taken.has(key)) continue;
|
|
105
|
+
writeText(path.join(memDir, `${name}.md`), `${chunk.replace(/\n+$/, '')}\n`);
|
|
106
|
+
existing.add(name);
|
|
107
|
+
taken.add(key);
|
|
108
|
+
written.push(name);
|
|
109
|
+
const desc = fmGet(fm, 'description').trim();
|
|
110
|
+
indexLines.push(`- [${name}](${name}.md)${desc ? ` \u2014 ${desc}` : ''}`);
|
|
111
|
+
}
|
|
112
|
+
if (indexLines.length) {
|
|
113
|
+
const index = path.join(memDir, 'MEMORY.md');
|
|
114
|
+
const current = existsSync(index) ? `${readText(index).replace(/\n+$/, '')}\n`
|
|
115
|
+
: '# Memory Index\n';
|
|
116
|
+
writeText(index, `${current}${indexLines.join('\n')}\n`);
|
|
117
|
+
}
|
|
118
|
+
return written;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `_harness_tui_views._memory_drop_index` — remove the index line(s) naming `<name>.md`.
|
|
123
|
+
*
|
|
124
|
+
* ITS PYTHON HOME IS THE TUI, and it is here because this is where its SIBLINGS' twins are:
|
|
125
|
+
* `appendAgentLesson` writes the index lines this deletes and `consolidateMemory` rebuilds
|
|
126
|
+
* them, so the three rules about MEMORY.md's format live together rather than one of them
|
|
127
|
+
* living beside a web endpoint. P7 ports the TUI's memory view onto this same function.
|
|
128
|
+
*
|
|
129
|
+
* The P6f plan asked whether P5's `learn` had already brought it across. It had not —
|
|
130
|
+
* nothing in `js/` referenced it — which is the third time a plan's "already ported" column
|
|
131
|
+
* was answered by reading rather than by trusting.
|
|
132
|
+
*
|
|
133
|
+
* SUBSTRING, NOT PARSE, exactly as the reference is: any line containing `(<name>.md)` goes,
|
|
134
|
+
* so a hand-written index in a different shape still loses its entry. And the file is only
|
|
135
|
+
* REWRITTEN when something actually changed, which keeps a delete of an unindexed fact from
|
|
136
|
+
* touching the user's mtime.
|
|
137
|
+
*/
|
|
138
|
+
export function memoryDropIndex(memDir, name) {
|
|
139
|
+
const idx = path.join(memDir, 'MEMORY.md');
|
|
140
|
+
let lines;
|
|
141
|
+
try {
|
|
142
|
+
lines = splitLines(readText(idx));
|
|
143
|
+
} catch {
|
|
144
|
+
return; // the reference's `except OSError: return`
|
|
145
|
+
}
|
|
146
|
+
const keep = lines.filter((ln) => !ln.includes(`(${name}.md)`));
|
|
147
|
+
if (keep.length === lines.length) return;
|
|
148
|
+
try {
|
|
149
|
+
writeText(idx, `${keep.join('\n')}\n`);
|
|
150
|
+
} catch { /* the reference swallows OSError here too */ }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `consolidate_memory` — rebuild MEMORY.md from the fact files actually on disk: index
|
|
155
|
+
* orphans, prune dead lines, report duplicate descriptions (never auto-merge — nuance is
|
|
156
|
+
* the user's to keep).
|
|
157
|
+
*/
|
|
158
|
+
export function consolidateMemory(memDir) {
|
|
159
|
+
const skip = RESERVED_STEMS;
|
|
160
|
+
const facts = new Map();
|
|
161
|
+
for (const name of sortPaths(globMd(memDir))) {
|
|
162
|
+
const s = stem(name);
|
|
163
|
+
if (skip.has(s.toLowerCase())) continue;
|
|
164
|
+
try {
|
|
165
|
+
facts.set(s, fmGet(frontmatter(readText(path.join(memDir, name)))[0], 'description').trim());
|
|
166
|
+
} catch { continue; }
|
|
167
|
+
}
|
|
168
|
+
const index = path.join(memDir, 'MEMORY.md');
|
|
169
|
+
const oldSlugs = new Set();
|
|
170
|
+
if (existsSync(index)) {
|
|
171
|
+
for (const line of splitLines(readText(index))) {
|
|
172
|
+
const m = /^- \[[^\]]*\]\(([^)]+)\.md\)/.exec(line.trim());
|
|
173
|
+
if (m) oldSlugs.add(m[1]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const lines = ['# Memory Index', ''];
|
|
177
|
+
for (const [slug, desc] of facts) {
|
|
178
|
+
lines.push(`- [${slug}](${slug}.md)${desc ? ` \u2014 ${desc}` : ''}`);
|
|
179
|
+
}
|
|
180
|
+
writeText(index, `${lines.join('\n')}\n`);
|
|
181
|
+
const seen = new Map();
|
|
182
|
+
const dups = [];
|
|
183
|
+
for (const [slug, desc] of facts) {
|
|
184
|
+
if (desc && seen.has(desc)) dups.push([seen.get(desc), slug]);
|
|
185
|
+
if (!seen.has(desc)) seen.set(desc, slug);
|
|
186
|
+
}
|
|
187
|
+
const added = [...facts.keys()].filter((s) => !oldSlugs.has(s)).sort();
|
|
188
|
+
const pruned = [...oldSlugs].filter((s) => !facts.has(s)).sort();
|
|
189
|
+
return { added, pruned, duplicates: dups };
|
|
190
|
+
}
|
package/js/hosts/native.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A faithful translation of `_build_emit._write_native_layer` and the frontmatter
|
|
5
5
|
* builders it dispatches to. Capability specs become host-native subagents and skills:
|
|
6
|
-
* one dialect per host (OpenCode / Claude Code), vendored skill folders copied
|
|
6
|
+
* one agent dialect per host family (OpenCode / Claude Code), vendored skill folders copied
|
|
7
7
|
* through verbatim, authoring templates shipped flat.
|
|
8
8
|
*
|
|
9
9
|
* WHY THIS IS THE HARD HALF. `js/build/render.mjs` is a pure function of `src/` and `themes/`;
|
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
* 2. Claim-on-create decides which files get written AT ALL: a target that already
|
|
16
16
|
* exists and was not in the previous manifest belongs to the user, and is skipped
|
|
17
17
|
* with a warning. So the output is a function of what is already on disk.
|
|
18
|
-
* 3.
|
|
19
|
-
* (`<name>.agent.md`) that drags a link rewrite along with it.
|
|
18
|
+
* 3. Two agent dialects diverge inside one loop, on the same filenames.
|
|
20
19
|
*
|
|
21
20
|
* None of the three is reachable from the golden harness's happy path: golden emits into
|
|
22
21
|
* a fresh tree with an empty overrides stub. `tests/unit/user_files.test.mjs` drives
|
|
@@ -32,9 +31,10 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
32
31
|
import path from 'node:path';
|
|
33
32
|
import { writeText, copyFile, readText } from '../lib/fs.mjs';
|
|
34
33
|
import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
|
|
34
|
+
import { relPosix } from '../lib/text.mjs';
|
|
35
35
|
|
|
36
36
|
/** What a folder skill writes where it needs its own directory — see `writeNativeLayer`. */
|
|
37
|
-
|
|
37
|
+
const SKILL_DIR_PLACEHOLDER = '<this-skill-directory>';
|
|
38
38
|
|
|
39
39
|
/** Mirrors `_build_core.VENDORED_SKILL_DIRS`. */
|
|
40
40
|
export const VENDORED_SKILL_DIRS = new Set(['react-view-transitions', 'daydream', 'token-report', 'explain-changes']);
|
|
@@ -108,11 +108,6 @@ export function firstBlockquote(text) {
|
|
|
108
108
|
return '';
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/** `_build_emit.desc_of`. */
|
|
112
|
-
export function descOf(text) {
|
|
113
|
-
return firstBlockquote(text);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
111
|
/**
|
|
117
112
|
* The `**Trigger:**` paragraph of a skill, flattened to one line — markdown links reduced
|
|
118
113
|
* to their text, HTML comments dropped, whitespace collapsed. '' when the skill has none.
|
|
@@ -155,7 +150,7 @@ function triggerOf(text) {
|
|
|
155
150
|
*/
|
|
156
151
|
const DESCRIPTION_CAP = 320;
|
|
157
152
|
function skillDescription(text) {
|
|
158
|
-
return withWhen(
|
|
153
|
+
return withWhen(firstBlockquote(text), triggerOf(text));
|
|
159
154
|
}
|
|
160
155
|
|
|
161
156
|
/**
|
|
@@ -188,7 +183,7 @@ function dispatchOf(text) {
|
|
|
188
183
|
|
|
189
184
|
/** The emitted `description:` of an agent — purpose + its first dispatch condition. */
|
|
190
185
|
export function agentDescription(text) {
|
|
191
|
-
return withWhen(
|
|
186
|
+
return withWhen(firstBlockquote(text), dispatchOf(text));
|
|
192
187
|
}
|
|
193
188
|
|
|
194
189
|
/** Purpose + ` Use when: ` + the first sentence of `trig`, capped — see `skillDescription`. */
|
|
@@ -299,11 +294,9 @@ function given(v) {
|
|
|
299
294
|
* `Object.prototype` for an agent named `constructor`, `toString` or `valueOf`, and would
|
|
300
295
|
* hand back a function where Python hands back `None`.
|
|
301
296
|
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* while this returns undefined for every key and emits no override lines. Not a byte
|
|
306
|
-
* difference on any path the gate covers — a difference in which malformed input crashes.
|
|
297
|
+
* A value that is present but NOT an object (`"reviewer": "haiku"` instead of
|
|
298
|
+
* `"reviewer": {"model": "haiku"}`) never reaches here from a file: `loadAgentOverrides`
|
|
299
|
+
* warns and drops it. A caller handing one in directly gets no override lines.
|
|
307
300
|
*/
|
|
308
301
|
function agentOverride(overrides, stem) {
|
|
309
302
|
return (Object.hasOwn(overrides, stem) && overrides[stem]) || {};
|
|
@@ -366,13 +359,36 @@ function opencodeAgentFrontmatter(stem, text, overrides, theme = null) {
|
|
|
366
359
|
return fm;
|
|
367
360
|
}
|
|
368
361
|
|
|
369
|
-
/** `
|
|
362
|
+
/** The keys the OpenCode dialect renders. Claude's honours `model` alone — see `writeNativeLayer`. */
|
|
363
|
+
const OVERRIDE_KEYS = ['model', 'temperature', 'variant', 'steps'];
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* '' when `v` may be written into a frontmatter line, else why not.
|
|
367
|
+
*
|
|
368
|
+
* Each value lands VERBATIM after `key: `, so a string carrying a line break writes frontmatter
|
|
369
|
+
* keys of its own (`"haiku\npermission: allow"`), and anything `formatValue` cannot render — a
|
|
370
|
+
* boolean, a list, an object — threw a bare TypeError that aborted the emit with a stack trace
|
|
371
|
+
* naming nothing the user wrote.
|
|
372
|
+
*/
|
|
373
|
+
function overrideProblem(v) {
|
|
374
|
+
if (typeof v === 'string') return /[\r\n\u2028\u2029]/.test(v) ? 'contains a line break' : '';
|
|
375
|
+
// `parseJson` yields exactly one object `isDict` refuses: its number wrapper.
|
|
376
|
+
const isNumber = typeof v === 'object' && v !== null && !Array.isArray(v) && !isDict(v);
|
|
377
|
+
if (isNumber && Number.isFinite(+v)) return '';
|
|
378
|
+
return 'is not a string or a finite number';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* `_build_emit._load_agent_overrides`, with every value that would break the frontmatter
|
|
383
|
+
* dropped and named on stderr — the user's file, so a bad entry is a warning, never an abort.
|
|
384
|
+
*/
|
|
370
385
|
export function loadAgentOverrides(base) {
|
|
386
|
+
const file = path.join(base, 'agent-overrides.json');
|
|
371
387
|
let data;
|
|
372
388
|
try {
|
|
373
389
|
// parseJson, not JSON.parse: the override values are rendered with `formatValue`, which
|
|
374
390
|
// needs the int/float distinction the raw literal carries and `JSON.parse` discards.
|
|
375
|
-
data = parseJson(readText(
|
|
391
|
+
data = parseJson(readText(file));
|
|
376
392
|
} catch {
|
|
377
393
|
// Missing or malformed => no overrides, so agents inherit the host model.
|
|
378
394
|
// Wider than Python's `except (OSError, json.JSONDecodeError)` by one case: a file
|
|
@@ -382,12 +398,72 @@ export function loadAgentOverrides(base) {
|
|
|
382
398
|
return {};
|
|
383
399
|
}
|
|
384
400
|
const agents = isDict(data) ? data.agents : null;
|
|
385
|
-
|
|
401
|
+
if (!isDict(agents)) return {};
|
|
402
|
+
// In place: `data` is a fresh parse, and deleting keeps a `__proto__`-named entry an own
|
|
403
|
+
// property, where copying into a new object would assign a prototype instead.
|
|
404
|
+
for (const [stem, entry] of Object.entries(agents)) {
|
|
405
|
+
if (entry === null) continue;
|
|
406
|
+
if (!isDict(entry)) {
|
|
407
|
+
warn(`[geneseed] WARN: ${file}: agents.${stem} is not an object — skipped`);
|
|
408
|
+
delete agents[stem];
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
for (const key of OVERRIDE_KEYS) {
|
|
412
|
+
if (!given(entry[key])) continue;
|
|
413
|
+
const why = overrideProblem(entry[key]);
|
|
414
|
+
if (!why) continue;
|
|
415
|
+
warn(`[geneseed] WARN: ${file}: agents.${stem}.${key} ${why} — skipped`);
|
|
416
|
+
delete entry[key];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return agents;
|
|
386
420
|
}
|
|
387
421
|
|
|
388
|
-
/**
|
|
389
|
-
|
|
390
|
-
|
|
422
|
+
/**
|
|
423
|
+
* Warn once per override key the Claude dialect drops. The seeded `agent-overrides.json`
|
|
424
|
+
* advertises all four keys on every host, and Claude Code's agent frontmatter takes `model`
|
|
425
|
+
* only — so a `temperature` set there was silently a no-op.
|
|
426
|
+
*/
|
|
427
|
+
function warnClaudeIgnored(overrides) {
|
|
428
|
+
const ignored = new Set();
|
|
429
|
+
for (const entry of Object.values(overrides)) {
|
|
430
|
+
if (!isDict(entry)) continue;
|
|
431
|
+
for (const key of OVERRIDE_KEYS.slice(1)) if (given(entry[key])) ignored.add(key);
|
|
432
|
+
}
|
|
433
|
+
for (const key of ignored) {
|
|
434
|
+
warn(`[geneseed] WARN: agent-overrides.json sets '${key}', which Claude-family hosts do `
|
|
435
|
+
+ "not support — only 'model' is applied");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Claim-on-create, as a closure over one emit's prior manifest: `claim(dest)` is true when
|
|
441
|
+
* `dest` may be (over)written, false for a pre-existing file no previous emit owned — the
|
|
442
|
+
* user's, left alone, named on stderr, and kept out of the manifest by the caller.
|
|
443
|
+
*
|
|
444
|
+
* Its own function because every writer whose path lands in `owned` needs it, and uninstall
|
|
445
|
+
* deletes what `owned` names: the OpenCode command and primary-agent writers pushed their
|
|
446
|
+
* paths without it, so uninstall removed a user's own `command/commit.md`. ONE closure per
|
|
447
|
+
* emit, shared by all of them, so the pre-manifest header still prints once.
|
|
448
|
+
*
|
|
449
|
+
* `oldOwned === null` (or no `cfg`) is a caller doing no ownership at all: claim everything.
|
|
450
|
+
*/
|
|
451
|
+
export function claimer(oldOwned, cfg, manifestExisted = true) {
|
|
452
|
+
const oldSet = oldOwned !== null && oldOwned !== undefined ? new Set(oldOwned) : null;
|
|
453
|
+
let headerPrinted = false;
|
|
454
|
+
return (dest) => {
|
|
455
|
+
if (oldSet === null || cfg === null || !existsSync(dest)) return true;
|
|
456
|
+
const rel = relPosix(cfg, dest);
|
|
457
|
+
if (oldSet.has(rel)) return true;
|
|
458
|
+
if (!manifestExisted && !headerPrinted) {
|
|
459
|
+
warn('[geneseed] first emit over a pre-manifest install — existing files '
|
|
460
|
+
+ 'are treated as yours');
|
|
461
|
+
headerPrinted = true;
|
|
462
|
+
}
|
|
463
|
+
warn(`[geneseed] kept your existing ${rel} — skipped Geneseed's copy to avoid `
|
|
464
|
+
+ 'clobbering it');
|
|
465
|
+
return false;
|
|
466
|
+
};
|
|
391
467
|
}
|
|
392
468
|
|
|
393
469
|
/**
|
|
@@ -406,6 +482,9 @@ function relPosix(base, target) {
|
|
|
406
482
|
* has the placeholder replaced by the answer. Only vendored folders carry the placeholder — a
|
|
407
483
|
* flat skill has no siblings to point at.
|
|
408
484
|
*
|
|
485
|
+
* `opts.claim` — a `claimer` closure the caller shares with its other writers; built from
|
|
486
|
+
* `oldOwned`/`cfg`/`manifestExisted` when absent.
|
|
487
|
+
*
|
|
409
488
|
* Returns `{ nAgents, nSkills, written }` — Python's 3-tuple, with `written` as absolute
|
|
410
489
|
* paths in write order.
|
|
411
490
|
*
|
|
@@ -419,30 +498,13 @@ function relPosix(base, target) {
|
|
|
419
498
|
*/
|
|
420
499
|
export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null, {
|
|
421
500
|
host = 'opencode', oldOwned = null, cfg = null, manifestExisted = true,
|
|
422
|
-
theme = null, src, skillDirOf = null,
|
|
501
|
+
theme = null, src, skillDirOf = null, claim = claimer(oldOwned, cfg, manifestExisted),
|
|
423
502
|
} = {}) {
|
|
424
503
|
const ov = overrides || {};
|
|
425
|
-
|
|
504
|
+
if (host === 'claude') warnClaudeIgnored(ov);
|
|
426
505
|
let nAgents = 0;
|
|
427
506
|
let nSkills = 0;
|
|
428
507
|
const written = [];
|
|
429
|
-
let headerPrinted = false;
|
|
430
|
-
|
|
431
|
-
// True -> ok to (over)write; false -> a pre-existing file we never owned, so it is the
|
|
432
|
-
// user's: leave it, warn, and keep it out of the manifest.
|
|
433
|
-
const claim = (dest) => {
|
|
434
|
-
if (oldSet === null || cfg === null || !existsSync(dest)) return true;
|
|
435
|
-
const rel = relPosix(cfg, dest);
|
|
436
|
-
if (oldSet.has(rel)) return true;
|
|
437
|
-
if (!manifestExisted && !headerPrinted) {
|
|
438
|
-
warn('[geneseed] first emit over a pre-manifest install — existing files '
|
|
439
|
-
+ 'are treated as yours');
|
|
440
|
-
headerPrinted = true;
|
|
441
|
-
}
|
|
442
|
-
warn(`[geneseed] kept your existing ${rel} — skipped Geneseed's copy to avoid `
|
|
443
|
-
+ 'clobbering it');
|
|
444
|
-
return false;
|
|
445
|
-
};
|
|
446
508
|
|
|
447
509
|
const write = (dest, text) => {
|
|
448
510
|
mkdirSync(path.dirname(dest), { recursive: true });
|
|
@@ -498,13 +560,9 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
|
|
|
498
560
|
let dest;
|
|
499
561
|
let kind;
|
|
500
562
|
if (folder === 'agents') {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
} else {
|
|
505
|
-
fm = opencodeAgentFrontmatter(stem, text, ov, theme);
|
|
506
|
-
dest = path.join(agentsDir, `${stem}.md`);
|
|
507
|
-
}
|
|
563
|
+
fm = host === 'claude' ? claudeAgentFrontmatter(stem, text, ov)
|
|
564
|
+
: opencodeAgentFrontmatter(stem, text, ov, theme);
|
|
565
|
+
dest = path.join(agentsDir, `${stem}.md`);
|
|
508
566
|
kind = 'agent';
|
|
509
567
|
} else {
|
|
510
568
|
// Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
|
package/js/hosts/opencode.mjs
CHANGED
|
@@ -16,18 +16,21 @@
|
|
|
16
16
|
*
|
|
17
17
|
* TWO STREAMS, AND THE SPLIT IS NOT TIDY. `_warn_if_overrides_stale` prints to STDOUT
|
|
18
18
|
* while every warning in `js/hosts/native.mjs` goes to stderr. That is the Python's own
|
|
19
|
-
* inconsistency and it is reproduced, not corrected:
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* a stray stdout byte silently disarms a blocking gate.
|
|
19
|
+
* inconsistency and it is reproduced, not corrected: `tests/unit/agent_overrides.test.mjs`
|
|
20
|
+
* pins the stale notice to stdout. It is safe because this is the generator's CLI, which
|
|
21
|
+
* prints progress to stdout by design — unlike a hook path, where a stray stdout byte
|
|
22
|
+
* silently disarms a blocking gate.
|
|
24
23
|
*/
|
|
25
24
|
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
26
25
|
import path from 'node:path';
|
|
27
26
|
import { writeText, readText, copyFile } from '../lib/fs.mjs';
|
|
28
27
|
import { jsonDumps, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
|
|
29
28
|
import { comparePaths } from '../lib/paths.mjs';
|
|
30
|
-
import {
|
|
29
|
+
import { sourceReleaseVersion } from '../build/version.mjs';
|
|
30
|
+
import { firstBlockquote, stripSkillBodyLinks, pushOverrideLines } from './native.mjs';
|
|
31
|
+
|
|
32
|
+
// Re-exported for `js/web/api.mjs` and the tests, which import it from here.
|
|
33
|
+
export { sourceReleaseVersion };
|
|
31
34
|
|
|
32
35
|
/** ANSI colour-name -> integer (0-7). Mirrors `_build_emit._ANSI`. */
|
|
33
36
|
const ANSI = {
|
|
@@ -94,9 +97,17 @@ const AGENT_OVERRIDES_STUB = {
|
|
|
94
97
|
},
|
|
95
98
|
};
|
|
96
99
|
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
/**
|
|
101
|
+
* `_build_emit.COMMAND_SET` — the hot skills worth a one-keystroke trigger, as command name ->
|
|
102
|
+
* skill name. A map, not a list, because the two can differ: `code-review` became
|
|
103
|
+
* `geneseed-code-review` (so it no longer shadows a host built-in) while the command stays
|
|
104
|
+
* `/code-review`. As a list the renamed entry missed its skill and was dropped in silence, so a
|
|
105
|
+
* missing skill is now a thrown error in `writeCommandLayer`.
|
|
106
|
+
*/
|
|
107
|
+
const COMMAND_SET = {
|
|
108
|
+
commit: 'commit', plan: 'plan', 'code-review': 'geneseed-code-review',
|
|
109
|
+
'review-response': 'review-response', ship: 'ship', debug: 'debug', research: 'research',
|
|
110
|
+
};
|
|
100
111
|
|
|
101
112
|
/** Mirrors `_build_emit.PONYTAIL_COMMAND_BODY`. */
|
|
102
113
|
const PONYTAIL_COMMAND_BODY =
|
|
@@ -113,22 +124,6 @@ function truthyEnv(name) {
|
|
|
113
124
|
return ['1', 'on', 'true', 'yes'].includes((process.env[name] || '').toLowerCase());
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
/**
|
|
117
|
-
* `_build_core.source_release_version`. It reads `cfg.config`, so it takes `cfg` rather than
|
|
118
|
-
* importing core — and it stayed HERE when core landed as `js/build/source.mjs`, which is why
|
|
119
|
-
* `js/build/version.mjs` reaches back into the OpenCode module for it.
|
|
120
|
-
*/
|
|
121
|
-
export function sourceReleaseVersion(cfg) {
|
|
122
|
-
try {
|
|
123
|
-
const data = parseJson(readText(cfg.config));
|
|
124
|
-
const v = data && typeof data === 'object' && !Array.isArray(data) ? data.version : null;
|
|
125
|
-
if (typeof v === 'string' && v) return v;
|
|
126
|
-
} catch {
|
|
127
|
-
// OSError / JSONDecodeError — degrade to "not newer" rather than raising.
|
|
128
|
-
}
|
|
129
|
-
return '0.0.0';
|
|
130
|
-
}
|
|
131
|
-
|
|
132
127
|
const GRAY = 8;
|
|
133
128
|
const GREEN = 2;
|
|
134
129
|
const RED = 1;
|
|
@@ -181,7 +176,6 @@ export function writeTheme(themesDir, themeName, theme) {
|
|
|
181
176
|
return dest;
|
|
182
177
|
}
|
|
183
178
|
|
|
184
|
-
/** `_build_emit._color_theme_json`. */
|
|
185
179
|
/**
|
|
186
180
|
* `_build_emit._PALETTE_ROLES` — every role some slot reads, as a set.
|
|
187
181
|
*
|
|
@@ -192,6 +186,7 @@ export function writeTheme(themesDir, themeName, theme) {
|
|
|
192
186
|
*/
|
|
193
187
|
export const PALETTE_ROLES = new Set(Object.values(SLOT_ROLE));
|
|
194
188
|
|
|
189
|
+
/** `_build_emit._color_theme_json`. */
|
|
195
190
|
export function colorThemeJson(palette, transparent) {
|
|
196
191
|
const t = {};
|
|
197
192
|
for (const [slot, role] of Object.entries(SLOT_ROLE)) {
|
|
@@ -272,8 +267,16 @@ export function ensureAgentOverridesStub(cfg, base) {
|
|
|
272
267
|
writeText(dest, `${jsonDumpsIndent(stub, { ensureAscii: false })}\n`);
|
|
273
268
|
}
|
|
274
269
|
|
|
275
|
-
/**
|
|
276
|
-
|
|
270
|
+
/**
|
|
271
|
+
* `_build_emit._write_primary_agent` — the opt-in `mode: primary` orchestrator.
|
|
272
|
+
*
|
|
273
|
+
* `claim` is `claimer`'s closure from `js/hosts/native.mjs`, and this and the two command
|
|
274
|
+
* writers below take it for the reason `writeNativeLayer` does: their paths are pushed into
|
|
275
|
+
* `owned`, and uninstall deletes what `owned` names — so writing over a user's own
|
|
276
|
+
* `command/commit.md` without the check made uninstall delete it. Default: claim everything,
|
|
277
|
+
* for a caller with no manifest to consult.
|
|
278
|
+
*/
|
|
279
|
+
export function writePrimaryAgent(cfg, agentsDir, overrides, claim = () => true) {
|
|
277
280
|
if (!truthyEnv('GENESEED_PRIMARY') || !existsSync(cfg.primaryAgentSrc)
|
|
278
281
|
|| !statSync(cfg.primaryAgentSrc).isFile()) {
|
|
279
282
|
return null;
|
|
@@ -286,13 +289,14 @@ export function writePrimaryAgent(cfg, agentsDir, overrides) {
|
|
|
286
289
|
&& overrides.orchestrator) || {};
|
|
287
290
|
pushOverrideLines(fm, ov);
|
|
288
291
|
const dest = path.join(agentsDir, 'orchestrator.md');
|
|
292
|
+
if (!claim(dest)) return null;
|
|
289
293
|
mkdirSync(path.dirname(dest), { recursive: true });
|
|
290
294
|
writeText(dest, `---\n${fm.join('\n')}\n---\n\n${body}`);
|
|
291
295
|
return dest;
|
|
292
296
|
}
|
|
293
297
|
|
|
294
298
|
/** `_build_emit._write_command_layer` — the opt-in /slash commands. */
|
|
295
|
-
export function writeCommandLayer(cfg, items, commandDir) {
|
|
299
|
+
export function writeCommandLayer(cfg, items, commandDir, claim = () => true) {
|
|
296
300
|
if (!truthyEnv('GENESEED_COMMANDS')) return [];
|
|
297
301
|
const byName = new Map();
|
|
298
302
|
for (const { text, src } of items) {
|
|
@@ -304,12 +308,15 @@ export function writeCommandLayer(cfg, items, commandDir) {
|
|
|
304
308
|
}
|
|
305
309
|
}
|
|
306
310
|
const written = [];
|
|
307
|
-
for (const name of COMMAND_SET) {
|
|
308
|
-
const text = byName.get(
|
|
309
|
-
if (text === undefined)
|
|
310
|
-
|
|
311
|
-
|
|
311
|
+
for (const [name, skill] of Object.entries(COMMAND_SET)) {
|
|
312
|
+
const text = byName.get(skill);
|
|
313
|
+
if (text === undefined) {
|
|
314
|
+
throw new Error(`COMMAND_SET: /${name} runs skill '${skill}', which src/skills/ does not have`);
|
|
315
|
+
}
|
|
312
316
|
const dest = path.join(commandDir, `${name}.md`);
|
|
317
|
+
if (!claim(dest)) continue;
|
|
318
|
+
const desc = firstBlockquote(text);
|
|
319
|
+
const body = stripSkillBodyLinks(text.replace(/^\n+/, ''));
|
|
313
320
|
mkdirSync(path.dirname(dest), { recursive: true });
|
|
314
321
|
writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
|
|
315
322
|
written.push(dest);
|
|
@@ -317,9 +324,10 @@ export function writeCommandLayer(cfg, items, commandDir) {
|
|
|
317
324
|
return written;
|
|
318
325
|
}
|
|
319
326
|
|
|
320
|
-
/** `_build_emit._write_ponytail_command` — registered UNCONDITIONALLY. */
|
|
321
|
-
export function writePonytailCommand(commandDir) {
|
|
327
|
+
/** `_build_emit._write_ponytail_command` — registered UNCONDITIONALLY; null when the user's. */
|
|
328
|
+
export function writePonytailCommand(commandDir, claim = () => true) {
|
|
322
329
|
const dest = path.join(commandDir, 'ponytail.md');
|
|
330
|
+
if (!claim(dest)) return null;
|
|
323
331
|
mkdirSync(path.dirname(dest), { recursive: true });
|
|
324
332
|
const desc = 'Set the ponytail minimal-code level for the session: lite | full | ultra | off';
|
|
325
333
|
writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${PONYTAIL_COMMAND_BODY}`);
|