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.
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/commands/hook.js +22 -0
- package/src/commands/init.js +12 -11
- package/src/commands/remove.js +2 -0
- package/src/generator/express.js +26 -1
- package/src/generator/fastapi.js +26 -1
- package/src/generator/manifest.js +20 -4
- package/src/parser/express.js +43 -1
- package/src/parser/fastapi_extract.py +132 -32
- package/src/server/condenser.js +145 -0
- package/src/server/crystallize.js +89 -0
- package/src/server/idle.js +42 -0
- package/src/server/stdio.js +248 -8
- package/src/ui/style.js +66 -0
- package/templates/express-router.txt +215 -20
- package/templates/fastapi-router.txt +208 -35
|
@@ -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
|
+
}
|
package/src/server/stdio.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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 = () => {
|
|
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:
|
|
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:
|
|
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 ?? []) {
|