futura-scion 0.2.2 → 0.2.4
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/bin/scion.js +146 -0
- package/package.json +2 -1
- package/src/brain/memory.js +24 -0
- package/src/config.js +29 -1
- package/src/http.js +72 -1
- package/src/mind/brief.js +145 -6
- package/src/mind/builtins-register.js +88 -0
- package/src/mind/nlu.js +18 -0
- package/src/mind/research/academic.js +183 -0
- package/src/mind/research/corroborate.js +110 -0
- package/src/mind/research/extract.js +90 -0
- package/src/mind/research/fallback.js +51 -0
- package/src/mind/research/fetcher.js +147 -0
- package/src/mind/research/index.js +204 -0
- package/src/mind/research/loop-learner.js +121 -0
- package/src/mind/research/rank.js +162 -0
- package/src/mind/research/search.js +191 -0
- package/src/mind/scaffold.js +158 -0
- package/src/mind/tools.js +20 -2
- package/templates/agent-tool.yaml +69 -0
- package/templates/node-api.yaml +116 -0
- package/templates/node-cli.yaml +69 -0
- package/templates/python-service.yaml +101 -0
- package/templates/static-web.yaml +64 -0
package/bin/scion.js
CHANGED
|
@@ -84,6 +84,33 @@ const oracle = realVerifiers ? { verifiers: realVerifiers } : { verifyFn };
|
|
|
84
84
|
*/
|
|
85
85
|
const CLARIFY_MAX_DEPTH = 3;
|
|
86
86
|
|
|
87
|
+
/** Human chat summary of a brief model (+ optional verification). */
|
|
88
|
+
function briefSummary(model, verification, intake) {
|
|
89
|
+
const reqs = model.requirements || [];
|
|
90
|
+
const lines = [];
|
|
91
|
+
if (intake) {
|
|
92
|
+
lines.push(intake.added > 0
|
|
93
|
+
? `Noted — ${intake.added} new requirement${intake.added > 1 ? 's' : ''} added to brief #${model.id} (${intake.total} total).`
|
|
94
|
+
: `Already captured — brief #${model.id} has ${intake.total} requirement${intake.total === 1 ? '' : 's'}.`);
|
|
95
|
+
} else {
|
|
96
|
+
lines.push(`Brief #${model.id}: ${reqs.length} requirement${reqs.length === 1 ? '' : 's'}.`);
|
|
97
|
+
}
|
|
98
|
+
for (const r of reqs.slice(0, 8)) {
|
|
99
|
+
const mark = r.status === 'pass' ? '✓' : r.status === 'fail' ? '✗' : r.checkable ? '◇' : '·';
|
|
100
|
+
lines.push(` ${mark} ${r.kind}: ${String(r.text).slice(0, 90)}${r.checkable ? '' : ' (judgment — needs a human or a model)'}`);
|
|
101
|
+
}
|
|
102
|
+
if (reqs.length > 8) lines.push(` …and ${reqs.length - 8} more`);
|
|
103
|
+
if (verification) {
|
|
104
|
+
lines.push(`Gate verification: ${verification.passed} passed, ${verification.failed} failed` +
|
|
105
|
+
(verification.coverage != null ? `, coverage ${(verification.coverage * 100).toFixed(0)}%` : '') +
|
|
106
|
+
(verification.failed > 0 ? ' — failing requirements block completion.' :
|
|
107
|
+
verification.passed > 0 ? ' — the contract holds.' : ''));
|
|
108
|
+
} else {
|
|
109
|
+
lines.push('Say "verify the brief" to run the checks against the tree.');
|
|
110
|
+
}
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
async function oneShot(text, opts = {}, depth = 0) {
|
|
88
115
|
const { interpret, slotsToPayload, clarify, resolveClarification, resetLearnedTermsCache } = await import('../src/mind/nlu.js');
|
|
89
116
|
const { getProjectScopes, resolveScopes } = await import('../src/mind/scopes.js');
|
|
@@ -142,6 +169,29 @@ async function oneShot(text, opts = {}, depth = 0) {
|
|
|
142
169
|
const saved = save({ type: 'observation', title: content.slice(0, 80), content, tags: ['remembered'], confidence: 90, provenance: 'explicit' });
|
|
143
170
|
return { ok: true, intent: 'remember', result: saved };
|
|
144
171
|
}
|
|
172
|
+
// Brief intent — context arrives IN THE CHAT, not as a file. The message
|
|
173
|
+
// itself is ingested into the active brief (accumulated across turns);
|
|
174
|
+
// "verify"/"check" phrasing settles the active brief instead.
|
|
175
|
+
if (interp.intent === 'brief') {
|
|
176
|
+
const briefs = await import('../src/mind/brief.js');
|
|
177
|
+
if (arg === 'verify' || restArgs.includes('--verify') || /\b(verify|check|settle)\b/i.test(text)) {
|
|
178
|
+
const id = briefs.activeBriefId();
|
|
179
|
+
if (!id) return { ok: false, intent: 'brief', reply: 'no active brief — state a requirement first (e.g. "the report must contain totals").' };
|
|
180
|
+
const c = briefs.compileBrief(id);
|
|
181
|
+
const v = await briefs.verifyBrief(id);
|
|
182
|
+
return {
|
|
183
|
+
ok: true, intent: 'brief',
|
|
184
|
+
reply: briefSummary(briefs.getBrief(id), v),
|
|
185
|
+
result: { briefId: id, compiled: c.compiled, verification: v },
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const intake = await briefs.chatIntake(text);
|
|
189
|
+
return {
|
|
190
|
+
ok: true, intent: 'brief',
|
|
191
|
+
reply: briefSummary(intake.model, null, intake),
|
|
192
|
+
result: { briefId: intake.briefId, added: intake.added, total: intake.total },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
145
195
|
|
|
146
196
|
// Ambiguous: ask ONE deterministic question with the top-N ranked
|
|
147
197
|
// interpretations instead of a cold escalation or a silent guess. Every
|
|
@@ -741,6 +791,98 @@ switch (cmd || '') {
|
|
|
741
791
|
console.log(JSON.stringify(out, null, 2));
|
|
742
792
|
break;
|
|
743
793
|
}
|
|
794
|
+
case 'scaffold': {
|
|
795
|
+
// CONSTRUCTION: compose a template into a project skeleton, gated by
|
|
796
|
+
// the stack's real verifier recipe before success is reported.
|
|
797
|
+
const { scaffold, listTemplates, loadTemplate } = await import('../src/mind/scaffold.js');
|
|
798
|
+
if (!arg || arg === 'list' || arg === '--list') {
|
|
799
|
+
const tpls = listTemplates();
|
|
800
|
+
if (tpls.length === 0) { console.log('no templates in templates/ — add YAML manifests'); break; }
|
|
801
|
+
for (const t of tpls) console.log(`${t.id.padEnd(16)} ${(t.stack ?? '-').toString().padEnd(8)} ${t.description}`);
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
const flags = restArgs.filter((a) => a.startsWith('--'));
|
|
805
|
+
const positional = restArgs.filter((a) => !a.startsWith('--'));
|
|
806
|
+
const target = positional[0]; // output directory
|
|
807
|
+
if (!target) {
|
|
808
|
+
console.error('usage: scion scaffold <template|list> <target-dir> [--name x] [--title y] [--port N] [--param k=v] [--no-verify]');
|
|
809
|
+
process.exitCode = 2;
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
const tpl = loadTemplate(arg);
|
|
813
|
+
const params = {};
|
|
814
|
+
for (const f of flags) {
|
|
815
|
+
const m = f.match(/^--([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
816
|
+
if (m) params[m[1]] = m[2];
|
|
817
|
+
}
|
|
818
|
+
// Default missing params to sensible template-agnostic values instead
|
|
819
|
+
// of a raw throw — the CLI user should see WHICH param is missing in a
|
|
820
|
+
// friendly line, not a stack trace.
|
|
821
|
+
for (const req of ['name', 'title', 'port', 'description']) {
|
|
822
|
+
if (!(req in params)) {
|
|
823
|
+
if (req === 'port') params.port = '3000';
|
|
824
|
+
else if (req === 'title') params.title = params.name ?? arg;
|
|
825
|
+
else if (req === 'name') params.name = params.name ?? 'app';
|
|
826
|
+
else params.description ??= `built with FS scaffold (${arg})`;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
let r;
|
|
830
|
+
try {
|
|
831
|
+
r = await scaffold(arg, { params, cwd: resolve(target), verify: !flags.includes('--no-verify') });
|
|
832
|
+
} catch (err) {
|
|
833
|
+
console.error(`scaffold failed: ${err.message}`);
|
|
834
|
+
process.exitCode = 1;
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
if (r.ok === false && r.error) {
|
|
838
|
+
console.error(`scaffold failed: ${r.error}`);
|
|
839
|
+
process.exitCode = 1;
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
console.log(`scaffold ${r.template} → ${target}`);
|
|
843
|
+
for (const f of r.files) console.log(` + ${f}`);
|
|
844
|
+
if (r.gate) {
|
|
845
|
+
console.log(`gate: ${r.gate.verified ? 'VERIFIED' : 'FAILED'} — [${r.gate.evidence.exit_codes.join(',')}]`);
|
|
846
|
+
if (!r.gate.verified) process.exitCode = 1;
|
|
847
|
+
} else {
|
|
848
|
+
console.log('gate: skipped (no stack recipe)');
|
|
849
|
+
}
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
case 'research': {
|
|
853
|
+
// The research rung, driven from the CLI (webresearcher lineage):
|
|
854
|
+
// deterministic-first web research with a completion gate.
|
|
855
|
+
const q = [arg, ...restArgs.filter((a) => !a.startsWith('--'))].filter(Boolean).join(' ').trim();
|
|
856
|
+
const flags = restArgs.filter((a) => a.startsWith('--')) || [];
|
|
857
|
+
const { runResearch } = await import('../src/mind/research/index.js');
|
|
858
|
+
if (!q) {
|
|
859
|
+
console.error('usage: scion research "<question>" [--no-persist] [--pages N] [--deep] [--learn]');
|
|
860
|
+
process.exitCode = 2;
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
const recipe = flags.includes('--deep')
|
|
864
|
+
? { id: 'deep-dive', parameters: { max_pages: 16 }, verify: { min_pages: 2, min_items: 5, min_corroborated: 1 } }
|
|
865
|
+
: undefined;
|
|
866
|
+
const report = await runResearch(q, {
|
|
867
|
+
recipe,
|
|
868
|
+
persist: !flags.includes('--no-persist'),
|
|
869
|
+
max_pages: Number((flags.find((f) => f.startsWith('--pages')) ?? '').split('=')[1]) || (flags.includes('--pages') ? Number(restArgs[restArgs.indexOf('--pages') + 1]) : undefined) || undefined,
|
|
870
|
+
});
|
|
871
|
+
console.log(`research: ${report.pages} pages → ${report.items.length} items ` +
|
|
872
|
+
`(${report.census.SUPPORTED} corroborated, ${report.census.CONFLICTING} conflicting)`);
|
|
873
|
+
console.log(`gate: ${report.gate.verdict} — ${report.gate.checks.map((c) => `${c.check}:${c.pass ? 'ok' : 'fail'}(${c.actual})`).join(' ')}`);
|
|
874
|
+
for (const f of report.summary.KEY_FACT.slice(0, 5)) console.log(` ✓ ${f.slice(0, 140)}`);
|
|
875
|
+
for (const c of report.summary.CONTRADICTION.slice(0, 3)) console.log(` ⚡ ${c.slice(0, 140)}`);
|
|
876
|
+
for (const e of report.summary.EVIDENCE.slice(0, 3)) console.log(` · ${e.slice(0, 140)}`);
|
|
877
|
+
if (report.fallback) console.log(` [llm-fallback] ${report.fallback.synthesis.slice(0, 200)}`);
|
|
878
|
+
if (flags.includes('--learn')) {
|
|
879
|
+
const { learnResearchLoops } = await import('../src/mind/research/loop-learner.js');
|
|
880
|
+
const learned = learnResearchLoops();
|
|
881
|
+
console.log(`loop-learner: ${learned.length} lesson(s) persisted`);
|
|
882
|
+
}
|
|
883
|
+
process.exitCode = report.gate.verdict === 'PASS' ? 0 : 1;
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
744
886
|
case 'conventions': {
|
|
745
887
|
// Measure + persist project conventions (cortex convention-detector
|
|
746
888
|
// lineage, as EVIDENCE): discovered, never enforced.
|
|
@@ -834,6 +976,10 @@ switch (cmd || '') {
|
|
|
834
976
|
leaseMs, pollMs,
|
|
835
977
|
primaryUrl: followerMode || primaryUrl ? (primaryUrl || null) : null,
|
|
836
978
|
});
|
|
979
|
+
// Lever 3: the capability tools (web-search, scholar-search, scaffold-*)
|
|
980
|
+
// register at boot so serve/UI/MCP all share the widened hands.
|
|
981
|
+
const { registerBuiltinCapabilities } = await import('../src/mind/builtins-register.js');
|
|
982
|
+
registerBuiltinCapabilities();
|
|
837
983
|
const api = await startApi({ port: Number(arg) || undefined, leader });
|
|
838
984
|
leader.start();
|
|
839
985
|
// FS Desktop support: provider-backed generator (rung G) from config,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "futura-scion",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "The fused scion of cortex-os-agent + persona: one zero-LLM-dependent agent stack — Mind proposes, Muscle executes, Gate disposes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"bin/",
|
|
14
14
|
"src/",
|
|
15
15
|
"recipes/",
|
|
16
|
+
"templates/",
|
|
16
17
|
"config/",
|
|
17
18
|
"knowledge/",
|
|
18
19
|
"README.md",
|
package/src/brain/memory.js
CHANGED
|
@@ -368,6 +368,30 @@ export function list(opts = {}) {
|
|
|
368
368
|
return rows.map(r => ({ ...r, tags: JSON.parse(r.tags || '[]') }));
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
/**
|
|
372
|
+
* reinforce — DEDUP-REINFORCE (webresearcher cortex_bridge lineage): when
|
|
373
|
+
* new evidence matches an existing memory, STRENGTHEN it instead of
|
|
374
|
+
* duplicating. Confidence rises by delta (capped at 100), importance to at
|
|
375
|
+
* least the floor, and the access counter ticks. Repeated corroboration is
|
|
376
|
+
* a trust signal — the memory-graph weights read it on every recall.
|
|
377
|
+
* @param {number} id — existing memory id
|
|
378
|
+
* @param {object} [opts] — { confidence_delta=5, importance_floor=null }
|
|
379
|
+
* @returns {{ id, confidence, importance, reinforced: true }}
|
|
380
|
+
*/
|
|
381
|
+
export function reinforce(id, opts = {}) {
|
|
382
|
+
const db = getDb();
|
|
383
|
+
const row = db.prepare('SELECT confidence, importance FROM memories WHERE id = ?').get(id);
|
|
384
|
+
if (!row) throw new Error(`memory.reinforce: no memory ${id}`);
|
|
385
|
+
const delta = opts.confidence_delta ?? 5;
|
|
386
|
+
const confidence = Math.min(100, row.confidence + delta);
|
|
387
|
+
const importance = opts.importance_floor != null
|
|
388
|
+
? Math.max(row.importance, opts.importance_floor)
|
|
389
|
+
: Math.min(10, row.importance + (delta >= 5 ? 1 : 0));
|
|
390
|
+
db.prepare('UPDATE memories SET confidence = ?, importance = ?, access_count = access_count + 1 WHERE id = ?')
|
|
391
|
+
.run(confidence, importance, id);
|
|
392
|
+
return { id, confidence, importance, reinforced: true };
|
|
393
|
+
}
|
|
394
|
+
|
|
371
395
|
export function stats() {
|
|
372
396
|
const db = getDb();
|
|
373
397
|
const row = db.prepare(
|
package/src/config.js
CHANGED
|
@@ -59,6 +59,22 @@ function parseScalar(raw) {
|
|
|
59
59
|
return v.replace(/^['"]|['"]$/g, '');
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Strip a block scalar's BASE indentation while preserving relative
|
|
64
|
+
* indentation (Python code in templates lives or dies by its indents).
|
|
65
|
+
* The base is the indentation of the FIRST content line; subsequent lines
|
|
66
|
+
* are dedented by the same amount. `|` literal blocks are exact; `>` folded
|
|
67
|
+
* blocks joined with spaces downstream, so per-line dedent is harmless.
|
|
68
|
+
*/
|
|
69
|
+
function dedentBlockLine(line, blockScalar) {
|
|
70
|
+
if (blockScalar.folded) return line.trim();
|
|
71
|
+
if (blockScalar.baseIndent === undefined) {
|
|
72
|
+
blockScalar.baseIndent = line.match(/^\s*/)[0].length;
|
|
73
|
+
}
|
|
74
|
+
const cut = Math.min(blockScalar.baseIndent, line.match(/^\s*/)[0].length);
|
|
75
|
+
return line.slice(cut);
|
|
76
|
+
}
|
|
77
|
+
|
|
62
78
|
/**
|
|
63
79
|
* Minimal YAML-subset parser: nested maps by indentation, scalars, inline
|
|
64
80
|
* arrays, `#` comments. Deliberately NOT full YAML — unsupported syntax
|
|
@@ -144,11 +160,23 @@ export function parseYaml(text, source = CONFIG_PATH) {
|
|
|
144
160
|
if (!match) {
|
|
145
161
|
// Continuation line of a block scalar ("key: >" folded text)?
|
|
146
162
|
if (blockScalar !== null && /^\s+\S/.test(line)) {
|
|
147
|
-
blockScalar.text.push(line
|
|
163
|
+
blockScalar.text.push(dedentBlockLine(line, blockScalar));
|
|
148
164
|
continue;
|
|
149
165
|
}
|
|
150
166
|
throw new Error(`config: ${source}:${lineno} unsupported syntax — ${JSON.stringify(line.trim())}`);
|
|
151
167
|
}
|
|
168
|
+
// A block scalar is OPEN and this line is deeper-indented than its key:
|
|
169
|
+
// it is SCALAR TEXT, never a new key — even when it looks like
|
|
170
|
+
// "word: value" (template manifests embed whole files in block scalars;
|
|
171
|
+
// their `port: 3000`-shaped lines are content, not structure). Leading
|
|
172
|
+
// indentation is PRESERVED relative to the block's first content line —
|
|
173
|
+
// Python files in templates live or die by their indentation.
|
|
174
|
+
if (blockScalar !== null
|
|
175
|
+
&& match[1].length > (blockScalar.indent ?? 0)
|
|
176
|
+
&& !/^\s*-\s/.test(line)) {
|
|
177
|
+
blockScalar.text.push(dedentBlockLine(line, blockScalar));
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
152
180
|
const [, ws, key, rest] = match;
|
|
153
181
|
const indent = ws.length;
|
|
154
182
|
|
package/src/http.js
CHANGED
|
@@ -275,7 +275,32 @@ export function createApiServer(opts = {}) {
|
|
|
275
275
|
|
|
276
276
|
let reply; let meta;
|
|
277
277
|
try {
|
|
278
|
-
|
|
278
|
+
// Brief intent: requirements arrive IN THE CHAT — ingest with
|
|
279
|
+
// streamed stage events (parsed → compiled), or verify on request.
|
|
280
|
+
if (interp.intent === 'brief') {
|
|
281
|
+
const briefs = await import('./mind/brief.js');
|
|
282
|
+
if (/\b(verify|check|settle)\b/i.test(body.text)) {
|
|
283
|
+
const id = briefs.activeBriefId();
|
|
284
|
+
if (!id) {
|
|
285
|
+
reply = 'No active brief yet — state a requirement first (e.g. "the report must contain a totals row").';
|
|
286
|
+
meta = { rung: 'brief' };
|
|
287
|
+
} else {
|
|
288
|
+
emit('progress', { stage: 'brief-compile', detail: `compiling brief ${id}` });
|
|
289
|
+
const c = briefs.compileBrief(id);
|
|
290
|
+
emit('progress', { stage: 'brief-verify', detail: `${c.compiled} checks through the Gate` });
|
|
291
|
+
const v = await briefs.verifyBrief(id);
|
|
292
|
+
briefs.setActiveBrief(null); // settled — matches chatIntake settle semantics
|
|
293
|
+
reply = briefChatSummary(briefs.getBrief(id), v);
|
|
294
|
+
meta = { rung: 'brief', detail: `brief ${id}: ${v.passed}✓ ${v.failed}✗` };
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
emit('progress', { stage: 'brief-parse', detail: 'parsing requirements from the message' });
|
|
298
|
+
const intake = await briefs.chatIntake(body.text);
|
|
299
|
+
emit('progress', { stage: 'brief-compiled', detail: `${intake.model.requirements.filter(r => r.checkable).length} machine-checkable` });
|
|
300
|
+
reply = briefChatSummary(intake.model, null, intake);
|
|
301
|
+
meta = { rung: 'brief', detail: `brief ${intake.briefId}: +${intake.added} (${intake.total} total)` };
|
|
302
|
+
}
|
|
303
|
+
} else if (interp.intent === 'show' || interp.intent === 'search' || interp.intent === 'remember' || mode === 'chat') {
|
|
279
304
|
reply = await chatAnswer(interp, body.text);
|
|
280
305
|
meta = { rung: 'nlu', detail: `intent: ${interp.intent} (${Math.round(interp.confidence * 100)}%)` };
|
|
281
306
|
} else {
|
|
@@ -325,6 +350,25 @@ export function createApiServer(opts = {}) {
|
|
|
325
350
|
const { interpret, slotsToPayload } = await nlu();
|
|
326
351
|
const interp = interpret(body.text);
|
|
327
352
|
const mode = body.mode === 'agent' || body.mode === 'chat' ? body.mode : 'chat';
|
|
353
|
+
// Brief intent: requirements arrive IN THE CHAT — ingest into the
|
|
354
|
+
// active brief (accumulated across turns), or verify on request.
|
|
355
|
+
if (interp.intent === 'brief') {
|
|
356
|
+
const briefs = await import('./mind/brief.js');
|
|
357
|
+
if (/\b(verify|check|settle)\b/i.test(body.text)) {
|
|
358
|
+
const id = briefs.activeBriefId();
|
|
359
|
+
if (!id) {
|
|
360
|
+
return send(res, 200, { reply: 'No active brief yet — state a requirement first (e.g. "the report must contain a totals row").', meta: { rung: 'brief' } });
|
|
361
|
+
}
|
|
362
|
+
const c = briefs.compileBrief(id);
|
|
363
|
+
const v = await briefs.verifyBrief(id);
|
|
364
|
+
return send(res, 200, { reply: briefChatSummary(briefs.getBrief(id), v), meta: { rung: 'brief', detail: `brief ${id}: ${v.passed}✓ ${v.failed}✗` } });
|
|
365
|
+
}
|
|
366
|
+
const intake = await briefs.chatIntake(body.text);
|
|
367
|
+
return send(res, 200, {
|
|
368
|
+
reply: briefChatSummary(intake.model, null, intake),
|
|
369
|
+
meta: { rung: 'brief', detail: `brief ${intake.briefId}: +${intake.added} (${intake.total} total)` },
|
|
370
|
+
});
|
|
371
|
+
}
|
|
328
372
|
if (interp.intent === 'show' || interp.intent === 'search' || interp.intent === 'remember' || mode === 'chat') {
|
|
329
373
|
const reply = await chatAnswer(interp, body.text);
|
|
330
374
|
return send(res, 200, { reply, meta: { rung: 'nlu', detail: `intent: ${interp.intent} (${Math.round(interp.confidence * 100)}%)` } });
|
|
@@ -489,6 +533,33 @@ async function nlu() {
|
|
|
489
533
|
}
|
|
490
534
|
|
|
491
535
|
/** Chat mode: answer from kernel state — never invents, never mutates. */
|
|
536
|
+
/** Human chat summary of a brief model (shared by /ui/act and stream). */
|
|
537
|
+
function briefChatSummary(model, verification, intake) {
|
|
538
|
+
const reqs = model.requirements || [];
|
|
539
|
+
const lines = [];
|
|
540
|
+
if (intake) {
|
|
541
|
+
lines.push(intake.added > 0
|
|
542
|
+
? `Noted — ${intake.added} new requirement${intake.added > 1 ? 's' : ''} added to brief #${model.id} (${intake.total} total).`
|
|
543
|
+
: `Already captured — brief #${model.id} has ${intake.total} requirement${intake.total === 1 ? '' : 's'}.`);
|
|
544
|
+
} else {
|
|
545
|
+
lines.push(`Brief #${model.id}: ${reqs.length} requirement${reqs.length === 1 ? '' : 's'}.`);
|
|
546
|
+
}
|
|
547
|
+
for (const r of reqs.slice(0, 8)) {
|
|
548
|
+
const mark = r.status === 'pass' ? '✓' : r.status === 'fail' ? '✗' : r.checkable ? '◇' : '·';
|
|
549
|
+
lines.push(`${mark} ${r.kind}: ${String(r.text).slice(0, 90)}${r.checkable ? '' : ' (judgment — needs a human or a model)'}`);
|
|
550
|
+
}
|
|
551
|
+
if (reqs.length > 8) lines.push(`…and ${reqs.length - 8} more`);
|
|
552
|
+
if (verification) {
|
|
553
|
+
lines.push(`Gate verification: ${verification.passed} passed, ${verification.failed} failed` +
|
|
554
|
+
(verification.coverage != null ? `, coverage ${(verification.coverage * 100).toFixed(0)}%` : '') +
|
|
555
|
+
(verification.failed > 0 ? ' — failing requirements block completion.' :
|
|
556
|
+
verification.passed > 0 ? ' — the contract holds.' : ''));
|
|
557
|
+
} else {
|
|
558
|
+
lines.push('Say "verify the brief" to run the checks against the tree.');
|
|
559
|
+
}
|
|
560
|
+
return lines.join('\n');
|
|
561
|
+
}
|
|
562
|
+
|
|
492
563
|
async function chatAnswer(interp, text) {
|
|
493
564
|
if (interp.intent === 'show') {
|
|
494
565
|
const what = interp.target || '';
|
package/src/mind/brief.js
CHANGED
|
@@ -119,12 +119,26 @@ export function ingest(opts) {
|
|
|
119
119
|
continue;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// Rule sentences: MUST/SHALL/SHOULD/cannot/never/always…
|
|
122
|
+
// Rule sentences: MUST/SHALL/SHOULD/cannot/never/always… Compound
|
|
123
|
+
// chat lines ("X must exist and the customer wants a nicer UI") are
|
|
124
|
+
// split on conjunctions so each requirement stands alone — a checkable
|
|
125
|
+
// rule is never muddled with an adjacent wish.
|
|
123
126
|
if (RULE_MODAL_RE.test(line)) {
|
|
124
127
|
const modality = /must\s+not|shall\s+not|cannot|can't|can’t|never|forbidden|not\s+be\s+allowed/i.test(line) ? 'must-not'
|
|
125
128
|
: /must|shall|requires/i.test(line) ? 'must'
|
|
126
129
|
: /should/i.test(line) ? 'should' : 'always';
|
|
127
|
-
|
|
130
|
+
const conjuncts = line
|
|
131
|
+
// sentence boundaries are the primary split inside a chat line
|
|
132
|
+
.split(/(?<=[.!?])\s+/)
|
|
133
|
+
.flatMap(s => s.split(/\s+(?:and|also|plus)\s+(?=(?:the\s+|a\s+|an\s+|all\s+|every\s+)?\w+\s+(?:must|shall|should|cannot|never|wants?|needs?|is\s+not\s+allowed))/i))
|
|
134
|
+
.map(s => s.trim().replace(/[.]$/, '')).filter(Boolean);
|
|
135
|
+
for (const part of conjuncts) {
|
|
136
|
+
const m = /must\s+not|shall\s+not|cannot|can't|can’t|never|forbidden/i.test(part) ? 'must-not'
|
|
137
|
+
: /must|shall|requires/i.test(part) ? 'must'
|
|
138
|
+
: /should/i.test(part) ? 'should'
|
|
139
|
+
: /^(the\s+)?(customer|user|client|team)/i.test(part) ? 'wish' : modality;
|
|
140
|
+
requirements.push({ kind: m === 'wish' ? 'judgment' : 'rule', text: part, subject: part, modality: m });
|
|
141
|
+
}
|
|
128
142
|
continue;
|
|
129
143
|
}
|
|
130
144
|
}
|
|
@@ -171,6 +185,21 @@ export function ingest(opts) {
|
|
|
171
185
|
export function classify(req) {
|
|
172
186
|
const t = String(req?.text || '');
|
|
173
187
|
|
|
188
|
+
// Compound sentences split FIRST: chat lines carry several requirements
|
|
189
|
+
// joined by "and"/"also" — each conjunct is classified on its own, so
|
|
190
|
+
// "X must exist and the customer wants a nicer UI" yields one checkable
|
|
191
|
+
// rule and one judgment, not one muddled compound.
|
|
192
|
+
const conjuncts = t.split(/\s+(?:and|also|plus|;)\s+/i).map(s => s.trim()).filter(s => s.length >= 8);
|
|
193
|
+
if (conjuncts.length > 1) {
|
|
194
|
+
const parts = conjuncts.map(conj => classify({ ...req, text: conj }));
|
|
195
|
+
if (parts.some(p => p.checkable)) {
|
|
196
|
+
// Prefer the checkable part as THIS requirement's spec; the other
|
|
197
|
+
// conjuncts are re-emitted by the caller via splitConjuncts().
|
|
198
|
+
const hit = parts.find(p => p.checkable);
|
|
199
|
+
return { ...hit, conjuncts };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
174
203
|
// Explicit directive wins: "check: <shell command>" or a backticked
|
|
175
204
|
// command anchored to an exit-code phrase — the author says exactly how
|
|
176
205
|
// to verify; the Gate runs it verbatim.
|
|
@@ -184,12 +213,14 @@ export function classify(req) {
|
|
|
184
213
|
|| t.match(/\b(?:must|shall|should)\s+(?:exist|be\s+present)[^.]*\b([\w./\\-]+\.[a-z0-9]{1,6})\b/i);
|
|
185
214
|
if (exists) return { checkable: true, spec: { type: 'file-exists', target: exists[1] } };
|
|
186
215
|
|
|
187
|
-
// file-lacks / file-contains: "X must not contain/contain Y".
|
|
188
|
-
|
|
189
|
-
|
|
216
|
+
// file-lacks / file-contains: "X must not contain/contain Y". The needle
|
|
217
|
+
// may be bare (no quotes) in chat speech: "must not contain the word ZZZZ".
|
|
218
|
+
const contains = t.match(/\b([\w./\\-]+\.[a-z0-9]{1,6})\b[^.]*?\b(?:must|shall|should)\s+not\s+(?:contain|include|reference|log)\s+(?:the\s+)?(?:word\s+|string\s+|secret[s]?\s+)?(?:["'`]([^"'`\n]+)["'`]|([A-Za-z0-9_]{3,})\b)/i)
|
|
219
|
+
|| t.match(/\b([\w./\\-]+\.[a-z0-9]{1,6})\b[^.]*?\b(?:must|shall|should)\s+(?:contain|include|reference)\s+(?:the\s+)?(?:word\s+|string\s+)?(?:["'`]([^"'`\n]+)["'`]|([A-Za-z0-9_]{3,})\b)/i);
|
|
190
220
|
if (contains) {
|
|
191
221
|
const negative = /must\s+not|shall\s+not|never/i.test(contains.input);
|
|
192
|
-
|
|
222
|
+
const needle = contains[2] ?? contains[3];
|
|
223
|
+
return { checkable: true, spec: { type: negative ? 'file-lacks' : 'file-contains', target: contains[1], needle } };
|
|
193
224
|
}
|
|
194
225
|
|
|
195
226
|
// regex: "must match /re/"
|
|
@@ -377,9 +408,117 @@ export function listBriefs(limit = 20) {
|
|
|
377
408
|
).all(limit);
|
|
378
409
|
}
|
|
379
410
|
|
|
411
|
+
// ─── Chat accumulation: the ACTIVE brief ────────────────────────────
|
|
412
|
+
|
|
413
|
+
const ACTIVE_KEY = 'active_brief';
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The active brief: requirements arriving in CHAT accumulate onto one
|
|
417
|
+
* brief (the conversation's working contract) until it is verified or
|
|
418
|
+
* explicitly replaced. Persisted in the brain meta table — restart-safe.
|
|
419
|
+
*
|
|
420
|
+
* @returns {number|null} brief id
|
|
421
|
+
*/
|
|
422
|
+
export function activeBriefId() {
|
|
423
|
+
try {
|
|
424
|
+
const row = getDb().prepare('SELECT value FROM meta WHERE key = ?').get(ACTIVE_KEY);
|
|
425
|
+
if (!row) return null;
|
|
426
|
+
const id = Number(JSON.parse(row.value));
|
|
427
|
+
return getDb().prepare('SELECT id FROM briefs WHERE id = ?').get(id) ? id : null;
|
|
428
|
+
} catch { return null; }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Point the accumulator at a brief (or clear with null). */
|
|
432
|
+
export function setActiveBrief(briefId) {
|
|
433
|
+
const db = getDb();
|
|
434
|
+
if (briefId == null) {
|
|
435
|
+
db.prepare('DELETE FROM meta WHERE key = ?').run(ACTIVE_KEY);
|
|
436
|
+
return { active: null };
|
|
437
|
+
}
|
|
438
|
+
if (!db.prepare('SELECT id FROM briefs WHERE id = ?').get(briefId)) {
|
|
439
|
+
throw new Error(`brief.setActiveBrief: no brief ${briefId}`);
|
|
440
|
+
}
|
|
441
|
+
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
|
442
|
+
.run(ACTIVE_KEY, JSON.stringify(Number(briefId)));
|
|
443
|
+
return { active: Number(briefId) };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Chat intake: append a message to the active brief (creating one on
|
|
448
|
+
* first requirement), re-parse, and report the accumulated model. Short
|
|
449
|
+
* messages add requirements; the message itself is the context, not a
|
|
450
|
+
* file path.
|
|
451
|
+
*
|
|
452
|
+
* @param {string} message — the raw chat text
|
|
453
|
+
* @param {Object} [opts] — { title? }
|
|
454
|
+
* @returns {{ briefId, added: number, total: number, newRequirements: Array, model: Object }}
|
|
455
|
+
*/
|
|
456
|
+
const SETTLE_RE = /^(verify|check|settle|run)( the)? brief\b/i;
|
|
457
|
+
export async function chatIntake(message, opts = {}) {
|
|
458
|
+
const db = getDb();
|
|
459
|
+
let briefId = activeBriefId();
|
|
460
|
+
|
|
461
|
+
if (!briefId) {
|
|
462
|
+
const b = ingest({ text: message, title: opts.title || message.slice(0, 80), source: 'chat' });
|
|
463
|
+
briefId = b.brief.id;
|
|
464
|
+
setActiveBrief(briefId);
|
|
465
|
+
const model = getBrief(briefId);
|
|
466
|
+
trailJournal('brief.chat-intake', { brief_id: briefId, added: model.requirements.length, created: true });
|
|
467
|
+
return { briefId, added: model.requirements.length, total: model.requirements.length, newRequirements: model.requirements, model };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Append to the active brief WITHOUT re-ingesting the whole document —
|
|
471
|
+
// re-parse drifts (the goal paragraph mutates as text grows, old rules
|
|
472
|
+
// resurface as "new"). Only the NEW message is parsed; dedup by exact
|
|
473
|
+
// text keeps repeats honest.
|
|
474
|
+
const existing = new Set(
|
|
475
|
+
db.prepare('SELECT text FROM requirements WHERE brief_id = ?').all(briefId).map(r => r.text)
|
|
476
|
+
);
|
|
477
|
+
const parsed = ingest({ text: message, title: opts.title, source: 'chat', persist: false });
|
|
478
|
+
// Goals are only minted at brief creation (the goal paragraph is the
|
|
479
|
+
// whole accumulated text — re-adding it per turn duplicates noise).
|
|
480
|
+
// Settle phrases route to verification, never into the contract.
|
|
481
|
+
if (SETTLE_RE.test(message.trim())) {
|
|
482
|
+
const model = getBrief(briefId);
|
|
483
|
+
trailJournal('brief.chat-settle', { brief_id: briefId });
|
|
484
|
+
const verification = await verifyBrief(briefId);
|
|
485
|
+
setActiveBrief(null); // settled — the next context starts a fresh brief
|
|
486
|
+
return { briefId, verified: true, added: 0, total: model.requirements.length, newRequirements: [], verification, model };
|
|
487
|
+
}
|
|
488
|
+
const fresh = parsed.requirements.filter(r => r.kind !== 'goal' && !existing.has(r.text));
|
|
489
|
+
const ins = db.prepare('INSERT INTO requirements (brief_id, kind, text, subject) VALUES (?, ?, ?, ?)');
|
|
490
|
+
for (const r of fresh) ins.run(briefId, r.kind, r.text, r.subject ?? null);
|
|
491
|
+
const prev = db.prepare('SELECT text FROM briefs WHERE id = ?').get(briefId);
|
|
492
|
+
db.prepare('UPDATE briefs SET text = ? WHERE id = ?').run(`${prev.text}\n${message}`, briefId);
|
|
493
|
+
|
|
494
|
+
const model = getBrief(briefId);
|
|
495
|
+
// Auto-compile any newly checkable requirements.
|
|
496
|
+
let compiledNew = 0;
|
|
497
|
+
for (const row of model.requirements) {
|
|
498
|
+
if (row.kind !== 'goal' && !row.check_spec) {
|
|
499
|
+
const cls = classify(row);
|
|
500
|
+
const cmd = cls.checkable ? compileCheck(cls.spec) : null;
|
|
501
|
+
const spec = cls.checkable ? (cmd ? JSON.stringify(typeof cmd === 'string' ? { cmd } : cmd) : null) : null;
|
|
502
|
+
db.prepare('UPDATE requirements SET checkable = ?, check_spec = ?, compiled_cmd = ? WHERE id = ?')
|
|
503
|
+
.run(cls.checkable ? 1 : 0, cls.checkable ? JSON.stringify(cls.spec) : null, spec, row.id);
|
|
504
|
+
if (spec) compiledNew++;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
trailJournal('brief.chat-intake', { brief_id: briefId, added: fresh.length, compiled_new: compiledNew });
|
|
508
|
+
return {
|
|
509
|
+
briefId,
|
|
510
|
+
added: fresh.length,
|
|
511
|
+
total: model.requirements.length,
|
|
512
|
+
newRequirements: fresh,
|
|
513
|
+
compiledNew,
|
|
514
|
+
model: getBrief(briefId),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
380
518
|
/** Reset (tests). */
|
|
381
519
|
export function reset() {
|
|
382
520
|
const db = getDb();
|
|
383
521
|
db.prepare('DELETE FROM requirements').run();
|
|
384
522
|
db.prepare('DELETE FROM briefs').run();
|
|
523
|
+
db.prepare('DELETE FROM meta WHERE key = ?').run(ACTIVE_KEY);
|
|
385
524
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mind/builtins-register.js — LEVER 3 (tool-calling breadth): registers the
|
|
3
|
+
* network + construction capabilities as FIRST-CLASS registry tools so the
|
|
4
|
+
* research rung (`research()`) and the swarm can invoke them by name like
|
|
5
|
+
* any shell tool. Import once at boot/serve; registration is idempotent.
|
|
6
|
+
*
|
|
7
|
+
* These are ASYNC capability tools (functions, not argv templates) — the
|
|
8
|
+
* registry stores their runner and runTool dispatches to it. Same rails
|
|
9
|
+
* apply: journaled, redacted, bounded.
|
|
10
|
+
*
|
|
11
|
+
* @module mind/builtins-register
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
import { registerTool } from './tools.js';
|
|
17
|
+
import { searchWeb } from './research/search.js';
|
|
18
|
+
import { listTemplates, loadTemplate } from './scaffold.js';
|
|
19
|
+
|
|
20
|
+
let registered = false;
|
|
21
|
+
|
|
22
|
+
/** Idempotently register the capability tools. */
|
|
23
|
+
export function registerBuiltinCapabilities() {
|
|
24
|
+
if (registered) return listTemplates();
|
|
25
|
+
registered = true;
|
|
26
|
+
|
|
27
|
+
registerTool({
|
|
28
|
+
name: 'web-search',
|
|
29
|
+
description: 'search the web (DuckDuckGo keyless / Tavily keyed); {topic} is the query',
|
|
30
|
+
tags: ['web', 'research', 'network'],
|
|
31
|
+
capability: async (opts = {}) => {
|
|
32
|
+
const r = await searchWeb(opts.topic ?? '', { max_results: opts.max_results ?? 5 });
|
|
33
|
+
return {
|
|
34
|
+
ok: r.results.length > 0,
|
|
35
|
+
stdout: r.results.map((x) => `- ${x.title}\n ${x.url}\n ${String(x.snippet ?? '').slice(0, 160)}`).join('\n').slice(0, 8000),
|
|
36
|
+
meta: { provider: r.provider, count: r.results.length, attempts: r.attempts.length },
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
registerTool({
|
|
42
|
+
name: 'scholar-search',
|
|
43
|
+
description: 'search scholarship (arXiv/Semantic Scholar/CrossRef); {topic} is the query',
|
|
44
|
+
tags: ['academic', 'research', 'network'],
|
|
45
|
+
capability: async (opts = {}) => {
|
|
46
|
+
const { searchScholar } = await import('./research/academic.js');
|
|
47
|
+
const papers = await searchScholar(opts.topic ?? '', { max_results: 5 });
|
|
48
|
+
return {
|
|
49
|
+
ok: papers.length > 0,
|
|
50
|
+
stdout: papers.map((p) => `- ${p.title} (${p.year ?? 'n.d.'}, cites: ${p.citation_count ?? '?'})\n ${p.url}`).join('\n').slice(0, 8000),
|
|
51
|
+
meta: { count: papers.length },
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
registerTool({
|
|
57
|
+
name: 'scaffold-list',
|
|
58
|
+
description: 'list constructable project templates (what FS can BUILD)',
|
|
59
|
+
tags: ['construction'],
|
|
60
|
+
capability: async () => {
|
|
61
|
+
const tpls = listTemplates();
|
|
62
|
+
return {
|
|
63
|
+
ok: tpls.length > 0,
|
|
64
|
+
stdout: tpls.map((t) => `${t.id.padEnd(16)} ${(t.stack ?? '-').padEnd(8)} ${t.description}`).join('\n'),
|
|
65
|
+
meta: { count: tpls.length },
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
registerTool({
|
|
71
|
+
name: 'scaffold-inspect',
|
|
72
|
+
description: 'show a template manifest (files, post commands, stack) for {topic}=template id',
|
|
73
|
+
tags: ['construction'],
|
|
74
|
+
capability: async (opts = {}) => {
|
|
75
|
+
try {
|
|
76
|
+
const t = loadTemplate(opts.topic ?? '');
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
stdout: `${t.name} (stack: ${t.stack ?? 'none'})\n${t.description}\nfiles:\n${t.files.map((f) => ` + ${f.path}`).join('\n')}\npost:\n${(t.post ?? []).map((c) => ` $ ${c}`).join('\n') || ' (none)'}`,
|
|
80
|
+
};
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return { ok: false, error: String(err?.message || err).slice(0, 200) };
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return listTemplates();
|
|
88
|
+
}
|