futura-scion 0.2.6 → 0.2.8
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 +79 -0
- package/bin/scion.js +107 -0
- package/config/scion.config.yaml +5 -1
- package/package.json +1 -1
- package/src/config.js +3 -1
- package/src/ladder.js +56 -1
- package/src/mind/agent-templates.js +287 -0
- package/src/mind/architecture.js +177 -8
- package/src/mind/commitbench.js +194 -0
- package/src/mind/generator.js +8 -1
- package/src/mind/librarian.js +109 -0
- package/src/mind/replay.js +10 -1
- package/src/mind/skills.js +210 -0
- package/src/mind/task-brief.js +24 -7
- package/src/mind/trust.js +118 -0
package/README.md
CHANGED
|
@@ -592,6 +592,40 @@ with the violation evidence attached — the human chooses refactor, move,
|
|
|
592
592
|
or amend the declaration, via the same `scion review` loop as every other
|
|
593
593
|
escalation.
|
|
594
594
|
|
|
595
|
+
#### Hub rules — shape, not just edges (`architecture.hubs`)
|
|
596
|
+
|
|
597
|
+
Edges police direction; HUBS police concentration, checked against the
|
|
598
|
+
schematic's measured fan-in/fan-out (`scion map` design data — curator
|
|
599
|
+
models derive the same numbers from edges, so no map build is required):
|
|
600
|
+
|
|
601
|
+
```yaml
|
|
602
|
+
architecture:
|
|
603
|
+
hubs:
|
|
604
|
+
max_fan_in: 100 # no file imported by more than N (change magnets)
|
|
605
|
+
max_fan_out: 20 # no file importing more than N (God modules)
|
|
606
|
+
max_layer_fan_in: 300 # no layer funneling more inbound edges than N
|
|
607
|
+
ignore: [vendor, gen] # path prefixes exempt from hub accounting
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
Hub violations are analyzer-shaped **warnings** (`arch-hub-fan-in`,
|
|
611
|
+
`arch-hub-fan-out`, `arch-hub-layer-fan-in`) carrying the measured number,
|
|
612
|
+
the declared ceiling, and the file — a design smell escalates to the human
|
|
613
|
+
arch lane, never machine-patched. All thresholds are opt-in (empty = not
|
|
614
|
+
checked); malformed or typo'd keys fail loud. Live on FS's own source at
|
|
615
|
+
diagnostic thresholds: `kernel/trail.js` flagged at 173 inbound edges
|
|
616
|
+
(change magnet), `daemon.js` at 22 imports (God module).
|
|
617
|
+
|
|
618
|
+
#### Map-first THINK (`schematic.enabled`)
|
|
619
|
+
|
|
620
|
+
Every ladder decision attaches the schematic digest — layers, entries,
|
|
621
|
+
hubs, exported surface — as context for all downstream rungs (generator
|
|
622
|
+
prompts, analyzer reasoning, research queries). Served from a warm cache
|
|
623
|
+
(`schematic.digest_ttl_ms`, default 30000) so latency-bounded scans are
|
|
624
|
+
never starved: one refresh per TTL window, amortized across every worker
|
|
625
|
+
and daemon scan. `taskId` and `mapContext` are ambient keys — stripped
|
|
626
|
+
before signature hashing, so identical problems replay identically across
|
|
627
|
+
task ids and map refreshes.
|
|
628
|
+
|
|
595
629
|
### Command over tools — the research rung (`scion tools`)
|
|
596
630
|
|
|
597
631
|
FS's rungs answer from what it knows. The RESEARCH rung gives it hands:
|
|
@@ -824,6 +858,51 @@ the current task needs, retain only what compounds.
|
|
|
824
858
|
rung's action threshold — so only REPETITION makes them actionable.
|
|
825
859
|
One occurrence is an observation; repetition is knowledge.
|
|
826
860
|
|
|
861
|
+
### FS as the learner of its own harness — Codebuff adoption organs (v0.2.8)
|
|
862
|
+
|
|
863
|
+
From studying the open-source Codebuff/Freebuff harnesses, six patterns were
|
|
864
|
+
adopted — each rebuilt to fit the zero-LLM doctrine, none copied as LLM
|
|
865
|
+
machinery:
|
|
866
|
+
|
|
867
|
+
- **Agent templates as data** (`agent-templates/`, `scion templates`): an
|
|
868
|
+
agent is a YAML contract — `spawner_prompt` (how a parent chooses it),
|
|
869
|
+
`input_schema`/`output_schema` (validated at the spawn boundary),
|
|
870
|
+
`tool_allowlist` (least privilege), `capabilities` (routed into the
|
|
871
|
+
kernel's role registry), `bounds`, and `spawnable_agents` — a spawn graph
|
|
872
|
+
that is cycle-checked at load and depth-bounded at spawn. Six templates
|
|
873
|
+
ship (fixer, researcher, mapper, librarian, constructor→verifier,
|
|
874
|
+
verifier). Routing is scored keyword matching, deterministic and journaled:
|
|
875
|
+
|
|
876
|
+
```bash
|
|
877
|
+
scion templates load|list|route "fix the bug"|spawn <id>
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
- **Skills — procedural knowledge packs** (`skills/`, `scion skills`):
|
|
881
|
+
`SKILL.md` files with a small frontmatter block (name, description,
|
|
882
|
+
triggers). Parsed loud, name/filename discipline enforced, trigger match
|
|
883
|
+
exact. Wired into ladder THINK: a task mentioning "ship a release"
|
|
884
|
+
absorbs the release-checklist skill into its ephemeral working set
|
|
885
|
+
automatically; swept at task end. New procedures = drop a file in
|
|
886
|
+
`skills/`, zero code.
|
|
887
|
+
|
|
888
|
+
- **The trust gate** (`scion trust`): content-addressed trust for every
|
|
889
|
+
loaded pack. `unseen` → observed; `pin` → checksum-pinned; a PINNED pack
|
|
890
|
+
that changes refuses loudly (with both hashes) until explicitly re-pinned.
|
|
891
|
+
|
|
892
|
+
- **The repo librarian** (`scion librarian <owner/repo> "<question>"`):
|
|
893
|
+
shallow-clone an external repo, run the codebase schematics over it, and
|
|
894
|
+
answer from the map with ranked relevant files — one deterministic pass
|
|
895
|
+
over a foreign codebase (Codebuff's librarian, no LLM).
|
|
896
|
+
|
|
897
|
+
- **Commit-reconstruction benchmark** (`src/mind/commitbench.js`): BuffBench's
|
|
898
|
+
honest methodology with the judge replaced by arithmetic — reconstruct
|
|
899
|
+
real commits against ground-truth diffs, scored on file recall/precision
|
|
900
|
+
+ token-level F1, hard subset reported. Oracle = 1.0, empty = 0, proven.
|
|
901
|
+
|
|
902
|
+
- **Declared brief budgets** (`config.brief`): the task working-set's
|
|
903
|
+
TTL/budget/read-limit are config policy (Codebuff CompactionPolicy
|
|
904
|
+
lineage), not module constants.
|
|
905
|
+
|
|
827
906
|
### FS Desktop — the standalone UI (chat / agent / plan / architect)
|
|
828
907
|
|
|
829
908
|
FS ships a real UI in two forms, both zero-build:
|
package/bin/scion.js
CHANGED
|
@@ -678,6 +678,113 @@ switch (cmd || '') {
|
|
|
678
678
|
}
|
|
679
679
|
break;
|
|
680
680
|
}
|
|
681
|
+
case 'templates': {
|
|
682
|
+
// Declarative agent templates (Codebuff agents-as-data lineage):
|
|
683
|
+
// scion templates load [dir] load every template yaml (default: agent-templates/)
|
|
684
|
+
// scion templates list the registry, with capabilities + spawn graph
|
|
685
|
+
// scion templates route <text> deterministic routing: which template for this problem
|
|
686
|
+
// scion templates spawn <id> bind the template's role (printed, not bound to a live worker)
|
|
687
|
+
const at = await import('../src/mind/agent-templates.js');
|
|
688
|
+
const sub = arg || 'list';
|
|
689
|
+
if (sub === 'load') {
|
|
690
|
+
const dir = restArgs[0] || 'agent-templates';
|
|
691
|
+
const loaded = at.loadTemplates(dir);
|
|
692
|
+
at.checkSpawnGraph();
|
|
693
|
+
console.log(JSON.stringify({ ok: true, dir, loaded }, null, 2));
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
if (sub === 'list') {
|
|
697
|
+
for (const t of at.listTemplates()) {
|
|
698
|
+
console.log(`${t.id.padEnd(14)} ${t.display_name.padEnd(14)} caps: ${t.capabilities.join(',')}${t.spawnable_agents?.length ? ` spawns: ${t.spawnable_agents.join(',')}` : ''}`);
|
|
699
|
+
}
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
if (sub === 'route') {
|
|
703
|
+
const text = restArgs.join(' ');
|
|
704
|
+
if (!text) { console.error('usage: scion templates route <problem text>'); process.exitCode = 2; break; }
|
|
705
|
+
const ranked = at.routeToTemplate(text);
|
|
706
|
+
for (const r of ranked) console.log(`${r.score} ${r.id.padEnd(14)} hits: ${r.hits.join(',') || '(capabilities)'}`);
|
|
707
|
+
if (ranked.length === 0) console.log('(no template matches — the ladder routes to the generalist)');
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
if (sub === 'spawn') {
|
|
711
|
+
if (!restArgs[0]) { console.error('usage: scion templates spawn <id>'); process.exitCode = 2; break; }
|
|
712
|
+
const r = at.spawnTemplate(restArgs[0]);
|
|
713
|
+
console.log(JSON.stringify({ ok: true, template: r.template.id, role: r.role.id, capabilities: r.role.capabilities }, null, 2));
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
console.error('usage: scion templates [load|list|route|spawn]');
|
|
717
|
+
process.exitCode = 2;
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
case 'skills': {
|
|
721
|
+
// Procedural knowledge packs (Codebuff SKILL.md lineage):
|
|
722
|
+
// scion skills load [dir...] load every SKILL.md / *.skill.md under the dirs
|
|
723
|
+
// scion skills list name + description of every loaded skill
|
|
724
|
+
// scion skills match <text> deterministic trigger match, scored
|
|
725
|
+
// scion skills show <name> the full body
|
|
726
|
+
const sk = await import('../src/mind/skills.js');
|
|
727
|
+
const sub = arg || 'list';
|
|
728
|
+
if (sub === 'load') {
|
|
729
|
+
const dirs = restArgs.length ? restArgs : ['skills'];
|
|
730
|
+
const loaded = sk.loadSkills(dirs);
|
|
731
|
+
console.log(JSON.stringify({ ok: true, loaded }, null, 2));
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
if (sub === 'match') {
|
|
735
|
+
const text = restArgs.join(' ');
|
|
736
|
+
if (!text) { console.error('usage: scion skills match <task text>'); process.exitCode = 2; break; }
|
|
737
|
+
for (const m of sk.matchSkills(text)) console.log(`${m.score} ${m.name.padEnd(24)} ${m.description}`);
|
|
738
|
+
if (sk.matchSkills(text).length === 0) console.log('(no skill matches)');
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
if (sub === 'show') {
|
|
742
|
+
const s = sk.getSkill(restArgs[0]);
|
|
743
|
+
if (!s) { console.error(`skills: unknown skill ${JSON.stringify(restArgs[0])} — known: ${sk.listSkills().map(x => x.name).join(', ') || '(none loaded)'}`); process.exitCode = 1; break; }
|
|
744
|
+
console.log(`# ${s.name} — ${s.description}\n(source: ${s.source})\n\n${s.body}`);
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
for (const s of sk.listSkills()) console.log(`${s.name.padEnd(24)} ${s.description}`);
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
case 'trust': {
|
|
751
|
+
// The trust gate for loaded packs (checksum pins, loud refusals):
|
|
752
|
+
// scion trust status <file> state: unseen | quarantine | pinned | changed
|
|
753
|
+
// scion trust pin <file> pin current content (operator action)
|
|
754
|
+
// scion trust unpin <file> remove the pin
|
|
755
|
+
// scion trust list every pin
|
|
756
|
+
const tr = await import('../src/mind/trust.js');
|
|
757
|
+
const sub = arg || 'list';
|
|
758
|
+
if (sub === 'pin') {
|
|
759
|
+
if (!restArgs[0]) { console.error('usage: scion trust pin <file>'); process.exitCode = 2; break; }
|
|
760
|
+
console.log(JSON.stringify(tr.pin(restArgs[0]), null, 2));
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
if (sub === 'unpin') {
|
|
764
|
+
if (!restArgs[0]) { console.error('usage: scion trust unpin <file>'); process.exitCode = 2; break; }
|
|
765
|
+
console.log(JSON.stringify(tr.unpin(restArgs[0]), null, 2));
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
if (sub === 'status') {
|
|
769
|
+
if (!restArgs[0]) { console.error('usage: scion trust status <file>'); process.exitCode = 2; break; }
|
|
770
|
+
console.log(JSON.stringify(tr.trustStatus(restArgs[0]), null, 2));
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
for (const p of tr.listTrusted()) console.log(`${p.sha256.slice(0, 12)} ${p.path} pinned ${p.pinned_at}`);
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
case 'librarian': {
|
|
777
|
+
// External repo comprehension: clone → schematic → structured answer.
|
|
778
|
+
// scion librarian <owner/repo|url> "<question>"
|
|
779
|
+
const lib = await import('../src/mind/librarian.js');
|
|
780
|
+
if (!arg || restArgs.length === 0) { console.error('usage: scion librarian <owner/repo> "<question>"'); process.exitCode = 2; break; }
|
|
781
|
+
const r = lib.askRepo(arg, restArgs.join(' '));
|
|
782
|
+
console.log(r.digest);
|
|
783
|
+
console.log(`\nrelevant files:`);
|
|
784
|
+
for (const f of r.relevant_files) console.log(` ${f}`);
|
|
785
|
+
console.log(`\nclone (kept for inspection): ${r.clone_dir} [files=${r.stats.files} symbols=${r.stats.symbols} ${r.stats.ms}ms]`);
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
681
788
|
case 'evolve': {
|
|
682
789
|
// The nightly harness-evolution pass (A4): mine weaknesses → propose
|
|
683
790
|
// harness edits → gate them (SICA utility) → apply the accepted. Bounded
|
package/config/scion.config.yaml
CHANGED
|
@@ -11,8 +11,12 @@ ladder:
|
|
|
11
11
|
min_insight_confidence: 0.55 # reasoner rung admission floor
|
|
12
12
|
|
|
13
13
|
llm:
|
|
14
|
-
daily_tokens:
|
|
14
|
+
daily_tokens: 500
|
|
15
15
|
|
|
16
|
+
apiKey: null
|
|
17
|
+
model: "qwen2.5-coder:7b"
|
|
18
|
+
baseUrl: "http://localhost:11434/v1"
|
|
19
|
+
provider: "ollama"
|
|
16
20
|
bounds:
|
|
17
21
|
max_turns: 25
|
|
18
22
|
timeout_ms: 300000
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -27,7 +27,9 @@ export const DEFAULTS = Object.freeze({
|
|
|
27
27
|
forge: { min_support: 3, min_precision: 0.7, max_trigger_terms: 3, max_seeds_per_run: 5 },
|
|
28
28
|
queue: { max_attempts: 3 },
|
|
29
29
|
analyzer: { enabled: true },
|
|
30
|
-
|
|
30
|
+
schematic: { enabled: true, digest_ttl_ms: 30000 }, // map-first THINK: inject the codebase digest into every ladder decide (warm-cache TTL)
|
|
31
|
+
brief: { ttl_s: 3600, budget_bytes: 64000, read_limit: 24, max_item_bytes: 8000 }, // the working-set policy, declared (Codebuff CompactionPolicy lineage) — task-brief defaults defer to this
|
|
32
|
+
architecture: { rules: [], hubs: { max_fan_in: null, max_fan_out: null, max_layer_fan_in: null, ignore: [] } }, // declared layer rules (mind/architecture.js); hubs thresholds are the schematic's measured shape (null = not checked)
|
|
31
33
|
daemon: { auto_fix: false, max_fixes_per_scan: 10 },
|
|
32
34
|
muscle: { path_deny: [], path_allow: [] }, // empty deny = shipped defaults
|
|
33
35
|
api: { token: null, port: 5107, host: '127.0.0.1' },
|
package/src/ladder.js
CHANGED
|
@@ -27,7 +27,12 @@ import * as replay from './mind/replay.js';
|
|
|
27
27
|
import * as seeds from './mind/seeds.js';
|
|
28
28
|
import * as brainReasoner from './brain/reasoner.js';
|
|
29
29
|
import * as memory from './brain/memory.js';
|
|
30
|
-
|
|
30
|
+
|
|
31
|
+
// The warm map: one digest, refreshed at most once per TTL window (see the
|
|
32
|
+
// MAP-FIRST THINK block in decide()). Module-level so a hot daemon's scans
|
|
33
|
+
// and every worker's THINK share the same amortized cost.
|
|
34
|
+
let warmMap = null;
|
|
35
|
+
const MAP_CONTEXT_TTL_MS = 30_000;import * as trail from './kernel/trail.js';
|
|
31
36
|
import { forge } from './mind/forge.js';
|
|
32
37
|
import { load as loadConfig } from './config.js';
|
|
33
38
|
import { analyzeSource, readTargetSource } from './mind/analyzer-rung.js';
|
|
@@ -154,6 +159,56 @@ export async function decide(problem, opts = {}) {
|
|
|
154
159
|
const memoryScope = opts.memoryScope ?? cfg.memory.scope ?? 'auto';
|
|
155
160
|
const minInsightConfidence = opts.minInsightConfidence ?? cfg.ladder.min_insight_confidence;
|
|
156
161
|
|
|
162
|
+
// MAP-FIRST THINK (the schematics contract): every task starts from the
|
|
163
|
+
// codebase map, not from re-reading source. The digest is the one-screen
|
|
164
|
+
// briefing — layers, entries, hubs, surface — attached to the input as
|
|
165
|
+
// `mapContext` for every downstream rung (analyzer reasoning, generator
|
|
166
|
+
// prompts, research queries).
|
|
167
|
+
// COST CONTRACT: mapFor() re-parses only changed files, but even its
|
|
168
|
+
// hash-only pass reads+hashes every file — per-decide that is real money
|
|
169
|
+
// on large trees and it starves latency-bounded scans. THINK context
|
|
170
|
+
// tolerates seconds of staleness, so the digest is served from a WARM
|
|
171
|
+
// cache (default 30s TTL, config schematic.digest_ttl_ms): at most one
|
|
172
|
+
// refresh per TTL window. Anything that needs fresh truth (the analyzer
|
|
173
|
+
// rung, the Gate) reads the real source as always — context never
|
|
174
|
+
// misleads: a map built for a different root, or disabled in config,
|
|
175
|
+
// is skipped silently.
|
|
176
|
+
if (cfg.schematic?.enabled !== false && !input.mapContext) {
|
|
177
|
+
try {
|
|
178
|
+
const ttl = Number(cfg.schematic?.digest_ttl_ms ?? MAP_CONTEXT_TTL_MS);
|
|
179
|
+
const stale = !warmMap || warmMap.root !== project || (Date.now() - warmMap.at > ttl);
|
|
180
|
+
if (stale) {
|
|
181
|
+
const sch = await import('./mind/schematic.js');
|
|
182
|
+
const prev = sch.loadModel();
|
|
183
|
+
const mapRoot = prev?.root ?? project;
|
|
184
|
+
const { model } = sch.mapFor(mapRoot);
|
|
185
|
+
warmMap = model && model.totalSymbols > 0
|
|
186
|
+
? { root: model.root, at: Date.now(), digest: sch.digest(model) }
|
|
187
|
+
: null;
|
|
188
|
+
}
|
|
189
|
+
if (warmMap?.digest) {
|
|
190
|
+
input.mapContext = warmMap.digest;
|
|
191
|
+
// SKILL MATCH (procedural context, same THINK pass): matched skills
|
|
192
|
+
// inject into the task's working set (ephemeral brief) — a skill
|
|
193
|
+
// for THIS task is fuel, not furniture. No match, no injection.
|
|
194
|
+
if (_routingTaskId) {
|
|
195
|
+
try {
|
|
196
|
+
const sk = await import('./mind/skills.js');
|
|
197
|
+
const { injected } = await sk.injectSkills(_routingTaskId, String(input.text || ''), { limit: 2 });
|
|
198
|
+
if (injected.length) trail.journal('ladder.skills-injected', { task: _routingTaskId, skills: injected.map(i => i.name) });
|
|
199
|
+
} catch (err) {
|
|
200
|
+
trail.journal('ladder.skills-skipped', { error: String(err?.message || err).slice(0, 120) });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
trail.journal('ladder.map-context', {
|
|
204
|
+
root: warmMap.root, cachedMs: Date.now() - warmMap.at,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
trail.journal('ladder.map-context-skipped', { error: String(err?.message || err).slice(0, 120) });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
157
212
|
// Rung 1 — replay (TEXT-ONLY problems): the zero-generation fast path.
|
|
158
213
|
// For problems carrying a concrete file the analyzer (below) runs FIRST:
|
|
159
214
|
// a replay verdict for a file task is an OUTCOME RECORD ("this was fixed
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mind/agent-templates.js — DECLARATIVE AGENT TEMPLATES (Codebuff
|
|
3
|
+
* agents-as-data lineage).
|
|
4
|
+
*
|
|
5
|
+
* The gap it closes: FS's swarm registers one hardcoded `swarm-generalist`
|
|
6
|
+
* role and every worker is identical. Codebuff's insight — the same doctrine
|
|
7
|
+
* as FS's rules/recipes/templates — is that AGENTS are data too: a template
|
|
8
|
+
* declares a display name, what problems it solves (so a parent can choose),
|
|
9
|
+
* the input/output schemas at its spawn boundary, the tool allowlist (least
|
|
10
|
+
* privilege), the capabilities it can claim, its bounds, and WHICH OTHER
|
|
11
|
+
* TEMPLATES it may spawn (the spawn graph).
|
|
12
|
+
*
|
|
13
|
+
* Zero-LLM: templates carry no model, no prompts-as-code, no handleSteps —
|
|
14
|
+
* a template is a CONTRACT for a kernel worker. Validation is loud at load
|
|
15
|
+
* (the offending path in every error), unknown template references fail at
|
|
16
|
+
* LOAD time, not spawn time, and everything journals to the trail.
|
|
17
|
+
*
|
|
18
|
+
* loadTemplates(dir) → load all *.yaml templates (loud, idempotent)
|
|
19
|
+
* registerTemplate(def) → register one (validated)
|
|
20
|
+
* getTemplate(id) / listTemplates()
|
|
21
|
+
* routeToTemplate(problem, opts) → deterministic capability routing:
|
|
22
|
+
* score templates against a task's text +
|
|
23
|
+
* capabilities; returns ranked candidates
|
|
24
|
+
* spawnTemplate(id, { taskId }) → bind a worker to the template's role
|
|
25
|
+
* contract (role registry integration)
|
|
26
|
+
*
|
|
27
|
+
* @module mind/agent-templates
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
33
|
+
import { join, basename, dirname } from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import { parseYaml } from '../config.js';
|
|
36
|
+
import * as roles from '../kernel/roles.js';
|
|
37
|
+
import { journal as trailJournal } from '../kernel/trail.js';
|
|
38
|
+
|
|
39
|
+
const _templates = new Map();
|
|
40
|
+
|
|
41
|
+
/** The shipped defaults ship with the package — data, always available. */
|
|
42
|
+
const SHIPPED_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'agent-templates');
|
|
43
|
+
let _bootstrapped = false;
|
|
44
|
+
|
|
45
|
+
/** Idempotent: every registry reader sees the shipped templates (load-once). */
|
|
46
|
+
function bootstrap() {
|
|
47
|
+
if (_bootstrapped) return;
|
|
48
|
+
_bootstrapped = true;
|
|
49
|
+
if (existsSync(SHIPPED_DIR)) loadTemplates(SHIPPED_DIR);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Spawn graph guard: an agent that spawns everything, spawns itself. */
|
|
53
|
+
const MAX_SPAWN_DEPTH = 8;
|
|
54
|
+
|
|
55
|
+
/* ------------------------------------------------------------------ *
|
|
56
|
+
* Validation — loud, with the offending path in every message.
|
|
57
|
+
* ------------------------------------------------------------------ */
|
|
58
|
+
|
|
59
|
+
const VALID_CAPABILITY = /^[a-z0-9:*._-]+$/;
|
|
60
|
+
|
|
61
|
+
export function validateTemplate(def, source = '<inline>') {
|
|
62
|
+
const where = `agent-templates[${def?.id || basename(String(source))}]`;
|
|
63
|
+
if (!def || typeof def !== 'object') {
|
|
64
|
+
throw new Error(`${where}: template must be an object`);
|
|
65
|
+
}
|
|
66
|
+
if (!def.id || typeof def.id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(def.id)) {
|
|
67
|
+
throw new Error(`${where}: id must be a lowercase dotted/hyphenated slug (got ${JSON.stringify(def.id)})`);
|
|
68
|
+
}
|
|
69
|
+
if (!def.display_name || typeof def.display_name !== 'string') {
|
|
70
|
+
throw new Error(`${where}: display_name is required (what reports show)`);
|
|
71
|
+
}
|
|
72
|
+
if (!def.spawner_prompt || typeof def.spawner_prompt !== 'string' || def.spawner_prompt.length < 8) {
|
|
73
|
+
throw new Error(`${where}: spawner_prompt is required — a parent must be able to CHOOSE this template from its description`);
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(def.capabilities) || def.capabilities.length === 0 ||
|
|
76
|
+
def.capabilities.some(c => typeof c !== 'string' || !VALID_CAPABILITY.test(c))) {
|
|
77
|
+
throw new Error(`${where}: capabilities must be a non-empty array of slug strings (codebuff lineage: capability routing starts in the template)`);
|
|
78
|
+
}
|
|
79
|
+
// Schemas: declared as field-type maps — deterministic, no zod-style magic.
|
|
80
|
+
for (const key of ['input_schema', 'output_schema']) {
|
|
81
|
+
const schema = def[key];
|
|
82
|
+
if (schema === undefined) continue;
|
|
83
|
+
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
|
|
84
|
+
throw new Error(`${where}: ${key} must be an object mapping field → type`);
|
|
85
|
+
}
|
|
86
|
+
for (const [field, type] of Object.entries(schema)) {
|
|
87
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(field)) {
|
|
88
|
+
throw new Error(`${where}: ${key} field ${JSON.stringify(field)} is not a slug`);
|
|
89
|
+
}
|
|
90
|
+
if (!['string', 'number', 'boolean', 'array', 'object', 'any'].includes(type)) {
|
|
91
|
+
throw new Error(`${where}: ${key}.${field} has unknown type ${JSON.stringify(type)} (string|number|boolean|array|object|any)`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (def.tool_allowlist !== undefined) {
|
|
96
|
+
if (!Array.isArray(def.tool_allowlist) || def.tool_allowlist.some(t => typeof t !== 'string' || !t)) {
|
|
97
|
+
throw new Error(`${where}: tool_allowlist must be an array of tool-name strings`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (def.spawnable_agents !== undefined) {
|
|
101
|
+
if (!Array.isArray(def.spawnable_agents) ||
|
|
102
|
+
def.spawnable_agents.some(a => typeof a !== 'string' || !a)) {
|
|
103
|
+
throw new Error(`${where}: spawnable_agents must be an array of template ids`);
|
|
104
|
+
}
|
|
105
|
+
if (def.spawnable_agents.includes(def.id)) {
|
|
106
|
+
throw new Error(`${where}: a template cannot spawn itself (spawn graph must be acyclic by construction)`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (def.bounds !== undefined && (typeof def.bounds !== 'object' || def.bounds === null || Array.isArray(def.bounds))) {
|
|
110
|
+
throw new Error(`${where}: bounds must be an object (max_turns | timeout_ms | ...)`);
|
|
111
|
+
}
|
|
112
|
+
if (def.max_concurrency !== undefined && (!Number.isFinite(def.max_concurrency) || def.max_concurrency < 1)) {
|
|
113
|
+
throw new Error(`${where}: max_concurrency must be a positive number`);
|
|
114
|
+
}
|
|
115
|
+
if (def.triggers !== undefined) {
|
|
116
|
+
if (!Array.isArray(def.triggers) || def.triggers.some(t => typeof t !== 'string' || !t)) {
|
|
117
|
+
throw new Error(`${where}: triggers must be an array of lowercase keyword strings (deterministic routing)`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return def;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ------------------------------------------------------------------ *
|
|
124
|
+
* Registry.
|
|
125
|
+
* ------------------------------------------------------------------ */
|
|
126
|
+
|
|
127
|
+
export function registerTemplate(def, opts = {}) {
|
|
128
|
+
validateTemplate(def, opts.source);
|
|
129
|
+
const prev = _templates.get(def.id);
|
|
130
|
+
_templates.set(def.id, {
|
|
131
|
+
id: def.id,
|
|
132
|
+
display_name: def.display_name,
|
|
133
|
+
spawner_prompt: def.spawner_prompt,
|
|
134
|
+
capabilities: [...new Set(def.capabilities)],
|
|
135
|
+
...(def.input_schema ? { input_schema: { ...def.input_schema } } : {}),
|
|
136
|
+
...(def.output_schema ? { output_schema: { ...def.output_schema } } : {}),
|
|
137
|
+
...(def.tool_allowlist ? { tool_allowlist: [...def.tool_allowlist] } : {}),
|
|
138
|
+
...(def.spawnable_agents ? { spawnable_agents: [...def.spawnable_agents] } : {}),
|
|
139
|
+
...(def.triggers ? { triggers: [...new Set(def.triggers)] } : {}),
|
|
140
|
+
...(def.bounds ? { bounds: { ...def.bounds } } : {}),
|
|
141
|
+
...(Number.isFinite(def.max_concurrency) ? { max_concurrency: def.max_concurrency } : {}),
|
|
142
|
+
...(def.source ? { source: def.source } : {}),
|
|
143
|
+
});
|
|
144
|
+
trailJournal('template.registered', { id: def.id, updated: !!prev });
|
|
145
|
+
return _templates.get(def.id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Load every *.yaml template from a directory (and the shipped defaults). */
|
|
149
|
+
export function loadTemplates(dir) {
|
|
150
|
+
const loaded = [];
|
|
151
|
+
if (dir) {
|
|
152
|
+
if (!existsSync(dir)) throw new Error(`agent-templates: directory not found: ${dir}`);
|
|
153
|
+
for (const name of readdirSync(dir).filter(n => n.endsWith('.yaml') || n.endsWith('.yml')).sort()) {
|
|
154
|
+
const path = join(dir, name);
|
|
155
|
+
const doc = parseYaml(readFileSync(path, 'utf8'), path);
|
|
156
|
+
const defs = Array.isArray(doc?.templates) ? doc.templates : [doc];
|
|
157
|
+
for (const def of defs) {
|
|
158
|
+
registerTemplate({ ...def, source: path });
|
|
159
|
+
loaded.push({ id: def.id, path });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
trailJournal('templates.loaded', { dir: dir || null, count: loaded.length });
|
|
164
|
+
return loaded;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function getTemplate(id) {
|
|
168
|
+
bootstrap();
|
|
169
|
+
return _templates.get(id) ?? null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function listTemplates() {
|
|
173
|
+
bootstrap();
|
|
174
|
+
return [..._templates.values()].sort((a, b) => a.id < b.id ? -1 : 1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ------------------------------------------------------------------ *
|
|
178
|
+
* Spawn-graph integrity — load-time cycle detection over spawnable_agents.
|
|
179
|
+
* Codebuff trusts its publishers; FS trusts no graph it hasn't checked.
|
|
180
|
+
* ------------------------------------------------------------------ */
|
|
181
|
+
|
|
182
|
+
export function checkSpawnGraph() {
|
|
183
|
+
const cycles = [];
|
|
184
|
+
const visiting = new Set();
|
|
185
|
+
const done = new Set();
|
|
186
|
+
const walk = (id, path) => {
|
|
187
|
+
if (done.has(id)) return;
|
|
188
|
+
if (visiting.has(id)) {
|
|
189
|
+
cycles.push([...path.slice(path.indexOf(id)), id]);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
visiting.add(id);
|
|
193
|
+
const t = _templates.get(id);
|
|
194
|
+
for (const child of (t?.spawnable_agents || [])) {
|
|
195
|
+
if (_templates.has(child)) walk(child, [...path, id]);
|
|
196
|
+
}
|
|
197
|
+
visiting.delete(id);
|
|
198
|
+
done.add(id);
|
|
199
|
+
};
|
|
200
|
+
for (const id of _templates.keys()) walk(id, []);
|
|
201
|
+
if (cycles.length > 0) {
|
|
202
|
+
throw new Error(`agent-templates: spawn graph has cycles: ${cycles.map(c => c.join(' → ')).join('; ')}`);
|
|
203
|
+
}
|
|
204
|
+
return { cycles: [] };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Depth-limited spawn closure — a spawn request beyond this is refused. */
|
|
208
|
+
export function spawnClosure(id) {
|
|
209
|
+
bootstrap();
|
|
210
|
+
if (!_templates.has(id)) {
|
|
211
|
+
throw new Error(`agent-templates.spawnClosure: unknown template ${JSON.stringify(id)} — known: ${[..._templates.keys()].join(', ') || '(none)'}`);
|
|
212
|
+
}
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
let frontier = [id];
|
|
215
|
+
let depth = 0;
|
|
216
|
+
while (frontier.length && depth < MAX_SPAWN_DEPTH) {
|
|
217
|
+
const next = [];
|
|
218
|
+
for (const cur of frontier) {
|
|
219
|
+
if (seen.has(cur)) continue;
|
|
220
|
+
seen.add(cur);
|
|
221
|
+
for (const child of (_templates.get(cur)?.spawnable_agents || [])) next.push(child);
|
|
222
|
+
}
|
|
223
|
+
frontier = next;
|
|
224
|
+
depth++;
|
|
225
|
+
}
|
|
226
|
+
return { templates: [...seen].sort(), depth };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ------------------------------------------------------------------ *
|
|
230
|
+
* Deterministic routing — WHICH template for THIS problem?
|
|
231
|
+
* Codebuff's spawnerPrompt is an LLM choice; FS's is a scored match:
|
|
232
|
+
* trigger keyword hits + capability coverage, deterministic and journaled.
|
|
233
|
+
* ------------------------------------------------------------------ */
|
|
234
|
+
|
|
235
|
+
export function routeToTemplate(problem, opts = {}) {
|
|
236
|
+
bootstrap();
|
|
237
|
+
const text = String(problem || '').toLowerCase();
|
|
238
|
+
const needCaps = new Set(opts.capabilities || []);
|
|
239
|
+
const scored = [];
|
|
240
|
+
for (const t of listTemplates()) {
|
|
241
|
+
let score = 0;
|
|
242
|
+
const hits = [];
|
|
243
|
+
for (const trig of (t.triggers || [])) {
|
|
244
|
+
if (text.includes(trig)) { score += 2; hits.push(trig); }
|
|
245
|
+
}
|
|
246
|
+
if (needCaps.size > 0) {
|
|
247
|
+
const covered = [...needCaps].filter(c =>
|
|
248
|
+
t.capabilities.includes(c) || t.capabilities.includes('*'));
|
|
249
|
+
if (covered.length < needCaps.size) continue; // hard capability gate
|
|
250
|
+
score += covered.length;
|
|
251
|
+
}
|
|
252
|
+
if (score > 0) scored.push({ id: t.id, display_name: t.display_name, score, hits });
|
|
253
|
+
}
|
|
254
|
+
scored.sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : 1));
|
|
255
|
+
trailJournal('template.route', { problem: String(problem || '').slice(0, 80), routed: scored[0]?.id ?? null, candidates: scored.length });
|
|
256
|
+
return scored;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* ------------------------------------------------------------------ *
|
|
260
|
+
* Spawn — a template becomes a ROLE (the kernel's claim gate is the
|
|
261
|
+
* only execution authority; templates never execute anything).
|
|
262
|
+
* ------------------------------------------------------------------ */
|
|
263
|
+
|
|
264
|
+
export function spawnTemplate(id, { workerId } = {}) {
|
|
265
|
+
bootstrap();
|
|
266
|
+
const t = _templates.get(id);
|
|
267
|
+
if (!t) {
|
|
268
|
+
throw new Error(`agent-templates.spawn: unknown template ${JSON.stringify(id)} — known: ${[..._templates.keys()].join(', ') || '(none)'}`);
|
|
269
|
+
}
|
|
270
|
+
const roleId = `agent-template:${id}`;
|
|
271
|
+
if (!roles.getRole(roleId)) {
|
|
272
|
+
roles.registerRole({
|
|
273
|
+
id: roleId,
|
|
274
|
+
capabilities: t.capabilities,
|
|
275
|
+
...(t.bounds ? { bounds: t.bounds } : {}),
|
|
276
|
+
...(Number.isFinite(t.max_concurrency) ? { max_concurrency: t.max_concurrency } : {}),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
let bound = null;
|
|
280
|
+
if (workerId) bound = roles.bindWorker(workerId, roleId);
|
|
281
|
+
trailJournal('template.spawned', { id, worker: workerId ?? null, role: roleId });
|
|
282
|
+
return { template: t, role: roles.getRole(roleId), bound };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function reset() {
|
|
286
|
+
_templates.clear();
|
|
287
|
+
}
|