sparda-mcp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,6 +45,12 @@ Your app learns what "self" looks like — and defends itself:
45
45
  - **Immune memory.** Diagnoses are cached as antibodies in `sparda.json` (git-versioned): the same failure later is diagnosed instantly, zero tokens. Your app accumulates antibodies as it lives — cloning the code doesn't clone its immune system.
46
46
  - **`sparda_get_context`.** One tool call gives the AI the full living context: tools, workflows, runtime telemetry, quarantine state, immune memory. Every AI session resumes where the last one stopped.
47
47
 
48
+ ## v0.4 — the recycling economy + first Labs organ
49
+ - **Recycling gauge (zero config).** The router counts calls answered from SPARDA's own knowledge vs calls that paid the host route (`GET /mcp/stats → recycle`), and the bridge counts the sampling calls its cached knowledge avoided. Day 1 it reads 0% — the circle fills with usage. A measure, never a promise.
50
+ - **Idle harvester.** All of SPARDA's internal work (analysis, persistence) runs only when the event loop is quiet, one job per tick. Perceived saturation: zero.
51
+ - **Sequence condenser (Labs, opt-in, default OFF).** Set `"labs": { "recordSequences": true }` in `sparda.json` (or `SPARDA_RECORD_SEQUENCES=1` for one session) and SPARDA observes when one tool's output feeds the next tool's input, recording the *circuit* — structure only (tool names, arg names, counts), never your data. Deterministic, zero LLM.
52
+ - **Crystallization: tools nobody wrote.** A read-only circuit observed 3× is born as a *composite tool*, announced mid-session via `tools/list_changed`: one call runs the whole chain, auto-feeding each linked argument from the previous step's real response. Your AI names it (MCP sampling, sanitized) or a deterministic name ships without any LLM. Write routes are never absorbed — their per-call confirmation stands.
53
+
48
54
  Where this is going: [ROADMAP.md](./ROADMAP.md).
49
55
 
50
56
  ## v0 supports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,3 +27,25 @@ export async function runHook(opts) {
27
27
  fs.chmodSync(hookPath, 0o755);
28
28
  console.error('[sparda] sentinel installed: routes re-sync after every commit (post-commit hook).');
29
29
  }
30
+
31
+ // Uninstall exactly what runHook installed (hard rule #4 applied to .git/hooks):
32
+ // delete the file if we created it whole, strip our exact block if we appended,
33
+ // line-filter as a last resort if the user edited around it. Returns whether
34
+ // anything was removed so `remove` can report it.
35
+ export function removeHook(cwd) {
36
+ const hookPath = path.join(cwd, '.git', 'hooks', 'post-commit');
37
+ let content;
38
+ try { content = fs.readFileSync(hookPath, 'utf8'); } catch { return false; }
39
+ if (!content.includes(MARKER)) return false;
40
+ const block = `${MARKER}\nnpx --no-install sparda-mcp sync --quiet || true\n`;
41
+ if (content === `#!/bin/sh\n${block}`) {
42
+ fs.rmSync(hookPath);
43
+ return true;
44
+ }
45
+ let out = content.includes(`\n${block}`) ? content.replace(`\n${block}`, '') : content.replace(block, '');
46
+ if (out.includes(MARKER)) {
47
+ out = out.split('\n').filter((l) => l !== MARKER && l !== 'npx --no-install sparda-mcp sync --quiet || true').join('\n');
48
+ }
49
+ fs.writeFileSync(hookPath, out);
50
+ return true;
51
+ }
@@ -8,13 +8,14 @@ import { parseFastAPIProject } from '../parser/fastapi.js';
8
8
  import { sanitizeDescription } from '../security/sanitize.js';
9
9
  import { generateExpress } from '../generator/express.js';
10
10
  import { generateFastAPI } from '../generator/fastapi.js';
11
+ import { c, gradient, colorizeJson } from '../ui/style.js';
11
12
 
12
13
 
13
14
  const VERSION = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
14
15
 
