klypix-mcp 1.19.0 → 1.20.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-mcp.mjs +6 -4
- package/package.json +2 -2
- package/src/global-brain-hook.mjs +43 -11
- package/src/klypix-core.mjs +43 -5
- package/src/klypix-format.mjs +136 -5
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -138,22 +138,24 @@ server.registerTool('brain_insights', {
|
|
|
138
138
|
|
|
139
139
|
server.registerTool('brain_connect', {
|
|
140
140
|
title: 'Connect related-but-unlinked brain cards (densify the graph)',
|
|
141
|
-
description: 'Finds genuinely related cards that AREN\'T linked yet and proposes connections — semantic similarity when the on-device model is installed, else shared tags + [[mentions]]. Dry-run by default (review the suggestions); pass apply:true to draw them (ADDITIVE — never deletes; the human can remove any arrow). Use after brain_insights flags many orphans, to turn a flat list into a real knowledge graph.',
|
|
141
|
+
description: 'Finds genuinely related cards that AREN\'T linked yet and proposes connections — semantic similarity when the on-device model is installed, else shared tags + [[mentions]]. Dry-run by default (review the suggestions); pass apply:true to draw them (ADDITIVE — never deletes; the human can remove any arrow). Use after brain_insights flags many orphans, to turn a flat list into a real knowledge graph. To DISMISS a brain_reconcile false-positive contradiction, pass pairs:[{fromId,toId}] (the ids are in the reconcile output) with relationship:"not_contradiction" — a persisted dismissal that stops that pair ever resurfacing as a candidate.',
|
|
142
142
|
inputSchema: {
|
|
143
143
|
canvas: z.string().optional().describe('Canvas filename/path. Defaults to the project brain ("brain").'),
|
|
144
144
|
apply: z.boolean().optional().describe('false (default) = suggest only; true = draw the connections.'),
|
|
145
145
|
max: z.number().optional().describe('Max connections to propose/draw (default 24).'),
|
|
146
146
|
threshold: z.number().optional().describe('Min semantic similarity 0–1 to link (default 0.45). Higher = fewer, tighter links.'),
|
|
147
|
+
pairs: z.array(z.object({ fromId: z.string(), toId: z.string() })).optional().describe('Explicit card-id pairs to connect (bypasses auto-proposal). Use to dismiss a reconcile false-positive: pass the two card ids with relationship:"not_contradiction".'),
|
|
148
|
+
relationship: z.string().optional().describe('Relationship for explicit `pairs` (e.g. "not_contradiction" to permanently dismiss a contradiction candidate, or "relates_to", "depends_on", "supports").'),
|
|
147
149
|
},
|
|
148
|
-
}, async ({ canvas, apply, max, threshold }) => toContent(await opBrainConnect({ vault: VAULT, canvas, apply, max, threshold, log })));
|
|
150
|
+
}, async ({ canvas, apply, max, threshold, pairs, relationship }) => toContent(await opBrainConnect({ vault: VAULT, canvas, apply, max, threshold, pairs, relationship, log })));
|
|
149
151
|
|
|
150
152
|
server.registerTool('brain_reconcile', {
|
|
151
153
|
title: 'Reconcile the brain — contradictions between cards + unrecorded migrations',
|
|
152
|
-
description: 'Truth maintenance
|
|
154
|
+
description: 'Truth maintenance. (1) CONTRADICTIONS: finds same-subject live card pairs where one carries an explicit correction cue (uppercase "CORRECTION", "was WRONG", "OBSOLETE" — that side is the presumed truth) or the two use opposite polarity words (deferred↔wired, broken↔fixed, dead↔live), i.e. stale facts whose correction never got linked — candidates only, YOU confirm each: retire the stale card via brain_note ✓. Dismiss a FALSE positive (either kind) by connecting the two ids with brain_connect pairs + relationship:"not_contradiction" — persisted, so it never resurfaces. (2) MIGRATIONS: lists committed migration files (Supabase / Rails / Prisma / Knex / generic) that NO brain card references, so an applied-but-unnarrated rollout can be recorded. (3) LEGACY: pre-v1.15 raw-bash ship cards to tidy. Reads ONLY the filesystem — never the database, never the network — and changes nothing. Run it periodically, or when recall surfaces something you believe is stale.',
|
|
153
155
|
inputSchema: {
|
|
154
156
|
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
|
155
157
|
root: z.string().optional().describe("Project root holding the migrations dir (default: the brain file's folder)."),
|
|
156
|
-
mode: z.enum(['all', 'contradictions', 'migrations']).optional().describe('Which pass to run (default "all").'),
|
|
158
|
+
mode: z.enum(['all', 'contradictions', 'migrations', 'legacy']).optional().describe('Which pass to run (default "all"): contradictions · migrations · legacy (pre-v1.15 raw-bash ship cards to tidy).'),
|
|
157
159
|
},
|
|
158
160
|
}, async ({ canvas, root, mode }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root, mode })));
|
|
159
161
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "An open, local-first, agent-neutral canvas file your AI reads and writes over MCP — works with Claude, Cursor, Cline, any model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"node": ">=18"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "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"
|
|
56
|
+
"test": "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"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -307,6 +307,12 @@ function touchSession(sid, patch = {}) {
|
|
|
307
307
|
// the per-prompt recall renders a re-hit as one headline instead of
|
|
308
308
|
// re-paying the full card (a ~600-word card was injected 3× before).
|
|
309
309
|
injected: patch.injected !== undefined ? patch.injected : (prev.injected ?? []),
|
|
310
|
+
// Sibling ledger for LARGE cards (>1KB) with a much deeper cap: the
|
|
311
|
+
// 100-entry `injected` set evicts old ids in a long session, so a big
|
|
312
|
+
// card injected early could re-inflate full-text after ~100 other
|
|
313
|
+
// cards scrolled it out. Big cards are rare, so this cap effectively
|
|
314
|
+
// never evicts one — enforcing "no >1KB card full-text twice".
|
|
315
|
+
injectedBig: patch.injectedBig !== undefined ? patch.injectedBig : (prev.injectedBig ?? []),
|
|
310
316
|
startedAt: prev.startedAt || now, lastSeen: now,
|
|
311
317
|
});
|
|
312
318
|
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
@@ -436,9 +442,16 @@ function messageFooter(sid, tp) {
|
|
|
436
442
|
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(d2));
|
|
437
443
|
} catch { /* */ } finally { if (got) releaseLock(SESSIONS_LOCK); }
|
|
438
444
|
if (!show.length) return '';
|
|
445
|
+
// De-dupe identical message TEXT within one delivery: the same note can reach
|
|
446
|
+
// the lane twice (posted via both the 🧠 MSG marker and the brain_message MCP
|
|
447
|
+
// twin, or re-sent) as two distinct ids — all are already acked above, so
|
|
448
|
+
// dropping the repeats here shows each note once without ever re-surfacing it.
|
|
449
|
+
const seenTxt = new Set();
|
|
450
|
+
const showUniq = show.filter(m => { const k = String(m.text || '').replace(/\s+/g, ' ').trim().toLowerCase(); if (!k || seenTxt.has(k)) return false; seenTxt.add(k); return true; });
|
|
451
|
+
if (!showUniq.length) return '';
|
|
439
452
|
const ago = (ts) => { const mm = Math.max(0, Math.round((now - (ts || now)) / 60000)); return mm <= 0 ? 'just now' : `${mm}m ago`; };
|
|
440
453
|
const out = ['', '## 📨 Message(s) from another session in this project (delivered once — act on or reply to them)'];
|
|
441
|
-
for (const m of
|
|
454
|
+
for (const m of showUniq.slice(0, 6)) out.push(`- from ${String(m.from || '?').slice(0, 8)} · ${ago(m.ts)}: ${String(m.text).replace(/\s+/g, ' ').trim().slice(0, 400)}`);
|
|
442
455
|
out.push('Reply with `🧠 MSG [<their-id or all>]: <text>` — it reaches them on their next prompt.');
|
|
443
456
|
return '\n' + out.join('\n');
|
|
444
457
|
}
|
|
@@ -1236,15 +1249,30 @@ async function promptRetrieve(lib) {
|
|
|
1236
1249
|
if (struct && typeof lib.correctionOverlaysFor === 'function') {
|
|
1237
1250
|
try { overlays = lib.correctionOverlaysFor(struct, freshHits.map(h => h.card)); } catch { /* best-effort */ }
|
|
1238
1251
|
}
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1252
|
+
// Awaits-merge decay: a hit saying "PR #N awaits merge" gets a merged-overlay
|
|
1253
|
+
// when a harvested ship event already recorded #N MERGED (deterministic, no
|
|
1254
|
+
// retirement — the fact is right, only its status decayed).
|
|
1255
|
+
let mergeOv = new Map();
|
|
1256
|
+
if (struct && typeof lib.mergeOverlaysFor === 'function') {
|
|
1257
|
+
try { mergeOv = lib.mergeOverlaysFor(struct, freshHits.map(h => h.card)); } catch { /* best-effort */ }
|
|
1258
|
+
}
|
|
1259
|
+
const mergeTag = (id) => { const m = mergeOv.get(id); return m ? `\n ↳ ⚠️ PR #${m.num} is since MERGED${m.date ? ` (ship event ${m.date})` : ''} — this "awaits merge" note is stale; nothing to do.` : ''; };
|
|
1260
|
+
// Per-session injection dedup: a card already shown full-text this session
|
|
1261
|
+
// renders as one headline, not another ~600 words of context. LARGE cards
|
|
1262
|
+
// (>1KB) are tracked in a separate, deep-capped ledger so the 100-entry
|
|
1263
|
+
// `injected` set's eviction can never re-inflate one (the observed 3× bug).
|
|
1264
|
+
const BIG_CHARS = 1000;
|
|
1241
1265
|
let me = null; try { me = readSessions().find(s => s.id === sid) || null; } catch { /* */ }
|
|
1242
1266
|
const injected = new Set((me && Array.isArray(me.injected)) ? me.injected : []);
|
|
1267
|
+
const injectedBig = new Set((me && Array.isArray(me.injectedBig)) ? me.injectedBig : []);
|
|
1268
|
+
const isBig = (c) => flat(c.text).length > BIG_CHARS;
|
|
1269
|
+
const wasInjected = (c) => injected.has(c.id) || (isBig(c) && injectedBig.has(c.id));
|
|
1243
1270
|
const shownNow = new Set();
|
|
1244
1271
|
lines.push(semMode === 'sem-hit'
|
|
1245
1272
|
? "# Related prior decisions (semantic match — no exact keyword overlap; full brain via the klypix-canvas MCP)"
|
|
1246
1273
|
: "# Relevant prior decisions from this project's brain (task-matched; full brain via the klypix-canvas MCP)");
|
|
1247
|
-
const newlyInjected = [];
|
|
1274
|
+
const newlyInjected = [], newlyBig = [];
|
|
1275
|
+
const noteInjected = (c) => { newlyInjected.push(c.id); if (isBig(c)) newlyBig.push(c.id); };
|
|
1248
1276
|
for (const h of freshHits) {
|
|
1249
1277
|
if (shownNow.has(h.card.id)) continue; // already rendered this turn (e.g. as a corrector)
|
|
1250
1278
|
const ov = overlays.get(h.card.id);
|
|
@@ -1256,11 +1284,11 @@ async function promptRetrieve(lib) {
|
|
|
1256
1284
|
let correctorLine = false;
|
|
1257
1285
|
if (!shownNow.has(ov.by.id)) {
|
|
1258
1286
|
correctorLine = true;
|
|
1259
|
-
if (
|
|
1287
|
+
if (wasInjected(ov.by)) {
|
|
1260
1288
|
lines.push(`- ⚠️ CORRECTED — current (already shown this session): ${head(ov.by, 110)}`);
|
|
1261
1289
|
} else {
|
|
1262
1290
|
lines.push(`- ⚠️ CORRECTED — current: ${flat(ov.by.text)}`);
|
|
1263
|
-
|
|
1291
|
+
noteInjected(ov.by);
|
|
1264
1292
|
}
|
|
1265
1293
|
shownNow.add(ov.by.id);
|
|
1266
1294
|
}
|
|
@@ -1268,16 +1296,20 @@ async function promptRetrieve(lib) {
|
|
|
1268
1296
|
shownNow.add(h.card.id);
|
|
1269
1297
|
continue;
|
|
1270
1298
|
}
|
|
1271
|
-
if (
|
|
1272
|
-
lines.push(`- (already shown this session) ${head(h.card, 110)}`);
|
|
1299
|
+
if (wasInjected(h.card)) {
|
|
1300
|
+
lines.push(`- (already shown this session) ${head(h.card, 110)}${mergeTag(h.card.id)}`);
|
|
1273
1301
|
shownNow.add(h.card.id);
|
|
1274
1302
|
continue;
|
|
1275
1303
|
}
|
|
1276
|
-
lines.push(`- ${flat(h.card.text)}`);
|
|
1304
|
+
lines.push(`- ${flat(h.card.text)}${mergeTag(h.card.id)}`);
|
|
1277
1305
|
shownNow.add(h.card.id);
|
|
1278
|
-
|
|
1306
|
+
noteInjected(h.card);
|
|
1307
|
+
}
|
|
1308
|
+
if (newlyInjected.length) {
|
|
1309
|
+
const patch = { injected: [...new Set([...injected, ...newlyInjected])].slice(-100) };
|
|
1310
|
+
if (newlyBig.length) patch.injectedBig = [...new Set([...injectedBig, ...newlyBig])].slice(-400);
|
|
1311
|
+
touchSession(sid, patch);
|
|
1279
1312
|
}
|
|
1280
|
-
if (newlyInjected.length) touchSession(sid, { injected: [...new Set([...injected, ...newlyInjected])].slice(-100) });
|
|
1281
1313
|
}
|
|
1282
1314
|
const parts = [];
|
|
1283
1315
|
if (lines.length) parts.push(lines.join('\n'));
|
package/src/klypix-core.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite,
|
|
27
27
|
findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
|
|
28
28
|
selectGardenCandidates, applyGarden, detectContradictions,
|
|
29
|
-
rankForQuestion, questionContextToMarkdown,
|
|
29
|
+
rankForQuestion, questionContextToMarkdown, findLegacyShipCards,
|
|
30
30
|
} from './klypix-format.mjs';
|
|
31
31
|
|
|
32
32
|
// ── Card / connection input shape (single source for every face) ─────────────
|
|
@@ -462,14 +462,30 @@ export async function opBrainReconcile({ vault, canvas, root, mode = 'all' }) {
|
|
|
462
462
|
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
463
463
|
const lines = pairs.map((p, i) =>
|
|
464
464
|
`${i + 1}. ${p.why} · overlap ${p.overlap}\n`
|
|
465
|
-
+ ` · likely STALE [${p.stale.area || '?'}] ${flat(p.stale.text).slice(0, 180)}\n`
|
|
466
|
-
+ ` · likely CURRENT [${p.fresh.area || '?'}] ${flat(p.fresh.text).slice(0, 180)}`);
|
|
467
|
-
sections.push(`# ⚔️ ${pairs.length} contradiction candidate(s) — confirm, then reconcile\n_Candidates only — nothing was changed. For each REAL contradiction: retire the stale card with \`brain_note\` marker \`✓\` (text = what it resolved to), or record a correction-cue decision ("CORRECTION: …", uppercase) — capture auto-supersedes it across areas. Dismissing a FALSE positive
|
|
465
|
+
+ ` · likely STALE [${p.stale.area || '?'}] (id ${p.stale.id}) ${flat(p.stale.text).slice(0, 180)}\n`
|
|
466
|
+
+ ` · likely CURRENT [${p.fresh.area || '?'}] (id ${p.fresh.id}) ${flat(p.fresh.text).slice(0, 180)}`);
|
|
467
|
+
sections.push(`# ⚔️ ${pairs.length} contradiction candidate(s) — confirm, then reconcile\n_Candidates only — nothing was changed. For each REAL contradiction: retire the stale card with \`brain_note\` marker \`✓\` (text = what it resolved to), or record a correction-cue decision ("CORRECTION: …", uppercase) — capture auto-supersedes it across areas. Dismissing a FALSE positive (either kind — polarity OR correction-cue): \`brain_connect\` with \`pairs:[{fromId, toId}]\` and \`relationship:"not_contradiction"\` using the ids above — the dismissal is persisted, so that pair never resurfaces here again._\n\n${lines.join('\n')}`);
|
|
468
468
|
} else if (mode === 'contradictions') {
|
|
469
469
|
sections.push('✓ No contradiction candidates — no live card pair shows a correction cue or a polarity flip over the same subject.');
|
|
470
470
|
}
|
|
471
471
|
}
|
|
472
472
|
|
|
473
|
+
// (1b) LEGACY SHIP CARDS — pre-v1.15 auto-captured "merged PR — auto-captured
|
|
474
|
+
// (`gh …`)" cards with raw shell text / path-scraped junk PR numbers. Already
|
|
475
|
+
// excluded from repeat-matching (so they no longer cry wolf); this surfaces them
|
|
476
|
+
// for optional one-time cleanup. Suggestion-only — retire each with a ✓ marker.
|
|
477
|
+
if (mode === 'all' || mode === 'legacy') {
|
|
478
|
+
const { cards: legacy, total } = findLegacyShipCards(struct);
|
|
479
|
+
if (legacy.length) {
|
|
480
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
481
|
+
const lines = legacy.map(c => `- (id ${c.id}) [${c.area || '?'}] ${flat(c.text).slice(0, 140)}`);
|
|
482
|
+
const more = total > legacy.length ? `\n\n…and ${total - legacy.length} more.` : '';
|
|
483
|
+
sections.push(`# 🧹 ${total} legacy raw-bash ship card(s) — optional cleanup\n_Pre-v1.15 auto-capture residue (raw shell command / path-scraped PR numbers). They are ALREADY excluded from repeat-detection, so this is cosmetic hygiene, not a correctness fix. To tidy: retire each with \`brain_note\` marker \`✓\`, or leave them — they no longer trigger false repeat warnings._\n\n${lines.join('\n')}${more}`);
|
|
484
|
+
} else if (mode === 'legacy') {
|
|
485
|
+
sections.push('✓ No legacy raw-bash ship cards — every ship card is a clean fact.');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
473
489
|
// (2) MIGRATIONS — the brain reconciled against committed external state.
|
|
474
490
|
// Migrations live in the CODE repo (usually beside brain.klypix), not in a
|
|
475
491
|
// separate canvas vault — so default the root to the brain file's folder.
|
|
@@ -530,7 +546,7 @@ export async function opBrainGarden({ vault, canvas, apply = false, syntheses })
|
|
|
530
546
|
}
|
|
531
547
|
}
|
|
532
548
|
|
|
533
|
-
export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, log = () => {} }) {
|
|
549
|
+
export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, pairs = null, relationship = null, label = null, log = () => {} }) {
|
|
534
550
|
const tgt = brainTarget(vault, canvas);
|
|
535
551
|
if (tgt.ambiguous) return ambiguousBrainErr(tgt.ambiguous);
|
|
536
552
|
if (!tgt.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}.`);
|
|
@@ -542,6 +558,28 @@ export async function opBrainConnect({ vault, canvas, apply = false, max = 24, t
|
|
|
542
558
|
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
|
|
543
559
|
const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
|
|
544
560
|
|
|
561
|
+
// ── Explicit-pairs mode ─────────────────────────────────────────────────────
|
|
562
|
+
// Draw EXACTLY the given card-id pairs with a chosen relationship, bypassing the
|
|
563
|
+
// auto-proposer. This is the persisted DISMISS path for a reconcile false
|
|
564
|
+
// positive: pass relationship:"not_contradiction" and detectContradictions will
|
|
565
|
+
// treat the pair as settled forever (the escape hatch a correction-cue false
|
|
566
|
+
// positive — which has no stale card to retire — previously lacked).
|
|
567
|
+
if (Array.isArray(pairs) && pairs.length) {
|
|
568
|
+
const rel = typeof relationship === 'string' && relationship ? relationship : 'relates_to';
|
|
569
|
+
const lbl = (typeof label === 'string' && label) ? label : (rel === 'not_contradiction' ? 'not a contradiction' : undefined);
|
|
570
|
+
const explicit = pairs
|
|
571
|
+
.filter(p => p && p.fromId && p.toId && byId.has(p.fromId) && byId.has(p.toId) && p.fromId !== p.toId)
|
|
572
|
+
.map(p => ({ fromId: p.fromId, toId: p.toId, relationship: rel, ...(lbl ? { label: lbl } : {}) }));
|
|
573
|
+
if (!explicit.length) return err('pairs needs [{fromId, toId}, …] where both ids are real cards in this brain (see the ids in brain_reconcile output).');
|
|
574
|
+
const render2 = (e) => `- ${flat(byId.get(e.fromId)?.text)} ↔ ${flat(byId.get(e.toId)?.text)} (${e.relationship}${e.label ? `: ${e.label}` : ''})`;
|
|
575
|
+
if (!apply) return { blocks: [text(`# ${explicit.length} explicit connection(s) to draw\n_Re-run with apply:true to draw them.${rel === 'not_contradiction' ? ' These pairs will then be permanently dismissed as contradiction candidates.' : ''}_\n\n${explicit.map(render2).join('\n')}`)] };
|
|
576
|
+
try {
|
|
577
|
+
const { buffer, added } = await addBrainConnections(fs.readFileSync(file), explicit);
|
|
578
|
+
await atomicWrite(file, buffer);
|
|
579
|
+
return { blocks: [text(`✓ Drew ${added} connection(s)${rel === 'not_contradiction' ? ' — these pair(s) are now dismissed and will NOT resurface as brain_reconcile contradiction candidates' : ''}.\n\n${explicit.slice(0, added).map(render2).join('\n')}`)] };
|
|
580
|
+
} catch (e) { return err(`Apply failed (brain unchanged): ${e.message}`); }
|
|
581
|
+
}
|
|
582
|
+
|
|
545
583
|
let edges = [];
|
|
546
584
|
let mode = 'structural (shared tags + [[mentions]])';
|
|
547
585
|
const pipe = await Promise.race([getEmbedder(log), new Promise(r => setTimeout(() => r(null), 20_000))]);
|
package/src/klypix-format.mjs
CHANGED
|
@@ -127,7 +127,11 @@ export async function parseKlypix(buffer) {
|
|
|
127
127
|
return { struct, zip, assetPaths, isV4, canvas, manifest };
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
// 'not_contradiction' is a DISMISSAL edge, not a topical relation: an agent draws
|
|
131
|
+
// it between two cards a reconcile pass flagged as a FALSE contradiction, and
|
|
132
|
+
// detectContradictions then treats the pair as settled forever (the persisted
|
|
133
|
+
// dismiss path the correction-cue false-positive previously lacked).
|
|
134
|
+
const REL = new Set(['leads_to', 'depends_on', 'relates_to', 'conflicts_with', 'supports', 'questions', 'costs', 'blocks', 'not_contradiction']);
|
|
131
135
|
|
|
132
136
|
/**
|
|
133
137
|
* Build a real .klypix v4 file (nodebuffer) from a simple spec:
|
|
@@ -827,13 +831,17 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
827
831
|
const out = [];
|
|
828
832
|
const push = (...lines) => { for (const l of lines) { out.push(l); used += l.length + 1; } };
|
|
829
833
|
|
|
834
|
+
// Overdue open cards (self-declared deadline passed) — badged inline so a
|
|
835
|
+
// stale-dated reminder is flagged the next session instead of decaying silently.
|
|
836
|
+
const overdueById = findOverdueOpenCards(struct).byId;
|
|
837
|
+
const odBadge = (c) => { const o = overdueById.get(c.id); return o ? ` · ⏰ OVERDUE — deadline ${o.date} passed ${o.daysOverdue}d ago; verify or close (✓)` : ''; };
|
|
830
838
|
push(`# ${struct.title} — brain brief`);
|
|
831
839
|
push(`*${struct.format} · ${struct.counts.cards} cards · ${struct.counts.connections} connections · tiered brief (focus + open + last ${recentDays}d headlines); full cards via klypix-canvas MCP search*`);
|
|
832
840
|
if (focus.length) {
|
|
833
841
|
push('', '## 📌 Human focus (cards the human placed here — act on these first)');
|
|
834
|
-
for (const c of focus) push(`- ${fr(c)}${flat(c.text)}`);
|
|
842
|
+
for (const c of focus) push(`- ${fr(c)}${flat(c.text)}${odBadge(c)}`);
|
|
835
843
|
}
|
|
836
|
-
if (open.length) { push('', '## Open questions & goals'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}`); }
|
|
844
|
+
if (open.length) { push('', '## Open questions & goals'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}${odBadge(c)}`); }
|
|
837
845
|
if (skills.length) {
|
|
838
846
|
push('', '## 🛠️ Skills — how we do things here (reusable; applies every session)');
|
|
839
847
|
for (const c of skills.slice(0, maxSkills)) push(`- ${fr(c)}${flat(c.text)}`);
|
|
@@ -942,9 +950,13 @@ export function structToUltraBrief(struct, { freshness = null, briefPath = '.cla
|
|
|
942
950
|
for (const c of conflicts.slice(0, 4)) { if (!pushIf(`- ${clip(c.from, 70)} ⚔️ ${clip(c.to, 70)}`)) break; shown++; }
|
|
943
951
|
if (shown < conflicts.length) pushIf(`- …and ${conflicts.length - shown} more conflict(s) — in the full brief.`);
|
|
944
952
|
}
|
|
945
|
-
|
|
953
|
+
// Overdue opens lead (and get a ⏰ prefix) so a passed deadline is never the
|
|
954
|
+
// line that falls off the bottom of the preview-sized budget.
|
|
955
|
+
const overdueById = findOverdueOpenCards(struct).byId;
|
|
956
|
+
const openSorted = open.slice().sort((a, b) => (overdueById.has(b.id) ? 1 : 0) - (overdueById.has(a.id) ? 1 : 0));
|
|
957
|
+
if (open.length && pushIf('') && pushIf(`## Open questions & goals (${open.length}${overdueById.size ? `, ${overdueById.size} ⏰ overdue` : ''})`)) {
|
|
946
958
|
let shown = 0;
|
|
947
|
-
for (const c of
|
|
959
|
+
for (const c of openSorted) { if (!pushIf(`- ${overdueById.has(c.id) ? '⏰ OVERDUE ' : ''}${fr(c)}${head(c)}`)) break; shown++; }
|
|
948
960
|
if (shown < open.length) pushIf(`- …and ${open.length - shown} more — in the full brief.`);
|
|
949
961
|
}
|
|
950
962
|
const areas = struct.cards.filter(c => c.type === 'container' && !/^archive$/i.test(c.title || ''))
|
|
@@ -1193,6 +1205,35 @@ const REPEAT_VERB_STOP = new Set([
|
|
|
1193
1205
|
// PR#), or a kebab/snake identifier. A #file-/#dir- tag-stem match counts as an
|
|
1194
1206
|
// entity at match time regardless of shape (tags are capture-stamped anchors).
|
|
1195
1207
|
const isEntityToken = (t) => /\d/.test(t) || t.includes('-') || t.includes('_');
|
|
1208
|
+
// ── Legacy raw-bash ship cards (pre-v1.15 auto-capture residue) ──────────────
|
|
1209
|
+
// Before the v1.15 ship-capture rewrite, auto-harvest dumped the RAW shell command
|
|
1210
|
+
// into the card ("🏁 merged PR #850 — auto-captured (`cd /c/Users/…/8db42`)") and
|
|
1211
|
+
// scraped stray path digits as PR numbers ("merged PR #238886"). These are dense
|
|
1212
|
+
// with generic ship verbs + junk numbers, so the possible-repeat warner kept
|
|
1213
|
+
// matching them and crying wolf — training agents to ignore repeat warnings. They
|
|
1214
|
+
// are (a) excluded from detectRepeatWork below and (b) surfaced by findLegacyShip-
|
|
1215
|
+
// Cards for a one-time cleanup. Detection is SIGNATURE-based (not just "old"), so a
|
|
1216
|
+
// CLEAN ship card ("Ship: 🏁 merged PR #286") is never touched.
|
|
1217
|
+
export const isLegacyRawShipCard = (text) => {
|
|
1218
|
+
const t = String(text || '');
|
|
1219
|
+
if (!/🏁|\bmerged\b|\brelease|\bpublish|\bshipped\b|\btagged\b/i.test(t)) return false; // ship-shaped only
|
|
1220
|
+
return /auto-captured/i.test(t) // the explicit pre-v1.15 stamp
|
|
1221
|
+
|| /`[^`]*\b(?:cd|gh|git|npm|node|npx)\b[^`]*`/i.test(t) // a raw shell command left inside the card
|
|
1222
|
+
|| /(?:\/c\/users\/|[a-z]:[\\/]+users[\\/]|\/(?:home|users|mnt|tmp)\/)/i.test(t) // a filesystem-path fragment
|
|
1223
|
+
|| /\b(?:PR|pull request|#)\s*#?\d{6,}\b/i.test(t); // a 6+ digit "PR number" = scraped path digits, not a real PR
|
|
1224
|
+
};
|
|
1225
|
+
// One-time cleanup surface: the legacy raw-bash ship cards still live in a brain.
|
|
1226
|
+
// Suggestion-only (retire each with a ✓ marker) — they're already excluded from
|
|
1227
|
+
// repeat-matching, so this is cosmetic hygiene, not a correctness fix.
|
|
1228
|
+
export function findLegacyShipCards(struct, { max = 20 } = {}) {
|
|
1229
|
+
const empty = { cards: [], total: 0 };
|
|
1230
|
+
if (!struct || !Array.isArray(struct.cards)) return empty;
|
|
1231
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1232
|
+
const hits = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim()
|
|
1233
|
+
&& !isArchived(c) && !/↩|✅/.test(c.text) && isLegacyRawShipCard(c.text));
|
|
1234
|
+
return { cards: hits.slice(0, max), total: hits.length };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1196
1237
|
export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTokens = 2 } = {}) {
|
|
1197
1238
|
const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
|
|
1198
1239
|
if (tokens.length < minTokens || !struct || !Array.isArray(struct.cards)) return [];
|
|
@@ -1203,6 +1244,7 @@ export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTok
|
|
|
1203
1244
|
if (c.type === 'container' || !(c.text || '').trim()) continue;
|
|
1204
1245
|
const kind = kindOf(c.text);
|
|
1205
1246
|
if (!kind) continue; // only COMPLETED-work cards qualify
|
|
1247
|
+
if (isLegacyRawShipCard(c.text)) continue; // pre-v1.15 raw-bash residue — never a repeat candidate (nonsense scraped PR numbers)
|
|
1206
1248
|
// Score the first MEANINGFUL line, not a marker stamp: supersede/resolve
|
|
1207
1249
|
// prepend "↩︎ superseded <date>" / lead with "✅ …", which would otherwise
|
|
1208
1250
|
// become the title and hide the real content from title-weighted matching.
|
|
@@ -1307,6 +1349,12 @@ export function detectContradictions(struct, { minOverlap = 0.45, topK = 12 } =
|
|
|
1307
1349
|
for (const e of struct.connections || []) {
|
|
1308
1350
|
const k1 = e.fromId + '|' + e.toId, k2 = e.toId + '|' + e.fromId;
|
|
1309
1351
|
if (e.label === 'superseded by' || e.label === 'closed by' || e.relationship === 'conflicts_with') { settled.add(k1); settled.add(k2); }
|
|
1352
|
+
// Explicit "not a contradiction" dismissal — settles BOTH a cue pair and a
|
|
1353
|
+
// polarity pair. This is the persisted escape hatch for a correction-cue
|
|
1354
|
+
// FALSE positive: it had no stale card to retire, so a plain link never
|
|
1355
|
+
// cleared it and it re-reported on every run forever. A deliberate
|
|
1356
|
+
// not_contradiction edge (brain_connect pairs:…) now permanently dismisses it.
|
|
1357
|
+
if (e.relationship === 'not_contradiction' || e.label === 'not a contradiction') { settled.add(k1); settled.add(k2); }
|
|
1310
1358
|
if (e.label !== 'auto') { linked.add(k1); linked.add(k2); }
|
|
1311
1359
|
}
|
|
1312
1360
|
const out = [];
|
|
@@ -1577,6 +1625,50 @@ export function correctionOverlaysFor(struct, cards, { at = CORRECTION_SUPERSEDE
|
|
|
1577
1625
|
return out;
|
|
1578
1626
|
}
|
|
1579
1627
|
|
|
1628
|
+
// ── Awaits-merge decay — the deterministic twin of the correction overlay ────
|
|
1629
|
+
// A milestone written minutes before the human merges ("PR #332 awaits founder
|
|
1630
|
+
// merge") stays stale forever, even though ship-event auto-capture DOES record
|
|
1631
|
+
// "merged PR #332" — nothing linked the two. Matching a "PR #N" + awaits-cue card
|
|
1632
|
+
// against a harvested "merged PR #N" is EXACT-STRING, zero-inference work. Given
|
|
1633
|
+
// the cards recall is about to inject, return for each one carrying an unmet
|
|
1634
|
+
// merge that the ship event contradicts a merged-overlay {num, by, date}. The
|
|
1635
|
+
// caller annotates the card (never retires it — the fact is right, only its
|
|
1636
|
+
// status decayed). Pure + cheap. PR numbers are capped at 5 digits — a 6+ digit
|
|
1637
|
+
// "#N" is a path-scraped junk number (see isLegacyRawShipCard), never a real PR.
|
|
1638
|
+
const PR_MERGE_AWAIT_RE = /\b(?:await(?:s|ing)?|pending|not\s+yet|yet\s+to\s+be|to\s+be|unmerged|needs?)\b[^.\n]{0,24}?\bmerg/i;
|
|
1639
|
+
const PR_MERGED_RE = /\bmerged\b[^.\n]{0,16}?(?:PR|pull\s+request|#)\s*#?(\d{1,5})\b/i;
|
|
1640
|
+
const PR_REFS_RE = /\b(?:PR|pull\s+request|#)\s*#?(\d{1,5})\b/ig;
|
|
1641
|
+
function prNumbersIn(text) {
|
|
1642
|
+
const out = new Set(); const s = String(text || '');
|
|
1643
|
+
PR_REFS_RE.lastIndex = 0; let m;
|
|
1644
|
+
while ((m = PR_REFS_RE.exec(s))) out.add(m[1]);
|
|
1645
|
+
return out;
|
|
1646
|
+
}
|
|
1647
|
+
export function mergeOverlaysFor(struct, cards) {
|
|
1648
|
+
const out = new Map();
|
|
1649
|
+
if (!struct || !Array.isArray(struct.cards) || !Array.isArray(cards) || !cards.length) return out;
|
|
1650
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1651
|
+
// Index PR number → the NEWEST live card that says it merged (ship event or hand marker).
|
|
1652
|
+
const mergedBy = new Map();
|
|
1653
|
+
for (const c of struct.cards) {
|
|
1654
|
+
if (c.type === 'container' || isArchived(c) || !(c.text || '').trim()) continue;
|
|
1655
|
+
const mm = PR_MERGED_RE.exec(c.text); if (!mm) continue;
|
|
1656
|
+
const prev = mergedBy.get(mm[1]);
|
|
1657
|
+
if (!prev || (c.createdAt || 0) >= (prev.createdAt || 0)) mergedBy.set(mm[1], c);
|
|
1658
|
+
}
|
|
1659
|
+
if (!mergedBy.size) return out;
|
|
1660
|
+
for (const card of cards) {
|
|
1661
|
+
if (!card || !card.id || !(card.text || '').trim()) continue;
|
|
1662
|
+
if (PR_MERGED_RE.test(card.text)) continue; // the hit IS a merge note — nothing to overlay
|
|
1663
|
+
if (!PR_MERGE_AWAIT_RE.test(card.text)) continue; // must actually claim it's awaiting a merge
|
|
1664
|
+
for (const num of prNumbersIn(card.text)) {
|
|
1665
|
+
const by = mergedBy.get(num);
|
|
1666
|
+
if (by && by.id !== card.id) { out.set(card.id, { kind: 'merged', num, by, date: by.createdAt ? new Date(by.createdAt).toISOString().slice(0, 10) : null }); break; }
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
return out;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1580
1672
|
// ── Auto-skill classifier (skills emerge from the flow, not just the '+' marker) ─
|
|
1581
1673
|
// A REUSABLE skill (how-to / gotcha / convention) reads as a GENERAL RULE that
|
|
1582
1674
|
// applies next time — distinct from a one-time decision ("we shipped X"). This is
|
|
@@ -2124,6 +2216,45 @@ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
|
|
|
2124
2216
|
return { gaps: out.slice(0, max), total: out.length };
|
|
2125
2217
|
}
|
|
2126
2218
|
|
|
2219
|
+
// ── Open-question deadline awareness ─────────────────────────────────────────
|
|
2220
|
+
// An open card can carry a self-declared deadline ("Rotate NPM_TOKEN before
|
|
2221
|
+
// ~2026-07-03"). Once that day passes the card is OVERDUE — but nothing surfaced
|
|
2222
|
+
// it, so it decayed silently and served the same stale reminder a day late. This
|
|
2223
|
+
// parses an EXPLICIT, cue-anchored ISO date only (a bare date elsewhere in the
|
|
2224
|
+
// card — "shipped 2026-06-30" — is NOT a deadline), so a false overdue flag can't
|
|
2225
|
+
// fire. Deadline = end of the named UTC day. Used to badge overdue opens in both
|
|
2226
|
+
// brief tiers (the "flag it in the next session's brief" acceptance criterion).
|
|
2227
|
+
const DEADLINE_RE = /\b(?:before|by|due(?:\s+(?:date|by|on))?|deadline|until|no\s+later\s+than|not\s+later\s+than|eod|end\s+of(?:\s+day)?)\b[^\n.]{0,32}?~?\s*(\d{4}-\d{2}-\d{2})\b/i;
|
|
2228
|
+
export function parseDeadline(text) {
|
|
2229
|
+
const m = DEADLINE_RE.exec(String(text || ''));
|
|
2230
|
+
if (!m) return null;
|
|
2231
|
+
const ts = Date.parse(m[1] + 'T23:59:59Z'); // due at the END of the named day (UTC)
|
|
2232
|
+
return Number.isFinite(ts) ? { date: m[1], ts } : null;
|
|
2233
|
+
}
|
|
2234
|
+
// Live open (❓/🎯) cards whose parsed deadline has passed, newest-overdue-last so
|
|
2235
|
+
// the most-overdue lead. Injectable `now` for hermetic tests. Skips already
|
|
2236
|
+
// resolved/superseded cards (they carry ✅/↩︎) and skills (standing reference).
|
|
2237
|
+
export function findOverdueOpenCards(struct, { now = Date.now() } = {}) {
|
|
2238
|
+
const empty = { overdue: [], total: 0, byId: new Map() };
|
|
2239
|
+
if (!struct || !Array.isArray(struct.cards)) return empty;
|
|
2240
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
2241
|
+
const dayMs = (iso) => Date.parse(iso + 'T00:00:00Z');
|
|
2242
|
+
const nowDay = dayMs(new Date(now).toISOString().slice(0, 10));
|
|
2243
|
+
const out = [];
|
|
2244
|
+
for (const c of struct.cards) {
|
|
2245
|
+
if (c.type === 'container' || !(c.text || '').trim() || isArchived(c)) continue;
|
|
2246
|
+
if (/↩|✅/.test(c.text)) continue; // already superseded/resolved
|
|
2247
|
+
if (!/❓|🎯/.test(c.text) || /🛠/.test(c.text)) continue; // open questions & goals only
|
|
2248
|
+
const d = parseDeadline(c.text);
|
|
2249
|
+
// Overdue gate is LENIENT (end of the named day, d.ts) — a card isn't overdue
|
|
2250
|
+
// at 00:01 on its deadline day. The DISPLAY count is a whole-calendar-day
|
|
2251
|
+
// difference, so "before 2026-07-03" seen on 2026-07-04 reads "passed 1d ago".
|
|
2252
|
+
if (d && d.ts < now) out.push({ card: c, date: d.date, daysOverdue: Math.max(0, Math.round((nowDay - dayMs(d.date)) / 86_400_000)) });
|
|
2253
|
+
}
|
|
2254
|
+
out.sort((a, b) => b.daysOverdue - a.daysOverdue);
|
|
2255
|
+
return { overdue: out, total: out.length, byId: new Map(out.map(o => [o.card.id, o])) };
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2127
2258
|
// ── Deliberate note → capture input ──────────────────────────────────────────
|
|
2128
2259
|
// Turn ONE structured note into captureIntoBrain's input shape — the deliberate
|
|
2129
2260
|
// twin of the Stop hook's transcript marker parser. This is what lets an ON-DEMAND
|