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 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.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",
@@ -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.trim());
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
- if (interp.intent === 'show' || interp.intent === 'search' || interp.intent === 'remember' || mode === 'chat') {
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
- requirements.push({ kind: 'rule', text: line, subject: line, modality });
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
- 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]+)["'`]/i)
189
- || 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]+)["'`]/i);
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
- return { checkable: true, spec: { type: negative ? 'file-lacks' : 'file-contains', target: contains[1], needle: contains[2] } };
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
+ }