sparda-mcp 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -40
- package/SKILL.md +144 -0
- package/demo-app/package.json +7 -0
- package/demo-app/src/app.js +28 -0
- package/demo-app/src/routes/users.js +8 -0
- package/package.json +25 -3
- package/src/commands/demo.js +154 -0
- package/src/commands/doctor.js +51 -17
- package/src/commands/hook.js +21 -5
- package/src/commands/init.js +105 -33
- package/src/commands/remove.js +29 -10
- package/src/commands/report.js +372 -0
- package/src/commands/sync.js +37 -8
- package/src/detect.js +68 -18
- package/src/generator/express.js +86 -28
- package/src/generator/fastapi.js +66 -20
- package/src/generator/manifest.js +20 -13
- package/src/index.js +22 -2
- package/src/parser/express.js +144 -44
- package/src/parser/fastapi.js +13 -4
- package/src/probe/express-shim-esm.mjs +2 -2
- package/src/probe/express-shim.cjs +65 -22
- package/src/probe/integrate.js +34 -12
- package/src/probe/probe.js +59 -33
- package/src/probe/reconcile.js +24 -22
- package/src/security/sanitize.js +5 -1
- package/src/server/condenser.js +34 -12
- package/src/server/confirmation.js +75 -19
- package/src/server/context-carrier.js +36 -31
- package/src/server/crystallize.js +32 -9
- package/src/server/engine.js +118 -38
- package/src/server/idle.js +20 -4
- package/src/server/persistence.js +16 -5
- package/src/server/stdio.js +490 -162
- package/src/ui/style.js +30 -18
- package/templates/fastapi-router.txt +41 -13
|
@@ -71,8 +71,8 @@ function assertNoCRLF(headerName, value) {
|
|
|
71
71
|
new Error(`context value for "${headerName}" has illegal control chars`),
|
|
72
72
|
{
|
|
73
73
|
code: 'USER',
|
|
74
|
-
hint: `${headerName} must be a single header line — no CR, LF, or NUL
|
|
75
|
-
}
|
|
74
|
+
hint: `${headerName} must be a single header line — no CR, LF, or NUL`,
|
|
75
|
+
},
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
}
|
|
@@ -96,10 +96,10 @@ export function validateConfig(config) {
|
|
|
96
96
|
if (typeof config !== 'object' || Array.isArray(config)) {
|
|
97
97
|
return {
|
|
98
98
|
valid: false,
|
|
99
|
-
error: Object.assign(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
99
|
+
error: Object.assign(new Error('contextPropagation must be an object'), {
|
|
100
|
+
code: 'USER',
|
|
101
|
+
hint: 'Remove the key or provide a valid object',
|
|
102
|
+
}),
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
|
|
@@ -107,10 +107,10 @@ export function validateConfig(config) {
|
|
|
107
107
|
if (config.mode !== undefined && config.mode !== 'verbatim') {
|
|
108
108
|
return {
|
|
109
109
|
valid: false,
|
|
110
|
-
error: Object.assign(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
)
|
|
110
|
+
error: Object.assign(new Error('contextPropagation.mode must be "verbatim"'), {
|
|
111
|
+
code: 'USER',
|
|
112
|
+
hint: 'Only "verbatim" is supported — SPARDA never interprets context',
|
|
113
|
+
}),
|
|
114
114
|
};
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -119,9 +119,14 @@ export function validateConfig(config) {
|
|
|
119
119
|
return {
|
|
120
120
|
valid: false,
|
|
121
121
|
error: Object.assign(
|
|
122
|
-
new Error(
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
new Error(
|
|
123
|
+
'contextPropagation.from "mcp.session.metadata" is not supported (§0: caller-supplied context is insecure)',
|
|
124
|
+
),
|
|
125
|
+
{
|
|
126
|
+
code: 'USER',
|
|
127
|
+
hint: 'Remove "from" or set it to "launch". Values come from CLI flag or env vars.',
|
|
128
|
+
},
|
|
129
|
+
),
|
|
125
130
|
};
|
|
126
131
|
}
|
|
127
132
|
|
|
@@ -131,8 +136,8 @@ export function validateConfig(config) {
|
|
|
131
136
|
valid: false,
|
|
132
137
|
error: Object.assign(
|
|
133
138
|
new Error('contextPropagation.from must be "launch" or absent'),
|
|
134
|
-
{ code: 'USER', hint: 'Only operator-pinned launch-time context is supported' }
|
|
135
|
-
)
|
|
139
|
+
{ code: 'USER', hint: 'Only operator-pinned launch-time context is supported' },
|
|
140
|
+
),
|
|
136
141
|
};
|
|
137
142
|
}
|
|
138
143
|
|
|
@@ -141,10 +146,10 @@ export function validateConfig(config) {
|
|
|
141
146
|
if (!Array.isArray(config.headers)) {
|
|
142
147
|
return {
|
|
143
148
|
valid: false,
|
|
144
|
-
error: Object.assign(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
)
|
|
149
|
+
error: Object.assign(new Error('contextPropagation.headers must be an array'), {
|
|
150
|
+
code: 'USER',
|
|
151
|
+
hint: 'Provide an array of header name strings',
|
|
152
|
+
}),
|
|
148
153
|
};
|
|
149
154
|
}
|
|
150
155
|
|
|
@@ -153,8 +158,8 @@ export function validateConfig(config) {
|
|
|
153
158
|
valid: false,
|
|
154
159
|
error: Object.assign(
|
|
155
160
|
new Error(`contextPropagation.headers exceeds max ${MAX_HEADERS} items`),
|
|
156
|
-
{ code: 'USER', hint: `Reduce to ${MAX_HEADERS} headers or fewer` }
|
|
157
|
-
)
|
|
161
|
+
{ code: 'USER', hint: `Reduce to ${MAX_HEADERS} headers or fewer` },
|
|
162
|
+
),
|
|
158
163
|
};
|
|
159
164
|
}
|
|
160
165
|
|
|
@@ -162,10 +167,10 @@ export function validateConfig(config) {
|
|
|
162
167
|
if (typeof h !== 'string' || h.length === 0) {
|
|
163
168
|
return {
|
|
164
169
|
valid: false,
|
|
165
|
-
error: Object.assign(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
)
|
|
170
|
+
error: Object.assign(new Error('Each header must be a non-empty string'), {
|
|
171
|
+
code: 'USER',
|
|
172
|
+
hint: 'Check all items in contextPropagation.headers',
|
|
173
|
+
}),
|
|
169
174
|
};
|
|
170
175
|
}
|
|
171
176
|
}
|
|
@@ -260,7 +265,7 @@ export function resolveContext({ argv = [], env = {}, config = null } = {}) {
|
|
|
260
265
|
if (Buffer.byteLength(value, 'utf8') > MAX_VALUE_BYTES) {
|
|
261
266
|
throw Object.assign(
|
|
262
267
|
new Error(`context value for "${headerName}" exceeds ${MAX_VALUE_BYTES} bytes`),
|
|
263
|
-
{ code: 'USER', hint: 'Shorten the value or split across multiple headers' }
|
|
268
|
+
{ code: 'USER', hint: 'Shorten the value or split across multiple headers' },
|
|
264
269
|
);
|
|
265
270
|
}
|
|
266
271
|
|
|
@@ -270,10 +275,10 @@ export function resolveContext({ argv = [], env = {}, config = null } = {}) {
|
|
|
270
275
|
|
|
271
276
|
// Bounds: total header count (defensive — already bounded by declaredHeaders.length ≤ 8)
|
|
272
277
|
if (Object.keys(resolved).length > MAX_HEADERS) {
|
|
273
|
-
throw Object.assign(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
);
|
|
278
|
+
throw Object.assign(new Error(`context exceeds max ${MAX_HEADERS} headers`), {
|
|
279
|
+
code: 'USER',
|
|
280
|
+
hint: `Declare at most ${MAX_HEADERS} headers in contextPropagation`,
|
|
281
|
+
});
|
|
277
282
|
}
|
|
278
283
|
|
|
279
284
|
return Object.freeze({ headers: Object.freeze(resolved) });
|
|
@@ -313,7 +318,7 @@ export function fingerprintContext(ctx) {
|
|
|
313
318
|
for (const [name, value] of Object.entries(ctx.headers)) {
|
|
314
319
|
fp[name] = {
|
|
315
320
|
present: true,
|
|
316
|
-
hash: createHash('sha256').update(value).digest('hex').slice(0, 8)
|
|
321
|
+
hash: createHash('sha256').update(value).digest('hex').slice(0, 8),
|
|
317
322
|
};
|
|
318
323
|
}
|
|
319
324
|
return fp;
|
|
@@ -9,10 +9,14 @@ import { walkPayload } from './condenser.js';
|
|
|
9
9
|
// crystallizable = every step is an enabled GET and every link knows where to
|
|
10
10
|
// re-feed from. Re-checked at every bridge start: tools change, circuits stay.
|
|
11
11
|
export function eligibleForCrystallization(circuit, toolSpecs) {
|
|
12
|
-
return
|
|
12
|
+
return (
|
|
13
|
+
Array.isArray(circuit.steps) &&
|
|
14
|
+
circuit.steps.length >= 2 &&
|
|
13
15
|
circuit.steps.every((s) => toolSpecs[s]?.enabled && toolSpecs[s].method === 'GET') &&
|
|
14
|
-
Array.isArray(circuit.links) &&
|
|
15
|
-
circuit.links.
|
|
16
|
+
Array.isArray(circuit.links) &&
|
|
17
|
+
circuit.links.length > 0 &&
|
|
18
|
+
circuit.links.every((l) => typeof l.fromKey === 'string' && l.fromKey.length > 0)
|
|
19
|
+
);
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
// graceful degradation (survival rule): no sampling → a deterministic name and
|
|
@@ -30,8 +34,12 @@ export function fallbackComposite(circuit) {
|
|
|
30
34
|
|
|
31
35
|
// a sampled name is untrusted input: normalize hard, reject anything shapeless
|
|
32
36
|
export function normalizeCompositeName(raw) {
|
|
33
|
-
const n = String(raw ?? '')
|
|
34
|
-
.
|
|
37
|
+
const n = String(raw ?? '')
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.trim()
|
|
40
|
+
.replace(/[^a-z0-9_]+/g, '_')
|
|
41
|
+
.replace(/^_+|_+$/g, '')
|
|
42
|
+
.slice(0, 40);
|
|
35
43
|
return /^[a-z][a-z0-9_]{2,}$/.test(n) ? n : null;
|
|
36
44
|
}
|
|
37
45
|
|
|
@@ -43,7 +51,10 @@ export function compositeSchema(circuit, toolSpecs) {
|
|
|
43
51
|
for (const step of circuit.steps) {
|
|
44
52
|
for (const p of toolSpecs[step]?.params ?? []) {
|
|
45
53
|
if (autoFed.has(`${step}:${p.name}`) || properties[p.name]) continue;
|
|
46
|
-
properties[p.name] = {
|
|
54
|
+
properties[p.name] = {
|
|
55
|
+
type: p.type === 'unknown' ? 'string' : p.type,
|
|
56
|
+
description: `${p.description ?? p.in} (for ${step})`,
|
|
57
|
+
};
|
|
47
58
|
if (p.required) required.push(p.name);
|
|
48
59
|
}
|
|
49
60
|
}
|
|
@@ -55,7 +66,10 @@ export function compositeSchema(circuit, toolSpecs) {
|
|
|
55
66
|
export function findByKey(node, key) {
|
|
56
67
|
let found;
|
|
57
68
|
walkPayload(node, (k, v) => {
|
|
58
|
-
if (k === key) {
|
|
69
|
+
if (k === key) {
|
|
70
|
+
found = v;
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
59
73
|
});
|
|
60
74
|
return found;
|
|
61
75
|
}
|
|
@@ -78,8 +92,17 @@ export async function runComposite({ circuit, args, toolSpecs, invokeFn }) {
|
|
|
78
92
|
}
|
|
79
93
|
const payload = await invokeFn(step, stepArgs);
|
|
80
94
|
const status = payload?.upstreamStatus;
|
|
81
|
-
trace.push({
|
|
82
|
-
|
|
95
|
+
trace.push({
|
|
96
|
+
tool: step,
|
|
97
|
+
upstreamStatus: status ?? null,
|
|
98
|
+
...(payload?.error ? { error: payload.error } : {}),
|
|
99
|
+
});
|
|
100
|
+
if (
|
|
101
|
+
!payload ||
|
|
102
|
+
payload.error !== undefined ||
|
|
103
|
+
status === undefined ||
|
|
104
|
+
status >= 400
|
|
105
|
+
) {
|
|
83
106
|
return { ok: false, trace }; // honest failure: stop the chain, show where
|
|
84
107
|
}
|
|
85
108
|
outputs[step] = payload.data;
|
package/src/server/engine.js
CHANGED
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
// protect (rule #5) — same posture as the router's purity detector.
|
|
15
15
|
|
|
16
16
|
// bounded memory, same philosophy as antibodies (ADR-010) and circuits.
|
|
17
|
-
export const ENGINE_LIMITS = {
|
|
17
|
+
export const ENGINE_LIMITS = {
|
|
18
|
+
MAX_TOOLS: 100,
|
|
19
|
+
MAX_FIELDS: 60,
|
|
20
|
+
MAX_TS: 50,
|
|
21
|
+
MAX_AXONS: 200,
|
|
22
|
+
MAX_GHOSTS: 200,
|
|
23
|
+
};
|
|
18
24
|
|
|
19
25
|
// FNV-1a 32-bit: collapses a field's value to a fixed token so change can be
|
|
20
26
|
// detected without retaining the value. Non-crypto on purpose — nothing heavy,
|
|
@@ -29,7 +35,11 @@ function fnv1a(str) {
|
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
function safeStringify(v) {
|
|
32
|
-
try {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify(v) ?? 'null';
|
|
40
|
+
} catch {
|
|
41
|
+
return String(v);
|
|
42
|
+
}
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
// top-level field fingerprints only — bounded, value-free. A non-object body
|
|
@@ -71,25 +81,36 @@ export function createPredictiveEngine() {
|
|
|
71
81
|
const changed = [];
|
|
72
82
|
for (const [k, h] of hashes) {
|
|
73
83
|
const f = rec.fields.get(k);
|
|
74
|
-
if (!f) {
|
|
75
|
-
|
|
84
|
+
if (!f) {
|
|
85
|
+
// a field that wasn't there before is itself a change of shape
|
|
86
|
+
if (rec.fields.size < ENGINE_LIMITS.MAX_FIELDS)
|
|
87
|
+
rec.fields.set(k, { hash: h, seen: 1, changed: 0 });
|
|
76
88
|
changed.push(k);
|
|
77
89
|
continue;
|
|
78
90
|
}
|
|
79
91
|
f.seen += 1;
|
|
80
|
-
if (f.hash !== h) {
|
|
92
|
+
if (f.hash !== h) {
|
|
93
|
+
f.hash = h;
|
|
94
|
+
f.changed += 1;
|
|
95
|
+
changed.push(k);
|
|
96
|
+
}
|
|
81
97
|
}
|
|
82
98
|
// a field that vanished this call also counts as movement
|
|
83
99
|
for (const k of rec.fields.keys()) if (!hashes.has(k)) changed.push(k);
|
|
84
100
|
|
|
85
|
-
if (changed.length === 0) {
|
|
101
|
+
if (changed.length === 0) {
|
|
102
|
+
stats.noChange += 1;
|
|
103
|
+
return { type: 'NO_CHANGE', tool };
|
|
104
|
+
}
|
|
86
105
|
stats.delta += 1;
|
|
87
106
|
return { type: 'DELTA', tool, changedFields: changed };
|
|
88
107
|
}
|
|
89
108
|
|
|
90
109
|
// after a write the underlying resource may have moved — drop the model so a
|
|
91
110
|
// stale "stable" reading can't survive it. (Wired later; kept faithful here.)
|
|
92
|
-
function invalidate(tool) {
|
|
111
|
+
function invalidate(tool) {
|
|
112
|
+
tools.delete(tool);
|
|
113
|
+
}
|
|
93
114
|
|
|
94
115
|
// names + counts only — never a value. A field is "stable" once it has been
|
|
95
116
|
// seen more than once without ever changing; "volatile" the moment it moves;
|
|
@@ -122,7 +143,7 @@ const RHYTHM = { MIN_TS: 5, REGULARITY_THRESHOLD: 0.25 };
|
|
|
122
143
|
// only call timestamps (plain numbers — never a payload, never PII), bounded per
|
|
123
144
|
// tool, runtime-only.
|
|
124
145
|
export function createRhythmDetector() {
|
|
125
|
-
const times = new Map();
|
|
146
|
+
const times = new Map(); // tool -> number[] (ms timestamps, ring)
|
|
126
147
|
const patterns = new Map(); // tool -> { periodMs, confidence, observations, nextPredictedMs }
|
|
127
148
|
const stats = { patternsDetected: 0 };
|
|
128
149
|
|
|
@@ -160,7 +181,10 @@ export function createRhythmDetector() {
|
|
|
160
181
|
}
|
|
161
182
|
}
|
|
162
183
|
|
|
163
|
-
function invalidate(tool) {
|
|
184
|
+
function invalidate(tool) {
|
|
185
|
+
times.delete(tool);
|
|
186
|
+
patterns.delete(tool);
|
|
187
|
+
}
|
|
164
188
|
|
|
165
189
|
// detected patterns only — names + cadence, never a value. nextEstimate is an
|
|
166
190
|
// ISO instant so the reader can see WHEN the next call is expected.
|
|
@@ -195,7 +219,7 @@ const MYELIN = { THRESHOLD: 3, MAX_LAYERS: 10 };
|
|
|
195
219
|
// real latency in the immune system and won't report an invented number.
|
|
196
220
|
export function createMyelinTracker() {
|
|
197
221
|
const axons = new Map(); // "src-->tgt" -> { strength, traversals, lastTs, myelinated }
|
|
198
|
-
let prev = null;
|
|
222
|
+
let prev = null; // the previously observed tool — the chain is the session
|
|
199
223
|
|
|
200
224
|
function observe(tool, ts) {
|
|
201
225
|
if (prev !== null && prev !== tool) reinforce(`${prev}-->${tool}`, ts); // no self-edges
|
|
@@ -220,7 +244,11 @@ export function createMyelinTracker() {
|
|
|
220
244
|
let victimKey = null;
|
|
221
245
|
let victim = null;
|
|
222
246
|
for (const [k, a] of axons) {
|
|
223
|
-
if (
|
|
247
|
+
if (
|
|
248
|
+
!victim ||
|
|
249
|
+
a.strength < victim.strength ||
|
|
250
|
+
(a.strength === victim.strength && a.lastTs < victim.lastTs)
|
|
251
|
+
) {
|
|
224
252
|
victimKey = k;
|
|
225
253
|
victim = a;
|
|
226
254
|
}
|
|
@@ -276,7 +304,7 @@ export function createMyelinTracker() {
|
|
|
276
304
|
const NOETHER = { MIN_OBS: 5, MIN_STABILITY: 0.85 };
|
|
277
305
|
|
|
278
306
|
export function createNoetherScanner() {
|
|
279
|
-
const fields = new Map();
|
|
307
|
+
const fields = new Map(); // tool -> Map<field, { first, total, matches }>
|
|
280
308
|
const writeTools = new Set(); // names of tools observed mutating (bounded)
|
|
281
309
|
|
|
282
310
|
function observe(tool, result, isWrite) {
|
|
@@ -293,7 +321,8 @@ export function createNoetherScanner() {
|
|
|
293
321
|
for (const [k, h] of fieldHashes(result)) {
|
|
294
322
|
const f = rec.get(k);
|
|
295
323
|
if (!f) {
|
|
296
|
-
if (rec.size < ENGINE_LIMITS.MAX_FIELDS)
|
|
324
|
+
if (rec.size < ENGINE_LIMITS.MAX_FIELDS)
|
|
325
|
+
rec.set(k, { first: h, total: 1, matches: 1 });
|
|
297
326
|
continue;
|
|
298
327
|
}
|
|
299
328
|
f.total += 1;
|
|
@@ -301,7 +330,10 @@ export function createNoetherScanner() {
|
|
|
301
330
|
}
|
|
302
331
|
}
|
|
303
332
|
|
|
304
|
-
function invalidate(tool) {
|
|
333
|
+
function invalidate(tool) {
|
|
334
|
+
fields.delete(tool);
|
|
335
|
+
writeTools.delete(tool);
|
|
336
|
+
}
|
|
305
337
|
|
|
306
338
|
// conserved fields only — names + rates, never a value. Surfaced solely once writes
|
|
307
339
|
// have been observed, so each invariant is a genuine "stable despite mutation" claim.
|
|
@@ -313,7 +345,12 @@ export function createNoetherScanner() {
|
|
|
313
345
|
if (f.total < NOETHER.MIN_OBS) continue;
|
|
314
346
|
const rate = f.matches / f.total;
|
|
315
347
|
if (rate >= NOETHER.MIN_STABILITY) {
|
|
316
|
-
invariants.push({
|
|
348
|
+
invariants.push({
|
|
349
|
+
tool,
|
|
350
|
+
field,
|
|
351
|
+
stability: Number(rate.toFixed(3)),
|
|
352
|
+
observations: f.total,
|
|
353
|
+
});
|
|
317
354
|
}
|
|
318
355
|
}
|
|
319
356
|
}
|
|
@@ -332,11 +369,11 @@ export function createNoetherScanner() {
|
|
|
332
369
|
const GHOST = { MIN_OBS: 3, MIN_CORRELATION: 0.7 };
|
|
333
370
|
|
|
334
371
|
export function createGravitationalLens() {
|
|
335
|
-
const lastHash = new Map();
|
|
336
|
-
const corr = new Map();
|
|
337
|
-
let pendingWrite = null;
|
|
338
|
-
let snap = null;
|
|
339
|
-
let scored = new Set();
|
|
372
|
+
const lastHash = new Map(); // read tool -> last whole-result fingerprint
|
|
373
|
+
const corr = new Map(); // "W:::G" -> { hits, total }
|
|
374
|
+
let pendingWrite = null; // the write whose effect we are still attributing
|
|
375
|
+
let snap = null; // fingerprint of every read at the instant W fired
|
|
376
|
+
let scored = new Set(); // "W:::G" already counted this write episode
|
|
340
377
|
|
|
341
378
|
function observe(tool, result, isWrite) {
|
|
342
379
|
if (isWrite) {
|
|
@@ -348,7 +385,8 @@ export function createGravitationalLens() {
|
|
|
348
385
|
const h = fnv1a(safeStringify(result));
|
|
349
386
|
if (pendingWrite !== null && snap.has(tool)) {
|
|
350
387
|
const key = `${pendingWrite}:::${tool}`;
|
|
351
|
-
if (!scored.has(key)) {
|
|
388
|
+
if (!scored.has(key)) {
|
|
389
|
+
// one vote per write episode — never inflate a repeated read
|
|
352
390
|
scored.add(key);
|
|
353
391
|
let c = corr.get(key);
|
|
354
392
|
if (!c) {
|
|
@@ -360,7 +398,8 @@ export function createGravitationalLens() {
|
|
|
360
398
|
if (snap.get(tool) !== h) c.hits += 1; // the write moved this read
|
|
361
399
|
}
|
|
362
400
|
}
|
|
363
|
-
if (lastHash.size < ENGINE_LIMITS.MAX_TOOLS || lastHash.has(tool))
|
|
401
|
+
if (lastHash.size < ENGINE_LIMITS.MAX_TOOLS || lastHash.has(tool))
|
|
402
|
+
lastHash.set(tool, h);
|
|
364
403
|
}
|
|
365
404
|
|
|
366
405
|
// least-observed first; among equals, the least-correlated — same "weakest goes"
|
|
@@ -369,7 +408,11 @@ export function createGravitationalLens() {
|
|
|
369
408
|
let victimKey = null;
|
|
370
409
|
let victim = null;
|
|
371
410
|
for (const [k, c] of corr) {
|
|
372
|
-
if (
|
|
411
|
+
if (
|
|
412
|
+
!victim ||
|
|
413
|
+
c.total < victim.total ||
|
|
414
|
+
(c.total === victim.total && c.hits < victim.hits)
|
|
415
|
+
) {
|
|
373
416
|
victimKey = k;
|
|
374
417
|
victim = c;
|
|
375
418
|
}
|
|
@@ -379,7 +422,10 @@ export function createGravitationalLens() {
|
|
|
379
422
|
|
|
380
423
|
function invalidate(tool) {
|
|
381
424
|
lastHash.delete(tool);
|
|
382
|
-
if (pendingWrite === tool) {
|
|
425
|
+
if (pendingWrite === tool) {
|
|
426
|
+
pendingWrite = null;
|
|
427
|
+
snap = null;
|
|
428
|
+
}
|
|
383
429
|
for (const k of [...corr.keys()]) {
|
|
384
430
|
const [w, g] = k.split(':::');
|
|
385
431
|
if (w === tool || g === tool) corr.delete(k);
|
|
@@ -394,7 +440,12 @@ export function createGravitationalLens() {
|
|
|
394
440
|
const rate = c.hits / c.total;
|
|
395
441
|
if (rate >= GHOST.MIN_CORRELATION) {
|
|
396
442
|
const [writeTool, affects] = k.split(':::');
|
|
397
|
-
ghosts.push({
|
|
443
|
+
ghosts.push({
|
|
444
|
+
writeTool,
|
|
445
|
+
affects,
|
|
446
|
+
correlation: Number(rate.toFixed(3)),
|
|
447
|
+
observations: c.total,
|
|
448
|
+
});
|
|
398
449
|
}
|
|
399
450
|
}
|
|
400
451
|
return { ghosts };
|
|
@@ -413,7 +464,10 @@ export function createDependencyMap() {
|
|
|
413
464
|
noether.observe(tool, result, isWrite);
|
|
414
465
|
lens.observe(tool, result, isWrite);
|
|
415
466
|
},
|
|
416
|
-
invalidate(tool) {
|
|
467
|
+
invalidate(tool) {
|
|
468
|
+
noether.invalidate(tool);
|
|
469
|
+
lens.invalidate(tool);
|
|
470
|
+
},
|
|
417
471
|
snapshot() {
|
|
418
472
|
const n = noether.snapshot();
|
|
419
473
|
const l = lens.snapshot();
|
|
@@ -460,7 +514,9 @@ function canonicalize(v) {
|
|
|
460
514
|
}
|
|
461
515
|
return v;
|
|
462
516
|
}
|
|
463
|
-
function canonicalArgSig(args) {
|
|
517
|
+
function canonicalArgSig(args) {
|
|
518
|
+
return safeStringify(canonicalize(args));
|
|
519
|
+
}
|
|
464
520
|
|
|
465
521
|
export function createFlywheel() {
|
|
466
522
|
// entryKey "tool::<fnv1a(argsig)>" -> { tool, hash, hits, value, ts }. The key
|
|
@@ -468,7 +524,9 @@ export function createFlywheel() {
|
|
|
468
524
|
const cache = new Map();
|
|
469
525
|
const stats = { served: 0, misses: 0, evictions: 0 };
|
|
470
526
|
|
|
471
|
-
function keyFor(tool, args) {
|
|
527
|
+
function keyFor(tool, args) {
|
|
528
|
+
return `${tool}::${fnv1a(canonicalArgSig(args))}`;
|
|
529
|
+
}
|
|
472
530
|
|
|
473
531
|
// hot-path surface (wired in 5b): return a cached value ONLY when proven pure and
|
|
474
532
|
// fresh. now is injectable so freshness is deterministic in tests.
|
|
@@ -494,18 +552,29 @@ export function createFlywheel() {
|
|
|
494
552
|
cache.set(k, { tool, hash: h, hits: 1, value: result, ts });
|
|
495
553
|
return;
|
|
496
554
|
}
|
|
497
|
-
if (e.hash === h)
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
555
|
+
if (e.hash === h)
|
|
556
|
+
e.hits += 1; // another identical sighting — closer to / past the bar
|
|
557
|
+
else {
|
|
558
|
+
e.hash = h;
|
|
559
|
+
e.hits = 1;
|
|
560
|
+
} // the answer moved — restart the proof
|
|
561
|
+
e.value = result; // latest bytes always win
|
|
562
|
+
e.ts = ts; // freshness from this real fetch
|
|
501
563
|
}
|
|
502
564
|
|
|
503
565
|
// oldest fetch goes first — same "weakest goes" shape as circuits/axons/ghosts.
|
|
504
566
|
function evictOldest() {
|
|
505
567
|
let victimKey = null;
|
|
506
568
|
let oldest = Infinity;
|
|
507
|
-
for (const [k, e] of cache)
|
|
508
|
-
|
|
569
|
+
for (const [k, e] of cache)
|
|
570
|
+
if (e.ts < oldest) {
|
|
571
|
+
oldest = e.ts;
|
|
572
|
+
victimKey = k;
|
|
573
|
+
}
|
|
574
|
+
if (victimKey !== null) {
|
|
575
|
+
cache.delete(victimKey);
|
|
576
|
+
stats.evictions += 1;
|
|
577
|
+
}
|
|
509
578
|
}
|
|
510
579
|
|
|
511
580
|
// drop every entry for a tool — used by the spine to purge reads a write moved.
|
|
@@ -539,7 +608,9 @@ export function createSpardaEngine() {
|
|
|
539
608
|
// hot-path serve (R4.3): ask the flywheel for a proven-stable answer before the
|
|
540
609
|
// bridge pays the host. Always safe to call — returns {hit:false} until an organ
|
|
541
610
|
// has proof. The bridge gates the *use* of a hit behind SPARDA_FLYWHEEL (5b).
|
|
542
|
-
preCall(tool, args) {
|
|
611
|
+
preCall(tool, args) {
|
|
612
|
+
return flywheel.preCall(tool, args);
|
|
613
|
+
},
|
|
543
614
|
// ts is captured on the hot path (a cheap clock read) and passed in, so the
|
|
544
615
|
// cadence reflects real call times — not whenever the idle harvester drains.
|
|
545
616
|
// isWrite lets Bloc D separate state reads from mutations (a GET can't move the
|
|
@@ -554,18 +625,27 @@ export function createSpardaEngine() {
|
|
|
554
625
|
// ghost map (the slice-4 payoff) instead of nuking the whole cache. Runs in
|
|
555
626
|
// idle, so the bounded ghost scan never touches a request (rule #1).
|
|
556
627
|
flywheel.invalidate(tool);
|
|
557
|
-
for (const g of deps.snapshot().ghosts)
|
|
628
|
+
for (const g of deps.snapshot().ghosts)
|
|
629
|
+
if (g.writeTool === tool) flywheel.invalidate(g.affects);
|
|
558
630
|
} else {
|
|
559
631
|
flywheel.observe(tool, args, result, ts);
|
|
560
632
|
}
|
|
561
633
|
return predictive.observe(tool, result);
|
|
562
634
|
},
|
|
563
|
-
invalidate(tool) {
|
|
635
|
+
invalidate(tool) {
|
|
636
|
+
predictive.invalidate(tool);
|
|
637
|
+
rhythm.invalidate(tool);
|
|
638
|
+
myelin.invalidate(tool);
|
|
639
|
+
deps.invalidate(tool);
|
|
640
|
+
flywheel.invalidate(tool);
|
|
641
|
+
},
|
|
564
642
|
// structural cache purge (5b): only the bridge knows HTTP paths, so when a write
|
|
565
643
|
// hits a path it asks us to drop the cached GET on that SAME path. Flywheel-only —
|
|
566
644
|
// unlike invalidate(), it must NOT erase the sibling read's learned rhythm/myelin/
|
|
567
645
|
// deps, only its now-stale cached answer.
|
|
568
|
-
invalidateCache(tool) {
|
|
646
|
+
invalidateCache(tool) {
|
|
647
|
+
flywheel.invalidate(tool);
|
|
648
|
+
},
|
|
569
649
|
snapshot() {
|
|
570
650
|
return {
|
|
571
651
|
stability: predictive.snapshot(),
|
package/src/server/idle.js
CHANGED
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
// at zero even while the organism digests.
|
|
5
5
|
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
|
6
6
|
|
|
7
|
-
export function createIdleHarvester({
|
|
7
|
+
export function createIdleHarvester({
|
|
8
|
+
tickMs = 250,
|
|
9
|
+
busyLagMs = 25,
|
|
10
|
+
maxWaitMs = 5000,
|
|
11
|
+
maxQueue = 200,
|
|
12
|
+
} = {}) {
|
|
8
13
|
const resolutionMs = 10;
|
|
9
14
|
const histogram = monitorEventLoopDelay({ resolution: resolutionMs });
|
|
10
15
|
histogram.enable();
|
|
@@ -19,7 +24,11 @@ export function createIdleHarvester({ tickMs = 250, busyLagMs = 25, maxWaitMs =
|
|
|
19
24
|
const starving = Date.now() - queue[0].ts > maxWaitMs; // never wait forever on a loaded box
|
|
20
25
|
if (Number.isFinite(lagMs) && lagMs > busyLagMs && !starving) return;
|
|
21
26
|
const job = queue.shift();
|
|
22
|
-
try {
|
|
27
|
+
try {
|
|
28
|
+
job.fn();
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.error(`[sparda] idle job failed (dropped): ${e.message}`);
|
|
31
|
+
}
|
|
23
32
|
}, tickMs);
|
|
24
33
|
timer.unref?.();
|
|
25
34
|
|
|
@@ -33,10 +42,17 @@ export function createIdleHarvester({ tickMs = 250, busyLagMs = 25, maxWaitMs =
|
|
|
33
42
|
flush() {
|
|
34
43
|
while (queue.length) {
|
|
35
44
|
const job = queue.shift();
|
|
36
|
-
try {
|
|
45
|
+
try {
|
|
46
|
+
job.fn();
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.error(`[sparda] idle job failed (dropped): ${e.message}`);
|
|
49
|
+
}
|
|
37
50
|
}
|
|
38
51
|
},
|
|
39
|
-
stop() {
|
|
52
|
+
stop() {
|
|
53
|
+
clearInterval(timer);
|
|
54
|
+
histogram.disable();
|
|
55
|
+
},
|
|
40
56
|
pending: () => queue.length,
|
|
41
57
|
};
|
|
42
58
|
}
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
import fs from 'node:fs';
|
|
22
22
|
import fsp from 'node:fs/promises';
|
|
23
23
|
import path from 'node:path';
|
|
24
|
-
import os from 'node:os';
|
|
25
24
|
import crypto from 'node:crypto';
|
|
26
25
|
|
|
27
26
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -63,7 +62,11 @@ export function atomicWriteFileSync(file, content) {
|
|
|
63
62
|
return;
|
|
64
63
|
} catch (err) {
|
|
65
64
|
if (!TRANSIENT_LOCK_CODES.has(err.code) || attempt >= RENAME_MAX_ATTEMPTS) {
|
|
66
|
-
try {
|
|
65
|
+
try {
|
|
66
|
+
fs.unlinkSync(tmp);
|
|
67
|
+
} catch {
|
|
68
|
+
/* best effort */
|
|
69
|
+
}
|
|
67
70
|
throw err;
|
|
68
71
|
}
|
|
69
72
|
sleepSync(RENAME_BACKOFF_MS * attempt); // 10, 20, 30, 40 ms — clears a brief AV/indexer lock
|
|
@@ -128,7 +131,11 @@ export class LocalFileDriver {
|
|
|
128
131
|
fs.mkdirSync(this.#baseDir, { recursive: true });
|
|
129
132
|
}
|
|
130
133
|
#filePath(instanceId) {
|
|
131
|
-
const hash = crypto
|
|
134
|
+
const hash = crypto
|
|
135
|
+
.createHash('sha256')
|
|
136
|
+
.update(instanceId)
|
|
137
|
+
.digest('hex')
|
|
138
|
+
.slice(0, 16);
|
|
132
139
|
return path.join(this.#baseDir, `sparda_${hash}.json`);
|
|
133
140
|
}
|
|
134
141
|
async save(instanceId, data) {
|
|
@@ -194,7 +201,10 @@ export class RedisDriver {
|
|
|
194
201
|
} catch {
|
|
195
202
|
throw Object.assign(
|
|
196
203
|
new Error('RedisDriver requires the optional "ioredis" package.'),
|
|
197
|
-
{
|
|
204
|
+
{
|
|
205
|
+
code: 'USER',
|
|
206
|
+
hint: 'Run `npm install ioredis`, or use the default file driver.',
|
|
207
|
+
},
|
|
198
208
|
);
|
|
199
209
|
}
|
|
200
210
|
this.#client = new Redis({
|
|
@@ -213,7 +223,8 @@ export class RedisDriver {
|
|
|
213
223
|
try {
|
|
214
224
|
await this.#ready;
|
|
215
225
|
const payload = JSON.stringify(data);
|
|
216
|
-
if (this.#ttl > 0)
|
|
226
|
+
if (this.#ttl > 0)
|
|
227
|
+
await this.#client.setex(this.#key(instanceId), this.#ttl, payload);
|
|
217
228
|
else await this.#client.set(this.#key(instanceId), payload);
|
|
218
229
|
return true;
|
|
219
230
|
} catch {
|