15
16
  export async function runInit(opts) {
16
17
  const t0 = Date.now();
17
- p.intro(`SPARDA v${VERSION}`);
18
+ p.intro(`${gradient('SPARDA')} ${c.dim(`v${VERSION}`)}`);
18
19
 
19
20
  const s = p.spinner();
20
21
  s.start('Scanning project...');
@@ -25,13 +26,13 @@ export async function runInit(opts) {
25
26
  const res = parseExpressProject(opts.cwd, stack.entryFile);
26
27
  routes = res.routes;
27
28
  skipped = res.skipped;
28
- s.stop(`Stack detected: Express (${stack.moduleType.toUpperCase()}) — entry: ${stack.entryFile}, port: ${stack.port}`);
29
+ s.stop(`Stack detected: ${c.cyan(`Express (${stack.moduleType.toUpperCase()})`)} — entry: ${stack.entryFile}, port: ${stack.port}`);
29
30
  } else if (stack.framework === 'fastapi') {
30
31
  const res = parseFastAPIProject(opts.cwd, stack.entryFile, stack.pythonCmd);
31
32
  routes = res.routes;
32
33
  skipped = res.skipped;
33
34
  entryAppVars = res.entryAppVars;
34
- s.stop(`Stack detected: FastAPI — entry: ${stack.entryFile}, port: ${stack.port}`);
35
+ s.stop(`Stack detected: ${c.cyan('FastAPI')} — entry: ${stack.entryFile}, port: ${stack.port}`);
35
36
  }
36
37
 
37
38
  if (routes.length === 0) {
@@ -49,9 +50,9 @@ export async function runInit(opts) {
49
50
  p.log.info(`${routes.length} routes found — ${high} high confidence, ${routes.length - high} partial`);
50
51
 
51
52
  const preview = routes.slice(0, 8).map((r) =>
52
- `${r.mutating ? '✗' : '✓'} ${r.method.toUpperCase().padEnd(6)} ${r.path}${r.mutating ? ' (disabled: write-safety)' : ''}`
53
+ `${r.mutating ? c.red('✗') : c.green('✓')} ${r.method.toUpperCase().padEnd(6)} ${r.path}${r.mutating ? c.dim(' (disabled: write-safety)') : ''}`
53
54
  ).join('\n');
54
- p.note(preview + (routes.length > 8 ? `\n... (+${routes.length - 8} more)` : ''), 'TOOLS TO GENERATE');
55
+ p.note(preview + (routes.length > 8 ? c.dim(`\n... (+${routes.length - 8} more)`) : ''), 'TOOLS TO GENERATE');
55
56
 
56
57
  if (flaggedCount) p.log.warn(`${flaggedCount} suspicious docstring(s) purged (prompt-injection defense)`);
57
58
  if (skipped.length) p.log.warn(`${skipped.length} route(s) skipped — details in .sparda/scan-report.json`);
@@ -84,15 +85,15 @@ export async function runInit(opts) {
84
85
  p.log.success('Wrote sparda.json');
85
86
 
86
87
  const cfg = JSON.stringify({ [path.basename(opts.cwd)]: { command: 'npx', args: ['sparda-mcp', 'dev'], cwd: opts.cwd } }, null, 2);
87
- p.outro(`Done in ${((Date.now() - t0) / 1000).toFixed(1)}s.
88
+ p.outro(`${c.green(`Done in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)}
88
89
 
89
90
  Next steps:
90
- 1. Start your app: ${stack.framework === 'express' ? 'npm run dev' : 'fastapi dev'}
91
- 2. Start the MCP bridge: npx sparda-mcp dev
91
+ 1. Start your app: ${c.cyan(stack.framework === 'express' ? 'npm run dev' : 'fastapi dev')}
92
+ 2. Start the MCP bridge: ${c.cyan('npx sparda-mcp dev')}
92
93
  3. Add to Claude Desktop config (claude_desktop_config.json):
93
94
 
94
- ${cfg.split('\n').map((l) => ' ' + l).join('\n')}
95
+ ${colorizeJson(cfg).split('\n').map((l) => ' ' + l).join('\n')}
95
96
 
96
- Write tools (POST/PUT/DELETE) are disabled by default.
97
- Enable them in sparda.json, then re-run \`npx sparda-mcp init --yes\`.`);
97
+ ${c.dim('Write tools (POST/PUT/DELETE) are disabled by default.')}
98
+ ${c.dim('Enable them in sparda.json, then re-run')} ${c.cyan('`npx sparda-mcp init --yes`')}${c.dim('.')}`);
98
99
  }
@@ -5,6 +5,7 @@ import * as p from '@clack/prompts';
5
5
  import { removeInjection as removeExpress } from '../generator/express.js';
6
6
  import { removeInjection as removeFastAPI } from '../generator/fastapi.js';
7
7
  import { detectStack } from '../detect.js';
8
+ import { removeHook } from './hook.js';
8
9
 
9
10
  export async function runRemove(opts) {
10
11
  const manifestPath = path.join(opts.cwd, 'sparda.json');
@@ -43,6 +44,7 @@ export async function runRemove(opts) {
43
44
  if (fs.existsSync(abs)) { fs.rmSync(abs); console.log(`✓ Deleted ${f}`); }
44
45
  }
45
46
  if (revertGitignore(opts.cwd, manifest.gitignore)) console.log('✓ Reverted .gitignore edit');
47
+ if (removeHook(opts.cwd)) console.log('✓ Uninstalled post-commit sentinel hook');
46
48
  fs.rmSync(manifestPath);
47
49
  fs.rmSync(path.join(opts.cwd, '.sparda'), { recursive: true, force: true });
48
50
  console.log('✓ Deleted sparda.json and .sparda/');
@@ -97,6 +97,7 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
97
97
  ...(gitignore ? { gitignore } : {}),
98
98
  ...(prev?.semantic ? { semantic: prev.semantic } : {}),
99
99
  ...(prev?.immune ? { immune: prev.immune } : {}),
100
+ ...(prev?.labs ? { labs: prev.labs } : {}),
100
101
  };
101
102
  atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
102
103
 
@@ -66,6 +66,7 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
66
66
  ...(gitignore ? { gitignore } : {}),
67
67
  ...(prev?.semantic ? { semantic: prev.semantic } : {}),
68
68
  ...(prev?.immune ? { immune: prev.immune } : {}),
69
+ ...(prev?.labs ? { labs: prev.labs } : {}),
69
70
  };
70
71
 
71
72
  atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
@@ -2,10 +2,11 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
- // Re-running init must not wipe what the user (or the semantic/immune passes)
6
- // wrote into sparda.json: per-tool `enabled` overrides, the cached `semantic`
7
- // enrichment and the `immune` memory (antibodies) survive as long as the
8
- // tool's method+path are unchanged.
5
+ // Re-running init must not wipe what the user (or the semantic/immune/Labs
6
+ // passes) wrote into sparda.json: per-tool `enabled` overrides, the cached
7
+ // `semantic` enrichment, the `immune` memory (antibodies) and the `labs`
8
+ // state (opt-in flags + observed circuits) survive as long as the tool's
9
+ // method+path are unchanged.
9
10
  export function carryOverManifest(cwd, tools) {
10
11
  let prev = null;
11
12
  try {
@@ -129,6 +129,14 @@ export function parseExpressProject(cwd, entryFile) {
129
129
 
130
130
  const mutating = method !== 'get';
131
131
  const params = [...pathParams];
132
+ // query params: handlers reveal them as req.query.X / destructured req.query —
133
+ // without this the AI cannot discover ?limit=… filters at all (E2E P2 finding)
134
+ const taken = new Set(params.map((x) => x.name));
135
+ for (const q of queryParamsOf(p)) {
136
+ if (taken.has(q)) continue;
137
+ taken.add(q);
138
+ params.push({ name: q, in: 'query', type: 'string', required: false, description: 'query parameter' });
139
+ }
132
140
  let confidence = 'high';
133
141
  if (mutating) {
134
142
  params.push({ name: 'body', in: 'body', type: 'object', required: false, description: 'JSON body — schema not statically detected' });
@@ -172,6 +180,40 @@ export function parseExpressProject(cwd, entryFile) {
172
180
  }
173
181
  }
174
182
 
183
+ // Query params are invisible in a route's signature; only the handler body
184
+ // reveals them. Covers the two canonical shapes — req.query.x / req.query['x']
185
+ // and `const { x } = req.query` — on inline handlers only (an Identifier
186
+ // handler lives elsewhere; guessing is worse than staying silent). Bounded.
187
+ function queryParamsOf(callPath) {
188
+ const reqNames = new Set();
189
+ for (const a of callPath.node.arguments) {
190
+ if ((a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression') && a.params[0]?.type === 'Identifier') {
191
+ reqNames.add(a.params[0].name);
192
+ }
193
+ }
194
+ const found = [];
195
+ if (!reqNames.size) return found;
196
+ const isReqQuery = (n) => n?.type === 'MemberExpression' && !n.computed &&
197
+ n.object?.type === 'Identifier' && reqNames.has(n.object.name) &&
198
+ n.property?.type === 'Identifier' && n.property.name === 'query';
199
+ callPath.traverse({
200
+ MemberExpression(q) {
201
+ const n = q.node;
202
+ if (!isReqQuery(n.object) || found.length >= 15) return;
203
+ if (!n.computed && n.property.type === 'Identifier') found.push(n.property.name);
204
+ else if (n.computed && n.property.type === 'StringLiteral') found.push(n.property.value);
205
+ },
206
+ VariableDeclarator(q) {
207
+ if (q.node.id.type !== 'ObjectPattern' || !isReqQuery(q.node.init)) return;
208
+ for (const prop of q.node.id.properties) {
209
+ if (found.length >= 15) break;
210
+ if (prop.type === 'ObjectProperty' && prop.key.type === 'Identifier') found.push(prop.key.name);
211
+ }
212
+ },
213
+ });
214
+ return [...new Set(found)];
215
+ }
216
+
175
217
  function joinPath(prefix, p) {
176
218
  const joined = `${prefix ?? ''}${p === '/' && prefix ? '' : p}`.replace(/\/{2,}/g, '/');
177
219
  return joined.startsWith('/') ? joined : `/${joined}`;
@@ -0,0 +1,145 @@
1
+ // server/condenser.js — ROADMAP round 2, brick 1: call-sequence recording (Labs).
2
+ // Deterministic, zero LLM, default OFF. Observes the session's current of tool
3
+ // calls and detects circuits — an output value of call A feeding an argument of
4
+ // a later call B. Persists STRUCTURE only (tool names, arg names, counts) into
5
+ // sparda.json: payload values can be PII and the manifest is committed to git.
6
+ import fs from 'node:fs';
7
+
8
+ const WINDOW = 20; // calls remembered per session (ring)
9
+ const MAX_VALUES = 50; // scalars kept per payload
10
+ const MAX_NODES = 200; // payload walk budget — nothing heavy, ever
11
+ const MAX_DEPTH = 4;
12
+ const MAX_CHAIN = 5; // a circuit longer than this is noise, not a workflow
13
+ const MAX_LINKS = 10;
14
+ const MAX_CIRCUITS = 30; // bounded memory, same philosophy as antibodies (ADR-010)
15
+ // an emergent capability is a suggestion only after N observations (survival rule)
16
+ export const CIRCUIT_OBSERVED_THRESHOLD = 3;
17
+
18
+ // Labs gate: explicit opt-in in sparda.json, or env override for one session.
19
+ export function sequenceRecordingEnabled(manifest, env = process.env) {
20
+ return manifest?.labs?.recordSequences === true || env.SPARDA_RECORD_SEQUENCES === '1';
21
+ }
22
+
23
+ export function createSequenceRecorder({ manifest, manifestPath, harvester, onCircuit }) {
24
+ manifest.labs ??= {};
25
+ manifest.labs.circuits ??= {};
26
+ const ring = [];
27
+
28
+ // hot path stays free: capture references, walk + match in idle (R4.4)
29
+ function record(tool, args, output) {
30
+ harvester.enqueue(() => analyze(tool, args, output));
31
+ }
32
+
33
+ function analyze(tool, args, output) {
34
+ const argEntries = extractArgEntries(args);
35
+ let link = null;
36
+ // most recent producer wins: the freshest output is the likeliest source
37
+ outer:
38
+ for (let i = ring.length - 1; i >= 0; i--) {
39
+ for (const [argName, value] of argEntries) {
40
+ if (ring[i].outValues.has(value)) {
41
+ // fromKey = where the value lived in the producer's output — the structure
42
+ // crystallization needs to re-feed the same arg at composite run time
43
+ link = { from: ring[i], arg: argName, fromKey: ring[i].outValues.get(value) };
44
+ break outer;
45
+ }
46
+ }
47
+ }
48
+ const chain = link ? [...link.from.chain.slice(-(MAX_CHAIN - 1)), tool] : [tool];
49
+ ring.push({ tool, chain, outValues: extractValues(output) });
50
+ if (ring.length > WINDOW) ring.shift();
51
+ if (!link) return;
52
+
53
+ const sig = chain.join('>');
54
+ const now = new Date().toISOString();
55
+ const circuits = manifest.labs.circuits;
56
+ const c = circuits[sig] ?? (circuits[sig] = { steps: chain, links: [], seen: 0, firstSeen: now, lastSeen: now });
57
+ c.seen += 1;
58
+ c.lastSeen = now;
59
+ if (c.links.length < MAX_LINKS &&
60
+ !c.links.some((l) => l.from === link.from.tool && l.to === tool && l.arg === link.arg)) {
61
+ c.links.push({ from: link.from.tool, to: tool, arg: link.arg, fromKey: link.fromKey });
62
+ }
63
+ evict(circuits);
64
+ persist();
65
+ if (c.seen === CIRCUIT_OBSERVED_THRESHOLD) onCircuit?.(sig, c);
66
+ }
67
+
68
+ // least-observed circuits make room first; among equals, the stalest
69
+ function evict(circuits) {
70
+ const keys = Object.keys(circuits);
71
+ if (keys.length <= MAX_CIRCUITS) return;
72
+ keys
73
+ .sort((a, b) => (circuits[a].seen - circuits[b].seen) || circuits[a].lastSeen.localeCompare(circuits[b].lastSeen))
74
+ .slice(0, keys.length - MAX_CIRCUITS)
75
+ .forEach((k) => delete circuits[k]);
76
+ }
77
+
78
+ // merge-write like persistImmune: never clobber fields written by others
79
+ function persist() {
80
+ try {
81
+ const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
82
+ onDisk.labs = { ...onDisk.labs, circuits: manifest.labs.circuits };
83
+ fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
84
+ } catch { /* disk briefly unavailable — circuits stay in memory */ }
85
+ }
86
+
87
+ return { record, circuits: () => manifest.labs.circuits };
88
+ }
89
+
90
+ // A scalar is link-worthy when it can plausibly be an identifier. Small ints
91
+ // (counts, pages, flags) and 1-char strings are the noise floor — excluded,
92
+ // unless a single digit sits under an id-ish key (id, userId, orderId…).
93
+ function candidateValue(key, v) {
94
+ if (typeof v === 'string') {
95
+ const s = v.trim();
96
+ if (s.length < 2 || s.length > 200) return null;
97
+ if (['true', 'false', 'null', 'undefined'].includes(s.toLowerCase())) return null;
98
+ return s;
99
+ }
100
+ if (typeof v === 'number' && Number.isFinite(v)) {
101
+ if (Math.abs(v) >= 10 || /id$/i.test(key ?? '')) return String(v);
102
+ }
103
+ return null;
104
+ }
105
+
106
+ // bounded iterative walk; array elements inherit the parent key (indices carry no
107
+ // meaning). Shared with crystallize.js (findByKey) — same budget everywhere.
108
+ export function walkPayload(node, visit) {
109
+ const stack = [[null, node, 0]];
110
+ let seen = 0;
111
+ while (stack.length && seen < MAX_NODES) {
112
+ const [key, v, depth] = stack.pop();
113
+ seen += 1;
114
+ if (v && typeof v === 'object') {
115
+ if (depth >= MAX_DEPTH) continue;
116
+ for (const [k, child] of Object.entries(v)) {
117
+ stack.push([Array.isArray(v) ? key : k, child, depth + 1]);
118
+ }
119
+ continue;
120
+ }
121
+ if (visit(key, v) === false) return;
122
+ }
123
+ }
124
+
125
+ // value → key it lived under (first sighting wins): has() drives link detection,
126
+ // get() gives crystallization its fromKey
127
+ function extractValues(output) {
128
+ const values = new Map();
129
+ walkPayload(output, (key, v) => {
130
+ const c = candidateValue(key, v);
131
+ if (c !== null && !values.has(c)) values.set(c, key ?? null);
132
+ return values.size < MAX_VALUES;
133
+ });
134
+ return values;
135
+ }
136
+
137
+ function extractArgEntries(args) {
138
+ const entries = [];
139
+ walkPayload(args, (key, v) => {
140
+ const c = candidateValue(key, v);
141
+ if (c !== null && key) entries.push([key, c]);
142
+ return entries.length < 20;
143
+ });
144
+ return entries;
145
+ }
@@ -0,0 +1,89 @@
1
+ // server/crystallize.js — ROADMAP round 2, brick 2: observation freezes state.
2
+ // A circuit observed enough times crystallizes into a composite tool: one MCP
3
+ // call that runs the whole chain, auto-feeding each linked argument from the
4
+ // previous step's output (by fromKey — structure recorded at observation time,
5
+ // never values). GET-only on purpose: a write inside a circuit keeps its own
6
+ // per-call confirmation (ADR-004); composites never bypass it.
7
+ import { walkPayload } from './condenser.js';
8
+
9
+ // crystallizable = every step is an enabled GET and every link knows where to
10
+ // re-feed from. Re-checked at every bridge start: tools change, circuits stay.
11
+ export function eligibleForCrystallization(circuit, toolSpecs) {
12
+ return Array.isArray(circuit.steps) && circuit.steps.length >= 2 &&
13
+ circuit.steps.every((s) => toolSpecs[s]?.enabled && toolSpecs[s].method === 'GET') &&
14
+ Array.isArray(circuit.links) && circuit.links.length > 0 &&
15
+ circuit.links.every((l) => typeof l.fromKey === 'string' && l.fromKey.length > 0);
16
+ }
17
+
18
+ // graceful degradation (survival rule): no sampling → a deterministic name and
19
+ // an honest description, built only from structure the manifest already holds
20
+ export function fallbackComposite(circuit) {
21
+ const flow = circuit.links
22
+ .map((l) => `'${l.arg}' of ${l.to} comes from '${l.fromKey}' of ${l.from}`)
23
+ .join('; ');
24
+ return {
25
+ name: `circuit_${circuit.steps.join('_then_')}`.slice(0, 60).replace(/_+$/, ''),
26
+ description: `Runs ${circuit.steps.join(', then ')} as one call — ${flow}.`,
27
+ source: 'deterministic',
28
+ };
29
+ }
30
+
31
+ // a sampled name is untrusted input: normalize hard, reject anything shapeless
32
+ export function normalizeCompositeName(raw) {
33
+ const n = String(raw ?? '').toLowerCase().trim()
34
+ .replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
35
+ return /^[a-z][a-z0-9_]{2,}$/.test(n) ? n : null;
36
+ }
37
+
38
+ // the composite exposes the union of its steps' params, minus the auto-fed ones
39
+ export function compositeSchema(circuit, toolSpecs) {
40
+ const autoFed = new Set(circuit.links.map((l) => `${l.to}:${l.arg}`));
41
+ const properties = {};
42
+ const required = [];
43
+ for (const step of circuit.steps) {
44
+ for (const p of toolSpecs[step]?.params ?? []) {
45
+ if (autoFed.has(`${step}:${p.name}`) || properties[p.name]) continue;
46
+ properties[p.name] = { type: p.type === 'unknown' ? 'string' : p.type, description: `${p.description ?? p.in} (for ${step})` };
47
+ if (p.required) required.push(p.name);
48
+ }
49
+ }
50
+ return { type: 'object', properties, ...(required.length ? { required } : {}) };
51
+ }
52
+
53
+ // first scalar living under `key` in a payload — same bounded walk as detection,
54
+ // so what was observable is exactly what is re-feedable
55
+ export function findByKey(node, key) {
56
+ let found;
57
+ walkPayload(node, (k, v) => {
58
+ if (k === key) { found = v; return false; }
59
+ });
60
+ return found;
61
+ }
62
+
63
+ // run the chain. The truth is always the real call (survival rule): every step
64
+ // hits the live route through the router, and the trace reports what happened.
65
+ export async function runComposite({ circuit, args, toolSpecs, invokeFn }) {
66
+ const outputs = {};
67
+ const trace = [];
68
+ let data = null;
69
+ for (const step of circuit.steps) {
70
+ const stepArgs = {};
71
+ for (const p of toolSpecs[step]?.params ?? []) {
72
+ if (args[p.name] !== undefined) stepArgs[p.name] = args[p.name];
73
+ }
74
+ for (const l of circuit.links) {
75
+ if (l.to !== step) continue;
76
+ const v = findByKey(outputs[l.from], l.fromKey);
77
+ if (v !== undefined) stepArgs[l.arg] = v;
78
+ }
79
+ const payload = await invokeFn(step, stepArgs);
80
+ const status = payload?.upstreamStatus;
81
+ trace.push({ tool: step, upstreamStatus: status ?? null, ...(payload?.error ? { error: payload.error } : {}) });
82
+ if (!payload || payload.error !== undefined || status === undefined || status >= 400) {
83
+ return { ok: false, trace }; // honest failure: stop the chain, show where
84
+ }
85
+ outputs[step] = payload.data;
86
+ data = payload.data;
87
+ }
88
+ return { ok: true, trace, data };
89
+ }
@@ -0,0 +1,42 @@
1
+ // server/idle.js — the idle harvester (ROADMAP R4.4): SPARDA's internal work
2
+ // (condensation, persistence, future organs) runs only when the event loop is
3
+ // quiet. One job per tick — a drip, never a burst: perceived saturation stays
4
+ // at zero even while the organism digests.
5
+ import { monitorEventLoopDelay } from 'node:perf_hooks';
6
+
7
+ export function createIdleHarvester({ tickMs = 250, busyLagMs = 25, maxWaitMs = 5000, maxQueue = 200 } = {}) {
8
+ const resolutionMs = 10;
9
+ const histogram = monitorEventLoopDelay({ resolution: resolutionMs });
10
+ histogram.enable();
11
+ const queue = [];
12
+
13
+ const timer = setInterval(() => {
14
+ if (!queue.length) return;
15
+ // recorded delays hover around the sampling resolution on a quiet loop —
16
+ // the lag is what exceeds it. NaN (no samples yet) counts as quiet.
17
+ const lagMs = histogram.mean / 1e6 - resolutionMs;
18
+ histogram.reset();
19
+ const starving = Date.now() - queue[0].ts > maxWaitMs; // never wait forever on a loaded box
20
+ if (Number.isFinite(lagMs) && lagMs > busyLagMs && !starving) return;
21
+ const job = queue.shift();
22
+ try { job.fn(); } catch (e) { console.error(`[sparda] idle job failed (dropped): ${e.message}`); }
23
+ }, tickMs);
24
+ timer.unref?.();
25
+
26
+ return {
27
+ enqueue(fn) {
28
+ if (queue.length >= maxQueue) return false; // bounded, like every SPARDA buffer
29
+ queue.push({ fn, ts: Date.now() });
30
+ return true;
31
+ },
32
+ // synchronous drain for shutdown: pending knowledge must reach disk
33
+ flush() {
34
+ while (queue.length) {
35
+ const job = queue.shift();
36
+ try { job.fn(); } catch (e) { console.error(`[sparda] idle job failed (dropped): ${e.message}`); }
37
+ }
38
+ },
39
+ stop() { clearInterval(timer); histogram.disable(); },
40
+ pending: () => queue.length,
41
+ };
42
+ }
@@ -9,8 +9,16 @@ import {
9
9
  ListPromptsRequestSchema, GetPromptRequestSchema,
10
10
  } from '@modelcontextprotocol/sdk/types.js';
11
11
  import { sanitizeDescription } from '../security/sanitize.js';
12
+ import { createIdleHarvester } from './idle.js';
13
+ import { createSequenceRecorder, sequenceRecordingEnabled } from './condenser.js';
14
+ import { eligibleForCrystallization, fallbackComposite, normalizeCompositeName, compositeSchema, runComposite } from './crystallize.js';
12
15
 
13
16
  const EVENT_POLL_MS = Number(process.env.SPARDA_EVENT_POLL_MS ?? 5000);
17
+ // the sampling budgets below are also what the recycling gauge counts as "avoided"
18
+ // when cached knowledge short-circuits a call — the estimate is honest by construction
19
+ const DIAGNOSIS_TOKENS = 120;
20
+ const SEMANTIC_TOKENS = 1500;
21
+ const CRYSTAL_TOKENS = 150; // naming a crystallized circuit: once per circuit, ever
14
22
 
15
23
  export async function startStdioBridge({ cwd, portOverride }) {
16
24
  // pitfall #1: neutralize any stray console.log from deps
@@ -36,10 +44,89 @@ export async function startStdioBridge({ cwd, portOverride }) {
36
44
  const disabled = () => Object.entries(toolSpecs).filter(([, t]) => !t.enabled);
37
45
 
38
46
  const server = new Server(
39
- { name: `sparda-${path.basename(cwd)}`, version: '0.3.0' },
47
+ { name: `sparda-${path.basename(cwd)}`, version: '0.4.0' },
40
48
  { capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, logging: {} } },
41
49
  );
42
50
 
51
+ // R4.1, intelligence side of the gauge: sampling calls NOT spent because cached
52
+ // knowledge (semantic pass, antibodies) answered instead. Compute side lives in
53
+ // the router (/mcp/stats.recycle).
54
+ const intel = { samplingAvoided: 0, tokensAvoidedEst: 0 };
55
+
56
+ // R4.4: every internal organ works only when the event loop is quiet
57
+ const harvester = createIdleHarvester();
58
+ // R2.1 (Labs, default OFF): record the current of calls, detect circuits;
59
+ // R2.2: at the observation threshold a circuit crystallizes into a composite tool
60
+ const composites = new Map(); // composite tool name -> { sig, circuit }
61
+ const recorder = sequenceRecordingEnabled(manifest)
62
+ ? createSequenceRecorder({
63
+ manifest, manifestPath, harvester,
64
+ onCircuit: (sig, c) => { crystallizeCircuit(sig, c).catch((e) => console.error(`[sparda] crystallization skipped: ${e.message}`)); },
65
+ })
66
+ : null;
67
+
68
+ const compositeNameTaken = (n) => Boolean(toolSpecs[n]) || composites.has(n) ||
69
+ ['sparda_info', 'sparda_list_disabled_tools', 'sparda_get_context'].includes(n);
70
+ const uniqueCompositeName = (base) => {
71
+ if (!compositeNameTaken(base)) return base;
72
+ for (let i = 2; ; i++) if (!compositeNameTaken(`${base}_${i}`)) return `${base}_${i}`;
73
+ };
74
+
75
+ async function crystallizeCircuit(sig, circuit) {
76
+ if (circuit.composite || !eligibleForCrystallization(circuit, toolSpecs)) {
77
+ // observed but not crystallizable (write step, missing fromKey…): say so, once
78
+ await server.sendLoggingMessage({
79
+ level: 'info', logger: 'sparda',
80
+ data: { source: 'condenser', circuit: sig, seen: circuit.seen, note: `circuit observed ${circuit.seen}× — not crystallized (composites are GET-only and need a traceable data flow)` },
81
+ }).catch(() => {});
82
+ return;
83
+ }
84
+ // the client's LLM names the newborn (one call, ever); no sampling → deterministic name
85
+ let named = null;
86
+ if (server.getClientCapabilities()?.sampling) {
87
+ try {
88
+ const flow = circuit.links.map((l) => `'${l.arg}' of ${l.to} comes from '${l.fromKey}' in the response of ${l.from}`).join('; ');
89
+ const res = await server.createMessage({
90
+ messages: [{
91
+ role: 'user',
92
+ content: {
93
+ type: 'text',
94
+ text: `A repeated tool-call sequence was observed ${circuit.seen}× in a live ${manifest.framework} app:\nsteps: ${circuit.steps.join(' -> ')}\ndata flow: ${flow}\ntools:\n${circuit.steps.map((s) => `- ${s}: ${toolSpecs[s].method} ${toolSpecs[s].path}`).join('\n')}\n\nReply with ONLY a JSON object {"name": "<snake_case verb_noun, max 40 chars>", "description": "<one sentence: what this combined operation achieves in business terms>"}.`,
95
+ },
96
+ }],
97
+ maxTokens: CRYSTAL_TOKENS,
98
+ });
99
+ const raw = res?.content?.type === 'text' ? res.content.text : '';
100
+ const parsed = JSON.parse(raw.replace(/^```(json)?\s*/i, '').replace(/```\s*$/, ''));
101
+ const name = normalizeCompositeName(parsed.name);
102
+ const { text: desc, flagged } = sanitizeDescription(String(parsed.description ?? ''), '');
103
+ if (name && desc && !flagged) named = { name, description: desc, source: 'mcp-sampling' };
104
+ } catch { /* graceful degradation: deterministic naming below */ }
105
+ }
106
+ const comp = named ?? fallbackComposite(circuit);
107
+ comp.name = uniqueCompositeName(comp.name);
108
+ comp.createdAt = new Date().toISOString();
109
+ circuit.composite = comp;
110
+ persistLabs(manifestPath, manifest);
111
+ composites.set(comp.name, { sig, circuit });
112
+ await server.sendToolListChanged().catch(() => {});
113
+ await server.sendLoggingMessage({
114
+ level: 'info', logger: 'sparda',
115
+ data: { source: 'condenser', circuit: sig, composite: comp.name, note: `circuit observed ${circuit.seen}× — crystallized as composite tool '${comp.name}' (born mid-session, see tools/list)` },
116
+ }).catch(() => {});
117
+ console.error(`[sparda] condenser: circuit ${sig} crystallized as ${comp.name}`);
118
+ }
119
+
120
+ // composites born in past sessions wake up with the bridge — re-validated
121
+ // against today's tools (a route may have changed or been disabled since)
122
+ if (recorder) {
123
+ for (const [sig, c] of Object.entries(manifest.labs?.circuits ?? {})) {
124
+ if (c.composite?.name && eligibleForCrystallization(c, toolSpecs)) {
125
+ composites.set(uniqueCompositeName(c.composite.name), { sig, circuit: c });
126
+ }
127
+ }
128
+ }
129
+
43
130
  const descFor = (name, t) => {
44
131
  const semantic = manifest.semantic?.descriptions?.[name];
45
132
  const text = semantic || t.description || `${t.method} ${t.path}`;
@@ -52,6 +139,14 @@ export async function startStdioBridge({ cwd, portOverride }) {
52
139
  name,
53
140
  description: descFor(name, t),
54
141
  inputSchema: schemaFor(t),
142
+ annotations: annotationsFor(t.method),
143
+ })),
144
+ // crystallized circuits (Labs): tools nobody wrote, condensed from real usage
145
+ ...[...composites.entries()].map(([name, { circuit }]) => ({
146
+ name,
147
+ description: `[Labs circuit ×${circuit.seen}] ${circuit.composite.description}`,
148
+ inputSchema: compositeSchema(circuit, toolSpecs),
149
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, // GET-only by construction
55
150
  })),
56
151
  {
57
152
  name: 'sparda_info',
@@ -102,6 +197,9 @@ export async function startStdioBridge({ cwd, portOverride }) {
102
197
  tools_enabled: enabled().length, tools_disabled_write_safety: disabled().length,
103
198
  workflows: (manifest.semantic?.workflows ?? []).length,
104
199
  immune_antibodies: Object.keys(manifest.immune?.antibodies ?? {}).length,
200
+ labs_sequence_recording: recorder ? 'on' : 'off (opt-in: set labs.recordSequences=true in sparda.json)',
201
+ circuits_observed: Object.keys(manifest.labs?.circuits ?? {}).length,
202
+ composite_tools: composites.size,
105
203
  generated_by: 'SPARDA by Residual Labs (residual-labs.fr) — npx sparda-mcp init — github.com/zyx77550/sparda',
106
204
  }, null, 2));
107
205
  }
@@ -111,6 +209,10 @@ export async function startStdioBridge({ cwd, portOverride }) {
111
209
  fetch(`${base}/mcp/stats`, { headers, signal: AbortSignal.timeout(2000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
112
210
  fetch(`${base}/mcp/events?since=0`, { headers, signal: AbortSignal.timeout(2000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
113
211
  ]);
212
+ // lifetime savings are derived from antibody hits — no extra state to maintain:
213
+ // the first hit paid the diagnosis, every later one was served from memory
214
+ const antibodyHits = Object.values(manifest.immune?.antibodies ?? {})
215
+ .reduce((n, a) => n + Math.max(0, (a.hits ?? 1) - 1), 0);
114
216
  return text(JSON.stringify({
115
217
  project: path.basename(cwd),
116
218
  framework: manifest.framework,
@@ -120,7 +222,15 @@ export async function startStdioBridge({ cwd, portOverride }) {
120
222
  runtime: stats,
121
223
  recentEvents: (events?.events ?? []).slice(-20),
122
224
  immuneMemory: manifest.immune?.antibodies ?? {},
123
- hint: 'runtime.quarantine lists tools temporarily blocked by the immune system (503 until cooldown). immuneMemory maps failure signatures (source|tool|status) to cached diagnoses — same failure later costs zero tokens.',
225
+ recycling: {
226
+ compute: stats?.recycle ?? null,
227
+ intelligence: {
228
+ session: { samplingAvoided: intel.samplingAvoided, tokensAvoidedEst: intel.tokensAvoidedEst },
229
+ lifetime: { antibodyHits, tokensAvoidedEst: antibodyHits * DIAGNOSIS_TOKENS },
230
+ },
231
+ },
232
+ labs: { recordSequences: Boolean(recorder), compositeTools: [...composites.keys()], circuits: manifest.labs?.circuits ?? {} },
233
+ hint: 'runtime.quarantine lists tools temporarily blocked by the immune system (503 until cooldown). immuneMemory maps failure signatures (source|tool|status) to cached diagnoses — same failure later costs zero tokens. recycling measures how much compute/intelligence was served from SPARDA\'s own memory instead of being paid again. runtime.purity classifies each route by observation: pure = same args keep returning the same response (recyclable), volatile = it changed, erasing = writes. labs.circuits are observed call sequences where one tool\'s output fed the next one\'s input.',
124
234
  }, null, 2));
125
235
  }
126
236
  if (name === 'sparda_list_disabled_tools') {
@@ -129,6 +239,18 @@ export async function startStdioBridge({ cwd, portOverride }) {
129
239
  : 'No disabled tools.');
130
240
  }
131
241
 
242
+ // composite tools run their whole chain (GET-only by construction — no
243
+ // write confirmation to bypass), auto-feeding linked args between steps
244
+ const comp = composites.get(name);
245
+ if (comp) {
246
+ const result = await runComposite({ circuit: comp.circuit, args, toolSpecs, invokeFn: (tool, a) => invoke(base, key, tool, a) });
247
+ const pretty = JSON.stringify(result, null, 2);
248
+ return {
249
+ content: [{ type: 'text', text: pretty.length > 8000 ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]` : pretty }],
250
+ isError: !result.ok,
251
+ };
252
+ }
253
+
132
254
  const spec = toolSpecs[name];
133
255
  const isWrite = spec && spec.method !== 'GET';
134
256
 
@@ -152,6 +274,12 @@ export async function startStdioBridge({ cwd, portOverride }) {
152
274
  return { content: [{ type: 'text', text: `Host app error. Check that your server is still running on :${port}.` }], isError: true };
153
275
  }
154
276
 
277
+ // condenser tap (Labs): only AI-driven calls that succeeded feed the current —
278
+ // internal read-backs and failures are not workflow steps
279
+ if (recorder && payload.upstreamStatus !== undefined && payload.upstreamStatus < 400) {
280
+ recorder.record(name, args, payload.data);
281
+ }
282
+
155
283
  // proof-after-write: re-read the same path so the AI sees the effect of its write
156
284
  let proof = null;
157
285
  if (isWrite && payload.upstreamStatus < 400) {
@@ -172,14 +300,20 @@ export async function startStdioBridge({ cwd, portOverride }) {
172
300
 
173
301
  let pollTimer = null;
174
302
  server.oninitialized = () => {
175
- pollTimer = startEventPolling(server, base, key, manifest, manifestPath);
303
+ // a session resumed on a cached semantic pass skipped the enrichment sampling call
304
+ if (manifest.semantic) { intel.samplingAvoided += 1; intel.tokensAvoidedEst += SEMANTIC_TOKENS; }
305
+ pollTimer = startEventPolling(server, base, key, manifest, manifestPath, intel);
176
306
  runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }).catch((e) => console.error(`[sparda] semantic pass skipped: ${e.message}`));
177
307
  };
178
- server.onclose = () => { if (pollTimer) clearInterval(pollTimer); };
308
+ server.onclose = () => {
309
+ if (pollTimer) clearInterval(pollTimer);
310
+ harvester.flush(); // pending knowledge must reach disk before the lights go out
311
+ harvester.stop();
312
+ };
179
313
 
180
314
  const transport = new StdioServerTransport();
181
315
  await server.connect(transport);
182
- console.error(`[sparda] MCP bridge running. ${enabled().length} tools enabled, ${disabled().length} disabled (write-safety). Host: ${base}`);
316
+ console.error(`[sparda] MCP bridge running. ${enabled().length} tools enabled, ${disabled().length} disabled (write-safety).${recorder ? ' Labs: sequence recording ON.' : ''} Host: ${base}`);
183
317
  }
184
318
 
185
319
  async function invoke(base, key, tool, args) {
@@ -206,7 +340,7 @@ function pickPathArgs(spec, args) {
206
340
  // live error feed: host app errors reach the AI as MCP logging notifications.
207
341
  // immune memory: known failure signatures carry their cached diagnosis (zero tokens);
208
342
  // new signatures wake the client's LLM once, and the antibody is stored in sparda.json.
209
- function startEventPolling(server, base, key, manifest, manifestPath) {
343
+ function startEventPolling(server, base, key, manifest, manifestPath, intel) {
210
344
  let lastSeq = null; // first poll sets the baseline; only NEW errors are reported
211
345
  const timer = setInterval(async () => {
212
346
  try {
@@ -221,6 +355,8 @@ function startEventPolling(server, base, key, manifest, manifestPath) {
221
355
  if (antibody) {
222
356
  antibody.hits = (antibody.hits ?? 0) + 1;
223
357
  antibody.lastSeen = ev.ts;
358
+ intel.samplingAvoided += 1; // this diagnosis would have been a sampling call
359
+ intel.tokensAvoidedEst += DIAGNOSIS_TOKENS;
224
360
  persistImmune(manifestPath, manifest);
225
361
  await server.sendLoggingMessage({ level: 'error', logger: 'sparda', data: { ...ev, diagnosis: antibody.diagnosis } }).catch(() => {});
226
362
  } else {
@@ -254,7 +390,7 @@ async function diagnoseAndRemember({ server, manifest, manifestPath, ev, sig })
254
390
  text: `A tool of a live ${manifest.framework} app just failed.\nTool: ${ev.tool ?? 'n/a'}\nSource: ${ev.source}\nStatus: ${ev.status ?? 'n/a'}\nError message: ${ev.message}\n\nReply with ONE short sentence (max 140 chars): most likely root cause and fix direction. No preamble.`,
255
391
  },
256
392
  }],
257
- maxTokens: 120,
393
+ maxTokens: DIAGNOSIS_TOKENS,
258
394
  });
259
395
  const raw = res?.content?.type === 'text' ? res.content.text.trim() : '';
260
396
  const { text: clean, flagged } = sanitizeDescription(raw, '');
@@ -279,6 +415,14 @@ function persistImmune(manifestPath, manifest) {
279
415
  } catch { /* disk briefly unavailable — the antibody stays in memory */ }
280
416
  }
281
417
 
418
+ function persistLabs(manifestPath, manifest) {
419
+ try {
420
+ const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
421
+ onDisk.labs = { ...onDisk.labs, circuits: manifest.labs.circuits };
422
+ fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
423
+ } catch { /* disk briefly unavailable — the circuit stays in memory */ }
424
+ }
425
+
282
426
  // semantic pass: uses the CLIENT's own model via MCP sampling — zero key, zero cost.
283
427
  // Runs once, result cached in sparda.json (and preserved across re-init).
284
428
  async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }) {
@@ -294,7 +438,7 @@ async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs
294
438
  text: `These are API tools extracted from a ${manifest.framework} codebase:\n${inventory}\n\nReply with ONLY a JSON object, no prose, shaped as {"descriptions": {"<tool>": "<one clear sentence of what it does in business terms>"}, "workflows": [{"name": "<snake_case>", "description": "<goal>", "steps": ["<tool>", ...]}]}. Include 1-3 workflows that chain tools toward a realistic business goal. Use only the tool names listed above.`,
295
439
  },
296
440
  }],
