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 +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 +1 -0
- package/src/generator/fastapi.js +1 -0
- package/src/generator/manifest.js +5 -4
- package/src/parser/express.js +42 -0
- 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 +163 -8
- package/src/ui/style.js +66 -0
- package/templates/express-router.txt +50 -3
- package/templates/fastapi-router.txt +57 -3
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
package/src/commands/hook.js
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
}
|
package/src/commands/remove.js
CHANGED
|
@@ -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/');
|
package/src/generator/express.js
CHANGED
|
@@ -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
|
|
package/src/generator/fastapi.js
CHANGED
|
@@ -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
|
|
6
|
-
// wrote into sparda.json: per-tool `enabled` overrides, the cached
|
|
7
|
-
// enrichment
|
|
8
|
-
//
|
|
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 {
|
package/src/parser/express.js
CHANGED
|
@@ -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
|
+
}
|
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.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
|
-
|
|
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
|
-
|
|
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 = () => {
|
|
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:
|
|
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:
|
|
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 ?? []) {
|
package/src/ui/style.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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}
|