klypix-mcp 1.40.2 → 1.42.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.
@@ -57,6 +57,15 @@ fs.writeFileSync(path.join(vault, 'brain.klypix'), await buildKlypixMap({
57
57
  const baseEnv = Object.fromEntries(
58
58
  Object.entries(process.env).filter(([, value]) => typeof value === 'string'),
59
59
  );
60
+ // The harness simulates two INDEPENDENT hosts. When it itself runs inside an
61
+ // agent session (Claude Code exports CLAUDE_PID + CLAUDE_CODE_SESSION_ID to
62
+ // children), both simulated clients would inherit the SAME host identity and
63
+ // be correctly treated as one session's twin halves — failing every two-client
64
+ // check. Strip host/session identity; each client gets its own KLYPIX_SESSION_ID.
65
+ for (const key of [
66
+ 'CLAUDE_PID', 'KLYPIX_HOST_PID', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_SESSION_ID',
67
+ 'CODEX_THREAD_ID', 'CURSOR_SESSION_ID', 'CLINE_SESSION_ID', 'WINDSURF_SESSION_ID',
68
+ ]) delete baseEnv[key];
60
69
 
61
70
  async function connect(name, sessionId) {
62
71
  const logs = [];
@@ -33,6 +33,10 @@ import {
33
33
  import { mcpServerEntry } from '../src/agent-rules.mjs';
34
34
  import { createMcpPresence, KLYPIX_MCP_INSTRUCTIONS } from '../src/mcp-presence.mjs';
35
35
  import { spawnAutoUpdateHelper } from '../src/mcp-auto-update.mjs';
36
+ // Namespace import (already in-process via the klypix-core chain, so zero added
37
+ // load cost) so a bundle whose klypix-format predates classifyDecay degrades
38
+ // gracefully — a named import of a missing export would kill the whole server.
39
+ import * as brainFormat from '../src/klypix-format.mjs';
36
40
 
37
41
  // Real package version for the MCP handshake (was hardcoded '1.0.0', which
38
42
  // misled every client/version diagnosis — it could never reflect the true release).
@@ -118,7 +122,21 @@ const server = new McpServer(
118
122
  capabilities: { logging: {} },
119
123
  },
120
124
  );
121
- const mcpPresence = createMcpPresence({ server, initialVault: VAULT });
125
+ // Decay-aware LAST-KNOWN stamps for every MCP delivery surface (2026-07-28
126
+ // post-mortem, class B): the classifier lives ONCE in klypix-format.mjs and is
127
+ // INJECTED here so mcp-presence/agent-presence stay builtin-only. The typeof
128
+ // guards let a bundle predating the feature degrade to unstamped delivery —
129
+ // no crash, no stamp, never a throw.
130
+ const mcpPresence = createMcpPresence({
131
+ server,
132
+ initialVault: VAULT,
133
+ decay: typeof brainFormat.classifyDecay === 'function' ? {
134
+ classifyDecay: brainFormat.classifyDecay,
135
+ decayStaleMs: brainFormat.DECAY_STALE_MS,
136
+ decayMessageStamp: typeof brainFormat.decayMessageStamp === 'function' ? brainFormat.decayMessageStamp : undefined,
137
+ formatDecayAge: typeof brainFormat.formatDecayAge === 'function' ? brainFormat.formatDecayAge : undefined,
138
+ } : {},
139
+ });
122
140
 
123
141
  // Map a protocol-neutral core result → an MCP tool result.
124
142
  const toContent = (r) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.40.2",
4
- "description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Codex, Cursor, Cline, any model.",
3
+ "version": "1.42.0",
4
+ "description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Codex, Cursor, Cline, any model.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "keywords": [
@@ -58,7 +58,7 @@
58
58
  "node": ">=18"
59
59
  },
60
60
  "scripts": {
61
- "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs"
61
+ "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/presence-visibility.mjs"
62
62
  },
63
63
  "dependencies": {
64
64
  "@modelcontextprotocol/ext-apps": "^1.7.4",
@@ -113,6 +113,29 @@ function pruneMessages(messages, now) {
113
113
  .filter((message) => message?.id && now - Number(message.ts || 0) < MESSAGE_FRESH_MS);
114
114
  }
115
115
 
116
+ // Cap the message lane WITHOUT silently destroying undelivered notes: the old
117
+ // flat `.slice(-30)` evicted the OLDEST rows first regardless of delivery, so a
118
+ // burst of >30 messages could destroy a note nobody had seen yet. Evict
119
+ // delivered (seen-by-someone) messages first, oldest first; only then the
120
+ // oldest undelivered ones. Order of survivors is preserved.
121
+ export function capMessages(messages, cap = 30) {
122
+ const list = Array.isArray(messages) ? messages : [];
123
+ if (list.length <= cap) return list;
124
+ let excess = list.length - cap;
125
+ const dropped = new Set();
126
+ const evict = (predicate) => {
127
+ for (const message of list) {
128
+ if (!excess) return;
129
+ if (dropped.has(message) || !predicate(message)) continue;
130
+ dropped.add(message);
131
+ excess--;
132
+ }
133
+ };
134
+ evict((m) => Array.isArray(m?.seen) && m.seen.length > 0); // delivered at least once
135
+ evict(() => true); // still over cap → oldest of the rest
136
+ return list.filter((message) => !dropped.has(message));
137
+ }
138
+
116
139
  function normalizeFiles(files) {
117
140
  const seen = new Set();
118
141
  const out = [];
@@ -140,11 +163,13 @@ export function upsertSession({
140
163
  permissionMode = null,
141
164
  branch = null,
142
165
  intent,
166
+ intentSource = null,
143
167
  files,
144
168
  replaceFiles = false,
145
169
  event = null,
146
170
  channel = null,
147
171
  cwd = null,
172
+ hostPid = null,
148
173
  home,
149
174
  now = Date.now(),
150
175
  }) {
@@ -162,6 +187,14 @@ export function upsertSession({
162
187
  const mergedFiles = files === undefined
163
188
  ? normalizeFiles(previous.files)
164
189
  : normalizeFiles(replaceFiles ? files : [...(previous.files || []), ...(files || [])]);
190
+ // Intent freshness is its OWN timestamp: lastSeen is refreshed by heartbeats
191
+ // that never touch intent, so without intentAt a 100-minute-old intent renders
192
+ // under "active just now" (2026-07-29 audit). Stamped only when the intent
193
+ // VALUE actually changes; additive — old rows/readers are unaffected.
194
+ const nextIntent = intent !== undefined
195
+ ? String(intent || '').replace(/\s+/g, ' ').trim().slice(0, 160)
196
+ : (previous.intent || '');
197
+ const intentChanged = intent !== undefined && nextIntent !== (previous.intent || '');
165
198
  const next = {
166
199
  ...previous,
167
200
  id: String(id),
@@ -172,13 +205,19 @@ export function upsertSession({
172
205
  model: model ?? previous.model ?? null,
173
206
  permissionMode: permissionMode ?? previous.permissionMode ?? null,
174
207
  branch: branch ?? previous.branch ?? null,
175
- intent: intent !== undefined ? String(intent || '').replace(/\s+/g, ' ').trim().slice(0, 160) : (previous.intent || ''),
208
+ intent: nextIntent,
209
+ ...(intentChanged ? { intentAt: now, intentSource: intentSource || 'declared' }
210
+ : (previous.intentAt ? { intentAt: previous.intentAt, intentSource: previous.intentSource || null } : {})),
176
211
  files: mergedFiles,
177
212
  event: event ?? previous.event ?? null,
178
213
  ...(Object.keys(channelSeen).length
179
214
  ? { channels: Object.keys(channelSeen), channelSeen }
180
215
  : {}),
181
216
  cwd: cwd ? path.resolve(cwd) : (previous.cwd || path.dirname(brainPath)),
217
+ // Host-process correlation (e.g. Claude Code exports CLAUDE_PID to every
218
+ // child): lets readers recognize one logical session's lifecycle row and
219
+ // MCP row as TWINS instead of two independent peers.
220
+ hostPid: Number(hostPid) || previous.hostPid || null,
182
221
  startedAt: previous.startedAt || now,
183
222
  lastSeen: now,
184
223
  };
@@ -188,7 +227,7 @@ export function upsertSession({
188
227
  fs.writeFileSync(laneFile, JSON.stringify({
189
228
  ...data,
190
229
  sessions: kept.slice(-40),
191
- messages: pruneMessages(data.messages, now).slice(-30),
230
+ messages: capMessages(pruneMessages(data.messages, now), 30),
192
231
  }));
193
232
  return kept.sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0));
194
233
  } finally {
@@ -226,7 +265,7 @@ export function removeSession({ brainPath, id, channel = null, home, now = Date.
226
265
  fs.writeFileSync(laneFile, JSON.stringify({
227
266
  ...data,
228
267
  sessions,
229
- messages: pruneMessages(data.messages, now).slice(-30),
268
+ messages: capMessages(pruneMessages(data.messages, now), 30),
230
269
  }));
231
270
  return sessions;
232
271
  } finally {
@@ -271,24 +310,45 @@ export function receiveMessages({
271
310
  && messageTargetsSession(message, me, sessionId));
272
311
  if (!unseen.length) return [];
273
312
 
274
- const unseenIds = new Set(unseen.map((message) => message.id));
275
- for (const message of messages) {
276
- if (!unseenIds.has(message.id)) continue;
277
- if (!Array.isArray(message.seen)) message.seen = [];
278
- if (!message.seen.includes(sessionId)) message.seen.push(sessionId);
279
- }
280
- fs.writeFileSync(laneFile, JSON.stringify({ ...data, sessions, messages }));
281
-
313
+ // Deliver-then-ack, never ack-then-truncate: the old path acked EVERY unseen
314
+ // message under lock but returned only the first 6 — anything past the cap
315
+ // was marked seen without ever being shown, permanently lost under the
316
+ // delivered-once contract (2026-07-29 audit). Ack exactly: the delivered 6,
317
+ // plus self-ignored texts and text-duplicates OF a delivered message (their
318
+ // content was shown once via the twin). Overflow stays unacked and arrives
319
+ // on the next call.
320
+ const normText = (message) => String(message.text || '').replace(/\s+/g, ' ').trim().toLowerCase();
282
321
  const ignored = new Set(ignoreTexts.map((text) => String(text || '').replace(/\s+/g, ' ').trim().toLowerCase()));
283
322
  const shown = [];
323
+ const dropped = []; // ignored (own sends) — content intentionally never shown
284
324
  const seenText = new Set();
325
+ const dupesByText = new Map();
285
326
  for (const message of unseen) {
286
- const key = String(message.text || '').replace(/\s+/g, ' ').trim().toLowerCase();
287
- if (!key || ignored.has(key) || seenText.has(key)) continue;
327
+ const key = normText(message);
328
+ if (!key || ignored.has(key)) { dropped.push(message); continue; }
329
+ if (seenText.has(key)) {
330
+ if (!dupesByText.has(key)) dupesByText.set(key, []);
331
+ dupesByText.get(key).push(message);
332
+ continue;
333
+ }
288
334
  seenText.add(key);
289
335
  shown.push(message);
290
336
  }
291
- return shown.slice(0, 6);
337
+ const delivered = shown.slice(0, 6);
338
+ const ackIds = new Set(delivered.map((message) => message.id));
339
+ for (const message of dropped) ackIds.add(message.id);
340
+ for (const message of delivered) {
341
+ for (const dupe of dupesByText.get(normText(message)) || []) ackIds.add(dupe.id);
342
+ }
343
+ if (ackIds.size) {
344
+ for (const message of messages) {
345
+ if (!ackIds.has(message.id)) continue;
346
+ if (!Array.isArray(message.seen)) message.seen = [];
347
+ if (!message.seen.includes(sessionId)) message.seen.push(sessionId);
348
+ }
349
+ fs.writeFileSync(laneFile, JSON.stringify({ ...data, sessions, messages }));
350
+ }
351
+ return delivered;
292
352
  } finally {
293
353
  if (gotLock) releaseLock(lockFile);
294
354
  }
@@ -368,7 +428,7 @@ export function postPresenceMessage({
368
428
  fs.writeFileSync(laneFile, JSON.stringify({
369
429
  ...data,
370
430
  sessions,
371
- messages: messages.slice(-30),
431
+ messages: capMessages(messages, 30),
372
432
  }));
373
433
  return { posted: true, message };
374
434
  } finally {
@@ -405,24 +465,81 @@ export function formatPresenceMessage(sessions, selfId, { includeSolo = false, n
405
465
  lines.push('Other active sessions:');
406
466
  for (const session of others.slice(0, 8)) {
407
467
  const ageMin = Math.max(0, Math.round((now - Number(session.lastSeen || now)) / 60_000));
468
+ // A heartbeat refreshes lastSeen while carrying an old intent forward — show
469
+ // the INTENT's own age when it meaningfully lags the heartbeat, so a
470
+ // 100-minute-old task line can never read as "what they're doing right now".
471
+ const intentAgeMin = session.intentAt ? Math.max(0, Math.round((now - Number(session.intentAt)) / 60_000)) : null;
472
+ const intentAge = intentAgeMin !== null && intentAgeMin - ageMin > 3 ? ` (intent set ${intentAgeMin}m ago)` : '';
408
473
  const details = [
409
474
  clientLabel(session),
410
475
  session.branch ? `branch ${session.branch}` : null,
411
- session.intent ? `"${String(session.intent).slice(0, 90)}"` : null,
476
+ session.intent ? `"${String(session.intent).slice(0, 90)}"${intentAge}` : null,
412
477
  `${ageMin}m ago`,
413
478
  ].filter(Boolean);
414
479
  lines.push(`- ${String(session.id).slice(0, 8)}: ${details.join(' | ')}`);
415
480
  }
416
- lines.push('Coordinate before touching shared files; use brain_message for a targeted note.');
481
+ // v1.32.0 law: a truncated list must never render as a complete one.
482
+ if (others.length > 8) lines.push(`- …and ${others.length - 8} more live session(s) not listed — brain_doctor shows all.`);
483
+ lines.push(`Presence is point-in-time (as of ${new Date(now).toISOString().slice(11, 16)}Z) — re-check before coordinating; use brain_message for a targeted note.`);
417
484
  return lines.join('\n');
418
485
  }
419
486
 
420
- export function formatReceivedMessages(messages, now = Date.now()) {
487
+ // ── Decay-aware LAST-KNOWN stamps (2026-07-28 post-mortem, class B) ──────────
488
+ // A delivered inter-session message is MEMORY, not a SENSOR: one older than 6h
489
+ // whose text asserts fast-decay build/deploy status ("no TestFlight upload
490
+ // triggered yet") must never read as CURRENT state — the ENGINE stamps it,
491
+ // never the reading model. The classifier lives ONCE in klypix-format.mjs;
492
+ // this file stays builtin-only, so consumers INJECT it (the optional third
493
+ // `decay` argument). With no injection the output is byte-identical to the
494
+ // unstamped form — an old bundle degrades to no stamp, never a throw. Each
495
+ // stamp is its OWN line appended AFTER the 400-char render slice (the v1.32.0
496
+ // law: a warning is never subject to the budget/cut it warns about) and exists
497
+ // in render output only — the lane file is never mutated.
498
+ const MSG_DECAY_STAMP_MS = 6 * 60 * 60 * 1000;
499
+ // classifyDecay is precision-first; mirror that here — only an explicit `true`
500
+ // or an explicitly fast-shaped object stamps. Anything ambiguous does NOT
501
+ // (a false stamp erodes trust in every stamp).
502
+ const isFastDecayResult = (r) => r === true || r === 'fast'
503
+ || (!!r && typeof r === 'object' && (r.fast === true || r.fastDecay === true || r.decay === 'fast' || r.class === 'fast' || r.kind === 'fast'));
504
+ const decayAgeLabel = (ms) => {
505
+ const h = Math.floor(Math.max(0, ms) / 3_600_000);
506
+ return h >= 48 ? `${Math.floor(h / 24)}d` : `${Math.max(1, h)}h`;
507
+ };
508
+
509
+ // Decay verdict for ONE delivered message: null, or { age, stampText } when it
510
+ // is stale (ts older than the engine threshold) AND its RAW text classifies
511
+ // fast-decay (raw, not the render slice — a claim cut out of the 400 chars
512
+ // must still stamp). Threshold, wording, and age format come from the injected
513
+ // engine surface ({ classifyDecay, decayStaleMs, decayMessageStamp,
514
+ // formatDecayAge }) so the renderers can never drift apart; the local
515
+ // fallbacks mirror klypix-format verbatim for a bundle old enough to carry the
516
+ // classifier but not the helpers.
517
+ export function messageDecayInfo(message, now = Date.now(), decay = {}) {
518
+ const { classifyDecay, decayStaleMs, decayMessageStamp, formatDecayAge } = decay || {};
519
+ if (typeof classifyDecay !== 'function') return null;
520
+ try {
521
+ const ts = Number(message?.ts) || 0;
522
+ const staleMs = Number(decayStaleMs) > 0 ? Number(decayStaleMs) : MSG_DECAY_STAMP_MS;
523
+ if (!ts || now - ts < staleMs) return null;
524
+ if (!isFastDecayResult(classifyDecay(String(message?.text || '')))) return null;
525
+ const ageMs = now - ts;
526
+ return {
527
+ age: typeof formatDecayAge === 'function' ? String(formatDecayAge(ageMs)) : decayAgeLabel(ageMs),
528
+ stampText: typeof decayMessageStamp === 'function'
529
+ ? String(decayMessageStamp(ageMs))
530
+ : `⏱️ This message is ${decayAgeLabel(ageMs)} old and contains build/deploy status — treat as LAST KNOWN, verify live before reporting it.`,
531
+ };
532
+ } catch { return null; } // stamping is best-effort — a classifier bug must never break delivery
533
+ }
534
+
535
+ export function formatReceivedMessages(messages, now = Date.now(), decay = {}) {
421
536
  if (!Array.isArray(messages) || !messages.length) return '';
422
537
  const lines = ['KLYPIX message(s) from another active session:'];
423
538
  for (const message of messages) {
424
539
  const ageMin = Math.max(0, Math.round((now - Number(message.ts || now)) / 60_000));
425
540
  lines.push(`- from ${String(message.from || '?').slice(0, 12)} (${ageMin}m ago): ${String(message.text || '').replace(/\s+/g, ' ').trim().slice(0, 400)}`);
541
+ const info = messageDecayInfo(message, now, decay);
542
+ if (info) lines.push(` ${info.stampText}`);
426
543
  }
427
544
  return lines.join('\n');
428
545
  }
@@ -521,8 +521,15 @@ function classifyFenced(file, version, frontmatter = null) {
521
521
  const raw = fs.readFileSync(file, 'utf8');
522
522
  const fence = parseFence(raw);
523
523
  if (!fence) return { status: 'missing' };
524
- if (fence.hash && sha8(fence.body.trim()) !== fence.hash) return { status: 'hand-edited', stampedVersion: fence.version };
525
- const bodyCurrent = sha8(fence.body.trim()) === INSTRUCTIONS_HASH;
524
+ // Normalize CRLF before hashing (matching norm() in the frontmatter branch
525
+ // below): the stamped hash is always computed from the LF-only canonical
526
+ // instructions, so a git-autocrlf checkout or CRLF-saving editor flipped
527
+ // EVERY projected file to hand-edited at once — the destructive-repair class
528
+ // — when the true status was a losslessly healable 'stale' (2026-07-29 audit,
529
+ // the likely cause of "9/14 HAND-EDITED at the same stamp").
530
+ const bodyHash = sha8(fence.body.replace(/\r\n/g, '\n').trim());
531
+ if (fence.hash && bodyHash !== fence.hash) return { status: 'hand-edited', stampedVersion: fence.version };
532
+ const bodyCurrent = bodyHash === INSTRUCTIONS_HASH;
526
533
  const stampConsistent = !fence.hash || fence.hash === INSTRUCTIONS_HASH;
527
534
  if (bodyCurrent && stampConsistent) {
528
535
  // Owned dedicated files are OURS wholesale — frontmatter included. Compare the
@@ -541,11 +548,20 @@ function classifyFenced(file, version, frontmatter = null) {
541
548
 
542
549
  // Shared markdown (AGENTS.md, copilot-instructions.md, GEMINI.md): merge our fenced
543
550
  // block in place, preserving the user's own prose outside the fence.
551
+ // A hand-edited file is about to be rebuilt wholesale — keep the human's version.
552
+ // One rolling .klypix-bak per file (repair stops being an all-or-nothing choice).
553
+ function backupHandEdited(file, status) {
554
+ if (status !== 'hand-edited' || !exists(file)) return;
555
+ try { fs.copyFileSync(file, file + '.klypix-bak'); } catch { /* best-effort */ }
556
+ }
557
+
544
558
  function fenceMerge(file, version) {
545
559
  // Zero-touch IFF the audit says 'ok' (same classifier, so check and write can
546
560
  // never disagree): current+consistent block → byte-identical file, no stamp-only
547
561
  // churn; a hand-edited stamp or stale body falls through and gets rebuilt.
548
- if (classifyFenced(file, version).status === 'ok') return { action: 'unchanged' };
562
+ const status = classifyFenced(file, version).status;
563
+ if (status === 'ok') return { action: 'unchanged' };
564
+ backupHandEdited(file, status);
549
565
  let cur = '';
550
566
  let had = false;
551
567
  if (exists(file)) { cur = fs.readFileSync(file, 'utf8'); had = true; }
@@ -564,7 +580,9 @@ function fenceMerge(file, version) {
564
580
  function writeDedicated(file, frontmatter, version) {
565
581
  // Zero-touch IFF the audit says 'ok' — which for owned dedicated files includes
566
582
  // the frontmatter (a stripped alwaysApply/trigger MUST be repaired, not skipped).
567
- if (classifyFenced(file, version, frontmatter || '').status === 'ok') return { action: 'unchanged' };
583
+ const status = classifyFenced(file, version, frontmatter || '').status;
584
+ if (status === 'ok') return { action: 'unchanged' };
585
+ backupHandEdited(file, status);
568
586
  const had = exists(file);
569
587
  const body = (frontmatter ? frontmatter + '\n' : '') + fencedBlock(version) + '\n';
570
588
  ensureDir(file); fs.writeFileSync(file, body, 'utf8');