297
- maxTokens: 1500,
441
+ maxTokens: SEMANTIC_TOKENS,
298
442
  });
299
443
 
300
444
  const raw = res?.content?.type === 'text' ? res.content.text : '';
@@ -326,6 +470,17 @@ async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs
326
470
  console.error(`[sparda] semantic pass done: ${Object.keys(descriptions).length} descriptions, ${workflows.length} workflows (cached in sparda.json)`);
327
471
  }
328
472
 
473
+ // MCP annotations: without them clients assume the scariest defaults and show
474
+ // destructive-looking hints on plain reads (E2E P2 finding). HTTP maps 1:1.
475
+ function annotationsFor(method) {
476
+ return {
477
+ readOnlyHint: method === 'GET',
478
+ destructiveHint: method === 'DELETE',
479
+ idempotentHint: method === 'GET' || method === 'PUT' || method === 'DELETE',
480
+ openWorldHint: false, // a local API behind the router, not the open internet
481
+ };
482
+ }
483
+
329
484
  function schemaFor(t) {
330
485
  const properties = {}; const required = [];
331
486
  for (const p of t.params ?? []) {
@@ -0,0 +1,66 @@
1
+ // ui/style.js — zero-dep ANSI styling for the HUMAN commands (init, remove,
2
+ // doctor). Never import this from the bridge path: there, stdout is the MCP
3
+ // protocol (hard rule #2) and stderr must stay grep-able plain text.
4
+
5
+ // SPARDA identity: violet → cyan, same stops as the brand gradient
6
+ const VIOLET = [192, 132, 252]; // #c084fc
7
+ const CYAN = [103, 232, 249]; // #67e8f9
8
+ const RESET = '\x1b[0m';
9
+
10
+ // evaluated per call so tests (and runtime env changes) see the truth;
11
+ // honors https://no-color.org and the FORCE_COLOR convention
12
+ function enabled() {
13
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') return true;
14
+ if (process.env.NO_COLOR !== undefined) return false;
15
+ return Boolean(process.stdout.isTTY) && process.env.TERM !== 'dumb';
16
+ }
17
+
18
+ function truecolor() {
19
+ return /truecolor|24bit/i.test(process.env.COLORTERM ?? '') ||
20
+ ['iTerm.app', 'vscode', 'WezTerm', 'ghostty'].includes(process.env.TERM_PROGRAM ?? '') ||
21
+ Boolean(process.env.WT_SESSION);
22
+ }
23
+
24
+ const sgr = (open) => (s) => (enabled() ? `\x1b[${open}m${s}${RESET}` : String(s));
25
+
26
+ export const c = {
27
+ dim: sgr('2'),
28
+ bold: sgr('1'),
29
+ green: sgr('32'),
30
+ yellow: sgr('33'),
31
+ red: sgr('31'),
32
+ cyan: sgr('38;5;117'),
33
+ violet: sgr('38;5;177'),
34
+ };
35
+
36
+ // the brand banner: per-character violet→cyan interpolation in truecolor,
37
+ // a stepped 256-color ramp elsewhere, plain text when colors are off
38
+ export function gradient(text) {
39
+ if (!enabled()) return text;
40
+ const chars = [...text];
41
+ if (truecolor()) {
42
+ const body = chars.map((ch, i) => {
43
+ const t = chars.length === 1 ? 0 : i / (chars.length - 1);
44
+ const [r, g, b] = VIOLET.map((v, k) => Math.round(v + (CYAN[k] - v) * t));
45
+ return `\x1b[1m\x1b[38;2;${r};${g};${b}m${ch}`;
46
+ }).join('');
47
+ return body + RESET;
48
+ }
49
+ const ramp = [177, 141, 147, 153, 117];
50
+ const body = chars.map((ch, i) => {
51
+ const step = ramp[Math.min(ramp.length - 1, Math.floor((i / chars.length) * ramp.length))];
52
+ return `\x1b[1m\x1b[38;5;${step}m${ch}`;
53
+ }).join('');
54
+ return body + RESET;
55
+ }
56
+
57
+ // single-pass JSON highlighter (keys violet, strings cyan, punctuation dim) —
58
+ // one pass on purpose: a second regex pass would chew the ANSI escapes of the first
59
+ export function colorizeJson(json) {
60
+ if (!enabled()) return json;
61
+ return json.replace(/("(?:[^"\\]|\\.)*")(\s*:)?|([{}[\],])/g, (m, str, colon, punct) => {
62
+ if (punct) return c.dim(punct);
63
+ if (colon !== undefined) return c.violet(str) + c.dim(colon);
64
+ return c.cyan(str);
65
+ });
66
+ }
@@ -15,6 +15,45 @@ let SPARDA_SEQ = 0;
15
15
  // immune system: tools that keep failing are quarantined so the AI can't hammer a broken route
