klypix-mcp 1.40.2 → 1.41.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/bin/klypix-worker.mjs +19 -1
- package/package.json +3 -3
- package/src/agent-presence.mjs +51 -1
- package/src/brain-doctor.mjs +82 -1
- package/src/brain-semantic.mjs +14 -3
- package/src/codex-brain-hook.mjs +86 -5
- package/src/global-brain-hook.mjs +71 -20
- package/src/klypix-core.mjs +21 -1
- package/src/klypix-format.mjs +140 -8
- package/src/mcp-presence.mjs +25 -9
package/bin/klypix-worker.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
4
|
-
"description": "Every project gets a brain
|
|
3
|
+
"version": "1.41.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"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@modelcontextprotocol/ext-apps": "^1.7.4",
|
package/src/agent-presence.mjs
CHANGED
|
@@ -417,12 +417,62 @@ export function formatPresenceMessage(sessions, selfId, { includeSolo = false, n
|
|
|
417
417
|
return lines.join('\n');
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
-
|
|
420
|
+
// ── Decay-aware LAST-KNOWN stamps (2026-07-28 post-mortem, class B) ──────────
|
|
421
|
+
// A delivered inter-session message is MEMORY, not a SENSOR: one older than 6h
|
|
422
|
+
// whose text asserts fast-decay build/deploy status ("no TestFlight upload
|
|
423
|
+
// triggered yet") must never read as CURRENT state — the ENGINE stamps it,
|
|
424
|
+
// never the reading model. The classifier lives ONCE in klypix-format.mjs;
|
|
425
|
+
// this file stays builtin-only, so consumers INJECT it (the optional third
|
|
426
|
+
// `decay` argument). With no injection the output is byte-identical to the
|
|
427
|
+
// unstamped form — an old bundle degrades to no stamp, never a throw. Each
|
|
428
|
+
// stamp is its OWN line appended AFTER the 400-char render slice (the v1.32.0
|
|
429
|
+
// law: a warning is never subject to the budget/cut it warns about) and exists
|
|
430
|
+
// in render output only — the lane file is never mutated.
|
|
431
|
+
const MSG_DECAY_STAMP_MS = 6 * 60 * 60 * 1000;
|
|
432
|
+
// classifyDecay is precision-first; mirror that here — only an explicit `true`
|
|
433
|
+
// or an explicitly fast-shaped object stamps. Anything ambiguous does NOT
|
|
434
|
+
// (a false stamp erodes trust in every stamp).
|
|
435
|
+
const isFastDecayResult = (r) => r === true || r === 'fast'
|
|
436
|
+
|| (!!r && typeof r === 'object' && (r.fast === true || r.fastDecay === true || r.decay === 'fast' || r.class === 'fast' || r.kind === 'fast'));
|
|
437
|
+
const decayAgeLabel = (ms) => {
|
|
438
|
+
const h = Math.floor(Math.max(0, ms) / 3_600_000);
|
|
439
|
+
return h >= 48 ? `${Math.floor(h / 24)}d` : `${Math.max(1, h)}h`;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// Decay verdict for ONE delivered message: null, or { age, stampText } when it
|
|
443
|
+
// is stale (ts older than the engine threshold) AND its RAW text classifies
|
|
444
|
+
// fast-decay (raw, not the render slice — a claim cut out of the 400 chars
|
|
445
|
+
// must still stamp). Threshold, wording, and age format come from the injected
|
|
446
|
+
// engine surface ({ classifyDecay, decayStaleMs, decayMessageStamp,
|
|
447
|
+
// formatDecayAge }) so the renderers can never drift apart; the local
|
|
448
|
+
// fallbacks mirror klypix-format verbatim for a bundle old enough to carry the
|
|
449
|
+
// classifier but not the helpers.
|
|
450
|
+
export function messageDecayInfo(message, now = Date.now(), decay = {}) {
|
|
451
|
+
const { classifyDecay, decayStaleMs, decayMessageStamp, formatDecayAge } = decay || {};
|
|
452
|
+
if (typeof classifyDecay !== 'function') return null;
|
|
453
|
+
try {
|
|
454
|
+
const ts = Number(message?.ts) || 0;
|
|
455
|
+
const staleMs = Number(decayStaleMs) > 0 ? Number(decayStaleMs) : MSG_DECAY_STAMP_MS;
|
|
456
|
+
if (!ts || now - ts < staleMs) return null;
|
|
457
|
+
if (!isFastDecayResult(classifyDecay(String(message?.text || '')))) return null;
|
|
458
|
+
const ageMs = now - ts;
|
|
459
|
+
return {
|
|
460
|
+
age: typeof formatDecayAge === 'function' ? String(formatDecayAge(ageMs)) : decayAgeLabel(ageMs),
|
|
461
|
+
stampText: typeof decayMessageStamp === 'function'
|
|
462
|
+
? String(decayMessageStamp(ageMs))
|
|
463
|
+
: `⏱️ This message is ${decayAgeLabel(ageMs)} old and contains build/deploy status — treat as LAST KNOWN, verify live before reporting it.`,
|
|
464
|
+
};
|
|
465
|
+
} catch { return null; } // stamping is best-effort — a classifier bug must never break delivery
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function formatReceivedMessages(messages, now = Date.now(), decay = {}) {
|
|
421
469
|
if (!Array.isArray(messages) || !messages.length) return '';
|
|
422
470
|
const lines = ['KLYPIX message(s) from another active session:'];
|
|
423
471
|
for (const message of messages) {
|
|
424
472
|
const ageMin = Math.max(0, Math.round((now - Number(message.ts || now)) / 60_000));
|
|
425
473
|
lines.push(`- from ${String(message.from || '?').slice(0, 12)} (${ageMin}m ago): ${String(message.text || '').replace(/\s+/g, ' ').trim().slice(0, 400)}`);
|
|
474
|
+
const info = messageDecayInfo(message, now, decay);
|
|
475
|
+
if (info) lines.push(` ${info.stampText}`);
|
|
426
476
|
}
|
|
427
477
|
return lines.join('\n');
|
|
428
478
|
}
|
package/src/brain-doctor.mjs
CHANGED
|
@@ -29,6 +29,16 @@ import { auditProject, codexGlobalInstructionsInstalled, resolveVersion } from '
|
|
|
29
29
|
import { codexPresenceHookStatus } from './codex-hooks.mjs';
|
|
30
30
|
import { inspectAutoUpdate } from './mcp-auto-update.mjs';
|
|
31
31
|
|
|
32
|
+
// klypix-format is the DECAY-GUARD seam (classifyDecay + the status renderer),
|
|
33
|
+
// loaded failure-tolerant: the doctor's doctrine is "an absent seam is a fact
|
|
34
|
+
// to report, not an error" — klypix-format pulls jszip, and a broken/partial
|
|
35
|
+
// install must degrade the layer to 'unknown', never kill the doctor. The
|
|
36
|
+
// sibling './' specifier resolves identically in the package (src/) and the
|
|
37
|
+
// flat deployed (~/.claude/project-brain) layouts; top-level await keeps
|
|
38
|
+
// inspect() synchronous for its existing callers.
|
|
39
|
+
let fmtLib = null;
|
|
40
|
+
try { fmtLib = await import('./klypix-format.mjs'); } catch { fmtLib = null; }
|
|
41
|
+
|
|
32
42
|
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
43
|
|
|
34
44
|
const sha = (s) => crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 16);
|
|
@@ -220,6 +230,49 @@ function inspectPeers(brainDir, brainPath, now) {
|
|
|
220
230
|
return { file, live, count: live.length };
|
|
221
231
|
}
|
|
222
232
|
|
|
233
|
+
// ── DECAY-GUARD layer (fast-decay status protection, 2026-07-28) ─────────────
|
|
234
|
+
// The brain is MEMORY, not a SENSOR: completed-status claims near release nouns
|
|
235
|
+
// ("uploaded", "LIVE", "installed", TestFlight/build N/npm/rollout) decay in
|
|
236
|
+
// hours, and a bundle that renders them as current state reproduces the stale-
|
|
237
|
+
// "what is remaining" incident class. This layer proves the protection is
|
|
238
|
+
// PRESENT, end-to-end: (a) the loaded engine exports classifyDecay; (b) its
|
|
239
|
+
// status renderer actually stamps a synthetic 20h-old stale claim as ⏱️ LAST
|
|
240
|
+
// KNOWN (behavior, not just an export string); (c) the DEPLOYED bundle files
|
|
241
|
+
// carry the feature. A bundle that predates the feature reads as DRIFT —
|
|
242
|
+
// silently lacking protection is exactly the failure mode — while an
|
|
243
|
+
// unloadable engine reads as a reportable fact, never a doctor crash.
|
|
244
|
+
export function inspectDecayGuard(brainDir, lib, now = Date.now()) {
|
|
245
|
+
const libLoaded = !!lib;
|
|
246
|
+
const exported = !!(lib && typeof lib.classifyDecay === 'function');
|
|
247
|
+
let rendererStamps = null; // null = not testable (no lib / no renderer export)
|
|
248
|
+
if (exported && typeof lib.statusContextToMarkdown === 'function') {
|
|
249
|
+
try {
|
|
250
|
+
// Minimal parseKlypix-shaped struct: one 20h-old fast-decay milestone.
|
|
251
|
+
const struct = {
|
|
252
|
+
title: 'decay-probe', format: 'probe', counts: { cards: 1, connections: 0 },
|
|
253
|
+
cards: [{
|
|
254
|
+
id: 'decay-probe-1', type: 'text', title: '', links: [], tags: [],
|
|
255
|
+
text: 'Release: 🏁 build 26 uploaded to TestFlight — rollout LIVE',
|
|
256
|
+
area: 'Release', createdAt: now - 20 * 3_600_000, parentId: null, evidence: null,
|
|
257
|
+
}],
|
|
258
|
+
connections: [],
|
|
259
|
+
};
|
|
260
|
+
const md = String(lib.statusContextToMarkdown(struct, { budgetChars: 4200, now }) || '');
|
|
261
|
+
rendererStamps = /⏱/.test(md) && /LAST KNOWN/i.test(md);
|
|
262
|
+
} catch { rendererStamps = false; } // a throwing renderer is NOT protecting anything
|
|
263
|
+
}
|
|
264
|
+
// Deployed-bundle currency (inspectVersion/inspectTools idiom: read the
|
|
265
|
+
// DEPLOYED file text — the running package can be newer than the machine's
|
|
266
|
+
// bundle). Absent files are a fact (null), never drift on their own.
|
|
267
|
+
const deployedFmt = readText(path.join(brainDir, 'klypix-format.mjs'));
|
|
268
|
+
const deployedHook = readText(path.join(brainDir, 'global-brain-hook.mjs'));
|
|
269
|
+
return {
|
|
270
|
+
libLoaded, exported, rendererStamps,
|
|
271
|
+
deployedFmtCurrent: deployedFmt == null ? null : /export (?:function|const) classifyDecay\b/.test(deployedFmt),
|
|
272
|
+
deployedHookCurrent: deployedHook == null ? null : deployedHook.includes('classifyDecay'),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
223
276
|
/**
|
|
224
277
|
* Inspect this machine's brain (+ a project's harness projection) as one report.
|
|
225
278
|
* @param {{ projectDir?: string, home?: string, now?: number, npmLatest?: string|null }} [opts]
|
|
@@ -246,6 +299,8 @@ export function inspect(opts = {}) {
|
|
|
246
299
|
};
|
|
247
300
|
const tools = inspectTools(brainDir, PKG_ROOT);
|
|
248
301
|
const peers = inspectPeers(brainDir, brainPath, now);
|
|
302
|
+
// opts.fmtLib is a test seam (stub engines); production uses the module lib.
|
|
303
|
+
const decayGuard = inspectDecayGuard(brainDir, opts.fmtLib !== undefined ? opts.fmtLib : fmtLib, now);
|
|
249
304
|
|
|
250
305
|
// Harness drift only counts toward the verdict for a real brain project; auditProject
|
|
251
306
|
// against the BAKED brain version (the deployed truth) when available.
|
|
@@ -275,6 +330,13 @@ export function inspect(opts = {}) {
|
|
|
275
330
|
? 'optional'
|
|
276
331
|
: (codexHooks.executionStatus === 'observed' ? 'ok' : 'warning')),
|
|
277
332
|
harness: hasBrain ? (harness.ok ? 'ok' : 'drift') : 'n/a',
|
|
333
|
+
// DECAY-GUARD drifts when the protection is provably missing: the loaded
|
|
334
|
+
// engine lacks classifyDecay, its renderer left the synthetic stale claim
|
|
335
|
+
// unstamped, or a deployed bundle file predates the feature. An unloadable
|
|
336
|
+
// engine is 'unknown' (a reportable fact, not a verdict flip).
|
|
337
|
+
decayGuard: !decayGuard.libLoaded ? 'unknown'
|
|
338
|
+
: (!decayGuard.exported || decayGuard.rendererStamps === false
|
|
339
|
+
|| decayGuard.deployedFmtCurrent === false || decayGuard.deployedHookCurrent === false) ? 'drift' : 'ok',
|
|
278
340
|
};
|
|
279
341
|
const drifted = Object.values(layers).filter(s => s === 'drift').length;
|
|
280
342
|
const verdict = !version.installed ? 'NOT-INSTALLED' : (drifted ? 'DRIFTED' : 'ALIGNED');
|
|
@@ -289,9 +351,10 @@ export function inspect(opts = {}) {
|
|
|
289
351
|
if (version.supervisorCapable && running.known && !supervisors.active) actions.push('/mcp reconnect once # activate the zero-restart supervisor; compatible future core updates hot-swap automatically');
|
|
290
352
|
if (hooks.missing.length) actions.push(`npx klypix-mcp install # half-wired: hooks not active — ${hooks.missing.join(', ')}`);
|
|
291
353
|
if (hasBrain && !harness.ok) actions.push('npx klypix-mcp link # harness configs drifted — re-project managed blocks');
|
|
354
|
+
if (layers.decayGuard === 'drift') actions.push('npx klypix-mcp install # decay-aware status guard missing/stale — stale build/deploy claims can render as CURRENT state');
|
|
292
355
|
}
|
|
293
356
|
|
|
294
|
-
return { verdict, layers, drifted, version, running, supervisors, autoUpdate, hooks, codexSmart, codexHooks, tools, peers, sessions: peers, harness, npm, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
|
|
357
|
+
return { verdict, layers, drifted, version, running, supervisors, autoUpdate, hooks, codexSmart, codexHooks, tools, peers, sessions: peers, harness, npm, decayGuard, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
|
|
295
358
|
}
|
|
296
359
|
|
|
297
360
|
// One-line drift summary (empty when clean) — for a footer / status line.
|
|
@@ -304,6 +367,7 @@ export function driftLine(r) {
|
|
|
304
367
|
if (r.running && r.running.matchesInstalled === false) bits.push(`live server v${r.running.version}≠installed v${r.version.baked} (/mcp reconnect)`);
|
|
305
368
|
if (r.hooks.missing.length) bits.push(`${r.hooks.missing.length} hook(s) unwired`);
|
|
306
369
|
if (r.project.hasBrain && !r.harness.ok) bits.push(`${r.harness.drift.length} harness file(s) drifted`);
|
|
370
|
+
if (r.layers?.decayGuard === 'drift') bits.push('decay-guard stale (fast-decay status claims unstamped)');
|
|
307
371
|
return bits.length ? `⚠️ brain DRIFTED: ${bits.join(' · ')}` : '';
|
|
308
372
|
}
|
|
309
373
|
|
|
@@ -388,6 +452,23 @@ export function render(r, opts = {}) {
|
|
|
388
452
|
// TOOLS
|
|
389
453
|
L.push(`${ok} ${c.bold}TOOLS${c.rst} ${r.tools.count} MCP verb(s)${r.tools.hash ? ` ${c.dim}[#${r.tools.hash}, ${r.tools.source}]${c.rst}` : ''}${r.tools.count ? `: ${c.dim}${r.tools.names.join(', ')}${c.rst}` : ''}`);
|
|
390
454
|
|
|
455
|
+
// DECAY-GUARD (fast-decay status claims must stamp as LAST KNOWN, not current)
|
|
456
|
+
if (r.decayGuard) {
|
|
457
|
+
const d = r.decayGuard;
|
|
458
|
+
const dmark = r.layers.decayGuard === 'ok' ? ok : warn;
|
|
459
|
+
if (r.layers.decayGuard === 'ok') {
|
|
460
|
+
L.push(`${dmark} ${c.bold}DECAY${c.rst} stale build/deploy claims stamp as ⏱️ LAST KNOWN ${c.dim}(classifyDecay + renderer self-test pass)${c.rst}`);
|
|
461
|
+
} else if (r.layers.decayGuard === 'unknown') {
|
|
462
|
+
L.push(`${dmark} ${c.bold}DECAY${c.rst} ${c.dim}engine not loadable — decay stamping unverified${c.rst}`);
|
|
463
|
+
} else {
|
|
464
|
+
const why = !d.exported ? 'engine predates classifyDecay'
|
|
465
|
+
: d.rendererStamps === false ? 'status renderer left a synthetic 20h-old stale claim UNSTAMPED'
|
|
466
|
+
: d.deployedFmtCurrent === false ? 'deployed klypix-format.mjs predates the decay guard'
|
|
467
|
+
: 'deployed global-brain-hook.mjs predates message stamps';
|
|
468
|
+
L.push(`${dmark} ${c.bold}DECAY${c.rst} ${c.red}${why} — stale status claims can render as CURRENT state${c.rst}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
391
472
|
// SESSIONS: this is an all-session count. A recent-chat row is not a heartbeat.
|
|
392
473
|
if (!r.sessions.count) L.push(`${ok} ${c.bold}SESSIONS${c.rst} 0 active sessions ${c.dim}(saved/recent chats are history, not active)${c.rst}`);
|
|
393
474
|
else {
|
package/src/brain-semantic.mjs
CHANGED
|
@@ -82,13 +82,24 @@ function readCachedVecs(brainPath, cards) {
|
|
|
82
82
|
// the hook keeps its exact lexical behavior). NEVER throws, NEVER embeds cards.
|
|
83
83
|
export async function semanticVecs(brainPath, struct, query, { timeoutMs = 1200 } = {}) {
|
|
84
84
|
try {
|
|
85
|
-
const pipe = await Promise.race([getEmbedder(), new Promise(r => setTimeout(() => r(null), timeoutMs))]);
|
|
86
|
-
if (!pipe) return null;
|
|
87
85
|
const q = String(query || '').toLowerCase().trim();
|
|
88
86
|
if (!q) return null;
|
|
87
|
+
// Deploy-gate, ENFORCED (2026-07-29): read the warm card-vector cache
|
|
88
|
+
// BEFORE any model work. This lane never embeds cards, so with no cached
|
|
89
|
+
// vectors there is nothing to rank against — and importing transformers
|
|
90
|
+
// here would spin onnxruntime worker threads inside a ONE-SHOT hook that
|
|
91
|
+
// process.exit(0)s the moment retrieval returns. On Windows (Node 24)
|
|
92
|
+
// that exit-vs-thread-teardown race aborts the whole hook process
|
|
93
|
+
// (libuv "!(handle->flags & UV_HANDLE_CLOSING)" in async.c), because
|
|
94
|
+
// `import('@huggingface/transformers')` resolves from ANY ambient
|
|
95
|
+
// node_modules (a dev repo's) even where the semantic install is absent.
|
|
96
|
+
// Empty cache → pure-lexical, zero model load, zero threads.
|
|
97
|
+
const vecsMap = readCachedVecs(brainPath, (struct && struct.cards) || []);
|
|
98
|
+
if (!vecsMap.size) return null;
|
|
99
|
+
const pipe = await Promise.race([getEmbedder(), new Promise(r => setTimeout(() => r(null), timeoutMs))]);
|
|
100
|
+
if (!pipe) return null;
|
|
89
101
|
const [qv] = await embedTexts(pipe, [q]);
|
|
90
102
|
if (!qv) return null;
|
|
91
|
-
const vecsMap = readCachedVecs(brainPath, (struct && struct.cards) || []);
|
|
92
103
|
return { qv, vecsMap, dot };
|
|
93
104
|
} catch { return null; }
|
|
94
105
|
}
|
package/src/codex-brain-hook.mjs
CHANGED
|
@@ -15,6 +15,10 @@ import {
|
|
|
15
15
|
} from './agent-presence.mjs';
|
|
16
16
|
import { recordCodexHookExecution } from './codex-hooks.mjs';
|
|
17
17
|
import { opBrainTaskContext } from './klypix-core.mjs';
|
|
18
|
+
// Namespace import (already in-process via the klypix-core chain, so zero added
|
|
19
|
+
// load cost) so a bundle whose klypix-format predates classifyDecay degrades
|
|
20
|
+
// gracefully — a named import of a missing export would kill the whole hook.
|
|
21
|
+
import * as brainFormat from './klypix-format.mjs';
|
|
18
22
|
import { findPresenceConflicts } from './mcp-presence.mjs';
|
|
19
23
|
|
|
20
24
|
function readInput() {
|
|
@@ -87,6 +91,77 @@ function emitSystemMessage(parts) {
|
|
|
87
91
|
process.stdout.write(JSON.stringify({ continue: true, systemMessage }));
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
// ── Decay-aware LAST-KNOWN stamps (2026-07-28 post-mortem, class B) ──────────
|
|
95
|
+
// Codex-lane parity with the Claude hook's messageFooter: a delivered message
|
|
96
|
+
// older than 6h whose text asserts fast-decay build/deploy status must never
|
|
97
|
+
// read as current state — the ENGINE stamps it, never the reading model. The
|
|
98
|
+
// classifier lives ONCE in klypix-format (typeof-guarded: a stale bundle
|
|
99
|
+
// degrades to no stamp, never a throw). Stamps are appended AFTER
|
|
100
|
+
// formatReceivedMessages' 400-char render slice so no cut can eat them (the
|
|
101
|
+
// v1.32.0 law: a warning is never subject to the budget it warns about), and
|
|
102
|
+
// exist in render output only — the lane file, its dedup keys, and its ack
|
|
103
|
+
// semantics are untouched. The shared renderer (agent-presence) stamps
|
|
104
|
+
// internally when handed the classifier; the pass below is belt-and-braces for
|
|
105
|
+
// a mixed bundle whose agent-presence predates that, and its dedup is
|
|
106
|
+
// per-MESSAGE — a message the renderer already stamped is skipped without
|
|
107
|
+
// suppressing stamps the other messages still need.
|
|
108
|
+
const MSG_DECAY_STAMP_MS = 6 * 60 * 60 * 1000;
|
|
109
|
+
// Precision-first consumption (mirrors the Claude hook): only an explicit
|
|
110
|
+
// `true` / explicitly fast-shaped object stamps — ambiguity never does.
|
|
111
|
+
const isFastDecayResult = (r) => r === true || r === 'fast'
|
|
112
|
+
|| (!!r && typeof r === 'object' && (r.fast === true || r.fastDecay === true || r.decay === 'fast' || r.class === 'fast' || r.kind === 'fast'));
|
|
113
|
+
export function stampReceivedMessages(messages, now = Date.now(),
|
|
114
|
+
classifier = (typeof brainFormat.classifyDecay === 'function' ? brainFormat.classifyDecay : null)) {
|
|
115
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
116
|
+
// Thread the full engine surface through — the shared renderer stamps each
|
|
117
|
+
// qualifying message internally (agent-presence stays builtin-only, so the
|
|
118
|
+
// engine functions ride the same injection; guarded for a stale bundle).
|
|
119
|
+
const base = classifier
|
|
120
|
+
? formatReceivedMessages(list, now, {
|
|
121
|
+
classifyDecay: classifier,
|
|
122
|
+
decayStaleMs: brainFormat.DECAY_STALE_MS,
|
|
123
|
+
decayMessageStamp: typeof brainFormat.decayMessageStamp === 'function' ? brainFormat.decayMessageStamp : undefined,
|
|
124
|
+
formatDecayAge: typeof brainFormat.formatDecayAge === 'function' ? brainFormat.formatDecayAge : undefined,
|
|
125
|
+
})
|
|
126
|
+
: formatReceivedMessages(list, now);
|
|
127
|
+
if (!base || !classifier) return base;
|
|
128
|
+
const staleMs = Number(brainFormat.DECAY_STALE_MS) > 0 ? Number(brainFormat.DECAY_STALE_MS) : MSG_DECAY_STAMP_MS; // threshold single-sourced in the engine
|
|
129
|
+
const stamps = list.map((m) => {
|
|
130
|
+
try {
|
|
131
|
+
const ts = Number(m?.ts) || 0;
|
|
132
|
+
if (!ts || now - ts < staleMs) return '';
|
|
133
|
+
if (!isFastDecayResult(classifier(String(m?.text || '')))) return '';
|
|
134
|
+
// Wording single-sourced in the engine (decayMessageStamp) so the
|
|
135
|
+
// renderers can never drift apart; local fallback for a stale bundle.
|
|
136
|
+
if (typeof brainFormat.decayMessageStamp === 'function') return ` ${brainFormat.decayMessageStamp(now - ts)}`;
|
|
137
|
+
const h = Math.floor((now - ts) / 3_600_000);
|
|
138
|
+
const label = h >= 48 ? `${Math.floor(h / 24)}d` : `${Math.max(1, h)}h`;
|
|
139
|
+
return ` ⏱️ This message is ${label} old and contains build/deploy status — treat as LAST KNOWN, verify live before reporting it.`;
|
|
140
|
+
} catch { return ''; } // best-effort — a classifier bug must never break delivery
|
|
141
|
+
});
|
|
142
|
+
if (!stamps.some(Boolean)) return base;
|
|
143
|
+
// Per-MESSAGE dedup granularity, not whole-output: pair each `- from …`
|
|
144
|
+
// render line with its message and add a stamp only when the line right
|
|
145
|
+
// after it is not already a stamp. A ⏱ inside a message's own quoted text is
|
|
146
|
+
// NOT a stamp and must not suppress the stamps the OTHER messages need (the
|
|
147
|
+
// old whole-output includes('⏱') guard silenced them all).
|
|
148
|
+
const isStampLine = (l) => /^\s*⏱/.test(String(l || ''));
|
|
149
|
+
const lines = base.split('\n');
|
|
150
|
+
const out = [];
|
|
151
|
+
let msgIdx = -1;
|
|
152
|
+
for (let i = 0; i < lines.length; i++) {
|
|
153
|
+
out.push(lines[i]);
|
|
154
|
+
if (!lines[i].startsWith('- from ')) continue;
|
|
155
|
+
msgIdx++;
|
|
156
|
+
if (msgIdx >= list.length || !stamps[msgIdx]) continue;
|
|
157
|
+
if (isStampLine(lines[i + 1])) { stamps[msgIdx] = ''; continue; } // renderer already stamped this one
|
|
158
|
+
out.push(stamps[msgIdx]);
|
|
159
|
+
stamps[msgIdx] = '';
|
|
160
|
+
}
|
|
161
|
+
const leftovers = stamps.filter(Boolean); // unknown layout → stamps still delivered at the end
|
|
162
|
+
return leftovers.length ? [...out, ...leftovers].join('\n') : out.join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
90
165
|
function formatConflictWarning(conflicts, event) {
|
|
91
166
|
if (!conflicts.length) return '';
|
|
92
167
|
const moment = event === 'PreToolUse' ? 'before this edit runs' : 'after this file operation';
|
|
@@ -187,7 +262,7 @@ async function main() {
|
|
|
187
262
|
if (event === 'SessionStart') {
|
|
188
263
|
emitSystemMessage([
|
|
189
264
|
formatPresenceMessage(sessions, sessionId, { includeSolo: true }),
|
|
190
|
-
|
|
265
|
+
stampReceivedMessages(messages),
|
|
191
266
|
]);
|
|
192
267
|
return;
|
|
193
268
|
}
|
|
@@ -197,18 +272,24 @@ async function main() {
|
|
|
197
272
|
emitSystemMessage([
|
|
198
273
|
context,
|
|
199
274
|
formatPresenceMessage(sessions, sessionId),
|
|
200
|
-
|
|
275
|
+
stampReceivedMessages(messages),
|
|
201
276
|
]);
|
|
202
277
|
return;
|
|
203
278
|
}
|
|
204
279
|
if (event === 'PreToolUse' || event === 'PostToolUse') {
|
|
205
280
|
emitSystemMessage([
|
|
206
281
|
formatConflictWarning(conflicts, event),
|
|
207
|
-
|
|
282
|
+
stampReceivedMessages(messages),
|
|
208
283
|
]);
|
|
209
284
|
return;
|
|
210
285
|
}
|
|
211
|
-
if (messages.length) emitSystemMessage([
|
|
286
|
+
if (messages.length) emitSystemMessage([stampReceivedMessages(messages)]);
|
|
212
287
|
}
|
|
213
288
|
|
|
214
|
-
|
|
289
|
+
// Run as the hook by default. The ONLY skip is the explicit opt-out flag a
|
|
290
|
+
// hermetic test sets before importing this module for its pure exports —
|
|
291
|
+
// production never sets it (mirrors global-brain-hook.mjs), so runtime
|
|
292
|
+
// behavior is byte-identical.
|
|
293
|
+
if (!process.env.KLYPIX_BRAIN_NO_MAIN) {
|
|
294
|
+
main().catch(() => {}).finally(() => process.exit(0));
|
|
295
|
+
}
|
|
@@ -40,6 +40,11 @@ const STATE = path.resolve(CWD, '.claude', 'brain-capture-state.json');
|
|
|
40
40
|
// · ev: <path[:line]>, <path>, PR#<n> — evidence anchors; file
|
|
41
41
|
// paths get their git blob OID stamped so the brief can later
|
|
42
42
|
// flag the card when that code drifts.
|
|
43
|
+
// · verify: <command> — the exact live-probe for a fast-decay
|
|
44
|
+
// status claim (build/deploy/release state). Once the claim is
|
|
45
|
+
// stale, the status renderer re-prints it as "VERIFY: <cmd>"
|
|
46
|
+
// instead of asserting the claim as current. Emit PS-5.1-safe
|
|
47
|
+
// commands (no && chaining; use `;` or separate lines).
|
|
43
48
|
const MARKER = /🧠\s*BRAIN\s*(?:\[([^\]]+)\])?\s*([?!✓~+]?)\s*:\s*(.+)$/i;
|
|
44
49
|
const sha = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
|
|
45
50
|
// Numeric semver compare (major.minor.patch; pre-release tags ignored). <0 if
|
|
@@ -72,20 +77,25 @@ function parseEvidence(s) {
|
|
|
72
77
|
}
|
|
73
78
|
return refs.length ? refs : null;
|
|
74
79
|
}
|
|
75
|
-
// Pull optional `closes:` / `ev:` suffixes off the END of a marker
|
|
76
|
-
// order), returning the cleaned body + parsed extras. The suffix
|
|
77
|
-
// at the first
|
|
78
|
-
//
|
|
80
|
+
// Pull optional `closes:` / `ev:` / `verify:` suffixes off the END of a marker
|
|
81
|
+
// body (any order), returning the cleaned body + parsed extras. The suffix
|
|
82
|
+
// region starts at the first known key, so a decision can carry none, any, or
|
|
83
|
+
// all without the keywords leaking into the card text. The three value regexes
|
|
84
|
+
// must stay in LOCKSTEP: each value ends at the NEXT known key (or end of
|
|
85
|
+
// line), so omitting a key from one lookahead silently folds "verify: …" into
|
|
86
|
+
// that key's value — parseEvidence would mint junk file refs from it.
|
|
79
87
|
function splitMarkerSuffixes(body) {
|
|
80
|
-
const m = body.match(/\s+(?:closes|ev):/i);
|
|
81
|
-
if (!m) return { body, closes: '', evidence: null };
|
|
88
|
+
const m = body.match(/\s+(?:closes|ev|verify):/i);
|
|
89
|
+
if (!m) return { body, closes: '', evidence: null, verify: '' };
|
|
82
90
|
const suffix = body.slice(m.index);
|
|
83
|
-
const closesM = suffix.match(/\bcloses:\s*(.+?)\s*(?=\s+\bev:|$)/i);
|
|
84
|
-
const evM = suffix.match(/\bev:\s*(.+?)\s*(?=\s+\bcloses:|$)/i);
|
|
91
|
+
const closesM = suffix.match(/\bcloses:\s*(.+?)\s*(?=\s+\bev:|\s+\bverify:|$)/i);
|
|
92
|
+
const evM = suffix.match(/\bev:\s*(.+?)\s*(?=\s+\bcloses:|\s+\bverify:|$)/i);
|
|
93
|
+
const verifyM = suffix.match(/\bverify:\s*(.+?)\s*(?=\s+\bcloses:|\s+\bev:|$)/i);
|
|
85
94
|
return {
|
|
86
95
|
body: body.slice(0, m.index).trim(),
|
|
87
96
|
closes: closesM ? closesM[1].trim() : '',
|
|
88
97
|
evidence: evM ? parseEvidence(evM[1].trim()) : null,
|
|
98
|
+
verify: verifyM ? verifyM[1].trim().slice(0, 200) : '',
|
|
89
99
|
};
|
|
90
100
|
}
|
|
91
101
|
// ── Self-healing brain (decision lifecycle, part 3) ──────────────────────────
|
|
@@ -421,8 +431,43 @@ function ownMcpSendTexts(tp) {
|
|
|
421
431
|
return out;
|
|
422
432
|
} catch { return new Set(); }
|
|
423
433
|
}
|
|
434
|
+
// ── Decay-aware message stamps (2026-07-28 post-mortem, class B) ─────────────
|
|
435
|
+
// A delivered inter-session message is MEMORY, not a SENSOR: a peer's "no
|
|
436
|
+
// TestFlight upload triggered yet" relayed ~12h later read as CURRENT state and
|
|
437
|
+
// produced the third stale-"what is remaining" incident. The ENGINE stamps any
|
|
438
|
+
// delivered message older than 6h whose text asserts fast-decay build/deploy
|
|
439
|
+
// status — the discipline lives in the output contract, never in the reading
|
|
440
|
+
// model's judgment. The classifier lives ONCE in klypix-format.mjs
|
|
441
|
+
// (lib.classifyDecay), consumed behind a typeof guard so an older bundled lib
|
|
442
|
+
// degrades to no stamp — never a throw. The stamp is its OWN line appended
|
|
443
|
+
// AFTER the 400-char render slice (the v1.32.0 law: a warning is never subject
|
|
444
|
+
// to the budget/cut it warns about) and lives in render output only — the lane
|
|
445
|
+
// file is never mutated (dedup + ack key on the RAW message text).
|
|
446
|
+
const MSG_DECAY_STAMP_MS = 6 * 60 * 60 * 1000;
|
|
447
|
+
// classifyDecay is precision-first; mirror that here — only an explicit `true`
|
|
448
|
+
// or an explicitly fast-shaped object stamps. Anything ambiguous does NOT
|
|
449
|
+
// (a false stamp erodes trust in every stamp).
|
|
450
|
+
const isFastDecayResult = (r) => r === true || r === 'fast'
|
|
451
|
+
|| (!!r && typeof r === 'object' && (r.fast === true || r.fastDecay === true || r.decay === 'fast' || r.class === 'fast' || r.kind === 'fast'));
|
|
452
|
+
const msgAgeLabel = (ms) => { const h = Math.floor(ms / 3_600_000); return h >= 48 ? `${Math.floor(h / 24)}d` : `${Math.max(1, h)}h`; };
|
|
453
|
+
function decayStampForMessage(text, ts, now, lib) {
|
|
454
|
+
try {
|
|
455
|
+
if (!lib || typeof lib.classifyDecay !== 'function') return ''; // old bundled lib → no stamp, never a throw
|
|
456
|
+
const t = Number(ts) || 0;
|
|
457
|
+
const staleMs = Number(lib.DECAY_STALE_MS) > 0 ? Number(lib.DECAY_STALE_MS) : MSG_DECAY_STAMP_MS; // threshold single-sourced in the engine
|
|
458
|
+
if (!t || now - t < staleMs) return '';
|
|
459
|
+
if (!isFastDecayResult(lib.classifyDecay(String(text || '')))) return '';
|
|
460
|
+
// Wording single-sourced in the engine (lib.decayMessageStamp) so the
|
|
461
|
+
// renderers can never drift apart; local fallback only for a lib old
|
|
462
|
+
// enough to have the classifier but not the stamp builder.
|
|
463
|
+
return typeof lib.decayMessageStamp === 'function'
|
|
464
|
+
? lib.decayMessageStamp(now - t)
|
|
465
|
+
: `⏱️ This message is ${msgAgeLabel(now - t)} old and contains build/deploy status — treat as LAST KNOWN, verify live before reporting it.`;
|
|
466
|
+
} catch { return ''; } // stamping is best-effort — a classifier bug must never break delivery
|
|
467
|
+
}
|
|
424
468
|
// Surface unseen messages addressed to me, mark them seen (delivered once) under lock.
|
|
425
|
-
|
|
469
|
+
// `lib` (the loaded klypix-format) is optional — absent/old lib just skips stamps.
|
|
470
|
+
function messageFooter(sid, tp, lib) {
|
|
426
471
|
if (!sid) return '';
|
|
427
472
|
let data = {}; try { data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); } catch { return ''; }
|
|
428
473
|
const all = Array.isArray(data.messages) ? data.messages : [];
|
|
@@ -455,7 +500,12 @@ function messageFooter(sid, tp) {
|
|
|
455
500
|
if (!showUniq.length) return '';
|
|
456
501
|
const ago = (ts) => { const mm = Math.max(0, Math.round((now - (ts || now)) / 60000)); return mm <= 0 ? 'just now' : `${mm}m ago`; };
|
|
457
502
|
const out = ['', '## 📨 Message(s) from another session in this project (delivered once — act on or reply to them)'];
|
|
458
|
-
for (const m of showUniq.slice(0, 6))
|
|
503
|
+
for (const m of showUniq.slice(0, 6)) {
|
|
504
|
+
out.push(`- from ${String(m.from || '?').slice(0, 8)} · ${ago(m.ts)}: ${String(m.text).replace(/\s+/g, ' ').trim().slice(0, 400)}`);
|
|
505
|
+
// Engine-emitted LAST-KNOWN stamp — its own line, after the 400-char slice.
|
|
506
|
+
const stamp = decayStampForMessage(m.text, m.ts, now, lib);
|
|
507
|
+
if (stamp) out.push(` ${stamp}`);
|
|
508
|
+
}
|
|
459
509
|
out.push('Reply with `🧠 MSG [<their-id or all>]: <text>` — it reaches them on their next prompt.');
|
|
460
510
|
return '\n' + out.join('\n');
|
|
461
511
|
}
|
|
@@ -1149,9 +1199,10 @@ async function capture(lib) {
|
|
|
1149
1199
|
const m = MARKER.exec(trimmed); if (!m) continue;
|
|
1150
1200
|
const area = (m[1] || '').trim(), type = m[2] || '';
|
|
1151
1201
|
let body = m[3].trim(); if (!body) continue;
|
|
1152
|
-
// Strip optional `closes:` / `ev:` suffixes off the body so
|
|
1153
|
-
// leak into the card text; they drive the close-link
|
|
1154
|
-
|
|
1202
|
+
// Strip optional `closes:` / `ev:` / `verify:` suffixes off the body so
|
|
1203
|
+
// they don't leak into the card text; they drive the close-link,
|
|
1204
|
+
// evidence, and live-probe below.
|
|
1205
|
+
const { body: cleanBody, closes, evidence, verify } = splitMarkerSuffixes(body);
|
|
1155
1206
|
body = cleanBody; if (!body) continue;
|
|
1156
1207
|
const preview = body.slice(0, 90);
|
|
1157
1208
|
// EXAMPLE/doc guard — rejects marker-SYNTAX documentation (which
|
|
@@ -1183,7 +1234,7 @@ async function capture(lib) {
|
|
|
1183
1234
|
// ✓ resolves an EXISTING card (stamped ✅ + archived) — not a new card.
|
|
1184
1235
|
if (type === '✓') { resolutions.push({ area, text: body }); ledger.push({ action: 'resolve', area, preview }); continue; }
|
|
1185
1236
|
// ~ updates the matching card in place (small corrections).
|
|
1186
|
-
if (type === '~') { updates.push({ area, text: body, createdVia: 'claude-code', ...(evidence ? { evidence } : {}) }); ledger.push({ action: 'update', area, preview, ...(evidence ? { ev: evidence.map(e => e.ref) } : {}) }); continue; }
|
|
1237
|
+
if (type === '~') { updates.push({ area, text: body, createdVia: 'claude-code', ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}) }); ledger.push({ action: 'update', area, preview, ...(evidence ? { ev: evidence.map(e => e.ref) } : {}) }); continue; }
|
|
1187
1238
|
// Type → scannable prefix + border color: ? open question (amber),
|
|
1188
1239
|
// ! milestone (blue), + skill (violet), else decision (green). A plain
|
|
1189
1240
|
// decision whose text reads as a reusable RULE is AUTO-promoted to a
|
|
@@ -1199,7 +1250,7 @@ async function capture(lib) {
|
|
|
1199
1250
|
const fileTags = recentTags.slice(-4).filter(t => t !== areaTag);
|
|
1200
1251
|
const tagLine = [areaTag, ...fileTags].filter(Boolean).join(' ');
|
|
1201
1252
|
const card = (area ? `${area}: ${prefix}${body}` : `${prefix}${body}`) + (tagLine ? `\n${tagLine}` : '');
|
|
1202
|
-
cards.push({ text: card, area, borderColor, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}) });
|
|
1253
|
+
cards.push({ text: card, area, borderColor, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}) });
|
|
1203
1254
|
ledger.push({ action: type === '?' ? 'add-question' : type === '!' ? 'add-milestone' : isSkill ? 'add-skill' : 'add-decision', area, preview, files: fileTags, ...(closes ? { closes } : {}), ...(evidence ? { ev: evidence.map(e => e.ref) } : {}) });
|
|
1204
1255
|
}
|
|
1205
1256
|
}
|
|
@@ -1286,7 +1337,7 @@ async function capture(lib) {
|
|
|
1286
1337
|
try {
|
|
1287
1338
|
const merged = readState(); for (const k of seen) merged.add(k);
|
|
1288
1339
|
const res = await lib.captureIntoBrain(fs.readFileSync(BRAIN), {
|
|
1289
|
-
cards: cards.map(c => ({ text: c.text, color: '#e8e8ed', borderColor: c.borderColor, area: c.area, createdVia: c.createdVia || 'claude-code', ...(c.closes ? { closes: c.closes } : {}), ...(c.evidence ? { evidence: c.evidence } : {}) })),
|
|
1340
|
+
cards: cards.map(c => ({ text: c.text, color: '#e8e8ed', borderColor: c.borderColor, area: c.area, createdVia: c.createdVia || 'claude-code', ...(c.closes ? { closes: c.closes } : {}), ...(c.evidence ? { evidence: c.evidence } : {}), ...(c.verify ? { verify: c.verify } : {}) })),
|
|
1290
1341
|
resolutions,
|
|
1291
1342
|
updates,
|
|
1292
1343
|
});
|
|
@@ -1449,7 +1500,7 @@ async function promptRetrieve(lib) {
|
|
|
1449
1500
|
// Other live sessions in THIS repo — surfaced even when the prompt retrieves
|
|
1450
1501
|
// nothing (a peer's presence/ship is itself the signal). Empty string when solo.
|
|
1451
1502
|
const peers = peerFooter(sid);
|
|
1452
|
-
const messages = messageFooter(sid, input.transcript_path); // 📨 deliberate notes another session left for me (delivered once)
|
|
1503
|
+
const messages = messageFooter(sid, input.transcript_path, lib); // 📨 deliberate notes another session left for me (delivered once)
|
|
1453
1504
|
let hits = [], repeats = [], struct = null;
|
|
1454
1505
|
if (tokens.length) {
|
|
1455
1506
|
struct = await cachedStruct(lib);
|
|
@@ -1984,7 +2035,7 @@ async function read(lib) {
|
|
|
1984
2035
|
+ ruleDraftsFooter(input.session_id, struct, { markShown: false })
|
|
1985
2036
|
+ selfCheckFooter() + doctorFooter() + versionCurrencyFooter() + legendFooter() + memoryFooter();
|
|
1986
2037
|
const emitFull = () => {
|
|
1987
|
-
process.stdout.write(full + messageFooter(input.session_id || '', input.transcript_path));
|
|
2038
|
+
process.stdout.write(full + messageFooter(input.session_id || '', input.transcript_path, lib));
|
|
1988
2039
|
appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(full), cards: struct?.counts?.cards ?? null }, 500);
|
|
1989
2040
|
};
|
|
1990
2041
|
// --full = everything to stdout (manual runs); also the fallback when the
|
|
@@ -2021,7 +2072,7 @@ async function read(lib) {
|
|
|
2021
2072
|
// 📨 messages are delivered-ONCE (acked the moment this reads them) — they
|
|
2022
2073
|
// go right after the ultra brief, at the top of the visible window, never
|
|
2023
2074
|
// after a stack of footers that could push them past a preview cut.
|
|
2024
|
-
const messages = messageFooter(input.session_id || '', input.transcript_path);
|
|
2075
|
+
const messages = messageFooter(input.session_id || '', input.transcript_path, lib);
|
|
2025
2076
|
const out = ultra + messages + healLine + draftLine
|
|
2026
2077
|
+ inflightFooter(input.session_id, struct)
|
|
2027
2078
|
+ selfCheckFooter() + doctorFooter() + versionCurrencyFooter();
|
|
@@ -2103,5 +2154,5 @@ if (!process.env.KLYPIX_BRAIN_NO_MAIN) {
|
|
|
2103
2154
|
|
|
2104
2155
|
// Exported for hermetic unit tests only (gated by KLYPIX_BRAIN_NO_MAIN above so the
|
|
2105
2156
|
// import doesn't run main()/exit the test). Not part of the runtime hook contract.
|
|
2106
|
-
export { refreshNpmCurrency, versionCurrencyFooter, bakedBrainVersion, httpsFetchLatest, cmpSemver };
|
|
2157
|
+
export { refreshNpmCurrency, versionCurrencyFooter, bakedBrainVersion, httpsFetchLatest, cmpSemver, decayStampForMessage, messageFooter, splitMarkerSuffixes };
|
|
2107
2158
|
// (shouldSelfUpdate is exported at its declaration above — the auto-propagation decision seam for tests)
|
package/src/klypix-core.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
brainLensData, lensToMarkdown, deathDateOfCard,
|
|
32
32
|
statusContextToMarkdown, findFulfillmentCandidates,
|
|
33
33
|
splitQueryTokens, scoreCardsAgainstQuery, correctionOverlaysFor,
|
|
34
|
+
isFastDecayCard, DECAY_STALE_MS, formatDecayAge,
|
|
34
35
|
} from './klypix-format.mjs';
|
|
35
36
|
import { findProjectBrain } from './agent-presence.mjs';
|
|
36
37
|
|
|
@@ -482,14 +483,26 @@ export async function opBrainTaskContext({
|
|
|
482
483
|
const clean = flat(value);
|
|
483
484
|
return clean.length > limit ? `${clean.slice(0, limit - 1)}…` : clean;
|
|
484
485
|
};
|
|
486
|
+
// Decay-aware status (2026-07-28 post-mortem): a fast-decay build/deploy
|
|
487
|
+
// claim >6h old is stamped ⏱️ LAST KNOWN at the ENTRY level, so every
|
|
488
|
+
// brain_sync host (Codex included — this is its only context surface, it
|
|
489
|
+
// never calls statusContextToMarkdown) gets the warning from the engine,
|
|
490
|
+
// not from model judgment. Best-effort: classification failure → no stamp.
|
|
491
|
+
const nowTs = Date.now();
|
|
485
492
|
const entries = hits.map((hit) => {
|
|
486
493
|
const correction = overlays.get(hit.card.id)?.by || null;
|
|
494
|
+
let decayAge = null;
|
|
495
|
+
try {
|
|
496
|
+
const ageMs = (hit.card.createdAt || 0) > 0 ? nowTs - hit.card.createdAt : 0;
|
|
497
|
+
if (ageMs > DECAY_STALE_MS && isFastDecayCard(hit.card)) decayAge = formatDecayAge(ageMs);
|
|
498
|
+
} catch { decayAge = null; }
|
|
487
499
|
return {
|
|
488
500
|
id: hit.card.id,
|
|
489
501
|
area: flat(hit.card.area) || 'Notes',
|
|
490
502
|
text: clip(hit.card.text, 420),
|
|
491
503
|
score: Number(hit.score.toFixed(2)),
|
|
492
504
|
correctedBy: correction ? clip(correction.text, 420) : null,
|
|
505
|
+
...(decayAge ? { lastKnown: true, age: decayAge } : {}),
|
|
493
506
|
};
|
|
494
507
|
});
|
|
495
508
|
const maxChars = Math.max(800, Math.min(5000, Number(budgetChars) || 2800));
|
|
@@ -501,7 +514,11 @@ export async function opBrainTaskContext({
|
|
|
501
514
|
} else {
|
|
502
515
|
for (const entry of entries) {
|
|
503
516
|
if (entry.correctedBy) lines.push(`- [${entry.area}] CURRENT CORRECTION: ${entry.correctedBy}`);
|
|
504
|
-
|
|
517
|
+
// The LAST KNOWN stamp is a PREFIX: the final .slice(0, maxChars) can cut
|
|
518
|
+
// a line's tail, and the warning must never be the part that gets cut
|
|
519
|
+
// (v1.32.0 law — a claim may truncate, its warning may not).
|
|
520
|
+
const stamp = entry.lastKnown ? ` ⏱️ LAST KNOWN (${entry.age} old — verify live before reporting):` : '';
|
|
521
|
+
lines.push(`- [${entry.area}]${entry.correctedBy ? ' superseded context:' : ''}${stamp} ${entry.text}`);
|
|
505
522
|
if (lines.join('\n').length >= maxChars) break;
|
|
506
523
|
}
|
|
507
524
|
lines.push('This is a bounded start-of-task capsule, not the whole brain; use brain_ask for broad status/history questions.');
|
|
@@ -568,6 +585,9 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
|
|
|
568
585
|
// STRONG shape only: a work request that merely mentions 'pending'/'TODO'
|
|
569
586
|
// must keep its ranked answer (review fix); the phrase-shaped "what is
|
|
570
587
|
// remaining?" gets the computed section.
|
|
588
|
+
// Decay-aware stamps (⏱️ LAST KNOWN + VERIFY probe on >6h build/deploy
|
|
589
|
+
// claims) ride IN the renderer — statusContextToMarkdown stamps internally,
|
|
590
|
+
// so this call inherits them with no options needed (now defaults inside).
|
|
571
591
|
let statusMd = '';
|
|
572
592
|
if (result.statusStrong && !timeTravel) {
|
|
573
593
|
try { statusMd = statusContextToMarkdown(struct); mode += ' + status-mode'; } catch { statusMd = ''; }
|
package/src/klypix-format.mjs
CHANGED
|
@@ -63,6 +63,21 @@ export async function atomicWrite(filePath, buf) {
|
|
|
63
63
|
catch (e) { try { fs.rmSync(tmp); } catch { /* */ } throw e; }
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// ── verify: suffix (decay-aware status, 2026-07-28 post-mortem) ──────────────
|
|
67
|
+
// A card may carry the EXACT live-probe command for its fast-decay claim as a
|
|
68
|
+
// `verify:` suffix ("🏁 build 26 uploaded verify: gh run list --limit 5"), the
|
|
69
|
+
// same marker grammar as closes:/ev: (value runs to the next known key or end
|
|
70
|
+
// of line). The hook strips+persists it as a machine field at capture; cards
|
|
71
|
+
// authored by humans/the app/brain_note keep it in prose — so parseKlypix
|
|
72
|
+
// falls back to a prose parse and every render surface sees ONE `verify` field.
|
|
73
|
+
const VERIFY_SUFFIX_RE = /(?:^|\s)verify:\s*([^\n]+)/i;
|
|
74
|
+
export function parseVerifySuffix(text) {
|
|
75
|
+
const m = VERIFY_SUFFIX_RE.exec(String(text || ''));
|
|
76
|
+
if (!m) return null;
|
|
77
|
+
const v = m[1].split(/\s+\b(?:closes|ev):/i)[0].trim();
|
|
78
|
+
return v || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
/**
|
|
67
82
|
* Parse a .klypix/.any buffer into a structured object + the loaded zip (so
|
|
68
83
|
* callers can extract binary assets). Throws on a non-canvas file.
|
|
@@ -122,6 +137,11 @@ export async function parseKlypix(buffer) {
|
|
|
122
137
|
// Evidence anchors (file:line / PR#) with the git blob OID stamped at
|
|
123
138
|
// capture-time — lets the hook flag a card whose cited code drifted.
|
|
124
139
|
evidence: Array.isArray(it.evidence) && it.evidence.length ? it.evidence : null,
|
|
140
|
+
// Live-probe command for a fast-decay status claim (decay-aware
|
|
141
|
+
// status): machine field when captured via a verify: marker suffix,
|
|
142
|
+
// else parsed from prose so non-hook-authored cards count too.
|
|
143
|
+
verify: (typeof it.verify === 'string' && it.verify.trim()) ? it.verify.trim()
|
|
144
|
+
: (it.type === 'text' ? parseVerifySuffix(it.content) : null),
|
|
125
145
|
// Machine death-date (epoch ms) written by the gardener at
|
|
126
146
|
// consolidation — the prose "⤵ consolidated" stamp's reliable twin,
|
|
127
147
|
// so as_of time-travel never depends on parsing prose.
|
|
@@ -518,6 +538,8 @@ export async function appendIntoContainers(buffer, addition) {
|
|
|
518
538
|
...(card.createdVia ? { createdVia: String(card.createdVia) } : {}),
|
|
519
539
|
// Evidence anchors (file:line / PR#) — additive, ignored by older readers.
|
|
520
540
|
...(Array.isArray(card.evidence) && card.evidence.length ? { evidence: card.evidence } : {}),
|
|
541
|
+
// Live-probe command for a fast-decay claim — additive, ignored by older readers.
|
|
542
|
+
...(typeof card.verify === 'string' && card.verify.trim() ? { verify: card.verify.trim() } : {}),
|
|
521
543
|
content: wrapped, fontSize: G.FONT,
|
|
522
544
|
color: card.color || '#e8e8ed', border: true, borderColor: card.borderColor || card.color || 'rgba(16,185,129,0.45)',
|
|
523
545
|
fillColor: 'rgba(18,18,26,0.85)', heading: !!card.heading, fontFamily: 'Thmanyah Sans',
|
|
@@ -1182,7 +1204,15 @@ export function areaStatusDigest(struct, { activeDays = 30, maxAreas = 20, now =
|
|
|
1182
1204
|
const miles = cs.filter(isMilestoneCard).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
1183
1205
|
const opens = cs.filter(isOpenCard);
|
|
1184
1206
|
const m = miles[0];
|
|
1185
|
-
|
|
1207
|
+
// Decay-aware headline (2026-07-28 post-mortem): a fast-decay milestone
|
|
1208
|
+
// older than DECAY_STALE_MS never leads an area as bare current state.
|
|
1209
|
+
// The engine appends the age + a verify cue — the brief is the one
|
|
1210
|
+
// surface every session reads, and models do not compute ages from the
|
|
1211
|
+
// ISO date on their own (that omission was the exact incident).
|
|
1212
|
+
const mAge = m ? now - (m.createdAt || 0) : 0;
|
|
1213
|
+
const mileStale = m && mAge >= DECAY_STALE_MS && isFastDecayCard(m)
|
|
1214
|
+
? ` (${formatDecayAge(mAge)} — verify live)` : '';
|
|
1215
|
+
const mileTxt = m ? `last 🏁 ${day(m.createdAt)}${mileStale} “${cut(flat(m.text).replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, ''), 70)}”` : 'no 🏁 yet';
|
|
1186
1216
|
rows.push({ newest, line: `- ${area} — ${mileTxt} · ${opens.length} open · latest ${day(newest)}` });
|
|
1187
1217
|
}
|
|
1188
1218
|
rows.sort((a, b) => b.newest - a.newest);
|
|
@@ -1678,6 +1708,86 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
|
|
|
1678
1708
|
return out.join('\n') + '\n';
|
|
1679
1709
|
}
|
|
1680
1710
|
|
|
1711
|
+
// ── Decay-aware status assertions (2026-07-28 post-mortem, 3rd stale-status ──
|
|
1712
|
+
// incident). The brain is a MEMORY, not a SENSOR: for fast-decay facts (what's
|
|
1713
|
+
// on TestFlight, what's on npm, whether the rollout flipped) its job is to
|
|
1714
|
+
// carry WHERE to look, never assert WHAT is currently true. classifyDecay
|
|
1715
|
+
// detects texts that make such assertions so every render surface can stamp
|
|
1716
|
+
// them ⏱️ LAST KNOWN instead of presenting them as current state. This moves
|
|
1717
|
+
// the "verify before reporting" discipline from model judgment (fails on weak
|
|
1718
|
+
// models, occasionally on strong ones) into the engine's output contract.
|
|
1719
|
+
//
|
|
1720
|
+
// PRECISION OVER RECALL — a missed stamp is the status quo; a false stamp
|
|
1721
|
+
// erodes trust in every stamp. A text classifies ONLY when, inside one
|
|
1722
|
+
// sentence/line segment (split on .!?\n — NOT ';', the actual incident text
|
|
1723
|
+
// was "ready for next TestFlight build; no upload/tag triggered yet"), BOTH:
|
|
1724
|
+
// VERB class — completed-status forms: uploaded / published / released /
|
|
1725
|
+
// deployed / installed / shipped / submitted / staged / flipped /
|
|
1726
|
+
// triggered / rolled out|back / went|is|now live / green — or a
|
|
1727
|
+
// NEGATIVE-pending assertion ("no upload triggered yet", "not yet
|
|
1728
|
+
// published", "awaiting App Store review"), which decays just as fast.
|
|
1729
|
+
// NOUN class — release-shaped subjects: TestFlight / App Store / npm / CI /
|
|
1730
|
+
// release(s) / rollout / build N / vX.Y[.Z] and X.Y.Z version literals.
|
|
1731
|
+
// Or the text carries an ev: run/release id ("ev: gh run 16204339183") — a
|
|
1732
|
+
// machine receipt is by definition a point-in-time observation.
|
|
1733
|
+
// DELIBERATE NON-MATCHES (each is a live false-positive class we tested out):
|
|
1734
|
+
// · bare-infinitive process/architecture prose — "releases go through OIDC
|
|
1735
|
+
// npm publish", "the release pipeline: build → sign → upload" (no
|
|
1736
|
+
// completed verb form; 'publish'/'upload' ≠ 'published'/'uploaded');
|
|
1737
|
+
// · "staged rollout/release/deployment" AND the hyphenated "staged-rollout"
|
|
1738
|
+
// as adjectives (vs "draft staged") — the hyphen form was a live FP on the
|
|
1739
|
+
// real brain's Capabilities card ("staged-rollout auto-updater");
|
|
1740
|
+
// · 'released'/'shipped' with no release noun in the segment — "shipped the
|
|
1741
|
+
// garden fix" is a durable milestone, not a fast-decay build status;
|
|
1742
|
+
// · hyphenated 'live-' adjectives ("live-verified", "live-traced");
|
|
1743
|
+
// · questions/plans about releasing ("should we upload nightly builds?").
|
|
1744
|
+
export const DECAY_STALE_MS = 6 * 3_600_000; // older than this → LAST KNOWN, never current
|
|
1745
|
+
const DECAY_VERB_RE = /\b(?:uploaded|published|released|deployed|installed|shipped|submitted|flipped|triggered|green)\b|\bstaged\b(?![\s-]+(?:rollout|rollouts|release[sd]?|deploy\w*|migration\w*|approach))|\brolled[\s-]?(?:out|back)\b|\brolling[\s-]?out\b|\b(?:went|is|are|now|already)\s+live\b|\blive\b(?!-)/i;
|
|
1746
|
+
const DECAY_PENDING_RE = /\b(?:not\s+yet|hasn'?t(?:\s+been)?|haven'?t(?:\s+been)?|nothing(?:\s+has)?(?:\s+been)?|never)\s+(?:been\s+)?(?:uploaded|published|released|deployed|installed|shipped|triggered|tagged|submitted|gone\s+live)\b|\bno\s+(?:\w+[\s/-]+){0,2}?(?:upload|publish|deploy|release|install|build|tag|rollout)[\w/-]*(?:\s+\S+){0,3}?\s+(?:yet|so\s+far|triggered|started|fired|happened)\b|\b(?:awaiting|waiting\s+(?:for|on)|pending)\s+(?:apple|app\s?store|testflight|review|approval|upload|release|rollout)\b/i;
|
|
1747
|
+
const DECAY_NOUN_RE = /\btest\s?flight\b|\bapp\s?store\b|\bnpm\b|\bci\b|\brelease(?:s)?\b|\brollout(?:s)?\b|\bbuild\s*#?\s*\d+\b|\bv\d+(?:\.\d+)+\b|\b\d+\.\d+\.\d+\b/i;
|
|
1748
|
+
const DECAY_EVRUN_RE = /\bev:\s*[^\n]{0,60}?\b(?:run|workflow|release|build|rollout|deploy)[a-z-]*\s*[#:/]?\s*\d{3,}/i;
|
|
1749
|
+
export function classifyDecay(text) {
|
|
1750
|
+
const t = String(text || '');
|
|
1751
|
+
if (!t.trim()) return false;
|
|
1752
|
+
if (DECAY_EVRUN_RE.test(t)) return true;
|
|
1753
|
+
// A '.' only ends a segment when followed by whitespace/end — a bare-dot
|
|
1754
|
+
// split shredded version literals ("1.3.28 live" became "1"/"3"/"28 live"
|
|
1755
|
+
// and the noun never met its verb).
|
|
1756
|
+
for (const seg of t.split(/[\n!?]+|\.(?=\s|$)/)) {
|
|
1757
|
+
if (!seg.trim()) continue;
|
|
1758
|
+
if (DECAY_NOUN_RE.test(seg) && (DECAY_VERB_RE.test(seg) || DECAY_PENDING_RE.test(seg))) return true;
|
|
1759
|
+
}
|
|
1760
|
+
return false;
|
|
1761
|
+
}
|
|
1762
|
+
// A struct card decays if its TEXT classifies, or its machine evidence carries
|
|
1763
|
+
// a run/release-shaped receipt (the ev: suffix is stripped from hook-captured
|
|
1764
|
+
// prose, so the text alone can't see it).
|
|
1765
|
+
export const isFastDecayCard = (c) => classifyDecay(c?.text)
|
|
1766
|
+
|| (Array.isArray(c?.evidence) && c.evidence.some(e => DECAY_EVRUN_RE.test('ev: ' + String(e?.ref || ''))));
|
|
1767
|
+
// Compact age for stamps ("20h", "3d") — both message renderers show minutes
|
|
1768
|
+
// only, which reads as noise at 12h+ ("720m ago").
|
|
1769
|
+
export const formatDecayAge = (ms) => {
|
|
1770
|
+
const h = Math.floor(Math.max(0, ms) / 3_600_000);
|
|
1771
|
+
if (h < 1) return `${Math.max(1, Math.floor(Math.max(0, ms) / 60_000))}m`;
|
|
1772
|
+
return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`;
|
|
1773
|
+
};
|
|
1774
|
+
// The live-probe to print after a LAST KNOWN claim: the card's own verify:
|
|
1775
|
+
// command wins; else a per-area default; else a generic re-verify line.
|
|
1776
|
+
// Emitted probes must be PS-5.1-safe: ';' separators, never '&&'.
|
|
1777
|
+
export function decayVerifyProbe(card) {
|
|
1778
|
+
const v = typeof card?.verify === 'string' && card.verify.trim() ? card.verify.trim() : null;
|
|
1779
|
+
if (v) return v;
|
|
1780
|
+
const a = String(card?.area || '').toLowerCase();
|
|
1781
|
+
if (/release|app\s?store|appstore|\bios\b|testflight|ship|deploy|rollout/.test(a)) return 'gh run list --limit 5; gh release list --limit 5';
|
|
1782
|
+
if (/drive|admin/.test(a)) return 'probe the live prod endpoint (HTTP) before reporting';
|
|
1783
|
+
return 're-verify live before reporting this as current';
|
|
1784
|
+
}
|
|
1785
|
+
// Engine-emitted stamp for a delivered inter-session message (class B of the
|
|
1786
|
+
// post-mortem taxonomy) — EXACT wording from the brief, single-sourced here so
|
|
1787
|
+
// the Claude-hook and agent-presence renderers can never drift apart.
|
|
1788
|
+
export const decayMessageStamp = (ageMs) =>
|
|
1789
|
+
`⏱️ This message is ${formatDecayAge(ageMs)} old and contains build/deploy status — treat as LAST KNOWN, verify live before reporting it.`;
|
|
1790
|
+
|
|
1681
1791
|
// ── Status mode (T7, 2026-07-23) — the computed answer for status questions ──
|
|
1682
1792
|
// "What is remaining?" must be answered from STATE, not lexical matching:
|
|
1683
1793
|
// status vocabulary is anti-correlated with truth (cards saying "remaining"
|
|
@@ -1688,10 +1798,24 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
|
|
|
1688
1798
|
// maxOpen defaults to NO cap: the open list is the answer to a status question,
|
|
1689
1799
|
// so it is sized to fit (per-card width adapts) rather than sliced. Callers can
|
|
1690
1800
|
// still pass a cap explicitly.
|
|
1691
|
-
export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChars = 4200 } = {}) {
|
|
1801
|
+
export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChars = 4200, now = Date.now() } = {}) {
|
|
1692
1802
|
if (!struct || !Array.isArray(struct.cards)) return '';
|
|
1693
1803
|
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
1694
1804
|
const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
|
|
1805
|
+
// ⏱️ LAST KNOWN (decay-aware status, 2026-07-28): a fast-decay claim older
|
|
1806
|
+
// than 6h renders as last-known observation + live probe, NEVER as current
|
|
1807
|
+
// state. The stamp scaffolding lives OUTSIDE cut() and rides pushAlways —
|
|
1808
|
+
// the v1.32.0 law: a warning is never subject to the budget/width it warns
|
|
1809
|
+
// about, so a crowded brain can shorten the CLAIM but never the WARNING.
|
|
1810
|
+
// best-effort try: a malformed card must degrade to an unstamped line, not
|
|
1811
|
+
// take down the whole status section (opBrainAsk catch would eat it all).
|
|
1812
|
+
const decayStamp = (c) => {
|
|
1813
|
+
try {
|
|
1814
|
+
const age = (c.createdAt || 0) > 0 ? now - c.createdAt : 0;
|
|
1815
|
+
if (age <= DECAY_STALE_MS || !isFastDecayCard(c)) return null;
|
|
1816
|
+
return { age: formatDecayAge(age), probe: decayVerifyProbe(c) };
|
|
1817
|
+
} catch { return null; }
|
|
1818
|
+
};
|
|
1695
1819
|
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1696
1820
|
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
|
|
1697
1821
|
const out = [];
|
|
@@ -1750,7 +1874,9 @@ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChar
|
|
|
1750
1874
|
for (const c of top) {
|
|
1751
1875
|
if (used > budgetChars) break;
|
|
1752
1876
|
const flags = `${overdueById.has(c.id) ? ' ⏰OVERDUE' : ''}${overlays.has(c.id) ? ' ⚠️CORRECTED' : ''}${fulfills.has(c.id) ? ' ⏳likely-fulfilled' : ''}`;
|
|
1753
|
-
|
|
1877
|
+
const d = decayStamp(c);
|
|
1878
|
+
if (d) pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ⏱️ LAST KNOWN (${d.age}): ${cut(flat(c.text), perCard)} — VERIFY: ${d.probe}`);
|
|
1879
|
+
else pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ${cut(flat(c.text), perCard)}`);
|
|
1754
1880
|
shown++;
|
|
1755
1881
|
}
|
|
1756
1882
|
if (shown < opens.length) pushAlways(`- ⚠️ …and ${opens.length - shown} more open item(s) — this list is NOT complete; call brain_ask before reporting what remains.`);
|
|
@@ -1759,7 +1885,11 @@ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChar
|
|
|
1759
1885
|
if (miles.length) {
|
|
1760
1886
|
pushAlways('');
|
|
1761
1887
|
pushAlways('## Newest milestones');
|
|
1762
|
-
for (const m of miles.slice(0, 3))
|
|
1888
|
+
for (const m of miles.slice(0, 3)) {
|
|
1889
|
+
const d = decayStamp(m);
|
|
1890
|
+
if (d) pushAlways(`- [${flat(m.area) || '?'}] ${day(m.createdAt)} ⏱️ LAST KNOWN (${d.age}): ${cut(flat(m.text), 130)} — VERIFY: ${d.probe}`);
|
|
1891
|
+
else pushAlways(`- [${flat(m.area) || '?'}] ${day(m.createdAt)} ${cut(flat(m.text), 130)}`);
|
|
1892
|
+
}
|
|
1763
1893
|
}
|
|
1764
1894
|
pushAlways('');
|
|
1765
1895
|
return out.join('\n') + '\n';
|
|
@@ -2870,13 +3000,14 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
2870
3000
|
// Self-heal: a ~ update re-stamps the evidence (fresh OID +
|
|
2871
3001
|
// verifiedAt), so confirming/correcting a drifted fact marks it ✅.
|
|
2872
3002
|
if (Array.isArray(u.evidence) && u.evidence.length) j.evidence = u.evidence;
|
|
3003
|
+
if (typeof u.verify === 'string' && u.verify.trim()) j.verify = u.verify.trim();
|
|
2873
3004
|
});
|
|
2874
3005
|
best.text = isTerseConfirm ? `${best.text}\n(re-affirmed ${today}: ${u.text})` : u.text;
|
|
2875
3006
|
stats.updated++;
|
|
2876
3007
|
} else if (!nearDupExists(u.text)) {
|
|
2877
3008
|
// ~ fallback add is guarded like ✓'s: an unmatched ~ re-harvested
|
|
2878
3009
|
// from the transcript tail must not stack a copy every turn.
|
|
2879
|
-
cards.push({ text: (u.area ? `${u.area}: ` : '') + u.text + (u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : ''), area: u.area, createdVia: u.createdVia, ...(Array.isArray(u.evidence) && u.evidence.length ? { evidence: u.evidence } : {}) });
|
|
3010
|
+
cards.push({ text: (u.area ? `${u.area}: ` : '') + u.text + (u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : ''), area: u.area, createdVia: u.createdVia, ...(Array.isArray(u.evidence) && u.evidence.length ? { evidence: u.evidence } : {}), ...(typeof u.verify === 'string' && u.verify.trim() ? { verify: u.verify.trim() } : {}) });
|
|
2880
3011
|
}
|
|
2881
3012
|
}
|
|
2882
3013
|
|
|
@@ -2901,6 +3032,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
2901
3032
|
j.createdAt = now;
|
|
2902
3033
|
if (card.createdVia) j.createdVia = String(card.createdVia);
|
|
2903
3034
|
if (Array.isArray(card.evidence) && card.evidence.length) j.evidence = card.evidence;
|
|
3035
|
+
if (typeof card.verify === 'string' && card.verify.trim()) j.verify = card.verify.trim();
|
|
2904
3036
|
});
|
|
2905
3037
|
best.text = String(card.text);
|
|
2906
3038
|
cards.splice(i, 1);
|
|
@@ -3561,17 +3693,17 @@ export function findOverdueOpenCards(struct, { now = Date.now() } = {}) {
|
|
|
3561
3693
|
// (milestone) | '✓' (resolve+archive a match) | '~' (update a match in place) |
|
|
3562
3694
|
// '+' (skill — a REUSABLE how-to/gotcha/procedure, standing reference that always
|
|
3563
3695
|
// surfaces and never ages out, distinct from a point-in-time decision).
|
|
3564
|
-
export function noteToCaptureInput({ text = '', area = '', marker = '', closes = '', evidence = null, createdVia = 'mcp' } = {}) {
|
|
3696
|
+
export function noteToCaptureInput({ text = '', area = '', marker = '', closes = '', evidence = null, verify = null, createdVia = 'mcp' } = {}) {
|
|
3565
3697
|
const body = String(text).trim();
|
|
3566
3698
|
if (!body) return { cards: [], resolutions: [], updates: [] };
|
|
3567
3699
|
const a = String(area || '').trim();
|
|
3568
3700
|
if (marker === '✓') return { cards: [], resolutions: [{ area: a, text: body }], updates: [] };
|
|
3569
|
-
if (marker === '~') return { cards: [], resolutions: [], updates: [{ area: a, text: body, createdVia, ...(evidence ? { evidence } : {}) }] };
|
|
3701
|
+
if (marker === '~') return { cards: [], resolutions: [], updates: [{ area: a, text: body, createdVia, ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}) }] };
|
|
3570
3702
|
const prefix = marker === '?' ? '❓ ' : marker === '!' ? '🏁 ' : marker === '+' ? '🛠️ ' : '';
|
|
3571
3703
|
const borderColor = marker === '?' ? 'rgba(245,166,35,0.8)' : marker === '!' ? 'rgba(59,130,246,0.8)' : marker === '+' ? 'rgba(139,92,246,0.85)' : 'rgba(16,185,129,0.6)';
|
|
3572
3704
|
const tag = a ? `\n#${a.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : '';
|
|
3573
3705
|
const cardText = (a ? `${a}: ${prefix}${body}` : `${prefix}${body}`) + tag;
|
|
3574
|
-
return { cards: [{ text: cardText, area: a, color: '#e8e8ed', borderColor, createdVia, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}) }], resolutions: [], updates: [] };
|
|
3706
|
+
return { cards: [{ text: cardText, area: a, color: '#e8e8ed', borderColor, createdVia, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}) }], resolutions: [], updates: [] };
|
|
3575
3707
|
}
|
|
3576
3708
|
|
|
3577
3709
|
/**
|
package/src/mcp-presence.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
findProjectBrain,
|
|
13
13
|
formatPresenceMessage,
|
|
14
14
|
formatReceivedMessages,
|
|
15
|
+
messageDecayInfo,
|
|
15
16
|
peekMessages,
|
|
16
17
|
postPresenceMessage,
|
|
17
18
|
receiveMessages,
|
|
@@ -191,6 +192,14 @@ export function createMcpPresence({
|
|
|
191
192
|
now = () => Date.now(),
|
|
192
193
|
setIntervalFn = setInterval,
|
|
193
194
|
clearIntervalFn = clearInterval,
|
|
195
|
+
// Decay-aware LAST-KNOWN stamps (2026-07-28 post-mortem, class B): the
|
|
196
|
+
// injected engine surface ({ classifyDecay, decayStaleMs, decayMessageStamp,
|
|
197
|
+
// formatDecayAge } from klypix-format.mjs) that lets every MCP delivery
|
|
198
|
+
// surface — pollInbox logging, touch/decorateToolResult notices, brain_sync
|
|
199
|
+
// text + structured messages — stamp a stale build/deploy message as LAST
|
|
200
|
+
// KNOWN. Injection (not an import) keeps this file builtin-only; absent or
|
|
201
|
+
// partial (old bundle), delivery degrades to unstamped — never a throw.
|
|
202
|
+
decay = {},
|
|
194
203
|
} = {}) {
|
|
195
204
|
const sessionId = resolveMcpSessionId({ env });
|
|
196
205
|
const effectiveInboxPollMs = Number(env?.KLYPIX_MCP_INBOX_POLL_MS)
|
|
@@ -241,7 +250,7 @@ export function createMcpPresence({
|
|
|
241
250
|
now: now(),
|
|
242
251
|
}).filter((message) => !announcedMessageIds.has(message.id));
|
|
243
252
|
if (!pending.length) return [];
|
|
244
|
-
if (sendNotice(formatReceivedMessages(pending, now()))) {
|
|
253
|
+
if (sendNotice(formatReceivedMessages(pending, now(), decay))) {
|
|
245
254
|
for (const message of pending) announcedMessageIds.add(message.id);
|
|
246
255
|
while (announcedMessageIds.size > 100) {
|
|
247
256
|
announcedMessageIds.delete(announcedMessageIds.values().next().value);
|
|
@@ -296,7 +305,7 @@ export function createMcpPresence({
|
|
|
296
305
|
: '';
|
|
297
306
|
const notice = [
|
|
298
307
|
presence,
|
|
299
|
-
formatReceivedMessages(messages, stamp),
|
|
308
|
+
formatReceivedMessages(messages, stamp, decay),
|
|
300
309
|
].filter(Boolean).join('\n\n');
|
|
301
310
|
if (notice) sendNotice(notice);
|
|
302
311
|
return { sessions, messages, notice };
|
|
@@ -428,7 +437,7 @@ export function createMcpPresence({
|
|
|
428
437
|
].join('\n')
|
|
429
438
|
: 'No exact file overlap is currently reported by another synchronized task.';
|
|
430
439
|
const durationMs = Math.max(0, Date.now() - syncStartedAt);
|
|
431
|
-
const messagesText = formatReceivedMessages(report.messages, stamp);
|
|
440
|
+
const messagesText = formatReceivedMessages(report.messages, stamp, decay);
|
|
432
441
|
const structured = {
|
|
433
442
|
schemaVersion: 1,
|
|
434
443
|
status: completing ? 'complete' : 'active',
|
|
@@ -443,12 +452,19 @@ export function createMcpPresence({
|
|
|
443
452
|
},
|
|
444
453
|
peers: snapshot.peers,
|
|
445
454
|
conflicts,
|
|
446
|
-
messages: report.messages.map((message) =>
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
455
|
+
messages: report.messages.map((message) => {
|
|
456
|
+
// Raw structured path gets the same decay verdict as the rendered
|
|
457
|
+
// text: a consumer reading structured.messages directly must see the
|
|
458
|
+
// LAST-KNOWN marking, not re-derive it (additive fields, schema 1).
|
|
459
|
+
const decayInfo = messageDecayInfo(message, stamp, decay);
|
|
460
|
+
return {
|
|
461
|
+
id: message.id,
|
|
462
|
+
from: message.from,
|
|
463
|
+
text: message.text,
|
|
464
|
+
ts: message.ts,
|
|
465
|
+
...(decayInfo ? { lastKnown: true, age: decayInfo.age, stampText: decayInfo.stampText } : {}),
|
|
466
|
+
};
|
|
467
|
+
}),
|
|
452
468
|
alertsQueued,
|
|
453
469
|
delivery: {
|
|
454
470
|
proactive: 'mcp-logging-best-effort',
|