klypix-mcp 1.16.0 → 1.17.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 +4 -3
- package/package.json +2 -2
- package/src/agent-rules.mjs +6 -1
- package/src/global-brain-hook.mjs +120 -13
- package/src/klypix-core.mjs +51 -12
- package/src/klypix-format.mjs +382 -32
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -137,13 +137,14 @@ server.registerTool('brain_connect', {
|
|
|
137
137
|
}, async ({ canvas, apply, max, threshold }) => toContent(await opBrainConnect({ vault: VAULT, canvas, apply, max, threshold, log })));
|
|
138
138
|
|
|
139
139
|
server.registerTool('brain_reconcile', {
|
|
140
|
-
title: 'Reconcile the brain
|
|
141
|
-
description: '
|
|
140
|
+
title: 'Reconcile the brain — contradictions between cards + unrecorded migrations',
|
|
141
|
+
description: 'Truth maintenance in two passes. (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 ✓; a false POLARITY pair is dismissed by deliberately connecting the pair (brain_connect), while a correction-cue pair clears only when the stale card is retired. (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. 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.',
|
|
142
142
|
inputSchema: {
|
|
143
143
|
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
|
144
144
|
root: z.string().optional().describe("Project root holding the migrations dir (default: the brain file's folder)."),
|
|
145
|
+
mode: z.enum(['all', 'contradictions', 'migrations']).optional().describe('Which pass to run (default "all").'),
|
|
145
146
|
},
|
|
146
|
-
}, async ({ canvas, root }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root })));
|
|
147
|
+
}, async ({ canvas, root, mode }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root, mode })));
|
|
147
148
|
|
|
148
149
|
server.registerTool('brain_garden', {
|
|
149
150
|
title: 'Garden the brain — consolidate over-grown areas (sleep-time compute)',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.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"
|
|
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"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/src/agent-rules.mjs
CHANGED
|
@@ -54,6 +54,7 @@ Treat it as authoritative project context, and as **the place project knowledge
|
|
|
54
54
|
survives across sessions, agents, and context resets.
|
|
55
55
|
|
|
56
56
|
**At the start of a task — read it** so you know the project's state and past decisions:
|
|
57
|
+
- if \`.claude/brain-brief.md\` exists, read it — it is the full session brief the brain hook regenerates at every session start (Focus, open questions, skills, recent decisions).
|
|
57
58
|
- with the \`klypix-canvas\` MCP server: call \`search_canvases\` / \`read_canvas\` (canvas: \`"brain"\`), or \`brain_insights\` for the load-bearing cards.
|
|
58
59
|
- or via CLI: \`npx klypix-read brain.klypix\`
|
|
59
60
|
|
|
@@ -64,7 +65,11 @@ survives across sessions, agents, and context resets.
|
|
|
64
65
|
|
|
65
66
|
Capture **sparingly** — real decisions / milestones / open questions / reusable gotchas, not routine
|
|
66
67
|
steps — and capture it **at the moment you decide** (a one-line marker inline), not batched or left in
|
|
67
|
-
a scratch file to rot. Link related cards with \`[[other-card]]\`.
|
|
68
|
+
a scratch file to rot. Link related cards with \`[[other-card]]\`. To **correct a stale card**, include
|
|
69
|
+
the word \`CORRECTION\` (or "was WRONG" / "OBSOLETE" — UPPERCASE, the deliberate signal) in the
|
|
70
|
+
decision — capture then supersedes the stale card across ALL areas (archived with an arrow + a
|
|
71
|
+
receipt, restorable if wrong). Suspect stale facts survive somewhere? Run \`brain_reconcile\` — it
|
|
72
|
+
surfaces contradiction candidates to confirm.
|
|
68
73
|
|
|
69
74
|
**Memory routing (important).** If your host has its OWN memory/notes store, that is for *user*
|
|
70
75
|
preferences and how to work with this person — keep using it for that. But **project** knowledge
|
|
@@ -303,6 +303,10 @@ function touchSession(sid, patch = {}) {
|
|
|
303
303
|
// ships ACCUMULATE across turns (deduped, last 5) so a peer sees the
|
|
304
304
|
// session's recent ship-events, not just the latest turn's.
|
|
305
305
|
ships: patch.ships !== undefined ? [...new Set([...(prev.ships || []), ...patch.ships])].slice(-5) : (prev.ships ?? []),
|
|
306
|
+
// Card ids already injected full-text into THIS session's prompts —
|
|
307
|
+
// the per-prompt recall renders a re-hit as one headline instead of
|
|
308
|
+
// re-paying the full card (a ~600-word card was injected 3× before).
|
|
309
|
+
injected: patch.injected !== undefined ? patch.injected : (prev.injected ?? []),
|
|
306
310
|
startedAt: prev.startedAt || now, lastSeen: now,
|
|
307
311
|
});
|
|
308
312
|
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
@@ -348,7 +352,7 @@ function peerFooter(sid) {
|
|
|
348
352
|
else if (p.branch && myBranch && p.branch === myBranch) warn = ' · ⚠️ same branch — pull/rebase before you commit';
|
|
349
353
|
lines.push(`- session ${String(p.id).slice(0, 8)} · ${bits.join(' · ')}${warn}`);
|
|
350
354
|
}
|
|
351
|
-
lines.push('
|
|
355
|
+
lines.push('Coordinate BEFORE touching shared files: reply with `🧠 MSG [<their id-prefix or branch>]: <text>` (or call the `brain_message` MCP tool) — delivered once at their next prompt. Check the brain for what they decided/shipped.');
|
|
352
356
|
return lines.join('\n');
|
|
353
357
|
}
|
|
354
358
|
|
|
@@ -972,7 +976,10 @@ async function capture(lib) {
|
|
|
972
976
|
const key = sha(('ship|' + p.area + '|' + summary).toLowerCase());
|
|
973
977
|
if (seen.has(key)) break; // already captured this ship
|
|
974
978
|
seen.add(key);
|
|
975
|
-
|
|
979
|
+
// #auto marks machine-harvested provenance: the repeat-detector demands an
|
|
980
|
+
// entity-token match on these (they're dense with generic ship verbs) and
|
|
981
|
+
// the gardener consolidates them at a shorter age.
|
|
982
|
+
cards.push({ text: `${p.area}: 🏁 ${summary}\n#${slugify(p.area)} #auto`, area: p.area, borderColor: 'rgba(59,130,246,0.8)', createdVia: 'ship-event' });
|
|
976
983
|
shipSummaries.push(summary);
|
|
977
984
|
ledger.push({ action: 'ship-event', area: p.area, preview: summary });
|
|
978
985
|
break; // one pattern per command
|
|
@@ -1047,10 +1054,17 @@ async function capture(lib) {
|
|
|
1047
1054
|
const bits = [`${stats.added} added`];
|
|
1048
1055
|
if (stats.resolved) bits.push(`${stats.resolved} resolved`);
|
|
1049
1056
|
if (stats.updated) bits.push(`${stats.updated} updated`);
|
|
1057
|
+
if (stats.merged) bits.push(`${stats.merged} merged`);
|
|
1050
1058
|
if (stats.closed) bits.push(`${stats.closed} closed`);
|
|
1051
1059
|
if (stats.superseded) bits.push(`${stats.superseded} superseded`);
|
|
1052
1060
|
if (stats.linked) bits.push(`${stats.linked} linked`);
|
|
1053
1061
|
process.stderr.write(`[brain] capture: ${bits.join(' · ')} → brain.klypix\n`);
|
|
1062
|
+
// Receipt for correction-driven (cross-area / low-bar) supersedes — the
|
|
1063
|
+
// confirmation channel: say WHAT was archived and how to undo a wrong grab.
|
|
1064
|
+
if (Array.isArray(stats.corrections) && stats.corrections.length) {
|
|
1065
|
+
process.stderr.write(`[brain] correction supersede: ${stats.corrections.map(c => `"${c.old}" (${c.overlap})`).join('; ')} — archived + arrowed; restore from Archive or ~ update if wrong\n`);
|
|
1066
|
+
}
|
|
1067
|
+
if (stats.added > 0 && !stats.linked) process.stderr.write(`[brain] note: ${stats.added} card(s) landed unlinked — \`brain_connect\` (or [[wikilinks]] next time) wires them into the graph\n`);
|
|
1054
1068
|
appendJsonl(LEDGER, { ts: nowIso(), mode: 'capture', stats, decisions: ledger }, 1000);
|
|
1055
1069
|
appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'capture', ok: true, brainBytes: brainBytes(), added: stats.added, skipped: ledger.filter(d => d.action.startsWith('skipped')).length }, 500);
|
|
1056
1070
|
}
|
|
@@ -1196,20 +1210,74 @@ async function promptRetrieve(lib) {
|
|
|
1196
1210
|
const head = (c, n = 120) => { const t = flat(c.text); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
1197
1211
|
const lines = [];
|
|
1198
1212
|
if (repeats.length) {
|
|
1213
|
+
// A nudged "already shipped" card may itself have been CORRECTED since —
|
|
1214
|
+
// nudging the agent to reuse a stale fact would be worse than no nudge.
|
|
1215
|
+
let repOverlays = new Map();
|
|
1216
|
+
if (struct && typeof lib.correctionOverlaysFor === 'function') {
|
|
1217
|
+
try { repOverlays = lib.correctionOverlaysFor(struct, repeats.map(r => r.card)); } catch { /* best-effort */ }
|
|
1218
|
+
}
|
|
1199
1219
|
lines.push('## ⚠️ Possible repeat — this may already be done (reuse/supersede, don’t silently redo)');
|
|
1200
1220
|
for (const r of repeats) {
|
|
1201
1221
|
const verb = r.kind === 'superseded'
|
|
1202
1222
|
? `you moved OFF this ${day(r.card.createdAt)} — check what replaced it before redoing`
|
|
1203
1223
|
: `already ${r.kind} ${day(r.card.createdAt)} — reuse/build on it, or supersede it deliberately`;
|
|
1204
|
-
|
|
1224
|
+
const ov = repOverlays.get(r.card.id);
|
|
1225
|
+
const corr = ov ? ` · ⚠️ a live CORRECTION exists — read it first: “${head(ov.by, 90)}”` : '';
|
|
1226
|
+
lines.push(`- [${flat(r.card.area) || '?'}] ${head(r.card)} · ${verb}${corr}`);
|
|
1205
1227
|
}
|
|
1206
1228
|
lines.push('Read the matching card (klypix-canvas MCP / brain) BEFORE redoing. Other project? use search_all_brains.');
|
|
1207
1229
|
}
|
|
1208
1230
|
if (freshHits.length) {
|
|
1231
|
+
// Truth-decay guard: for each hit with a supersede/close edge or an
|
|
1232
|
+
// overlapping live correction-cue card, inject the CORRECTOR full-text
|
|
1233
|
+
// FIRST and reduce the stale hit to a labeled headline — a stale card
|
|
1234
|
+
// must never stand alone (the worst failure mode a memory can have).
|
|
1235
|
+
let overlays = new Map();
|
|
1236
|
+
if (struct && typeof lib.correctionOverlaysFor === 'function') {
|
|
1237
|
+
try { overlays = lib.correctionOverlaysFor(struct, freshHits.map(h => h.card)); } catch { /* best-effort */ }
|
|
1238
|
+
}
|
|
1239
|
+
// Per-session injection dedup: a card already shown full-text this
|
|
1240
|
+
// session renders as one headline, not another ~600 words of context.
|
|
1241
|
+
let me = null; try { me = readSessions().find(s => s.id === sid) || null; } catch { /* */ }
|
|
1242
|
+
const injected = new Set((me && Array.isArray(me.injected)) ? me.injected : []);
|
|
1243
|
+
const shownNow = new Set();
|
|
1209
1244
|
lines.push(semMode === 'sem-hit'
|
|
1210
1245
|
? "# Related prior decisions (semantic match — no exact keyword overlap; full brain via the klypix-canvas MCP)"
|
|
1211
1246
|
: "# Relevant prior decisions from this project's brain (task-matched; full brain via the klypix-canvas MCP)");
|
|
1212
|
-
|
|
1247
|
+
const newlyInjected = [];
|
|
1248
|
+
for (const h of freshHits) {
|
|
1249
|
+
if (shownNow.has(h.card.id)) continue; // already rendered this turn (e.g. as a corrector)
|
|
1250
|
+
const ov = overlays.get(h.card.id);
|
|
1251
|
+
if (ov) {
|
|
1252
|
+
// The STALE demotion is UNCONDITIONAL — even when the corrector
|
|
1253
|
+
// already rendered (as its own hit, or for a twin), the stale
|
|
1254
|
+
// card must never fall through to the plain full-text branch.
|
|
1255
|
+
// The corrector prints full-text at most once per session.
|
|
1256
|
+
let correctorLine = false;
|
|
1257
|
+
if (!shownNow.has(ov.by.id)) {
|
|
1258
|
+
correctorLine = true;
|
|
1259
|
+
if (injected.has(ov.by.id)) {
|
|
1260
|
+
lines.push(`- ⚠️ CORRECTED — current (already shown this session): ${head(ov.by, 110)}`);
|
|
1261
|
+
} else {
|
|
1262
|
+
lines.push(`- ⚠️ CORRECTED — current: ${flat(ov.by.text)}`);
|
|
1263
|
+
newlyInjected.push(ov.by.id);
|
|
1264
|
+
}
|
|
1265
|
+
shownNow.add(ov.by.id);
|
|
1266
|
+
}
|
|
1267
|
+
lines.push(`${correctorLine ? ' ↳' : '-'} ⚠️ recall matched a STALE card${ov.kind === 'edge' ? ' (superseded)' : ''}: “${head(h.card, 110)}” — do NOT act on it${correctorLine ? '' : ' (its CORRECTION is listed above)'}. Reconcile: \`brain_reconcile\` (contradictions) or a ✓/~ marker.`);
|
|
1268
|
+
shownNow.add(h.card.id);
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
if (injected.has(h.card.id)) {
|
|
1272
|
+
lines.push(`- (already shown this session) ${head(h.card, 110)}`);
|
|
1273
|
+
shownNow.add(h.card.id);
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
lines.push(`- ${flat(h.card.text)}`);
|
|
1277
|
+
shownNow.add(h.card.id);
|
|
1278
|
+
newlyInjected.push(h.card.id);
|
|
1279
|
+
}
|
|
1280
|
+
if (newlyInjected.length) touchSession(sid, { injected: [...new Set([...injected, ...newlyInjected])].slice(-100) });
|
|
1213
1281
|
}
|
|
1214
1282
|
const parts = [];
|
|
1215
1283
|
if (lines.length) parts.push(lines.join('\n'));
|
|
@@ -1437,6 +1505,8 @@ function legendFooter() {
|
|
|
1437
1505
|
+ '🧠 **Capture markers** — write these in your reply; the Stop hook harvests them into the brain (no separate log step). Use sparingly, for real decisions / milestones / discoveries:\n'
|
|
1438
1506
|
+ '`🧠 BRAIN [Area]: decision` · `[Area] ?: open question` · `[Area] !: milestone` · `[Area] +: 🛠️ skill (reusable how-to / gotcha — resurfaces every session, never ages out)` · `[Area] ✓: resolves+archives the matching card` · `[Area] ~: updates it in place` · 🎯 in text = a goal (reads as open).\n'
|
|
1439
1507
|
+ 'Optional suffixes: `closes: <card title / [[wikilink]]>` (resolve the strategy/question this fulfils) · `ev: <file[:line]>, PR#<n>` (anchor to code → auto drift-badge).\n'
|
|
1508
|
+
+ '**Correcting a stale card:** include the word `CORRECTION` (or "was WRONG" / "OBSOLETE" — UPPERCASE; casing is the deliberate-signal, casual prose never fires it) in the decision — the capture then hunts the stale card across ALL areas at a lower match bar and supersedes it (archived + arrowed, with a receipt; restore from Archive if it grabbed the wrong one). A rephrased duplicate `?` merges into the existing open question instead of stacking a twin.\n'
|
|
1509
|
+
+ '**Session brief:** the SessionStart hook prints a ≤2KB ultra brief and writes the FULL brief to `.claude/brain-brief.md` — read that file when planning non-trivial work.\n'
|
|
1440
1510
|
+ '**Routing:** capture project decisions / milestones / open questions / gotchas HERE, *at the moment you decide* — this brain is the shared, portable memory that survives context resets and the next agent reads. A host memory store (if any) is for *user* preferences; never leave project state only in a private scratchpad.\n'
|
|
1441
1511
|
+ 'Coordinate with a concurrent session: `🧠 MSG [<their-id or all>]: <text>` — a one-time note (NOT a brain card) delivered to that session on its next prompt.\n';
|
|
1442
1512
|
}
|
|
@@ -1447,18 +1517,55 @@ async function read(lib) {
|
|
|
1447
1517
|
// immediately. Files/ships come from Stop (what this session actually edits).
|
|
1448
1518
|
touchSession(input.session_id, { branch: gitBranch() });
|
|
1449
1519
|
const { struct } = await lib.parseKlypix(fs.readFileSync(BRAIN));
|
|
1450
|
-
// Default = TIERED brief (open questions + recent + area map) so the
|
|
1451
|
-
// session-start cost stays flat as the brain grows. --full = everything.
|
|
1452
1520
|
const { freshness, drifted } = computeFreshness(struct);
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1521
|
+
// The FULL brief: tiered brief + every self-heal/health footer. Messages are
|
|
1522
|
+
// deliberately NOT part of it (messageFooter ACKS on read — it must only ever
|
|
1523
|
+
// go to stdout, where the agent actually sees it, exactly once).
|
|
1524
|
+
const full = ((typeof lib.structToBrief === 'function') ? lib.structToBrief(struct, { freshness }) : lib.structToMarkdown(struct))
|
|
1525
|
+
+ inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(lib, struct)
|
|
1526
|
+
+ selfCheckFooter() + doctorFooter() + versionCurrencyFooter() + legendFooter() + memoryFooter();
|
|
1527
|
+
const emitFull = () => {
|
|
1528
|
+
process.stdout.write(full + messageFooter(input.session_id || '', input.transcript_path));
|
|
1529
|
+
appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(full), cards: struct?.counts?.cards ?? null }, 500);
|
|
1530
|
+
};
|
|
1531
|
+
// --full = everything to stdout (manual runs); also the fallback when the
|
|
1532
|
+
// live klypix-format predates the ultra tier (version skew) or the brief
|
|
1533
|
+
// file can't be written (stdout is then the only channel).
|
|
1534
|
+
if (process.argv.includes('--full') || typeof lib.structToUltraBrief !== 'function') return emitFull();
|
|
1535
|
+
// Default = ULTRA tier. The harness persists hook stdout and shows only a
|
|
1536
|
+
// ~2KB preview, so a 13KB brief was mostly invisible — write the FULL brief
|
|
1537
|
+
// to a stable project-local file and print a tier that fits the preview
|
|
1538
|
+
// whole: Focus + conflicts + open questions + alerts + the file's path.
|
|
1539
|
+
const briefRel = '.claude/brain-brief.md';
|
|
1540
|
+
try {
|
|
1541
|
+
fs.mkdirSync(path.resolve(CWD, '.claude'), { recursive: true });
|
|
1542
|
+
fs.writeFileSync(path.resolve(CWD, briefRel),
|
|
1543
|
+
'<!-- auto-generated by the brain hook at session start — read it, don\'t edit it; regenerated next session -->\n' + full, 'utf8');
|
|
1544
|
+
} catch { return emitFull(); }
|
|
1545
|
+
const ultra = lib.structToUltraBrief(struct, { freshness, briefPath: briefRel });
|
|
1546
|
+
// Self-heal tiers compress to ONE line up here; the actionable detail (which
|
|
1547
|
+
// cards, which markers to emit) lives in the brief file.
|
|
1548
|
+
const heals = [];
|
|
1549
|
+
if (drifted && drifted.length) heals.push(`${drifted.length} code-anchored fact(s) DRIFTED`);
|
|
1550
|
+
try {
|
|
1551
|
+
if (typeof lib.findUnrecordedMigrations === 'function') {
|
|
1552
|
+
const files = collectMigrationFiles(CWD);
|
|
1553
|
+
if (files.length) { const { total } = lib.findUnrecordedMigrations(struct, files, { max: 6 }); if (total) heals.push(`${total} unrecorded migration(s)`); }
|
|
1554
|
+
}
|
|
1555
|
+
} catch { /* */ }
|
|
1556
|
+
try { if (typeof lib.findStaleOpenCards === 'function') { const { total } = lib.findStaleOpenCards(struct, { max: 5 }); if (total) heals.push(`${total} open card(s) look already done`); } } catch { /* */ }
|
|
1557
|
+
const healLine = heals.length ? `\n🔧 Self-heal: ${heals.join(' · ')} — detail + fix markers in ${briefRel}.` : '';
|
|
1558
|
+
// 📨 messages are delivered-ONCE (acked the moment this reads them) — they
|
|
1559
|
+
// go right after the ultra brief, at the top of the visible window, never
|
|
1560
|
+
// after a stack of footers that could push them past a preview cut.
|
|
1561
|
+
const messages = messageFooter(input.session_id || '', input.transcript_path);
|
|
1562
|
+
const out = ultra + messages + healLine
|
|
1563
|
+
+ inflightFooter(input.session_id, struct)
|
|
1564
|
+
+ selfCheckFooter() + doctorFooter() + versionCurrencyFooter();
|
|
1565
|
+
process.stdout.write(out);
|
|
1459
1566
|
// Heartbeat: prove the brief actually injected (and how big) so a dead or
|
|
1460
1567
|
// stale live-copy of the hook stops being a silent no-op.
|
|
1461
|
-
appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(
|
|
1568
|
+
appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(out), fullBriefBytes: Buffer.byteLength(full), cards: struct?.counts?.cards ?? null }, 500);
|
|
1462
1569
|
}
|
|
1463
1570
|
|
|
1464
1571
|
// Registry of every brain this machine has touched — written on each hook run,
|
package/src/klypix-core.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown,
|
|
26
26
|
brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite,
|
|
27
27
|
findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
|
|
28
|
-
selectGardenCandidates, applyGarden,
|
|
28
|
+
selectGardenCandidates, applyGarden, detectContradictions,
|
|
29
29
|
} from './klypix-format.mjs';
|
|
30
30
|
|
|
31
31
|
// ── Card / connection input shape (single source for every face) ─────────────
|
|
@@ -405,7 +405,7 @@ export function collectMigrationFiles(root) {
|
|
|
405
405
|
}
|
|
406
406
|
return out;
|
|
407
407
|
}
|
|
408
|
-
export async function opBrainReconcile({ vault, canvas, root }) {
|
|
408
|
+
export async function opBrainReconcile({ vault, canvas, root, mode = 'all' }) {
|
|
409
409
|
const t = brainTarget(vault, canvas);
|
|
410
410
|
if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
|
|
411
411
|
if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
|
|
@@ -413,16 +413,49 @@ export async function opBrainReconcile({ vault, canvas, root }) {
|
|
|
413
413
|
let struct;
|
|
414
414
|
try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`Read failed: ${e.message}`); }
|
|
415
415
|
const stamp = brainStamp(file, struct, t.how);
|
|
416
|
+
const sections = [];
|
|
417
|
+
|
|
418
|
+
// (1) CONTRADICTIONS — the brain reconciled against ITSELF. Same-subject live
|
|
419
|
+
// pairs where one side carries an explicit correction cue (that side is the
|
|
420
|
+
// presumed truth) or the two use opposite polarity words (deferred↔wired,
|
|
421
|
+
// broken↔fixed …). Candidates only — nothing is changed here; the agent/human
|
|
422
|
+
// confirms each. This is the retroactive cleaner for stale/correction pairs
|
|
423
|
+
// that slipped past capture (cross-area + reworded → no supersede possible).
|
|
424
|
+
if (mode === 'all' || mode === 'contradictions') {
|
|
425
|
+
const pairs = detectContradictions(struct);
|
|
426
|
+
if (pairs.length) {
|
|
427
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
428
|
+
const lines = pairs.map((p, i) =>
|
|
429
|
+
`${i + 1}. ${p.why} · overlap ${p.overlap}\n`
|
|
430
|
+
+ ` · likely STALE [${p.stale.area || '?'}] ${flat(p.stale.text).slice(0, 180)}\n`
|
|
431
|
+
+ ` · likely CURRENT [${p.fresh.area || '?'}] ${flat(p.fresh.text).slice(0, 180)}`);
|
|
432
|
+
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: a **polarity** pair is dismissed by deliberately connecting the two cards (\`brain_connect\`); a **correction-cue** pair only clears when the stale card is retired (✓ / supersede) — a mere link does not settle a declared correction._\n\n${lines.join('\n')}`);
|
|
433
|
+
} else if (mode === 'contradictions') {
|
|
434
|
+
sections.push('✓ No contradiction candidates — no live card pair shows a correction cue or a polarity flip over the same subject.');
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// (2) MIGRATIONS — the brain reconciled against committed external state.
|
|
416
439
|
// Migrations live in the CODE repo (usually beside brain.klypix), not in a
|
|
417
440
|
// separate canvas vault — so default the root to the brain file's folder.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
441
|
+
if (mode === 'all' || mode === 'migrations') {
|
|
442
|
+
const repoRoot = root ? path.resolve(root) : path.dirname(file);
|
|
443
|
+
const files = collectMigrationFiles(repoRoot);
|
|
444
|
+
if (!files.length) {
|
|
445
|
+
if (mode === 'migrations') sections.push(`No migration files under ${repoRoot} (looked in: ${MIGRATION_DIRS.join(', ')}). Nothing to reconcile.`);
|
|
446
|
+
} else {
|
|
447
|
+
const { gaps, total } = findUnrecordedMigrations(struct, files, { max: 20 });
|
|
448
|
+
if (!gaps.length) sections.push(`✓ All ${files.length} migration(s) under ${path.basename(repoRoot)} are referenced by a brain card — no unrecorded rollouts.`);
|
|
449
|
+
else {
|
|
450
|
+
const lines = gaps.map(g => `- \`${g.path}\` — committed, but no brain card mentions it. If applied to prod, record it:\n \`🧠 BRAIN [DB] !: migration ${g.file.replace(/\.sql$/i, '')} applied to prod ev: ${g.path}\``);
|
|
451
|
+
const more = total > gaps.length ? `\n\n…and ${total - gaps.length} more.` : '';
|
|
452
|
+
sections.push(`# ⚠️ ${total} migration(s) committed but unrecorded in the brain\n_The brain can't see prod — it flags migrations that are in git but unmentioned, so you can confirm the rollout. It never asserts a migration was applied. To dismiss one without applying, record any card that names it (e.g. "committed, not applied")._\n\n${lines.join('\n')}${more}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!sections.length) sections.push('✓ Nothing to reconcile — no contradiction candidates, and no unrecorded migrations.');
|
|
458
|
+
return { blocks: [text(stamp + sections.join('\n\n---\n\n'))] };
|
|
426
459
|
}
|
|
427
460
|
|
|
428
461
|
// ── Brain gardener (two-phase: select → agent synthesizes → apply) ───────────
|
|
@@ -584,10 +617,16 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
|
|
|
584
617
|
await atomicWrite(file, out);
|
|
585
618
|
const s = res.stats || {};
|
|
586
619
|
const bits = [`${s.added || 0} added`];
|
|
587
|
-
for (const k of ['resolved', 'updated', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
|
|
620
|
+
for (const k of ['resolved', 'updated', 'merged', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
|
|
621
|
+
// Correction receipt — a correction-cue note superseded a card cross-area /
|
|
622
|
+
// below the plain 0.6 bar; say WHAT was archived and how to undo, so the
|
|
623
|
+
// widened match is always confirmable rather than silent.
|
|
624
|
+
const corr = (Array.isArray(s.corrections) && s.corrections.length)
|
|
625
|
+
? `\n↩︎ correction supersede: ${s.corrections.map(c => `"${c.old}"${c.area ? ` [${c.area}]` : ''} (overlap ${c.overlap})`).join('; ')} — archived + arrowed to your note. If it grabbed the wrong card, restore it from Archive or re-run with marker "~".`
|
|
626
|
+
: '';
|
|
588
627
|
// Name the resolved brain explicitly (basename + how) so a write never lands
|
|
589
628
|
// in a surprise file silently — the write-side twin of the read-op stamp.
|
|
590
|
-
return { blocks: [text(`✓ brain_note → ${path.basename(file)} (via ${t.how}) · ${bits.join(' · ')}. Reopen the brain in the KLYPIX app to see it
|
|
629
|
+
return { blocks: [text(`✓ brain_note → ${path.basename(file)} (via ${t.how}) · ${bits.join(' · ')}. Reopen the brain in the KLYPIX app to see it.${corr}`)] };
|
|
591
630
|
} catch (e) {
|
|
592
631
|
return err(`brain_note failed (brain unchanged): ${e.message}`);
|
|
593
632
|
}
|
package/src/klypix-format.mjs
CHANGED
|
@@ -681,6 +681,73 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
681
681
|
if (unshown > 0) hidden.push(`${unshown} older/over-budget decision${unshown === 1 ? '' : 's'}`);
|
|
682
682
|
if (archivedCount > 0) hidden.push(`${archivedCount} archived/superseded`);
|
|
683
683
|
if (hidden.length) push('', `*${hidden.join(' + ')} not shown — search the full brain via the klypix-canvas MCP (search/read tools) or \`node ~/.claude/project-brain/global-brain-hook.mjs --full\`.*`);
|
|
684
|
+
// Graph health, ambient: when a third of the live brain is unlinked, say so
|
|
685
|
+
// once (brain_insights has the detail; auto-linking at capture works the
|
|
686
|
+
// backlog down going forward).
|
|
687
|
+
const degIds = new Set();
|
|
688
|
+
for (const cn of struct.connections || []) { if (cn.fromId) degIds.add(cn.fromId); if (cn.toId) degIds.add(cn.toId); }
|
|
689
|
+
const orphanN = live.filter(c => !degIds.has(c.id)).length;
|
|
690
|
+
if (live.length >= 10 && orphanN / live.length > 0.3) push('', `*graph: ${orphanN}/${live.length} live cards have no connections — \`brain_connect\` (or capturing with [[wikilinks]]) densifies the graph.*`);
|
|
691
|
+
return out.join('\n') + '\n';
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── Ultra brief (SessionStart stdout tier) ───────────────────────────────────
|
|
695
|
+
// The harness persists hook stdout to a file and shows the agent only a ~2KB
|
|
696
|
+
// PREVIEW — a 13.5KB brief was mostly invisible (everything after Focus/open
|
|
697
|
+
// questions reached the agent only if it chose to open the file; it usually
|
|
698
|
+
// didn't). This tier is sized to fit that preview WHOLE: Focus + conflicts +
|
|
699
|
+
// open questions + a pointer to the FULL brief file the hook writes alongside.
|
|
700
|
+
// The pointer + marker legend are reserved OUT of the budget so they always fit.
|
|
701
|
+
export const ULTRA_BUDGET_CHARS = 1_800; // sibling of BUDGET_CHARS above — sized for the harness preview, not token cost
|
|
702
|
+
export function structToUltraBrief(struct, { freshness = null, briefPath = '.claude/brain-brief.md', budgetChars = ULTRA_BUDGET_CHARS } = {}) {
|
|
703
|
+
const texts = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
|
|
704
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
705
|
+
const isFocus = (c) => /(^|\s)focus\b/i.test(c.area || '');
|
|
706
|
+
const live = texts.filter(c => !isArchived(c));
|
|
707
|
+
const focus = live.filter(isFocus);
|
|
708
|
+
const open = live.filter(c => /❓|🎯/.test(c.text) && !/🛠/.test(c.text) && !isFocus(c));
|
|
709
|
+
const skills = live.filter(c => /🛠/.test(c.text) && !isFocus(c));
|
|
710
|
+
const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
|
|
711
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
712
|
+
const fr = (c) => (freshness && freshness[c.id]) ? freshness[c.id] + ' ' : '';
|
|
713
|
+
const safeCut = (t, n) => { let s = t.slice(0, n); if (/[\uD800-\uDBFF]$/.test(s)) s = s.slice(0, -1); return s.trimEnd() + '…'; };
|
|
714
|
+
const head = (c, max = 150) => { const t = flat(c.text); return t.length > max ? safeCut(t, max - 1) : t; };
|
|
715
|
+
const out = [];
|
|
716
|
+
let used = 0;
|
|
717
|
+
const push = (...ls) => { for (const l of ls) { out.push(l); used += l.length + 1; } };
|
|
718
|
+
// Length-aware guard: a line only lands if it FITS — one long focus card
|
|
719
|
+
// must not blow the tier past the preview it exists to fit inside.
|
|
720
|
+
const pushIf = (l) => { if (used + l.length + 1 > budget) return false; out.push(l); used += l.length + 1; return true; };
|
|
721
|
+
const tail = [
|
|
722
|
+
'',
|
|
723
|
+
`📖 **Full brief: \`${briefPath}\`** — skills (${skills.length}), milestones, recent decisions, connections, self-heal detail. READ IT before planning non-trivial work.`,
|
|
724
|
+
'🧠 Capture: `🧠 BRAIN [Area]: <decision>` · `?` question · `!` milestone · `+` skill · `✓` resolve · `~` update · a "CORRECTION: …" decision supersedes its stale card across areas · suffixes `closes:` / `ev:` (full legend in the brief file).',
|
|
725
|
+
];
|
|
726
|
+
const budget = Math.max(400, budgetChars - tail.reduce((s, l) => s + l.length + 1, 0));
|
|
727
|
+
push(`# ${struct.title} — brain (ultra brief)`);
|
|
728
|
+
push(`*${struct.counts.cards} cards · ${struct.counts.connections} connections — this is the preview tier; the full brief is one Read away (below)*`);
|
|
729
|
+
// clip = surrogate-safe truncation for arbitrary strings (head() covers cards).
|
|
730
|
+
const clip = (s, n) => { const t = flat(s); return t.length > n ? safeCut(t, n - 1) : t; };
|
|
731
|
+
if (focus.length && pushIf('') && pushIf('## 📌 Human focus (act on these first)')) {
|
|
732
|
+
let shown = 0;
|
|
733
|
+
for (const c of focus) { if (!pushIf(`- ${fr(c)}${head(c, 400)}`)) break; shown++; }
|
|
734
|
+
// Never present a truncated "act on these first" list as complete.
|
|
735
|
+
if (shown < focus.length) push(`- ⚠️ …and ${focus.length - shown} MORE focus card(s) — read the full brief before acting.`);
|
|
736
|
+
}
|
|
737
|
+
if (conflicts.length && pushIf('') && pushIf('## ⚠️ Conflicts to reconcile')) {
|
|
738
|
+
let shown = 0;
|
|
739
|
+
for (const c of conflicts.slice(0, 4)) { if (!pushIf(`- ${clip(c.from, 70)} ⚔️ ${clip(c.to, 70)}`)) break; shown++; }
|
|
740
|
+
if (shown < conflicts.length) pushIf(`- …and ${conflicts.length - shown} more conflict(s) — in the full brief.`);
|
|
741
|
+
}
|
|
742
|
+
if (open.length && pushIf('') && pushIf(`## Open questions & goals (${open.length})`)) {
|
|
743
|
+
let shown = 0;
|
|
744
|
+
for (const c of open) { if (!pushIf(`- ${fr(c)}${head(c)}`)) break; shown++; }
|
|
745
|
+
if (shown < open.length) pushIf(`- …and ${open.length - shown} more — in the full brief.`);
|
|
746
|
+
}
|
|
747
|
+
const areas = struct.cards.filter(c => c.type === 'container' && !/^archive$/i.test(c.title || ''))
|
|
748
|
+
.map(c => `${flat(c.title)} (${texts.filter(t => t.parentId === c.id).length})`);
|
|
749
|
+
if (areas.length) { pushIf(''); pushIf(clip('Areas: ' + areas.join(' · '), 240)); }
|
|
750
|
+
push(...tail);
|
|
684
751
|
return out.join('\n') + '\n';
|
|
685
752
|
}
|
|
686
753
|
|
|
@@ -712,11 +779,20 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
|
|
|
712
779
|
const titleW = wordsOf(c.title);
|
|
713
780
|
const bodyW = wordsOf(c.text);
|
|
714
781
|
const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
|
|
782
|
+
// Log-length normalization: a flat 1pt/word-hit made LONG cards outrank
|
|
783
|
+
// short ones purely by having more vocabulary to hit (one ~600-word card
|
|
784
|
+
// was re-injected into 3 prompts on those cheap hits). Body hits are
|
|
785
|
+
// scaled by log2 of the body's distinct-word count — ≤64 words is EXACTLY
|
|
786
|
+
// unpenalized (log2(64)=6), a 600-word card scores ~0.65/hit, so a
|
|
787
|
+
// body-only match on a big card needs 5 distinct hits to clear the recall
|
|
788
|
+
// minScore of 3 — by design. Title/tag hits (the precise signals) are
|
|
789
|
+
// untouched.
|
|
790
|
+
const lenNorm = Math.min(1, 6 / Math.max(6, Math.log2(bodyW.size || 1)));
|
|
715
791
|
let score = 0;
|
|
716
792
|
for (const tok of tokens) {
|
|
717
793
|
if (titleW.has(tok)) score += 3;
|
|
718
794
|
else if (tagStems.has(tok)) score += 3;
|
|
719
|
-
else if (bodyW.has(tok)) score +=
|
|
795
|
+
else if (bodyW.has(tok)) score += lenNorm;
|
|
720
796
|
}
|
|
721
797
|
if (score <= 0) continue;
|
|
722
798
|
if ((c.createdAt || 0) >= cutoff) score += 0.5; // gentle recency tiebreak, never dominant
|
|
@@ -785,6 +861,23 @@ export function findUnrecordedMigrations(struct, files, { max = 6 } = {}) {
|
|
|
785
861
|
// recall list still shows below). Returns each card's `kind` so the caller can
|
|
786
862
|
// say "reuse it" (shipped/resolved) vs "see what replaced it" (superseded).
|
|
787
863
|
// Pure + node-runnable; reuses the one shared tokenizer — no divergent scorer.
|
|
864
|
+
// Generic work-verbs establish that a prompt is WORK, not WHICH work — two of
|
|
865
|
+
// them shared with a 🏁 ship-card title used to clear the floor ("deploy it"
|
|
866
|
+
// flagged two unrelated PR-merge cards; zero true positives all session).
|
|
867
|
+
// Excluded from repeat scoring entirely; the loose recall list still sees them.
|
|
868
|
+
const REPEAT_VERB_STOP = new Set([
|
|
869
|
+
'deploy', 'deployed', 'deploying', 'deploys', 'ship', 'shipped', 'shipping', 'ships',
|
|
870
|
+
'merge', 'merged', 'merges', 'merging', 'release', 'released', 'releases', 'releasing',
|
|
871
|
+
'publish', 'published', 'publishes', 'publishing', 'push', 'pushed', 'land', 'landed',
|
|
872
|
+
'build', 'builds', 'building', 'built', 'check', 'checked', 'checking', 'checks',
|
|
873
|
+
'plan', 'planned', 'planning', 'plans', 'best', 'class', 'cut', 'hand', 'handoff',
|
|
874
|
+
'report', 'reports', 'live', 'latest', 'done', 'complete', 'completed', 'finish', 'finished',
|
|
875
|
+
'work', 'task', 'feature',
|
|
876
|
+
]);
|
|
877
|
+
// Entity-shaped token — the kind that pins WHICH work: carries a digit (version,
|
|
878
|
+
// PR#), or a kebab/snake identifier. A #file-/#dir- tag-stem match counts as an
|
|
879
|
+
// entity at match time regardless of shape (tags are capture-stamped anchors).
|
|
880
|
+
const isEntityToken = (t) => /\d/.test(t) || t.includes('-') || t.includes('_');
|
|
788
881
|
export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTokens = 2 } = {}) {
|
|
789
882
|
const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
|
|
790
883
|
if (tokens.length < minTokens || !struct || !Array.isArray(struct.cards)) return [];
|
|
@@ -802,13 +895,19 @@ export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTok
|
|
|
802
895
|
const titleW = wordsOf(firstMeaningful || c.title);
|
|
803
896
|
const bodyW = wordsOf(c.text);
|
|
804
897
|
const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
|
|
805
|
-
|
|
898
|
+
// Auto-harvested ship cards (#auto, one-line merge/release events) are
|
|
899
|
+
// dense with exactly the verbs above — they additionally need ≥1 ENTITY
|
|
900
|
+
// token matched before a nudge is worth showing.
|
|
901
|
+
const isAuto = (c.tags || []).some(t => String(t).toLowerCase().replace(/^#/, '') === 'auto');
|
|
902
|
+
let score = 0, matched = 0, entities = 0;
|
|
806
903
|
for (const tok of tokens) {
|
|
807
|
-
if (
|
|
808
|
-
|
|
809
|
-
else if (
|
|
904
|
+
if (REPEAT_VERB_STOP.has(tok)) continue; // "that it's work" ≠ "which work"
|
|
905
|
+
if (titleW.has(tok)) { score += 3; matched++; if (isEntityToken(tok)) entities++; }
|
|
906
|
+
else if (tagStems.has(tok)) { score += 3; matched++; entities++; }
|
|
907
|
+
else if (bodyW.has(tok)) { score += 1; matched++; if (isEntityToken(tok)) entities++; }
|
|
810
908
|
}
|
|
811
909
|
if (matched < minTokens || score < minScore) continue; // precision-first floor
|
|
910
|
+
if (isAuto && entities < 1) continue; // auto cards: entity anchor required
|
|
812
911
|
out.push({ card: c, score, kind });
|
|
813
912
|
}
|
|
814
913
|
out.sort((a, b) => b.score - a.score || (rank[b.kind] - rank[a.kind]) || (b.card.createdAt || 0) - (a.card.createdAt || 0));
|
|
@@ -855,6 +954,84 @@ export function detectConflicts(struct, { minOverlap = 0.45, topK = 12 } = {}) {
|
|
|
855
954
|
return out.slice(0, topK);
|
|
856
955
|
}
|
|
857
956
|
|
|
957
|
+
// ── Contradiction candidates (reconcile-proper) ──────────────────────────────
|
|
958
|
+
// The retroactive cleaner for stale/correction pairs that slipped past capture
|
|
959
|
+
// (cross-area + reworded → no supersede possible by construction). Unlike
|
|
960
|
+
// detectConflicts (a pre-filter for an LLM verifier), this is precision-scoped
|
|
961
|
+
// enough to surface DIRECTLY for human/agent confirmation: same-subject live
|
|
962
|
+
// pairs where (a) exactly ONE side carries an explicit correction cue
|
|
963
|
+
// (CORRECTION / OBSOLETE / was WRONG / stale note resolved) — that side is the
|
|
964
|
+
// presumed truth — or (b) the two sides use OPPOSITE polarity words about the
|
|
965
|
+
// same subject (deferred↔wired, broken↔fixed, dead↔live …). Pairs already
|
|
966
|
+
// settled by a supersede/close/conflict arrow are excluded; a plain relates_to
|
|
967
|
+
// arrow is NOT a settlement. Suggestion-only — never writes. Pure, node-runnable.
|
|
968
|
+
const POLARITY_PAIRS = [
|
|
969
|
+
['deferred', 'wired'], ['deferred', 'shipped'], ['deferred', 'live'], ['deferred', 'enabled'],
|
|
970
|
+
['broken', 'fixed'], ['broken', 'working'], ['dead', 'live'], ['dead', 'alive'],
|
|
971
|
+
['disabled', 'enabled'], ['blocked', 'unblocked'], ['wrong', 'correct'], ['removed', 'restored'],
|
|
972
|
+
];
|
|
973
|
+
// WORD-boundary matchers, precompiled — substring includes() made 'deadline'
|
|
974
|
+
// carry the 'dead' pole and 'delivery' carry 'live', and blocked↔unblocked
|
|
975
|
+
// could never fire ('unblocked' contains 'blocked').
|
|
976
|
+
const POLARITY_RES = POLARITY_PAIRS.map(([x, y]) => ({ x, y, rx: new RegExp(`\\b${x}\\b`), ry: new RegExp(`\\b${y}\\b`) }));
|
|
977
|
+
export function detectContradictions(struct, { minOverlap = 0.45, topK = 12 } = {}) {
|
|
978
|
+
if (!struct || !Array.isArray(struct.cards)) return [];
|
|
979
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
980
|
+
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
|
|
981
|
+
// Cue meta words stripped up front — same-subject matching must compare the
|
|
982
|
+
// SUBJECT, not the vocabulary of the correction act (see stripCueMeta above).
|
|
983
|
+
const wset = new Map(live.map(c => [c.id, stripCueMeta(new Set(queryTokens(c.text)))]));
|
|
984
|
+
const lower = new Map(live.map(c => [c.id, String(c.text).toLowerCase()]));
|
|
985
|
+
// settled — fully reconciled (supersede/close/conflict arrow): excludes the
|
|
986
|
+
// pair regardless of kind.
|
|
987
|
+
// linked — ANY deliberate edge (label !== 'auto'): dismisses a POLARITY pair
|
|
988
|
+
// ("I looked, they relate, not a contradiction"), but never a cue pair —
|
|
989
|
+
// a correction that [[wikilinks]] its stale card must still surface until
|
|
990
|
+
// the stale card is actually retired. Auto-drawn edges dismiss nothing.
|
|
991
|
+
const settled = new Set(), linked = new Set();
|
|
992
|
+
for (const e of struct.connections || []) {
|
|
993
|
+
const k1 = e.fromId + '|' + e.toId, k2 = e.toId + '|' + e.fromId;
|
|
994
|
+
if (e.label === 'superseded by' || e.label === 'closed by' || e.relationship === 'conflicts_with') { settled.add(k1); settled.add(k2); }
|
|
995
|
+
if (e.label !== 'auto') { linked.add(k1); linked.add(k2); }
|
|
996
|
+
}
|
|
997
|
+
const out = [];
|
|
998
|
+
for (let i = 0; i < live.length; i++) {
|
|
999
|
+
for (let j = i + 1; j < live.length; j++) {
|
|
1000
|
+
const a = live[i], b = live[j];
|
|
1001
|
+
if (settled.has(a.id + '|' + b.id)) continue;
|
|
1002
|
+
const A = wset.get(a.id), B = wset.get(b.id);
|
|
1003
|
+
if (A.size < 4 || B.size < 4) continue;
|
|
1004
|
+
let inter = 0; for (const t of A) if (B.has(t)) inter++;
|
|
1005
|
+
const overlap = inter / Math.min(A.size, B.size); // same subject?
|
|
1006
|
+
const aCue = hasCorrectionCue(a.text), bCue = hasCorrectionCue(b.text);
|
|
1007
|
+
// Cue-asymmetric pairs may also match on absolute subject mass (long
|
|
1008
|
+
// cards — see cueMatch above); cue-less pairs keep the strict ratio.
|
|
1009
|
+
const subjectHit = overlap >= minOverlap
|
|
1010
|
+
|| (aCue !== bCue && inter >= CUE_STRONG_SHARED && overlap >= CUE_RELAXED_COEF);
|
|
1011
|
+
if (!subjectHit) continue;
|
|
1012
|
+
let why = null, staleC = null, freshC = null;
|
|
1013
|
+
if (aCue !== bCue) {
|
|
1014
|
+
why = 'correction-cue'; // one side explicitly corrects — it is the presumed truth
|
|
1015
|
+
freshC = aCue ? a : b; staleC = aCue ? b : a;
|
|
1016
|
+
} else if (!aCue && !linked.has(a.id + '|' + b.id)) {
|
|
1017
|
+
const la = lower.get(a.id), lb = lower.get(b.id);
|
|
1018
|
+
for (const { x, y, rx, ry } of POLARITY_RES) {
|
|
1019
|
+
// each side must carry ONE pole only (word-level) — a card
|
|
1020
|
+
// narrating "from deferred to wired" holds both and
|
|
1021
|
+
// contradicts neither.
|
|
1022
|
+
if ((rx.test(la) && !ry.test(la) && ry.test(lb) && !rx.test(lb))
|
|
1023
|
+
|| (ry.test(la) && !rx.test(la) && rx.test(lb) && !ry.test(lb))) { why = `polarity: ${x} ↔ ${y}`; break; }
|
|
1024
|
+
}
|
|
1025
|
+
if (why) [staleC, freshC] = (a.createdAt || 0) <= (b.createdAt || 0) ? [a, b] : [b, a]; // later card = presumed truth
|
|
1026
|
+
}
|
|
1027
|
+
if (!why) continue;
|
|
1028
|
+
out.push({ stale: staleC, fresh: freshC, why, overlap: Math.round(overlap * 100) / 100, cue: aCue || bCue });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
out.sort((x, y) => (Number(y.cue) - Number(x.cue)) || (y.overlap - x.overlap));
|
|
1032
|
+
return out.slice(0, topK);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
858
1035
|
// ── Brain insights ───────────────────────────────────────────────────────────
|
|
859
1036
|
// "What matters here, and what am I forgetting?" — a deterministic structural
|
|
860
1037
|
// read of the brain (borrowed from graphify's GRAPH_REPORT, applied to the
|
|
@@ -948,9 +1125,12 @@ export function proposeStructuralConnections(struct, { maxPerCard = 2 } = {}) {
|
|
|
948
1125
|
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
|
|
949
1126
|
const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
|
|
950
1127
|
const titleIx = live.filter(c => (c.title || '').trim()).map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
|
|
1128
|
+
// 'auto' is PROVENANCE, not topic — as a shared tag it would link every
|
|
1129
|
+
// harvested ship card to every other one (junk edges that then push their
|
|
1130
|
+
// degree past the garden's dormancy guard).
|
|
951
1131
|
const tagsOf = (c) => (c.tags || [])
|
|
952
1132
|
.map(t => String(t).toLowerCase().replace(/^#/, ''))
|
|
953
|
-
.filter(t => t && t !== 'area' && t !== String(c.area || '').toLowerCase());
|
|
1133
|
+
.filter(t => t && t !== 'area' && t !== 'auto' && t !== String(c.area || '').toLowerCase());
|
|
954
1134
|
const cand = [];
|
|
955
1135
|
for (const c of live) {
|
|
956
1136
|
for (const link of (c.links || [])) {
|
|
@@ -1005,6 +1185,83 @@ const overlapScore = (a, b) => {
|
|
|
1005
1185
|
// failed to fire.)
|
|
1006
1186
|
const coverageOf = (target, hay) => { if (!target.size) return 0; let h = 0; for (const w of target) if (hay.has(w)) h++; return h / target.size; };
|
|
1007
1187
|
|
|
1188
|
+
// ── Truth decay (P1) — corrections must never lose to the cards they correct ─
|
|
1189
|
+
// A correction-cue note explicitly declares an older fact stale ("CORRECTION:",
|
|
1190
|
+
// "was WRONG", "OBSOLETE", "stale note resolved"). The same-area ≥0.6 supersede
|
|
1191
|
+
// can miss it BY CONSTRUCTION (the correction often lands in a different area,
|
|
1192
|
+
// reworded) — the stale card then stays live and recall serves it alone. Shared
|
|
1193
|
+
// by: the capture-side widened supersede, the recall-side overlay below, and
|
|
1194
|
+
// detectContradictions.
|
|
1195
|
+
// DELIBERATE cue only: the uppercase forms are the documented convention, and
|
|
1196
|
+
// case-sensitivity is what keeps casual prose ("the floor calc was wrong",
|
|
1197
|
+
// "remove obsolete helper", "color-correction") from firing a cross-area
|
|
1198
|
+
// supersede on an innocent card. "stale note resolved" is the one
|
|
1199
|
+
// natural-language phrase, accepted in any case (it is never incidental).
|
|
1200
|
+
export const CORRECTION_RE = /\bCORRECTIONS?\b|\bOBSOLETE\b|\bwas WRONG\b/;
|
|
1201
|
+
const CORRECTION_PHRASE_RE = /\bstale note (?:is )?resolved\b/i;
|
|
1202
|
+
export const hasCorrectionCue = (t) => CORRECTION_RE.test(String(t || '')) || CORRECTION_PHRASE_RE.test(String(t || ''));
|
|
1203
|
+
export const CORRECTION_SUPERSEDE_AT = 0.4; // widened cross-area bar (vs same-area SUPERSEDE_AT 0.6)
|
|
1204
|
+
// Cue META words describe the act of correcting, not the subject — left in, they
|
|
1205
|
+
// dilute the overlap denominator and push real correction pairs just under the
|
|
1206
|
+
// bar (the field fixture lands at 0.375 with them, 0.5 without). Stripped before
|
|
1207
|
+
// every correction-overlap comparison.
|
|
1208
|
+
const CORRECTION_META = new Set(['correction', 'corrections', 'obsolete', 'stale', 'note', 'notes', 'resolved', 'wrong']);
|
|
1209
|
+
const stripCueMeta = (set) => { const out = new Set(); for (const w of set) if (!CORRECTION_META.has(w)) out.add(w); return out; };
|
|
1210
|
+
// Long-card reality: a correction's SUBJECT is a fraction of each card — the
|
|
1211
|
+
// overlap COEFFICIENT alone punishes long↔long pairs (the real field pair
|
|
1212
|
+
// measures 0.33 with 17 shared subject tokens, under every per-ratio bar). A
|
|
1213
|
+
// cue-gated match therefore also fires on ABSOLUTE subject mass: ≥10 shared
|
|
1214
|
+
// meaningful tokens at ≥0.25 coefficient. Cue-gated ONLY — plain supersede and
|
|
1215
|
+
// polarity pairs keep their strict ratio bars (no cue prior to lean on).
|
|
1216
|
+
const CUE_STRONG_SHARED = 10, CUE_RELAXED_COEF = 0.25;
|
|
1217
|
+
// Floor 3 (not overlapScore's 4): after stripCueMeta a terse deliberate
|
|
1218
|
+
// correction ("CORRECTION: the vault default was WRONG — use cwd") keeps only
|
|
1219
|
+
// 3-ish subject tokens; at 4 it silently no-oped. ≤2-token corrections still
|
|
1220
|
+
// no-op (too little signal to archive on) — they land as a new card; use ~ to
|
|
1221
|
+
// edit a card in place instead.
|
|
1222
|
+
const cueMatch = (a, b, bar) => {
|
|
1223
|
+
if (a.size < 3 || b.size < 3) return 0;
|
|
1224
|
+
let inter = 0; for (const w of a) if (b.has(w)) inter++;
|
|
1225
|
+
const coef = inter / Math.min(a.size, b.size);
|
|
1226
|
+
return (coef >= bar || (inter >= CUE_STRONG_SHARED && coef >= CUE_RELAXED_COEF)) ? coef : 0;
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
// Recall-side guard: given the cards recall is about to inject, return for each
|
|
1230
|
+
// one the card that CORRECTS it, found two ways:
|
|
1231
|
+
// • edge — an outgoing "superseded by"/"closed by" arrow (drawn by capture or
|
|
1232
|
+
// a confirmed reconcile) whose successor still has text;
|
|
1233
|
+
// • cue — a LIVE correction-cue card that lexically overlaps it ≥ `at`, ANY
|
|
1234
|
+
// area (the un-edged pair the capture-time supersede missed).
|
|
1235
|
+
// The caller injects the corrector FIRST (labeled) and reduces the stale hit to
|
|
1236
|
+
// a headline — the stale text never stands alone. Pure + cheap: correction-cue
|
|
1237
|
+
// cards are rare and the hit list is ≤topK.
|
|
1238
|
+
export function correctionOverlaysFor(struct, cards, { at = CORRECTION_SUPERSEDE_AT } = {}) {
|
|
1239
|
+
const out = new Map();
|
|
1240
|
+
if (!struct || !Array.isArray(struct.cards) || !Array.isArray(cards) || !cards.length) return out;
|
|
1241
|
+
const byId = new Map(struct.cards.map(c => [c.id, c]));
|
|
1242
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1243
|
+
const successorOf = new Map();
|
|
1244
|
+
for (const cn of struct.connections || []) {
|
|
1245
|
+
if (cn.label === 'superseded by' || cn.label === 'closed by') successorOf.set(cn.fromId, cn.toId);
|
|
1246
|
+
}
|
|
1247
|
+
const cues = struct.cards.filter(c => c.type !== 'container' && !isArchived(c) && (c.text || '').trim() && hasCorrectionCue(c.text));
|
|
1248
|
+
for (const card of cards) {
|
|
1249
|
+
if (!card || !card.id) continue;
|
|
1250
|
+
const succ = successorOf.has(card.id) ? byId.get(successorOf.get(card.id)) : null;
|
|
1251
|
+
if (succ && (succ.text || '').trim()) { out.set(card.id, { kind: 'edge', by: succ }); continue; }
|
|
1252
|
+
if (hasCorrectionCue(card.text)) continue; // the hit IS a correction — nothing to overlay
|
|
1253
|
+
const cTok = tokenSet(card.text);
|
|
1254
|
+
let best = null, bestS = 0;
|
|
1255
|
+
for (const cue of cues) {
|
|
1256
|
+
if (cue.id === card.id) continue;
|
|
1257
|
+
const s = cueMatch(cTok, stripCueMeta(tokenSet(cue.text)), at);
|
|
1258
|
+
if (s > bestS) { bestS = s; best = cue; }
|
|
1259
|
+
}
|
|
1260
|
+
if (best && bestS > 0) out.set(card.id, { kind: 'cue', by: best, overlap: Math.round(bestS * 100) / 100 });
|
|
1261
|
+
}
|
|
1262
|
+
return out;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1008
1265
|
// ── Auto-skill classifier (skills emerge from the flow, not just the '+' marker) ─
|
|
1009
1266
|
// A REUSABLE skill (how-to / gotcha / convention) reads as a GENERAL RULE that
|
|
1010
1267
|
// applies next time — distinct from a one-time decision ("we shipped X"). This is
|
|
@@ -1024,9 +1281,12 @@ export function looksLikeSkill(text) {
|
|
|
1024
1281
|
}
|
|
1025
1282
|
|
|
1026
1283
|
export async function captureIntoBrain(buffer, { cards = [], resolutions = [], updates = [] } = {}) {
|
|
1027
|
-
const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45, CLOSE_COVER_AT = 0.6;
|
|
1284
|
+
const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45, CLOSE_COVER_AT = 0.6, QUESTION_MERGE_AT = 0.6;
|
|
1028
1285
|
let work = buffer;
|
|
1029
|
-
|
|
1286
|
+
// corrections[] lists cross-area/low-bar supersedes driven by a correction
|
|
1287
|
+
// cue, so the surface (brain_note result / capture stderr) can say WHAT was
|
|
1288
|
+
// archived and how to undo — the confirmation channel for the widened match.
|
|
1289
|
+
const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0, closed: 0, merged: 0, corrections: [] };
|
|
1030
1290
|
|
|
1031
1291
|
// Pass 1 — resolutions + supersede marking operate on EXISTING cards.
|
|
1032
1292
|
if (resolutions.length || cards.length || updates.length) {
|
|
@@ -1093,24 +1353,36 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1093
1353
|
};
|
|
1094
1354
|
canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
|
|
1095
1355
|
|
|
1096
|
-
// RESOLVE (✓ markers) — best live match in the area
|
|
1356
|
+
// RESOLVE (✓ markers) — best live match in the area, PLUS its near-tie
|
|
1357
|
+
// twins: a rephrased duplicate ❓ scores within a hair of its sibling, and
|
|
1358
|
+
// resolving only the first left the twin open forever (it kept surfacing
|
|
1359
|
+
// in every brief as still-to-do). Precision-kept: the set is the best
|
|
1360
|
+
// match ± 0.1, never everything above the loose 0.3 floor. ❓ preferred.
|
|
1097
1361
|
const milestonesFallback = [];
|
|
1098
1362
|
for (const r of resolutions) {
|
|
1099
1363
|
const rTok = tokenSet(r.text);
|
|
1100
|
-
|
|
1364
|
+
const cands = [];
|
|
1101
1365
|
for (const c of liveTextCards()) {
|
|
1102
1366
|
if (r.area && (c.area || '').toLowerCase() !== r.area.toLowerCase()) continue;
|
|
1367
|
+
if (/🛠/.test(c.text)) continue; // skills are standing reference — a ✓ must never archive one (mirror the supersede guard)
|
|
1103
1368
|
const s = overlapScore(rTok, tokenSet(c.text)) + (/❓|🎯/.test(c.text) ? 0.15 : 0);
|
|
1104
|
-
if (s >
|
|
1369
|
+
if (s > 0) cands.push({ c, s });
|
|
1105
1370
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1371
|
+
cands.sort((a, b) => b.s - a.s);
|
|
1372
|
+
const bestScore = cands.length ? cands[0].s : 0;
|
|
1373
|
+
const set = bestScore >= RESOLVE_AT
|
|
1374
|
+
? cands.filter(x => x.s >= Math.max(RESOLVE_AT, bestScore - 0.1)).slice(0, 3)
|
|
1375
|
+
: [];
|
|
1376
|
+
if (set.length) {
|
|
1377
|
+
for (const { c: best } of set) {
|
|
1378
|
+
await rewriteCard(best.id, j => {
|
|
1379
|
+
j.content = `${j.content}\n✅ ${today}: ${r.text}`;
|
|
1380
|
+
j.borderColor = 'rgba(16,185,129,0.35)';
|
|
1381
|
+
});
|
|
1382
|
+
await archiveCard(best.id);
|
|
1383
|
+
best.text += ` ✅ ${r.text}`; // keep in-memory struct honest for later matching
|
|
1384
|
+
stats.resolved++;
|
|
1385
|
+
}
|
|
1114
1386
|
} else {
|
|
1115
1387
|
milestonesFallback.push({ text: (r.area ? `${r.area}: ` : '') + `🏁 ${r.text}`, area: r.area, borderColor: 'rgba(59,130,246,0.8)' });
|
|
1116
1388
|
}
|
|
@@ -1146,6 +1418,34 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1146
1418
|
}
|
|
1147
1419
|
}
|
|
1148
1420
|
|
|
1421
|
+
// MERGE-ON-CAPTURE for duplicate open questions — a rephrased ❓ that
|
|
1422
|
+
// heavily overlaps an EXISTING live ❓ updates that card in place (fresh
|
|
1423
|
+
// wording + createdAt) instead of stacking a twin the close-pass would
|
|
1424
|
+
// later miss. (Supersede deliberately skips ? cards, so without this
|
|
1425
|
+
// twins could never merge at capture at all.)
|
|
1426
|
+
for (let i = cards.length - 1; i >= 0; i--) {
|
|
1427
|
+
const card = cards[i];
|
|
1428
|
+
if (!/❓/.test(card.text) || /🏁|🛠/.test(card.text)) continue;
|
|
1429
|
+
const nTok = tokenSet(card.text);
|
|
1430
|
+
let best = null, bestScore = 0;
|
|
1431
|
+
for (const c of liveTextCards()) {
|
|
1432
|
+
if (!/❓/.test(c.text)) continue;
|
|
1433
|
+
const s = overlapScore(nTok, tokenSet(c.text));
|
|
1434
|
+
if (s > bestScore) { bestScore = s; best = c; }
|
|
1435
|
+
}
|
|
1436
|
+
if (best && bestScore >= QUESTION_MERGE_AT) {
|
|
1437
|
+
await rewriteCard(best.id, j => {
|
|
1438
|
+
j.content = String(card.text);
|
|
1439
|
+
j.createdAt = now;
|
|
1440
|
+
if (card.createdVia) j.createdVia = String(card.createdVia);
|
|
1441
|
+
if (Array.isArray(card.evidence) && card.evidence.length) j.evidence = card.evidence;
|
|
1442
|
+
});
|
|
1443
|
+
best.text = String(card.text);
|
|
1444
|
+
cards.splice(i, 1);
|
|
1445
|
+
stats.merged++;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1149
1449
|
// SUPERSEDE — pre-mark old cards that a NEW decision replaces. The arrow
|
|
1150
1450
|
// to the new card is drawn in pass 2 (after the new ids exist), matched
|
|
1151
1451
|
// back by remembering which old card each new card displaced.
|
|
@@ -1153,22 +1453,36 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1153
1453
|
if (/❓|🎯|🏁|🛠/.test(card.text)) continue; // only plain decisions supersede (not questions/goals/milestones/skills)
|
|
1154
1454
|
const nTok = tokenSet(card.text);
|
|
1155
1455
|
const area = (card.area || '').toLowerCase();
|
|
1456
|
+
// A correction-cue note ("CORRECTION: … was WRONG") declares it
|
|
1457
|
+
// replaces something — widen the search to ALL areas and lower the
|
|
1458
|
+
// bar: a cross-area reworded correction could never fire the
|
|
1459
|
+
// same-area 0.6 path by construction, which is exactly how stale
|
|
1460
|
+
// cards outlived their corrections in the field.
|
|
1461
|
+
const isCorrection = hasCorrectionCue(card.text);
|
|
1462
|
+
const nTokCmp = isCorrection ? stripCueMeta(nTok) : nTok; // cue meta words dilute the denominator
|
|
1156
1463
|
let best = null, bestScore = 0;
|
|
1157
1464
|
for (const c of liveTextCards()) {
|
|
1158
|
-
if (area && (c.area || '').toLowerCase() !== area) continue;
|
|
1465
|
+
if (!isCorrection && area && (c.area || '').toLowerCase() !== area) continue;
|
|
1159
1466
|
if (/🛠/.test(c.text)) continue; // never auto-archive a 🛠️ skill via a decision's supersede — skills are standing reference (correct with ~)
|
|
1160
|
-
|
|
1467
|
+
// cueMatch returns 0 unless it clears the widened bar (ratio OR
|
|
1468
|
+
// absolute subject mass) — so for corrections, any non-zero fires.
|
|
1469
|
+
const s = isCorrection ? cueMatch(nTokCmp, tokenSet(c.text), CORRECTION_SUPERSEDE_AT) : overlapScore(nTok, tokenSet(c.text));
|
|
1161
1470
|
if (s > bestScore) { bestScore = s; best = c; }
|
|
1162
1471
|
}
|
|
1163
|
-
if (best && bestScore >= SUPERSEDE_AT) {
|
|
1472
|
+
if (best && (isCorrection ? bestScore > 0 : bestScore >= SUPERSEDE_AT)) {
|
|
1164
1473
|
await rewriteCard(best.id, j => {
|
|
1165
1474
|
j.content = `↩︎ superseded ${today}\n${j.content}`;
|
|
1166
1475
|
j.borderColor = 'rgba(120,120,135,0.5)';
|
|
1167
1476
|
});
|
|
1168
1477
|
await archiveCard(best.id);
|
|
1478
|
+
const wasCross = isCorrection && (bestScore < SUPERSEDE_AT || (area && (best.area || '').toLowerCase() !== area));
|
|
1169
1479
|
best.text = `↩︎ ${best.text}`;
|
|
1170
1480
|
card.__supersedes = best.id;
|
|
1171
1481
|
stats.superseded++;
|
|
1482
|
+
// Surface the widened match for confirmation: the caller tells the
|
|
1483
|
+
// agent what was archived and how to undo (restore from Archive /
|
|
1484
|
+
// re-run with ~) — the widened bar acts WITH a visible receipt.
|
|
1485
|
+
if (wasCross) stats.corrections.push({ old: (best.title || String(best.text).replace(/^↩︎\s*/, '').slice(0, 80)), area: best.area || null, overlap: Math.round(bestScore * 100) / 100 });
|
|
1172
1486
|
}
|
|
1173
1487
|
}
|
|
1174
1488
|
|
|
@@ -1184,25 +1498,41 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1184
1498
|
if (!target) continue;
|
|
1185
1499
|
const wantTitle = target.replace(/^\[\[/, '').replace(/\]\]$/, '').trim().toLowerCase();
|
|
1186
1500
|
const tTok = tokenSet(target);
|
|
1187
|
-
|
|
1501
|
+
// Collect EVERY live card the close-target covers — near-duplicate ❓
|
|
1502
|
+
// twins score together, and the old first-match-and-break resolved one
|
|
1503
|
+
// while its twin stayed "open" in every brief forever. Capped for
|
|
1504
|
+
// safety: a close-target is deliberate, so >4 matches means it was too
|
|
1505
|
+
// generic to trust beyond the strongest few.
|
|
1506
|
+
const matches = [];
|
|
1188
1507
|
for (const c of liveTextCards()) {
|
|
1508
|
+
if (/🛠/.test(c.text)) continue; // skills are standing reference — a closes: must never archive one (mirror the supersede guard)
|
|
1189
1509
|
const ct = (c.title || '').trim().toLowerCase();
|
|
1190
|
-
// Title fast-path: exact / prefix
|
|
1191
|
-
// target
|
|
1192
|
-
|
|
1510
|
+
// Title fast-path: exact / prefix (≥6 chars), or the card title
|
|
1511
|
+
// CONTAINS the target — the contains variant needs a LONGER target
|
|
1512
|
+
// (≥10) because a short generic word ("sandbox") appears in many
|
|
1513
|
+
// unrelated titles and the multi-close below would sweep them all.
|
|
1514
|
+
if (ct && wantTitle.length >= 6 && (ct === wantTitle || ct.startsWith(wantTitle) || wantTitle.startsWith(ct))) { matches.push({ c, cov: 1 }); continue; }
|
|
1515
|
+
if (ct && wantTitle.length >= 10 && ct.includes(wantTitle)) { matches.push({ c, cov: 1 }); continue; }
|
|
1193
1516
|
// Else target-coverage (≥2 tokens, no floor): a short deliberate
|
|
1194
1517
|
// close-target whose tokens are present in a card is a precise hit.
|
|
1195
|
-
if (tTok.size >= 2) { const cov = coverageOf(tTok, tokenSet(c.text)); if (cov
|
|
1518
|
+
if (tTok.size >= 2) { const cov = coverageOf(tTok, tokenSet(c.text)); if (cov >= CLOSE_COVER_AT) matches.push({ c, cov }); }
|
|
1196
1519
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1520
|
+
matches.sort((a, b) => b.cov - a.cov);
|
|
1521
|
+
// >4 matches means the target was too GENERIC to trust a sweep —
|
|
1522
|
+
// fall back to the single best match (the pre-1.17 behavior) rather
|
|
1523
|
+
// than archive four semi-related cards in iteration order.
|
|
1524
|
+
const chosen = matches.length > 4 ? matches.slice(0, 1) : matches;
|
|
1525
|
+
if (!chosen.length) continue;
|
|
1526
|
+
const ship = String(card.text).replace(/\s+/g, ' ').replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, '').trim().slice(0, 80);
|
|
1527
|
+
card.__closesIds = [];
|
|
1528
|
+
for (const { c: best } of chosen) {
|
|
1199
1529
|
await rewriteCard(best.id, j => {
|
|
1200
1530
|
j.content = `${j.content}\n✅ ${today}: closed by → ${ship}`;
|
|
1201
1531
|
j.borderColor = 'rgba(16,185,129,0.35)';
|
|
1202
1532
|
});
|
|
1203
1533
|
await archiveCard(best.id);
|
|
1204
1534
|
best.text = `✅ ${best.text}`;
|
|
1205
|
-
card.
|
|
1535
|
+
card.__closesIds.push(best.id);
|
|
1206
1536
|
stats.closed++;
|
|
1207
1537
|
}
|
|
1208
1538
|
}
|
|
@@ -1238,11 +1568,13 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1238
1568
|
const titleIndex = struct.cards
|
|
1239
1569
|
.filter(c => (c.title || '').trim())
|
|
1240
1570
|
.map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
|
|
1571
|
+
const newIds = new Set();
|
|
1241
1572
|
for (const card of cards) {
|
|
1242
1573
|
const created = findNew(card.text);
|
|
1243
1574
|
if (!created) continue;
|
|
1575
|
+
newIds.add(created.id);
|
|
1244
1576
|
if (card.__supersedes) addConn(card.__supersedes, created.id, 'superseded by', undefined);
|
|
1245
|
-
|
|
1577
|
+
for (const cid of (card.__closesIds || [])) addConn(cid, created.id, 'closed by', undefined);
|
|
1246
1578
|
for (const link of (created.links || [])) {
|
|
1247
1579
|
const want = String(link).trim().toLowerCase();
|
|
1248
1580
|
if (!want) continue;
|
|
@@ -1250,6 +1582,21 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1250
1582
|
if (target) addConn(created.id, target.id, undefined, 'relates_to');
|
|
1251
1583
|
}
|
|
1252
1584
|
}
|
|
1585
|
+
// Structural auto-link for the NEW cards — the graph used to form only
|
|
1586
|
+
// when someone explicitly ran brain_connect (never, in practice: 66%
|
|
1587
|
+
// orphans in the field). Run the cheap tag/[[title]] proposer over the
|
|
1588
|
+
// post-append struct and keep only edges touching a just-added card
|
|
1589
|
+
// (≤2 per card via the proposer's own cap), labeled 'auto' so they're
|
|
1590
|
+
// distinguishable from deliberate arrows. Best-effort: an auto-link
|
|
1591
|
+
// failure must never fail a capture.
|
|
1592
|
+
if (newIds.size) {
|
|
1593
|
+
try {
|
|
1594
|
+
for (const e of proposeStructuralConnections(struct, { maxPerCard: 2 })) {
|
|
1595
|
+
if (!newIds.has(e.fromId) && !newIds.has(e.toId)) continue;
|
|
1596
|
+
addConn(e.fromId, e.toId, 'auto', 'relates_to');
|
|
1597
|
+
}
|
|
1598
|
+
} catch { /* auto-linking is opportunistic */ }
|
|
1599
|
+
}
|
|
1253
1600
|
work = await finalizeBrainZip(zip, canvas, manifest, now);
|
|
1254
1601
|
}
|
|
1255
1602
|
|
|
@@ -1265,6 +1612,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1265
1612
|
// to Archive, and arrowed → the synthesis. Nothing is deleted (archived verbatim).
|
|
1266
1613
|
const GARDEN_KEEP_NEWEST = 8; // per area, never consolidate the newest N
|
|
1267
1614
|
const GARDEN_MIN_AGE_DAYS = 14; // only cards older than this are candidates
|
|
1615
|
+
const GARDEN_AUTO_MIN_AGE_DAYS = 7; // #auto ship-event cards age out sooner — the durable fact usually also exists as a hand-written milestone
|
|
1268
1616
|
const GARDEN_MIN_CANDIDATES = 3; // don't bother merging fewer than this
|
|
1269
1617
|
const GARDEN_MAX_DEGREE = 1; // SMART guard: protect load-bearing cards —
|
|
1270
1618
|
// only consolidate cards with ≤ this many connections (orphans + leaves). A
|
|
@@ -1311,9 +1659,11 @@ export function selectGardenCandidates(struct, { keepNewest = GARDEN_KEEP_NEWEST
|
|
|
1311
1659
|
const children = struct.cards
|
|
1312
1660
|
.filter(c => c.type === 'text' && c.parentId === ctn.id && (c.text || '').trim() && !/⤵|↩|✅|🛠/.test(c.text)) // 🛠️ skills are standing reference — never consolidate them away
|
|
1313
1661
|
.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
1662
|
+
const autoCutoff = now - GARDEN_AUTO_MIN_AGE_DAYS * 86_400_000;
|
|
1663
|
+
const isAuto = (c) => (c.tags || []).some(t => String(t).toLowerCase().replace(/^#/, '') === 'auto');
|
|
1314
1664
|
const old = children
|
|
1315
1665
|
.slice(0, Math.max(0, children.length - keepNewest))
|
|
1316
|
-
.filter(c => (c.createdAt || 0) < cutoff && (degree.get(c.id) || 0) <= maxDegree); // dormant: old AND peripheral
|
|
1666
|
+
.filter(c => (c.createdAt || 0) < (isAuto(c) ? autoCutoff : cutoff) && (degree.get(c.id) || 0) <= maxDegree); // dormant: old AND peripheral (#auto ages faster)
|
|
1317
1667
|
if (old.length >= minCandidates) out.push({ containerId: ctn.id, title, candidates: old.map(c => ({ id: c.id, text: c.text, createdAt: c.createdAt || 0, degree: degree.get(c.id) || 0 })) });
|
|
1318
1668
|
}
|
|
1319
1669
|
return out;
|