sparda-mcp 0.3.0 → 0.5.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.
@@ -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.5.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,16 @@ 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
+ sparding: manifest.sparding ?? {},
233
+ labs: { recordSequences: Boolean(recorder), compositeTools: [...composites.keys()], circuits: manifest.labs?.circuits ?? {} },
234
+ 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
235
  }, null, 2));
125
236
  }
126
237
  if (name === 'sparda_list_disabled_tools') {
@@ -129,6 +240,18 @@ export async function startStdioBridge({ cwd, portOverride }) {
129
240
  : 'No disabled tools.');
130
241
  }
131
242
 
243
+ // composite tools run their whole chain (GET-only by construction — no
244
+ // write confirmation to bypass), auto-feeding linked args between steps
245
+ const comp = composites.get(name);
246
+ if (comp) {
247
+ const result = await runComposite({ circuit: comp.circuit, args, toolSpecs, invokeFn: (tool, a) => invoke(base, key, tool, a) });
248
+ const pretty = JSON.stringify(result, null, 2);
249
+ return {
250
+ content: [{ type: 'text', text: pretty.length > 8000 ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]` : pretty }],
251
+ isError: !result.ok,
252
+ };
253
+ }
254
+
132
255
  const spec = toolSpecs[name];
133
256
  const isWrite = spec && spec.method !== 'GET';
134
257
 
@@ -143,15 +266,45 @@ export async function startStdioBridge({ cwd, portOverride }) {
143
266
  },
144
267
  }).catch(() => null);
145
268
  if (!answer || answer.action !== 'accept' || answer.content?.confirm !== true) {
269
+ const mockProof = {
270
+ version: 'sparding-proof/v0.1',
271
+ risk: 'blocked',
272
+ decision: 'block',
273
+ reasons: ['Write declined by user'],
274
+ };
275
+ recordSparding(manifestPath, manifest, name, mockProof);
146
276
  return { content: [{ type: 'text', text: `Write declined by user: ${spec.method} ${spec.path} was NOT executed.` }], isError: true };
147
277
  }
148
278
  }
149
279
 
150
280
  const payload = await invoke(base, key, name, args);
151
281
  if (payload === null) {
282
+ const mockProof = {
283
+ version: 'sparding-proof/v0.1',
284
+ risk: 'blocked',
285
+ decision: 'block',
286
+ reasons: ['Host app error or unreachable'],
287
+ };
288
+ recordSparding(manifestPath, manifest, name, mockProof);
152
289
  return { content: [{ type: 'text', text: `Host app error. Check that your server is still running on :${port}.` }], isError: true };
153
290
  }
154
291
 
292
+ const proofForRecord = payload.spardingProof ? { ...payload.spardingProof } : { version: 'sparding-proof/v0.1', decision: 'allow', risk: 'low', reasons: [] };
293
+ if (payload.upstreamStatus !== undefined && payload.upstreamStatus >= 400) {
294
+ proofForRecord.isExecutionError = true;
295
+ proofForRecord.reasons = [...(proofForRecord.reasons || []), `upstream error status ${payload.upstreamStatus}`];
296
+ } else if (payload.error) {
297
+ proofForRecord.isExecutionError = true;
298
+ proofForRecord.reasons = [...(proofForRecord.reasons || []), `invocation error: ${payload.error}`];
299
+ }
300
+ recordSparding(manifestPath, manifest, name, proofForRecord);
301
+
302
+ // condenser tap (Labs): only AI-driven calls that succeeded feed the current —
303
+ // internal read-backs and failures are not workflow steps
304
+ if (recorder && payload.upstreamStatus !== undefined && payload.upstreamStatus < 400) {
305
+ recorder.record(name, args, payload.data);
306
+ }
307
+
155
308
  // proof-after-write: re-read the same path so the AI sees the effect of its write
156
309
  let proof = null;
157
310
  if (isWrite && payload.upstreamStatus < 400) {
@@ -172,14 +325,20 @@ export async function startStdioBridge({ cwd, portOverride }) {
172
325
 
173
326
  let pollTimer = null;
174
327
  server.oninitialized = () => {
175
- pollTimer = startEventPolling(server, base, key, manifest, manifestPath);
328
+ // a session resumed on a cached semantic pass skipped the enrichment sampling call
329
+ if (manifest.semantic) { intel.samplingAvoided += 1; intel.tokensAvoidedEst += SEMANTIC_TOKENS; }
330
+ pollTimer = startEventPolling(server, base, key, manifest, manifestPath, intel);
176
331
  runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }).catch((e) => console.error(`[sparda] semantic pass skipped: ${e.message}`));
177
332
  };
178
- server.onclose = () => { if (pollTimer) clearInterval(pollTimer); };
333
+ server.onclose = () => {
334
+ if (pollTimer) clearInterval(pollTimer);
335
+ harvester.flush(); // pending knowledge must reach disk before the lights go out
336
+ harvester.stop();
337
+ };
179
338
 
180
339
  const transport = new StdioServerTransport();
181
340
  await server.connect(transport);
182
- console.error(`[sparda] MCP bridge running. ${enabled().length} tools enabled, ${disabled().length} disabled (write-safety). Host: ${base}`);
341
+ console.error(`[sparda] MCP bridge running. ${enabled().length} tools enabled, ${disabled().length} disabled (write-safety).${recorder ? ' Labs: sequence recording ON.' : ''} Host: ${base}`);
183
342
  }
184
343
 
185
344
  async function invoke(base, key, tool, args) {
@@ -206,7 +365,7 @@ function pickPathArgs(spec, args) {
206
365
  // live error feed: host app errors reach the AI as MCP logging notifications.
207
366
  // immune memory: known failure signatures carry their cached diagnosis (zero tokens);
208
367
  // new signatures wake the client's LLM once, and the antibody is stored in sparda.json.
209
- function startEventPolling(server, base, key, manifest, manifestPath) {
368
+ function startEventPolling(server, base, key, manifest, manifestPath, intel) {
210
369
  let lastSeq = null; // first poll sets the baseline; only NEW errors are reported
211
370
  const timer = setInterval(async () => {
212
371
  try {
@@ -221,6 +380,8 @@ function startEventPolling(server, base, key, manifest, manifestPath) {
221
380
  if (antibody) {
222
381
  antibody.hits = (antibody.hits ?? 0) + 1;
223
382
  antibody.lastSeen = ev.ts;
383
+ intel.samplingAvoided += 1; // this diagnosis would have been a sampling call
384
+ intel.tokensAvoidedEst += DIAGNOSIS_TOKENS;
224
385
  persistImmune(manifestPath, manifest);
225
386
  await server.sendLoggingMessage({ level: 'error', logger: 'sparda', data: { ...ev, diagnosis: antibody.diagnosis } }).catch(() => {});
226
387
  } else {
@@ -254,7 +415,7 @@ async function diagnoseAndRemember({ server, manifest, manifestPath, ev, sig })
254
415
  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
416
  },
256
417
  }],
257
- maxTokens: 120,
418
+ maxTokens: DIAGNOSIS_TOKENS,
258
419
  });
259
420
  const raw = res?.content?.type === 'text' ? res.content.text.trim() : '';
260
421
  const { text: clean, flagged } = sanitizeDescription(raw, '');
@@ -279,6 +440,74 @@ function persistImmune(manifestPath, manifest) {
279
440
  } catch { /* disk briefly unavailable — the antibody stays in memory */ }
280
441
  }
281
442
 
443
+ function recordSparding(manifestPath, manifest, tool, proof) {
444
+ try {
445
+ manifest.sparding ??= { version: 1, policies: {}, events: [], failures: {}, toolFingerprints: {} };
446
+ manifest.sparding.events ??= [];
447
+ manifest.sparding.failures ??= {};
448
+
449
+ const event = {
450
+ ts: new Date().toISOString(),
451
+ tool,
452
+ decision: proof.decision,
453
+ risk: proof.risk,
454
+ reasons: proof.reasons || [],
455
+ };
456
+ manifest.sparding.events.push(event);
457
+ if (manifest.sparding.events.length > 100) {
458
+ manifest.sparding.events.shift();
459
+ }
460
+
461
+ const isBlock = proof.decision === 'block';
462
+ const isError = proof.isExecutionError;
463
+ if (isBlock || isError) {
464
+ const reasonCode = proof.reasons && proof.reasons[0] ? proof.reasons[0].replace(/\s+/g, '_').toLowerCase() : 'unknown_error';
465
+ const sig = `${tool}|${reasonCode}`;
466
+ const prevFail = manifest.sparding.failures[sig] || { count: 0, lesson: '' };
467
+
468
+ let lesson = `Execution failed for tool: ${tool}.`;
469
+ if (reasonCode.includes('quarantined')) {
470
+ lesson = `Tool ${tool} was quarantined due to repeated failures.`;
471
+ } else if (reasonCode.includes('disabled')) {
472
+ lesson = `Tool ${tool} is disabled by write-safety policies.`;
473
+ } else if (reasonCode.includes('missing_path_param') || reasonCode.includes('missing_path')) {
474
+ lesson = `Client omitted a required path parameter on route ${tool}.`;
475
+ } else if (reasonCode.includes('declined')) {
476
+ lesson = `Human user declined confirmation for write tool ${tool}.`;
477
+ } else if (reasonCode.includes('policy_blocks')) {
478
+ lesson = `Security policies blocked the execution of ${tool}.`;
479
+ } else if (reasonCode.includes('upstream')) {
480
+ lesson = `Host application returned an error status code on route ${tool}.`;
481
+ }
482
+
483
+ manifest.sparding.failures[sig] = {
484
+ count: prevFail.count + 1,
485
+ lastSeen: new Date().toISOString(),
486
+ lesson,
487
+ };
488
+ }
489
+ persistSparding(manifestPath, manifest);
490
+ } catch (err) {
491
+ console.error(`[sparda] failed to record sparding proof: ${err.message}`);
492
+ }
493
+ }
494
+
495
+ function persistSparding(manifestPath, manifest) {
496
+ try {
497
+ const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
498
+ onDisk.sparding = manifest.sparding;
499
+ fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
500
+ } catch { /* disk briefly unavailable */ }
501
+ }
502
+
503
+ function persistLabs(manifestPath, manifest) {
504
+ try {
505
+ const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
506
+ onDisk.labs = { ...onDisk.labs, circuits: manifest.labs.circuits };
507
+ fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
508
+ } catch { /* disk briefly unavailable — the circuit stays in memory */ }
509
+ }
510
+
282
511
  // semantic pass: uses the CLIENT's own model via MCP sampling — zero key, zero cost.
283
512
  // Runs once, result cached in sparda.json (and preserved across re-init).
284
513
  async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }) {
@@ -294,7 +523,7 @@ async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs
294
523
  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
524
  },
296
525
  }],
297
- maxTokens: 1500,
526
+ maxTokens: SEMANTIC_TOKENS,
298
527
  });
299
528
 
300
529
  const raw = res?.content?.type === 'text' ? res.content.text : '';
@@ -326,6 +555,17 @@ async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs
326
555
  console.error(`[sparda] semantic pass done: ${Object.keys(descriptions).length} descriptions, ${workflows.length} workflows (cached in sparda.json)`);
327
556
  }
328
557
 
558
+ // MCP annotations: without them clients assume the scariest defaults and show
559
+ // destructive-looking hints on plain reads (E2E P2 finding). HTTP maps 1:1.
560
+ function annotationsFor(method) {
561
+ return {
562
+ readOnlyHint: method === 'GET',
563
+ destructiveHint: method === 'DELETE',
564
+ idempotentHint: method === 'GET' || method === 'PUT' || method === 'DELETE',
565
+ openWorldHint: false, // a local API behind the router, not the open internet
566
+ };
567
+ }
568
+
329
569
  function schemaFor(t) {
330
570
  const properties = {}; const required = [];
331
571
  for (const p of t.params ?? []) {