16
16
  const SPARDA_QUARANTINE__STATS_TYPE__ = {};
17
17
  const SPARDA_QUARANTINE_MS = Number(process.env.SPARDA_QUARANTINE_MS ?? 60000);
18
+ // recycling gauge: calls answered from SPARDA's own knowledge vs calls that paid the host route.
19
+ // Day 1 it reads 0% — the circle fills with usage (a measure, never a promise).
20
+ const SPARDA_RECYCLE__STATS_TYPE__ = { servedByCircle: 0, paidFull: 0 };
21
+ function spardaRecycleRate() {
22
+ const total = SPARDA_RECYCLE.servedByCircle + SPARDA_RECYCLE.paidFull;
23
+ return total ? Math.round((SPARDA_RECYCLE.servedByCircle * 100) / total) : 0;
24
+ }
25
+ // thermodynamic route classification: a GET whose repeated identical args keep
26
+ // returning identical bodies is observed-pure (its result pre-exists — recyclable);
27
+ // writes erase by definition. Observation only, never a guess.
28
+ const SPARDA_PURITY__STATS_TYPE__ = {};
29
+ function spardaHash(s__ANY_TYPE__) {
30
+ let h = 0x811c9dc5; // FNV-1a, capped: a fingerprint, not a checksum of megabytes
31
+ for (let i = 0; i < s.length && i < 65536; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
32
+ return h;
33
+ }
34
+ function spardaObservePurity(tool__ANY_TYPE__, argsig__ANY_TYPE__, body__ANY_TYPE__) {
35
+ const pu = SPARDA_PURITY[tool] ?? (SPARDA_PURITY[tool] = { sigs: {}, repeats: 0, mismatches: 0 });
36
+ const h = spardaHash(body);
37
+ const known = pu.sigs[argsig];
38
+ if (known === undefined) {
39
+ if (Object.keys(pu.sigs).length >= 20) return; // bounded: enough sigs to judge
40
+ pu.sigs[argsig] = h;
41
+ } else if (known === h) pu.repeats += 1;
42
+ else { pu.mismatches += 1; pu.sigs[argsig] = h; } // the latest real answer is the truth
43
+ }
44
+ function spardaPuritySnapshot() {
45
+ const out__STATS_TYPE__ = {};
46
+ for (const [name, spec] of Object.entries(SPARDA_TOOLS)) {
47
+ if (spec.method !== 'GET') { out[name] = { class: 'erasing', repeats: 0, mismatches: 0 }; continue; }
48
+ const pu = SPARDA_PURITY[name];
49
+ out[name] = {
50
+ class: !pu ? 'unknown' : pu.mismatches > 0 ? 'volatile' : pu.repeats >= 3 ? 'pure' : 'unknown',
51
+ repeats: pu ? pu.repeats : 0,
52
+ mismatches: pu ? pu.mismatches : 0,
53
+ };
54
+ }
55
+ return out;
56
+ }
18
57
  function spardaEvent(source__ANY_TYPE__, tool__ANY_TYPE__, status__ANY_TYPE__, message__ANY_TYPE__) {
19
58
  SPARDA_EVENTS.push({ seq: ++SPARDA_SEQ, ts: new Date().toISOString(), source, tool, status, message: String(message ?? '').slice(0, 500) });
20
59
  if (SPARDA_EVENTS.length > 100) SPARDA_EVENTS.shift();
@@ -33,7 +72,7 @@ spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
33
72
 
34
73
  spardaRouter.get('/tools', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json(SPARDA_TOOLS));
35
74
 
36
- spardaRouter.get('/stats', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json({ uptimeSec: Math.round(process.uptime()), stats: SPARDA_STATS, quarantine: SPARDA_QUARANTINE }));
75
+ spardaRouter.get('/stats', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json({ uptimeSec: Math.round(process.uptime()), stats: SPARDA_STATS, quarantine: SPARDA_QUARANTINE, recycle: { servedByCircle: SPARDA_RECYCLE.servedByCircle, paidFull: SPARDA_RECYCLE.paidFull, ratePct: spardaRecycleRate() }, purity: spardaPuritySnapshot() }));
37
76
 
38
77
  spardaRouter.get('/events', (req__REQ_TYPE__, res__RES_TYPE__) => {
39
78
  const since = Number(req.query.since ?? 0) || 0;
@@ -54,6 +93,7 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE_
54
93
  const quarantined = SPARDA_QUARANTINE[tool];
55
94
  if (quarantined) {
56
95
  if (Date.now() < quarantined.until) {
96
+ SPARDA_RECYCLE.servedByCircle += 1; // answered from immune memory — the doomed host call was never paid
57
97
  return res.status(503).json({ error: `tool quarantined (immune system): ${tool}`, reason: quarantined.reason, retryInMs: quarantined.until - Date.now() });
58
98
  }
59
99
  // half-open: cooldown elapsed — allow one probe; a single new 5xx re-quarantines
@@ -78,10 +118,15 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE_
78
118
  init.headers['content-type'] = 'application/json';
79
119
  init.body = JSON.stringify(args.body);
80
120
  }
121
+ SPARDA_RECYCLE.paidFull += 1; // the host route is about to be exercised — full price
81
122
  const upstream = await fetch(`http://127.0.0.1:${SPARDA_PORT}${url}`, { ...init, signal: AbortSignal.timeout(30_000) });
82
123
  const text = await upstream.text();
83
124
  let data; try { data = JSON.parse(text); } catch { data = text; }
84
125
  spardaRecord(tool, upstream.status, Date.now() - t0);
126
+ if (spec.method === 'GET' && upstream.status === 200) {
127
+ // canonical argsig: sorted entries, so the AI's argument order never splits a signature
128
+ spardaObservePurity(tool, JSON.stringify(Object.entries(args).filter((e__ANY_TYPE__) => e[1] !== undefined).sort()), text);
129
+ }
85
130
  if (upstream.status >= 500) spardaEvent('invoke', tool, upstream.status, text.slice(0, 200));
86
131
  return res.status(200).json({ upstreamStatus: upstream.status, data });
87
132
  } catch (err__ANY_TYPE__) {
@@ -92,14 +137,16 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE_
92
137
  });
93
138
 
94
139
  function spardaRecord(tool__ANY_TYPE__, status__ANY_TYPE__, ms__ANY_TYPE__) {
95
- const st = SPARDA_STATS[tool] ?? (SPARDA_STATS[tool] = { calls: 0, errors: 0, totalMs: 0, lastStatus: null, lastTs: null, consecutive5xx: 0 });
140
+ const st = SPARDA_STATS[tool] ?? (SPARDA_STATS[tool] = { calls: 0, errors: 0, clientErrors: 0, totalMs: 0, lastStatus: null, lastTs: null, consecutive5xx: 0 });
96
141
  // innate immunity: latency far beyond the learned baseline is an antigen
97
142
  if (st.calls >= 5 && ms > Math.max((st.totalMs / st.calls) * 10, 200)) {
98
143
  spardaEvent('immune', tool, status, `latency anomaly: ${ms}ms vs ~${Math.round(st.totalMs / st.calls)}ms baseline`);
99
144
  }
100
145
  st.calls += 1;
101
146
  st.totalMs += ms;
102
- if (status >= 400) st.errors += 1;
147
+ // 5xx = server failure (feeds the immune system); 4xx = a valid client answer (404 etc), tracked apart so the count doesn't lie
148
+ if (status >= 500) st.errors += 1;
149
+ else if (status >= 400) st.clientErrors += 1;
103
150
  st.lastStatus = status;
104
151
  st.lastTs = new Date().toISOString();
105
152
  if (status >= 500) st.consecutive5xx += 1;
@@ -13,6 +13,7 @@ import asyncio
13
13
  import time
14
14
  import datetime
15
15
  import os
16
+ import zlib
16
17
 
17
18
  # json.loads, not a Python literal: JSON true/false/null are not valid Python (E-009)
18
19
  SPARDA_TOOLS = json.loads(__TOOLS_JSON__)
@@ -26,6 +27,50 @@ SPARDA_START = time.time()
26
27
  # immune system: tools that keep failing are quarantined so the AI can't hammer a broken route
27
28
  SPARDA_QUARANTINE = {}
28
29
  SPARDA_QUARANTINE_MS = int(os.environ.get("SPARDA_QUARANTINE_MS", "60000"))
30
+ # recycling gauge: calls answered from SPARDA's own knowledge vs calls that paid the host route.
31
+ # Day 1 it reads 0% — the circle fills with usage (a measure, never a promise).
32
+ SPARDA_RECYCLE = {"servedByCircle": 0, "paidFull": 0}
33
+
34
+ def sparda_recycle_rate():
35
+ total = SPARDA_RECYCLE["servedByCircle"] + SPARDA_RECYCLE["paidFull"]
36
+ return round(SPARDA_RECYCLE["servedByCircle"] * 100 / total) if total else 0
37
+
38
+ # thermodynamic route classification: a GET whose repeated identical args keep
39
+ # returning identical bodies is observed-pure (its result pre-exists — recyclable);
40
+ # writes erase by definition. Observation only, never a guess.
41
+ SPARDA_PURITY = {}
42
+
43
+ def sparda_observe_purity(tool, argsig, body_bytes):
44
+ pu = SPARDA_PURITY.setdefault(tool, {"sigs": {}, "repeats": 0, "mismatches": 0})
45
+ h = zlib.crc32(body_bytes[:65536]) # a fingerprint, not a checksum of megabytes
46
+ known = pu["sigs"].get(argsig)
47
+ if known is None:
48
+ if len(pu["sigs"]) >= 20: # bounded: enough sigs to judge
49
+ return
50
+ pu["sigs"][argsig] = h
51
+ elif known == h:
52
+ pu["repeats"] += 1
53
+ else:
54
+ pu["mismatches"] += 1
55
+ pu["sigs"][argsig] = h # the latest real answer is the truth
56
+
57
+ def sparda_purity_snapshot():
58
+ out = {}
59
+ for name, spec in SPARDA_TOOLS.items():
60
+ if spec.get("method") != "GET":
61
+ out[name] = {"class": "erasing", "repeats": 0, "mismatches": 0}
62
+ continue
63
+ pu = SPARDA_PURITY.get(name)
64
+ if not pu:
65
+ cls = "unknown"
66
+ elif pu["mismatches"] > 0:
67
+ cls = "volatile"
68
+ elif pu["repeats"] >= 3:
69
+ cls = "pure"
70
+ else:
71
+ cls = "unknown"
72
+ out[name] = {"class": cls, "repeats": pu["repeats"] if pu else 0, "mismatches": pu["mismatches"] if pu else 0}
73
+ return out
29
74
 
30
75
  def sparda_event(source, tool, status, message):
31
76
  global SPARDA_SEQ
@@ -40,14 +85,17 @@ def sparda_event(source, tool, status, message):
40
85
  SPARDA_EVENTS.pop(0)
41
86
 
42
87
  def sparda_record(tool, status, ms):
43
- st = SPARDA_STATS.setdefault(tool, {"calls": 0, "errors": 0, "totalMs": 0, "lastStatus": None, "lastTs": None, "consecutive5xx": 0})
88
+ st = SPARDA_STATS.setdefault(tool, {"calls": 0, "errors": 0, "clientErrors": 0, "totalMs": 0, "lastStatus": None, "lastTs": None, "consecutive5xx": 0})
44
89
  # innate immunity: latency far beyond the learned baseline is an antigen
45
90
  if st["calls"] >= 5 and ms > max((st["totalMs"] / st["calls"]) * 10, 200):
46
91
  sparda_event("immune", tool, status, f"latency anomaly: {ms}ms vs ~{round(st['totalMs'] / st['calls'])}ms baseline")
47
92
  st["calls"] += 1
48
93
  st["totalMs"] += ms
49
- if status >= 400:
94
+ # 5xx = server failure (feeds the immune system); 4xx = a valid client answer (404 etc), tracked apart so the count doesn't lie
95
+ if status >= 500:
50
96
  st["errors"] += 1
97
+ elif status >= 400:
98
+ st["clientErrors"] += 1
51
99
  st["lastStatus"] = status
52
100
  st["lastTs"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
53
101
  if status >= 500:
@@ -88,7 +136,7 @@ async def get_tools(request: Request):
88
136
  async def get_stats(request: Request):
89
137
  if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
90
138
  return JSONResponse(status_code=401, content={"error": "unauthorized"})
91
- return {"uptimeSec": round(time.time() - SPARDA_START), "stats": SPARDA_STATS, "quarantine": SPARDA_QUARANTINE}
139
+ return {"uptimeSec": round(time.time() - SPARDA_START), "stats": SPARDA_STATS, "quarantine": SPARDA_QUARANTINE, "recycle": {**SPARDA_RECYCLE, "ratePct": sparda_recycle_rate()}, "purity": sparda_purity_snapshot()}
92
140
 
93
141
  @sparda_router.get("/events")
94
142
  async def get_events(request: Request):
@@ -131,6 +179,7 @@ async def invoke_tool(request: Request):
131
179
  if quarantined:
132
180
  now_ms = time.time() * 1000
133
181
  if now_ms < quarantined["until"]:
182
+ SPARDA_RECYCLE["servedByCircle"] += 1 # answered from immune memory — the doomed host call was never paid
134
183
  return JSONResponse(status_code=503, content={
135
184
  "error": f"tool quarantined (immune system): {tool}",
136
185
  "reason": quarantined["reason"],
@@ -173,6 +222,7 @@ async def invoke_tool(request: Request):
173
222
  headers["content-type"] = "application/json"
174
223
  body_bytes = json.dumps(args["body"]).encode("utf-8")
175
224
 
225
+ SPARDA_RECYCLE["paidFull"] += 1 # the host route is about to be exercised — full price
176
226
  loop = asyncio.get_running_loop()
177
227
  status, content_type, data_bytes = await loop.run_in_executor(
178
228
  executor, sync_fetch, url, method, headers, body_bytes
@@ -185,6 +235,10 @@ async def invoke_tool(request: Request):
185
235
  data = text_data
186
236
 
187
237
  sparda_record(tool, status, round((time.time() - t0) * 1000))
238
+ if method == "GET" and status == 200:
239
+ # canonical argsig: sorted items, so the AI's argument order never splits a signature
240
+ argsig = json.dumps(sorted((k, str(v)) for k, v in args.items() if v is not None))
241
+ sparda_observe_purity(tool, argsig, data_bytes)
188
242
  if status >= 500:
189
243
  sparda_event("invoke", tool, status, text_data[:200])
190
244
  return {"upstreamStatus": status, "data": data}