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.
@@ -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
- new Error('contextPropagation must be an object'),
101
- { code: 'USER', hint: 'Remove the key or provide a valid object' }
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
- new Error('contextPropagation.mode must be "verbatim"'),
112
- { code: 'USER', hint: 'Only "verbatim" is supported — SPARDA never interprets context' }
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('contextPropagation.from "mcp.session.metadata" is not supported (§0: caller-supplied context is insecure)'),
123
- { code: 'USER', hint: 'Remove "from" or set it to "launch". Values come from CLI flag or env vars.' }
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
- new Error('contextPropagation.headers must be an array'),
146
- { code: 'USER', hint: 'Provide an array of header name strings' }
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
- new Error('Each header must be a non-empty string'),
167
- { code: 'USER', hint: 'Check all items in contextPropagation.headers' }
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
- new Error(`context exceeds max ${MAX_HEADERS} headers`),
275
- { code: 'USER', hint: `Declare at most ${MAX_HEADERS} headers in contextPropagation` }
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 Array.isArray(circuit.steps) && circuit.steps.length >= 2 &&
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) && circuit.links.length > 0 &&
15
- circuit.links.every((l) => typeof l.fromKey === 'string' && l.fromKey.length > 0);
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 ?? '').toLowerCase().trim()
34
- .replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
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] = { type: p.type === 'unknown' ? 'string' : p.type, description: `${p.description ?? p.in} (for ${step})` };
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) { found = v; return false; }
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({ tool: step, upstreamStatus: status ?? null, ...(payload?.error ? { error: payload.error } : {}) });
82
- if (!payload || payload.error !== undefined || status === undefined || status >= 400) {
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;
@@ -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 = { MAX_TOOLS: 100, MAX_FIELDS: 60, MAX_TS: 50, MAX_AXONS: 200, MAX_GHOSTS: 200 };
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 { return JSON.stringify(v) ?? 'null'; } catch { return String(v); }
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) { // a field that wasn't there before is itself a change of shape
75
- if (rec.fields.size < ENGINE_LIMITS.MAX_FIELDS) rec.fields.set(k, { hash: h, seen: 1, changed: 0 });
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) { f.hash = h; f.changed += 1; changed.push(k); }
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) { stats.noChange += 1; return { type: 'NO_CHANGE', tool }; }
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) { tools.delete(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(); // tool -> number[] (ms timestamps, ring)
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) { times.delete(tool); patterns.delete(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; // the previously observed tool — the chain is the session
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 (!victim || a.strength < victim.strength || (a.strength === victim.strength && a.lastTs < victim.lastTs)) {
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(); // tool -> Map<field, { first, total, matches }>
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) rec.set(k, { first: h, total: 1, matches: 1 });
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) { fields.delete(tool); writeTools.delete(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({ tool, field, stability: Number(rate.toFixed(3)), observations: f.total });
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(); // read tool -> last whole-result fingerprint
336
- const corr = new Map(); // "W:::G" -> { hits, total }
337
- let pendingWrite = null; // the write whose effect we are still attributing
338
- let snap = null; // fingerprint of every read at the instant W fired
339
- let scored = new Set(); // "W:::G" already counted this write episode
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)) { // one vote per write episode — never inflate a repeated read
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)) lastHash.set(tool, h);
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 (!victim || c.total < victim.total || (c.total === victim.total && c.hits < victim.hits)) {
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) { pendingWrite = null; snap = null; }
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({ writeTool, affects, correlation: Number(rate.toFixed(3)), observations: c.total });
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) { noether.invalidate(tool); lens.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) { return safeStringify(canonicalize(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) { return `${tool}::${fnv1a(canonicalArgSig(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) e.hits += 1; // another identical sighting — closer to / past the bar
498
- else { e.hash = h; e.hits = 1; } // the answer movedrestart the proof
499
- e.value = result; // latest bytes always win
500
- e.ts = ts; // freshness from this real fetch
555
+ if (e.hash === h)
556
+ e.hits += 1; // another identical sightingcloser 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) if (e.ts < oldest) { oldest = e.ts; victimKey = k; }
508
- if (victimKey !== null) { cache.delete(victimKey); stats.evictions += 1; }
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) { return flywheel.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) if (g.writeTool === tool) flywheel.invalidate(g.affects);
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) { predictive.invalidate(tool); rhythm.invalidate(tool); myelin.invalidate(tool); deps.invalidate(tool); flywheel.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) { flywheel.invalidate(tool); },
646
+ invalidateCache(tool) {
647
+ flywheel.invalidate(tool);
648
+ },
569
649
  snapshot() {
570
650
  return {
571
651
  stability: predictive.snapshot(),
@@ -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({ tickMs = 250, busyLagMs = 25, maxWaitMs = 5000, maxQueue = 200 } = {}) {
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 { job.fn(); } catch (e) { console.error(`[sparda] idle job failed (dropped): ${e.message}`); }
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 { job.fn(); } catch (e) { console.error(`[sparda] idle job failed (dropped): ${e.message}`); }
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() { clearInterval(timer); histogram.disable(); },
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 { fs.unlinkSync(tmp); } catch { /* best effort */ }
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.createHash('sha256').update(instanceId).digest('hex').slice(0, 16);
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
- { code: 'USER', hint: 'Run `npm install ioredis`, or use the default file driver.' },
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) await this.#client.setex(this.#key(instanceId), this.#ttl, payload);
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 {