klypix-mcp 1.16.0 → 1.18.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 +586 -46
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.18.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 && node test/layout-cluster.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
|
@@ -518,6 +518,7 @@ export async function tidyBrain(buffer) {
|
|
|
518
518
|
for (const c of rootText) { const a = areaOfCard(c); const k = a.toLowerCase(); if (!groups.has(k)) groups.set(k, { title: a, ids: [] }); groups.get(k).ids.push(c.id); }
|
|
519
519
|
|
|
520
520
|
let moved = 0;
|
|
521
|
+
const createdNow = new Set(); // containers born in THIS pass have no meaningful previous position
|
|
521
522
|
const assignTo = (ctnId, ids) => { for (const id of ids) { const p = canvas.positions[id]; canvas.positions[id] = { ...p, parentId: ctnId, zKey: (p && p.zKey && isValidZKey(p.zKey)) ? p.zKey : nextZKey() }; moved++; } };
|
|
522
523
|
// Ensure a container exists for each area (create if missing); route root cards in.
|
|
523
524
|
for (const grp of groups.values()) {
|
|
@@ -525,6 +526,7 @@ export async function tidyBrain(buffer) {
|
|
|
525
526
|
let ctnId = byTitle.get(key);
|
|
526
527
|
if (!ctnId) {
|
|
527
528
|
ctnId = `ctn_${rand()}`;
|
|
529
|
+
createdNow.add(ctnId);
|
|
528
530
|
zip.file(`items/${shard(ctnId)}/${ctnId}.json`, JSON.stringify({ type: 'container', locked: false, createdAt: now, createdBy: 'agent', title: grp.title, collapsed: false, scopeLocked: false, borderColor: '#10b981' }));
|
|
529
531
|
canvas.order.push(ctnId);
|
|
530
532
|
canvas.positions[ctnId] = { x: G.START, y: G.START, w: G.AREA_W, h: G.TITLE_BAR + G.PAD * 2, zKey: nextZKey(), zIndex: canvas.order.length, parentId: null };
|
|
@@ -533,23 +535,211 @@ export async function tidyBrain(buffer) {
|
|
|
533
535
|
assignTo(ctnId, grp.ids);
|
|
534
536
|
}
|
|
535
537
|
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
538
|
+
// ── CLUSTER LAYOUT ────────────────────────────────────────────────────────
|
|
539
|
+
// The old shelf-grid stacked every area's cards in ONE column and packed
|
|
540
|
+
// containers in creation order — at 400+ cards the brain rendered as an
|
|
541
|
+
// unreadable strip and position encoded nothing. Now:
|
|
542
|
+
// • MASONRY inside each container: cards flow into 1-5 balanced columns
|
|
543
|
+
// targeting a ~1.15 height/width ratio → areas are squarish tiles.
|
|
544
|
+
// • CLUSTERS across containers: each area is placed greedily beside the
|
|
545
|
+
// already-placed area it shares the most connections with — related
|
|
546
|
+
// knowledge is literally near, cross-area arrows stay short.
|
|
547
|
+
// • 📌 Focus (the human steering surface) anchors the map center; Archive
|
|
548
|
+
// is pinned to the cold rim (its supersede/close arrows touch every
|
|
549
|
+
// area, so edge weights would otherwise drag it central).
|
|
550
|
+
// Deterministic (no RNG, stable ordering): the same brain always maps the
|
|
551
|
+
// same way, and one new card nudges its own cluster instead of reshuffling
|
|
552
|
+
// the world. At map zoom the app's capsule/dot tiers render this as the
|
|
553
|
+
// cluster-galaxy view; membership steering (dragging a card into an area)
|
|
554
|
+
// is preserved — tidy re-flows coordinates, never parentage.
|
|
555
|
+
// Containers are never masonry "kids" — a nested container would otherwise
|
|
556
|
+
// be committed twice (as its parent's 300×40 pseudo-card AND as its own
|
|
557
|
+
// box), stranding its children at abandoned coordinates.
|
|
539
558
|
const childrenOf = (cid) => canvas.order
|
|
540
|
-
.filter(id => canvas.positions[id] && canvas.positions[id].parentId === cid)
|
|
559
|
+
.filter(id => !containerIds.has(id) && canvas.positions[id] && canvas.positions[id].parentId === cid)
|
|
541
560
|
.sort((a, b) => (meta.get(a)?.createdAt || 0) - (meta.get(b)?.createdAt || 0));
|
|
542
|
-
const heightOf = (cid) => { const inner = childrenOf(cid).reduce((s, id) => s + (meta.get(id)?.h || 40) + G.CARD_GAP, 0); return Math.max(G.TITLE_BAR + G.PAD * 2, G.TITLE_BAR + G.PAD + inner + G.PAD); };
|
|
543
561
|
const orderedCtns = canvas.order.filter(id => containerIds.has(id) && canvas.positions[id]);
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
for (const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
562
|
+
const ctnTitle = new Map();
|
|
563
|
+
for (const c of struct.cards) if (c.type === 'container') ctnTitle.set(c.id, c.title || '');
|
|
564
|
+
for (const [t, id] of byTitle) if (!ctnTitle.has(id)) ctnTitle.set(id, t);
|
|
565
|
+
const isArchiveCtn = (cid) => /^archive$/i.test(String(ctnTitle.get(cid) || '').trim());
|
|
566
|
+
const isFocusCtn = (cid) => /(^|\s)focus\b/i.test(String(ctnTitle.get(cid) || ''));
|
|
567
|
+
|
|
568
|
+
if (orderedCtns.length) {
|
|
569
|
+
// 0. Flatten human-nested containers to root. The capture toolchain
|
|
570
|
+
// never nests; a hand-nested area would be committed twice (as its
|
|
571
|
+
// parent's 300×40 pseudo-card AND as its own box), stranding its
|
|
572
|
+
// children. Promoted areas keep their absolute spot via the
|
|
573
|
+
// incremental anchor below, so visually nothing jumps.
|
|
574
|
+
for (const cid of orderedCtns) {
|
|
575
|
+
const p = canvas.positions[cid];
|
|
576
|
+
if (p && p.parentId != null && containerIds.has(p.parentId)) canvas.positions[cid] = { ...p, parentId: null };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// 1. Masonry plan per container: pick the column count whose resulting
|
|
580
|
+
// box is closest to the target aspect, then flow cards (chronological)
|
|
581
|
+
// into the currently-shortest column.
|
|
582
|
+
const plans = new Map(); // cid -> { w, h, kids: [{id, dx, dy, h}] }
|
|
583
|
+
for (const cid of orderedCtns) {
|
|
584
|
+
const kids = childrenOf(cid);
|
|
585
|
+
const totalH = kids.reduce((s, id) => s + (meta.get(id)?.h || 40) + G.CARD_GAP, 0);
|
|
586
|
+
let k = 1, best = Infinity;
|
|
587
|
+
for (let n = 1; n <= 5 && n <= Math.max(1, kids.length); n++) {
|
|
588
|
+
const w = G.PAD * 2 + n * G.CARD_W + (n - 1) * G.CARD_GAP;
|
|
589
|
+
const h = G.TITLE_BAR + G.PAD * 2 + Math.max(40, totalH / n);
|
|
590
|
+
const score = Math.abs((h / w) - 1.15);
|
|
591
|
+
if (score < best) { best = score; k = n; }
|
|
592
|
+
}
|
|
593
|
+
const colY = new Array(k).fill(G.TITLE_BAR + G.PAD);
|
|
594
|
+
const placedKids = [];
|
|
595
|
+
for (const id of kids) {
|
|
596
|
+
const h = meta.get(id)?.h || 40;
|
|
597
|
+
let col = 0; for (let c = 1; c < k; c++) if (colY[c] < colY[col]) col = c;
|
|
598
|
+
placedKids.push({ id, dx: G.PAD + col * (G.CARD_W + G.CARD_GAP), dy: colY[col], h });
|
|
599
|
+
colY[col] += h + G.CARD_GAP;
|
|
600
|
+
}
|
|
601
|
+
plans.set(cid, {
|
|
602
|
+
w: G.PAD * 2 + k * G.CARD_W + (k - 1) * G.CARD_GAP,
|
|
603
|
+
h: Math.max(G.TITLE_BAR + G.PAD * 2, Math.max(...colY) + G.PAD),
|
|
604
|
+
kids: placedKids,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 2. Inter-area connection weights (Archive excluded — rim-pinned).
|
|
609
|
+
const parentOf = (id) => canvas.positions[id]?.parentId ?? null;
|
|
610
|
+
const weights = new Map();
|
|
611
|
+
for (const cn of (canvas.connections || [])) {
|
|
612
|
+
const pa = parentOf(cn.fromId), pb = parentOf(cn.toId);
|
|
613
|
+
if (!pa || !pb || pa === pb || !plans.has(pa) || !plans.has(pb)) continue;
|
|
614
|
+
if (isArchiveCtn(pa) || isArchiveCtn(pb)) continue;
|
|
615
|
+
const key = pa < pb ? pa + '|' + pb : pb + '|' + pa;
|
|
616
|
+
weights.set(key, (weights.get(key) || 0) + 1);
|
|
617
|
+
}
|
|
618
|
+
const wOf = (a, b) => weights.get(a < b ? a + '|' + b : b + '|' + a) || 0;
|
|
619
|
+
const degree = new Map(orderedCtns.map(cid => [cid, 0]));
|
|
620
|
+
for (const [key, n] of weights) { const [a, b] = key.split('|'); degree.set(a, (degree.get(a) || 0) + n); degree.set(b, (degree.get(b) || 0) + n); }
|
|
621
|
+
|
|
622
|
+
// 3. INCREMENTAL by default, full cluster pass only on migration.
|
|
623
|
+
// The full pass orders by (mutable) connectivity — running it per
|
|
624
|
+
// capture reshuffled the entire map the moment one cross-area arrow
|
|
625
|
+
// landed (field-measured: 45/45 containers teleported ~4.4k px on one
|
|
626
|
+
// wikilink). A memory's map must be as stable as the memory: normally
|
|
627
|
+
// every container ANCHORS to its previous spot (taken verbatim when
|
|
628
|
+
// nothing grew into it), so a capture moves at most the areas whose
|
|
629
|
+
// size actually changed. The full pass runs only when the previous
|
|
630
|
+
// layout isn't this engine's (legacy strip, foreign grid, degenerate
|
|
631
|
+
// aspect) — detected via the settings stamp + geometry heuristics.
|
|
632
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0); // code-unit compare — locale-independent, unlike bare localeCompare
|
|
633
|
+
const prev = new Map();
|
|
634
|
+
for (const cid of orderedCtns) {
|
|
635
|
+
if (createdNow.has(cid)) continue;
|
|
636
|
+
const p = canvas.positions[cid];
|
|
637
|
+
if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) prev.set(cid, { x: p.x, y: p.y, w: p.w || 0, h: p.h || 0 });
|
|
638
|
+
}
|
|
639
|
+
let fullPass = !(canvas.settings && canvas.settings.brainLayout === 'cluster-v1');
|
|
640
|
+
if (fullPass && prev.size >= 2 && [...prev.values()].some(b => b.w > 400)) fullPass = false; // stamp lost (e.g. app re-save) but geometry is clearly cluster-made
|
|
641
|
+
if (!fullPass && prev.size >= 2) {
|
|
642
|
+
const xs = [...prev.values()];
|
|
643
|
+
const w = Math.max(...xs.map(b => b.x + b.w)) - Math.min(...xs.map(b => b.x));
|
|
644
|
+
const h = Math.max(...xs.map(b => b.y + b.h)) - Math.min(...xs.map(b => b.y));
|
|
645
|
+
const aspect = h / Math.max(1, w);
|
|
646
|
+
if (aspect > 4 || aspect < 0.1) fullPass = true; // degenerate strip → re-map
|
|
647
|
+
}
|
|
648
|
+
if (prev.size < 2) fullPass = true;
|
|
649
|
+
|
|
650
|
+
const focusCtns = orderedCtns.filter(isFocusCtn);
|
|
651
|
+
const isRim = (c) => !isFocusCtn(c) && isArchiveCtn(c);
|
|
652
|
+
let placeOrder, rimCtns;
|
|
653
|
+
if (fullPass) {
|
|
654
|
+
// Hubs early so satellites attach to them; Focus anchors the map.
|
|
655
|
+
rimCtns = orderedCtns.filter(isRim);
|
|
656
|
+
const middle = orderedCtns.filter(c => !isFocusCtn(c) && !isRim(c))
|
|
657
|
+
.sort((a, b) => ((degree.get(b) || 0) - (degree.get(a) || 0))
|
|
658
|
+
|| (plans.get(b).kids.length - plans.get(a).kids.length)
|
|
659
|
+
|| cmp(String(ctnTitle.get(a) || ''), String(ctnTitle.get(b) || ''))
|
|
660
|
+
|| cmp(a, b));
|
|
661
|
+
placeOrder = [...focusCtns, ...middle];
|
|
662
|
+
} else {
|
|
663
|
+
// Previous spatial order (top-left claims its spot first) — an
|
|
664
|
+
// IMMUTABLE ordering, so new arrows can't reshuffle the queue.
|
|
665
|
+
const anchored = orderedCtns.filter(c => prev.has(c))
|
|
666
|
+
.sort((a, b) => (prev.get(a).y - prev.get(b).y) || (prev.get(a).x - prev.get(b).x) || cmp(a, b));
|
|
667
|
+
const fresh = orderedCtns.filter(c => !prev.has(c) && !isRim(c))
|
|
668
|
+
.sort((a, b) => ((degree.get(b) || 0) - (degree.get(a) || 0)) || cmp(a, b));
|
|
669
|
+
rimCtns = orderedCtns.filter(c => !prev.has(c) && isRim(c));
|
|
670
|
+
placeOrder = [...anchored, ...fresh];
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// 4. Greedy placement. Anchored containers try their previous spot
|
|
674
|
+
// verbatim first (zero movement when nothing grew into it), else the
|
|
675
|
+
// nearest free side/corner slot. Unanchored ones score by
|
|
676
|
+
// connection-weighted distance + a gentle compactness pull.
|
|
677
|
+
const GAP = G.COL_GAP;
|
|
678
|
+
const boxes = new Map();
|
|
679
|
+
const overlapsAny = (b) => { for (const o of boxes.values()) if (b.x < o.x + o.w + GAP && o.x < b.x + b.w + GAP && b.y < o.y + o.h + GAP && o.y < b.y + b.h + GAP) return true; return false; };
|
|
680
|
+
const cxOf = (b) => b.x + b.w / 2, cyOf = (b) => b.y + b.h / 2;
|
|
681
|
+
const distC = (a, b) => Math.hypot(cxOf(a) - cxOf(b), cyOf(a) - cyOf(b));
|
|
682
|
+
for (const cid of placeOrder) {
|
|
683
|
+
const plan = plans.get(cid);
|
|
684
|
+
const anchor = !fullPass && prev.has(cid) ? prev.get(cid) : null;
|
|
685
|
+
if (anchor) {
|
|
686
|
+
const b0 = { x: anchor.x, y: anchor.y, w: plan.w, h: plan.h };
|
|
687
|
+
if (!overlapsAny(b0)) { boxes.set(cid, b0); continue; }
|
|
688
|
+
}
|
|
689
|
+
if (!boxes.size) { boxes.set(cid, { x: anchor ? anchor.x : 0, y: anchor ? anchor.y : 0, w: plan.w, h: plan.h }); continue; }
|
|
690
|
+
let mx = 0, my = 0;
|
|
691
|
+
for (const b of boxes.values()) { mx += cxOf(b); my += cyOf(b); }
|
|
692
|
+
mx /= boxes.size; my /= boxes.size;
|
|
693
|
+
const anchorBox = anchor ? { x: anchor.x, y: anchor.y, w: plan.w, h: plan.h } : null;
|
|
694
|
+
let bestPos = null, bestScore = Infinity;
|
|
695
|
+
for (const o of boxes.values()) {
|
|
696
|
+
const cands = [
|
|
697
|
+
{ x: o.x + o.w + GAP, y: o.y }, { x: o.x, y: o.y + o.h + GAP },
|
|
698
|
+
{ x: o.x - GAP - plan.w, y: o.y }, { x: o.x, y: o.y - GAP - plan.h },
|
|
699
|
+
{ x: o.x + o.w + GAP, y: o.y + o.h + GAP }, { x: o.x - GAP - plan.w, y: o.y + o.h + GAP },
|
|
700
|
+
{ x: o.x + o.w + GAP, y: o.y - GAP - plan.h }, { x: o.x - GAP - plan.w, y: o.y - GAP - plan.h },
|
|
701
|
+
];
|
|
702
|
+
for (const c of cands) {
|
|
703
|
+
const b = { x: c.x, y: c.y, w: plan.w, h: plan.h };
|
|
704
|
+
if (overlapsAny(b)) continue;
|
|
705
|
+
let score;
|
|
706
|
+
if (anchorBox) {
|
|
707
|
+
score = distC(b, anchorBox); // reclaim the old neighborhood
|
|
708
|
+
} else {
|
|
709
|
+
let pull = 0, wsum = 0;
|
|
710
|
+
for (const [pid, pb] of boxes) { const w = wOf(cid, pid); if (w) { pull += w * distC(b, pb); wsum += w; } }
|
|
711
|
+
const toCentroid = Math.hypot(cxOf(b) - mx, cyOf(b) - my);
|
|
712
|
+
score = (wsum ? pull / wsum : toCentroid) + 0.05 * toCentroid;
|
|
713
|
+
}
|
|
714
|
+
if (score < bestScore) { bestScore = score; bestPos = b; }
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
boxes.set(cid, bestPos || { x: 0, y: Math.max(...[...boxes.values()].map(b => b.y + b.h)) + GAP, w: plan.w, h: plan.h });
|
|
718
|
+
}
|
|
719
|
+
// Rim containers (Archive without a previous spot / full pass): the
|
|
720
|
+
// cold right edge, each right of the last so they never stack.
|
|
721
|
+
for (const cid of rimCtns) {
|
|
722
|
+
const plan = plans.get(cid);
|
|
723
|
+
const maxX = boxes.size ? Math.max(...[...boxes.values()].map(b => b.x + b.w)) : 0;
|
|
724
|
+
const topY = boxes.size ? Math.min(...[...boxes.values()].map(b => b.y)) : 0;
|
|
725
|
+
boxes.set(cid, { x: maxX + GAP * 3, y: topY, w: plan.w, h: plan.h });
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// 5. Commit: normalize to START only when the map would drift off-origin
|
|
729
|
+
// (incremental runs keep absolute coordinates → anchored spots stay
|
|
730
|
+
// byte-identical), write containers + kids, stamp the layout engine.
|
|
731
|
+
const minX = Math.min(...[...boxes.values()].map(b => b.x));
|
|
732
|
+
const minY = Math.min(...[...boxes.values()].map(b => b.y));
|
|
733
|
+
const dx = (fullPass || minX < 0) ? G.START - minX : 0;
|
|
734
|
+
const dy = (fullPass || minY < 0) ? G.START - minY : 0;
|
|
735
|
+
for (const [cid, b] of boxes) {
|
|
736
|
+
const x = b.x + dx, y = b.y + dy;
|
|
737
|
+
canvas.positions[cid] = { ...canvas.positions[cid], x, y, w: b.w, h: b.h };
|
|
738
|
+
for (const kid of plans.get(cid).kids) {
|
|
739
|
+
canvas.positions[kid.id] = { ...canvas.positions[kid.id], x: x + kid.dx, y: y + kid.dy, w: G.CARD_W, h: kid.h };
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
canvas.settings = { ...(canvas.settings || {}), brainLayout: 'cluster-v1' };
|
|
553
743
|
}
|
|
554
744
|
|
|
555
745
|
const out = await finalizeBrainZip(zip, canvas, manifest, now);
|
|
@@ -681,6 +871,73 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
681
871
|
if (unshown > 0) hidden.push(`${unshown} older/over-budget decision${unshown === 1 ? '' : 's'}`);
|
|
682
872
|
if (archivedCount > 0) hidden.push(`${archivedCount} archived/superseded`);
|
|
683
873
|
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\`.*`);
|
|
874
|
+
// Graph health, ambient: when a third of the live brain is unlinked, say so
|
|
875
|
+
// once (brain_insights has the detail; auto-linking at capture works the
|
|
876
|
+
// backlog down going forward).
|
|
877
|
+
const degIds = new Set();
|
|
878
|
+
for (const cn of struct.connections || []) { if (cn.fromId) degIds.add(cn.fromId); if (cn.toId) degIds.add(cn.toId); }
|
|
879
|
+
const orphanN = live.filter(c => !degIds.has(c.id)).length;
|
|
880
|
+
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.*`);
|
|
881
|
+
return out.join('\n') + '\n';
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ── Ultra brief (SessionStart stdout tier) ───────────────────────────────────
|
|
885
|
+
// The harness persists hook stdout to a file and shows the agent only a ~2KB
|
|
886
|
+
// PREVIEW — a 13.5KB brief was mostly invisible (everything after Focus/open
|
|
887
|
+
// questions reached the agent only if it chose to open the file; it usually
|
|
888
|
+
// didn't). This tier is sized to fit that preview WHOLE: Focus + conflicts +
|
|
889
|
+
// open questions + a pointer to the FULL brief file the hook writes alongside.
|
|
890
|
+
// The pointer + marker legend are reserved OUT of the budget so they always fit.
|
|
891
|
+
export const ULTRA_BUDGET_CHARS = 1_800; // sibling of BUDGET_CHARS above — sized for the harness preview, not token cost
|
|
892
|
+
export function structToUltraBrief(struct, { freshness = null, briefPath = '.claude/brain-brief.md', budgetChars = ULTRA_BUDGET_CHARS } = {}) {
|
|
893
|
+
const texts = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
|
|
894
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
895
|
+
const isFocus = (c) => /(^|\s)focus\b/i.test(c.area || '');
|
|
896
|
+
const live = texts.filter(c => !isArchived(c));
|
|
897
|
+
const focus = live.filter(isFocus);
|
|
898
|
+
const open = live.filter(c => /❓|🎯/.test(c.text) && !/🛠/.test(c.text) && !isFocus(c));
|
|
899
|
+
const skills = live.filter(c => /🛠/.test(c.text) && !isFocus(c));
|
|
900
|
+
const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
|
|
901
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
902
|
+
const fr = (c) => (freshness && freshness[c.id]) ? freshness[c.id] + ' ' : '';
|
|
903
|
+
const safeCut = (t, n) => { let s = t.slice(0, n); if (/[\uD800-\uDBFF]$/.test(s)) s = s.slice(0, -1); return s.trimEnd() + '…'; };
|
|
904
|
+
const head = (c, max = 150) => { const t = flat(c.text); return t.length > max ? safeCut(t, max - 1) : t; };
|
|
905
|
+
const out = [];
|
|
906
|
+
let used = 0;
|
|
907
|
+
const push = (...ls) => { for (const l of ls) { out.push(l); used += l.length + 1; } };
|
|
908
|
+
// Length-aware guard: a line only lands if it FITS — one long focus card
|
|
909
|
+
// must not blow the tier past the preview it exists to fit inside.
|
|
910
|
+
const pushIf = (l) => { if (used + l.length + 1 > budget) return false; out.push(l); used += l.length + 1; return true; };
|
|
911
|
+
const tail = [
|
|
912
|
+
'',
|
|
913
|
+
`📖 **Full brief: \`${briefPath}\`** — skills (${skills.length}), milestones, recent decisions, connections, self-heal detail. READ IT before planning non-trivial work.`,
|
|
914
|
+
'🧠 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).',
|
|
915
|
+
];
|
|
916
|
+
const budget = Math.max(400, budgetChars - tail.reduce((s, l) => s + l.length + 1, 0));
|
|
917
|
+
push(`# ${struct.title} — brain (ultra brief)`);
|
|
918
|
+
push(`*${struct.counts.cards} cards · ${struct.counts.connections} connections — this is the preview tier; the full brief is one Read away (below)*`);
|
|
919
|
+
// clip = surrogate-safe truncation for arbitrary strings (head() covers cards).
|
|
920
|
+
const clip = (s, n) => { const t = flat(s); return t.length > n ? safeCut(t, n - 1) : t; };
|
|
921
|
+
if (focus.length && pushIf('') && pushIf('## 📌 Human focus (act on these first)')) {
|
|
922
|
+
let shown = 0;
|
|
923
|
+
for (const c of focus) { if (!pushIf(`- ${fr(c)}${head(c, 400)}`)) break; shown++; }
|
|
924
|
+
// Never present a truncated "act on these first" list as complete.
|
|
925
|
+
if (shown < focus.length) push(`- ⚠️ …and ${focus.length - shown} MORE focus card(s) — read the full brief before acting.`);
|
|
926
|
+
}
|
|
927
|
+
if (conflicts.length && pushIf('') && pushIf('## ⚠️ Conflicts to reconcile')) {
|
|
928
|
+
let shown = 0;
|
|
929
|
+
for (const c of conflicts.slice(0, 4)) { if (!pushIf(`- ${clip(c.from, 70)} ⚔️ ${clip(c.to, 70)}`)) break; shown++; }
|
|
930
|
+
if (shown < conflicts.length) pushIf(`- …and ${conflicts.length - shown} more conflict(s) — in the full brief.`);
|
|
931
|
+
}
|
|
932
|
+
if (open.length && pushIf('') && pushIf(`## Open questions & goals (${open.length})`)) {
|
|
933
|
+
let shown = 0;
|
|
934
|
+
for (const c of open) { if (!pushIf(`- ${fr(c)}${head(c)}`)) break; shown++; }
|
|
935
|
+
if (shown < open.length) pushIf(`- …and ${open.length - shown} more — in the full brief.`);
|
|
936
|
+
}
|
|
937
|
+
const areas = struct.cards.filter(c => c.type === 'container' && !/^archive$/i.test(c.title || ''))
|
|
938
|
+
.map(c => `${flat(c.title)} (${texts.filter(t => t.parentId === c.id).length})`);
|
|
939
|
+
if (areas.length) { pushIf(''); pushIf(clip('Areas: ' + areas.join(' · '), 240)); }
|
|
940
|
+
push(...tail);
|
|
684
941
|
return out.join('\n') + '\n';
|
|
685
942
|
}
|
|
686
943
|
|
|
@@ -712,11 +969,20 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
|
|
|
712
969
|
const titleW = wordsOf(c.title);
|
|
713
970
|
const bodyW = wordsOf(c.text);
|
|
714
971
|
const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
|
|
972
|
+
// Log-length normalization: a flat 1pt/word-hit made LONG cards outrank
|
|
973
|
+
// short ones purely by having more vocabulary to hit (one ~600-word card
|
|
974
|
+
// was re-injected into 3 prompts on those cheap hits). Body hits are
|
|
975
|
+
// scaled by log2 of the body's distinct-word count — ≤64 words is EXACTLY
|
|
976
|
+
// unpenalized (log2(64)=6), a 600-word card scores ~0.65/hit, so a
|
|
977
|
+
// body-only match on a big card needs 5 distinct hits to clear the recall
|
|
978
|
+
// minScore of 3 — by design. Title/tag hits (the precise signals) are
|
|
979
|
+
// untouched.
|
|
980
|
+
const lenNorm = Math.min(1, 6 / Math.max(6, Math.log2(bodyW.size || 1)));
|
|
715
981
|
let score = 0;
|
|
716
982
|
for (const tok of tokens) {
|
|
717
983
|
if (titleW.has(tok)) score += 3;
|
|
718
984
|
else if (tagStems.has(tok)) score += 3;
|
|
719
|
-
else if (bodyW.has(tok)) score +=
|
|
985
|
+
else if (bodyW.has(tok)) score += lenNorm;
|
|
720
986
|
}
|
|
721
987
|
if (score <= 0) continue;
|
|
722
988
|
if ((c.createdAt || 0) >= cutoff) score += 0.5; // gentle recency tiebreak, never dominant
|
|
@@ -785,6 +1051,23 @@ export function findUnrecordedMigrations(struct, files, { max = 6 } = {}) {
|
|
|
785
1051
|
// recall list still shows below). Returns each card's `kind` so the caller can
|
|
786
1052
|
// say "reuse it" (shipped/resolved) vs "see what replaced it" (superseded).
|
|
787
1053
|
// Pure + node-runnable; reuses the one shared tokenizer — no divergent scorer.
|
|
1054
|
+
// Generic work-verbs establish that a prompt is WORK, not WHICH work — two of
|
|
1055
|
+
// them shared with a 🏁 ship-card title used to clear the floor ("deploy it"
|
|
1056
|
+
// flagged two unrelated PR-merge cards; zero true positives all session).
|
|
1057
|
+
// Excluded from repeat scoring entirely; the loose recall list still sees them.
|
|
1058
|
+
const REPEAT_VERB_STOP = new Set([
|
|
1059
|
+
'deploy', 'deployed', 'deploying', 'deploys', 'ship', 'shipped', 'shipping', 'ships',
|
|
1060
|
+
'merge', 'merged', 'merges', 'merging', 'release', 'released', 'releases', 'releasing',
|
|
1061
|
+
'publish', 'published', 'publishes', 'publishing', 'push', 'pushed', 'land', 'landed',
|
|
1062
|
+
'build', 'builds', 'building', 'built', 'check', 'checked', 'checking', 'checks',
|
|
1063
|
+
'plan', 'planned', 'planning', 'plans', 'best', 'class', 'cut', 'hand', 'handoff',
|
|
1064
|
+
'report', 'reports', 'live', 'latest', 'done', 'complete', 'completed', 'finish', 'finished',
|
|
1065
|
+
'work', 'task', 'feature',
|
|
1066
|
+
]);
|
|
1067
|
+
// Entity-shaped token — the kind that pins WHICH work: carries a digit (version,
|
|
1068
|
+
// PR#), or a kebab/snake identifier. A #file-/#dir- tag-stem match counts as an
|
|
1069
|
+
// entity at match time regardless of shape (tags are capture-stamped anchors).
|
|
1070
|
+
const isEntityToken = (t) => /\d/.test(t) || t.includes('-') || t.includes('_');
|
|
788
1071
|
export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTokens = 2 } = {}) {
|
|
789
1072
|
const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
|
|
790
1073
|
if (tokens.length < minTokens || !struct || !Array.isArray(struct.cards)) return [];
|
|
@@ -802,13 +1085,19 @@ export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTok
|
|
|
802
1085
|
const titleW = wordsOf(firstMeaningful || c.title);
|
|
803
1086
|
const bodyW = wordsOf(c.text);
|
|
804
1087
|
const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
|
|
805
|
-
|
|
1088
|
+
// Auto-harvested ship cards (#auto, one-line merge/release events) are
|
|
1089
|
+
// dense with exactly the verbs above — they additionally need ≥1 ENTITY
|
|
1090
|
+
// token matched before a nudge is worth showing.
|
|
1091
|
+
const isAuto = (c.tags || []).some(t => String(t).toLowerCase().replace(/^#/, '') === 'auto');
|
|
1092
|
+
let score = 0, matched = 0, entities = 0;
|
|
806
1093
|
for (const tok of tokens) {
|
|
807
|
-
if (
|
|
808
|
-
|
|
809
|
-
else if (
|
|
1094
|
+
if (REPEAT_VERB_STOP.has(tok)) continue; // "that it's work" ≠ "which work"
|
|
1095
|
+
if (titleW.has(tok)) { score += 3; matched++; if (isEntityToken(tok)) entities++; }
|
|
1096
|
+
else if (tagStems.has(tok)) { score += 3; matched++; entities++; }
|
|
1097
|
+
else if (bodyW.has(tok)) { score += 1; matched++; if (isEntityToken(tok)) entities++; }
|
|
810
1098
|
}
|
|
811
1099
|
if (matched < minTokens || score < minScore) continue; // precision-first floor
|
|
1100
|
+
if (isAuto && entities < 1) continue; // auto cards: entity anchor required
|
|
812
1101
|
out.push({ card: c, score, kind });
|
|
813
1102
|
}
|
|
814
1103
|
out.sort((a, b) => b.score - a.score || (rank[b.kind] - rank[a.kind]) || (b.card.createdAt || 0) - (a.card.createdAt || 0));
|
|
@@ -855,6 +1144,84 @@ export function detectConflicts(struct, { minOverlap = 0.45, topK = 12 } = {}) {
|
|
|
855
1144
|
return out.slice(0, topK);
|
|
856
1145
|
}
|
|
857
1146
|
|
|
1147
|
+
// ── Contradiction candidates (reconcile-proper) ──────────────────────────────
|
|
1148
|
+
// The retroactive cleaner for stale/correction pairs that slipped past capture
|
|
1149
|
+
// (cross-area + reworded → no supersede possible by construction). Unlike
|
|
1150
|
+
// detectConflicts (a pre-filter for an LLM verifier), this is precision-scoped
|
|
1151
|
+
// enough to surface DIRECTLY for human/agent confirmation: same-subject live
|
|
1152
|
+
// pairs where (a) exactly ONE side carries an explicit correction cue
|
|
1153
|
+
// (CORRECTION / OBSOLETE / was WRONG / stale note resolved) — that side is the
|
|
1154
|
+
// presumed truth — or (b) the two sides use OPPOSITE polarity words about the
|
|
1155
|
+
// same subject (deferred↔wired, broken↔fixed, dead↔live …). Pairs already
|
|
1156
|
+
// settled by a supersede/close/conflict arrow are excluded; a plain relates_to
|
|
1157
|
+
// arrow is NOT a settlement. Suggestion-only — never writes. Pure, node-runnable.
|
|
1158
|
+
const POLARITY_PAIRS = [
|
|
1159
|
+
['deferred', 'wired'], ['deferred', 'shipped'], ['deferred', 'live'], ['deferred', 'enabled'],
|
|
1160
|
+
['broken', 'fixed'], ['broken', 'working'], ['dead', 'live'], ['dead', 'alive'],
|
|
1161
|
+
['disabled', 'enabled'], ['blocked', 'unblocked'], ['wrong', 'correct'], ['removed', 'restored'],
|
|
1162
|
+
];
|
|
1163
|
+
// WORD-boundary matchers, precompiled — substring includes() made 'deadline'
|
|
1164
|
+
// carry the 'dead' pole and 'delivery' carry 'live', and blocked↔unblocked
|
|
1165
|
+
// could never fire ('unblocked' contains 'blocked').
|
|
1166
|
+
const POLARITY_RES = POLARITY_PAIRS.map(([x, y]) => ({ x, y, rx: new RegExp(`\\b${x}\\b`), ry: new RegExp(`\\b${y}\\b`) }));
|
|
1167
|
+
export function detectContradictions(struct, { minOverlap = 0.45, topK = 12 } = {}) {
|
|
1168
|
+
if (!struct || !Array.isArray(struct.cards)) return [];
|
|
1169
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1170
|
+
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
|
|
1171
|
+
// Cue meta words stripped up front — same-subject matching must compare the
|
|
1172
|
+
// SUBJECT, not the vocabulary of the correction act (see stripCueMeta above).
|
|
1173
|
+
const wset = new Map(live.map(c => [c.id, stripCueMeta(new Set(queryTokens(c.text)))]));
|
|
1174
|
+
const lower = new Map(live.map(c => [c.id, String(c.text).toLowerCase()]));
|
|
1175
|
+
// settled — fully reconciled (supersede/close/conflict arrow): excludes the
|
|
1176
|
+
// pair regardless of kind.
|
|
1177
|
+
// linked — ANY deliberate edge (label !== 'auto'): dismisses a POLARITY pair
|
|
1178
|
+
// ("I looked, they relate, not a contradiction"), but never a cue pair —
|
|
1179
|
+
// a correction that [[wikilinks]] its stale card must still surface until
|
|
1180
|
+
// the stale card is actually retired. Auto-drawn edges dismiss nothing.
|
|
1181
|
+
const settled = new Set(), linked = new Set();
|
|
1182
|
+
for (const e of struct.connections || []) {
|
|
1183
|
+
const k1 = e.fromId + '|' + e.toId, k2 = e.toId + '|' + e.fromId;
|
|
1184
|
+
if (e.label === 'superseded by' || e.label === 'closed by' || e.relationship === 'conflicts_with') { settled.add(k1); settled.add(k2); }
|
|
1185
|
+
if (e.label !== 'auto') { linked.add(k1); linked.add(k2); }
|
|
1186
|
+
}
|
|
1187
|
+
const out = [];
|
|
1188
|
+
for (let i = 0; i < live.length; i++) {
|
|
1189
|
+
for (let j = i + 1; j < live.length; j++) {
|
|
1190
|
+
const a = live[i], b = live[j];
|
|
1191
|
+
if (settled.has(a.id + '|' + b.id)) continue;
|
|
1192
|
+
const A = wset.get(a.id), B = wset.get(b.id);
|
|
1193
|
+
if (A.size < 4 || B.size < 4) continue;
|
|
1194
|
+
let inter = 0; for (const t of A) if (B.has(t)) inter++;
|
|
1195
|
+
const overlap = inter / Math.min(A.size, B.size); // same subject?
|
|
1196
|
+
const aCue = hasCorrectionCue(a.text), bCue = hasCorrectionCue(b.text);
|
|
1197
|
+
// Cue-asymmetric pairs may also match on absolute subject mass (long
|
|
1198
|
+
// cards — see cueMatch above); cue-less pairs keep the strict ratio.
|
|
1199
|
+
const subjectHit = overlap >= minOverlap
|
|
1200
|
+
|| (aCue !== bCue && inter >= CUE_STRONG_SHARED && overlap >= CUE_RELAXED_COEF);
|
|
1201
|
+
if (!subjectHit) continue;
|
|
1202
|
+
let why = null, staleC = null, freshC = null;
|
|
1203
|
+
if (aCue !== bCue) {
|
|
1204
|
+
why = 'correction-cue'; // one side explicitly corrects — it is the presumed truth
|
|
1205
|
+
freshC = aCue ? a : b; staleC = aCue ? b : a;
|
|
1206
|
+
} else if (!aCue && !linked.has(a.id + '|' + b.id)) {
|
|
1207
|
+
const la = lower.get(a.id), lb = lower.get(b.id);
|
|
1208
|
+
for (const { x, y, rx, ry } of POLARITY_RES) {
|
|
1209
|
+
// each side must carry ONE pole only (word-level) — a card
|
|
1210
|
+
// narrating "from deferred to wired" holds both and
|
|
1211
|
+
// contradicts neither.
|
|
1212
|
+
if ((rx.test(la) && !ry.test(la) && ry.test(lb) && !rx.test(lb))
|
|
1213
|
+
|| (ry.test(la) && !rx.test(la) && rx.test(lb) && !ry.test(lb))) { why = `polarity: ${x} ↔ ${y}`; break; }
|
|
1214
|
+
}
|
|
1215
|
+
if (why) [staleC, freshC] = (a.createdAt || 0) <= (b.createdAt || 0) ? [a, b] : [b, a]; // later card = presumed truth
|
|
1216
|
+
}
|
|
1217
|
+
if (!why) continue;
|
|
1218
|
+
out.push({ stale: staleC, fresh: freshC, why, overlap: Math.round(overlap * 100) / 100, cue: aCue || bCue });
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
out.sort((x, y) => (Number(y.cue) - Number(x.cue)) || (y.overlap - x.overlap));
|
|
1222
|
+
return out.slice(0, topK);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
858
1225
|
// ── Brain insights ───────────────────────────────────────────────────────────
|
|
859
1226
|
// "What matters here, and what am I forgetting?" — a deterministic structural
|
|
860
1227
|
// read of the brain (borrowed from graphify's GRAPH_REPORT, applied to the
|
|
@@ -948,9 +1315,12 @@ export function proposeStructuralConnections(struct, { maxPerCard = 2 } = {}) {
|
|
|
948
1315
|
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
|
|
949
1316
|
const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
|
|
950
1317
|
const titleIx = live.filter(c => (c.title || '').trim()).map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
|
|
1318
|
+
// 'auto' is PROVENANCE, not topic — as a shared tag it would link every
|
|
1319
|
+
// harvested ship card to every other one (junk edges that then push their
|
|
1320
|
+
// degree past the garden's dormancy guard).
|
|
951
1321
|
const tagsOf = (c) => (c.tags || [])
|
|
952
1322
|
.map(t => String(t).toLowerCase().replace(/^#/, ''))
|
|
953
|
-
.filter(t => t && t !== 'area' && t !== String(c.area || '').toLowerCase());
|
|
1323
|
+
.filter(t => t && t !== 'area' && t !== 'auto' && t !== String(c.area || '').toLowerCase());
|
|
954
1324
|
const cand = [];
|
|
955
1325
|
for (const c of live) {
|
|
956
1326
|
for (const link of (c.links || [])) {
|
|
@@ -1005,6 +1375,83 @@ const overlapScore = (a, b) => {
|
|
|
1005
1375
|
// failed to fire.)
|
|
1006
1376
|
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
1377
|
|
|
1378
|
+
// ── Truth decay (P1) — corrections must never lose to the cards they correct ─
|
|
1379
|
+
// A correction-cue note explicitly declares an older fact stale ("CORRECTION:",
|
|
1380
|
+
// "was WRONG", "OBSOLETE", "stale note resolved"). The same-area ≥0.6 supersede
|
|
1381
|
+
// can miss it BY CONSTRUCTION (the correction often lands in a different area,
|
|
1382
|
+
// reworded) — the stale card then stays live and recall serves it alone. Shared
|
|
1383
|
+
// by: the capture-side widened supersede, the recall-side overlay below, and
|
|
1384
|
+
// detectContradictions.
|
|
1385
|
+
// DELIBERATE cue only: the uppercase forms are the documented convention, and
|
|
1386
|
+
// case-sensitivity is what keeps casual prose ("the floor calc was wrong",
|
|
1387
|
+
// "remove obsolete helper", "color-correction") from firing a cross-area
|
|
1388
|
+
// supersede on an innocent card. "stale note resolved" is the one
|
|
1389
|
+
// natural-language phrase, accepted in any case (it is never incidental).
|
|
1390
|
+
export const CORRECTION_RE = /\bCORRECTIONS?\b|\bOBSOLETE\b|\bwas WRONG\b/;
|
|
1391
|
+
const CORRECTION_PHRASE_RE = /\bstale note (?:is )?resolved\b/i;
|
|
1392
|
+
export const hasCorrectionCue = (t) => CORRECTION_RE.test(String(t || '')) || CORRECTION_PHRASE_RE.test(String(t || ''));
|
|
1393
|
+
export const CORRECTION_SUPERSEDE_AT = 0.4; // widened cross-area bar (vs same-area SUPERSEDE_AT 0.6)
|
|
1394
|
+
// Cue META words describe the act of correcting, not the subject — left in, they
|
|
1395
|
+
// dilute the overlap denominator and push real correction pairs just under the
|
|
1396
|
+
// bar (the field fixture lands at 0.375 with them, 0.5 without). Stripped before
|
|
1397
|
+
// every correction-overlap comparison.
|
|
1398
|
+
const CORRECTION_META = new Set(['correction', 'corrections', 'obsolete', 'stale', 'note', 'notes', 'resolved', 'wrong']);
|
|
1399
|
+
const stripCueMeta = (set) => { const out = new Set(); for (const w of set) if (!CORRECTION_META.has(w)) out.add(w); return out; };
|
|
1400
|
+
// Long-card reality: a correction's SUBJECT is a fraction of each card — the
|
|
1401
|
+
// overlap COEFFICIENT alone punishes long↔long pairs (the real field pair
|
|
1402
|
+
// measures 0.33 with 17 shared subject tokens, under every per-ratio bar). A
|
|
1403
|
+
// cue-gated match therefore also fires on ABSOLUTE subject mass: ≥10 shared
|
|
1404
|
+
// meaningful tokens at ≥0.25 coefficient. Cue-gated ONLY — plain supersede and
|
|
1405
|
+
// polarity pairs keep their strict ratio bars (no cue prior to lean on).
|
|
1406
|
+
const CUE_STRONG_SHARED = 10, CUE_RELAXED_COEF = 0.25;
|
|
1407
|
+
// Floor 3 (not overlapScore's 4): after stripCueMeta a terse deliberate
|
|
1408
|
+
// correction ("CORRECTION: the vault default was WRONG — use cwd") keeps only
|
|
1409
|
+
// 3-ish subject tokens; at 4 it silently no-oped. ≤2-token corrections still
|
|
1410
|
+
// no-op (too little signal to archive on) — they land as a new card; use ~ to
|
|
1411
|
+
// edit a card in place instead.
|
|
1412
|
+
const cueMatch = (a, b, bar) => {
|
|
1413
|
+
if (a.size < 3 || b.size < 3) return 0;
|
|
1414
|
+
let inter = 0; for (const w of a) if (b.has(w)) inter++;
|
|
1415
|
+
const coef = inter / Math.min(a.size, b.size);
|
|
1416
|
+
return (coef >= bar || (inter >= CUE_STRONG_SHARED && coef >= CUE_RELAXED_COEF)) ? coef : 0;
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
// Recall-side guard: given the cards recall is about to inject, return for each
|
|
1420
|
+
// one the card that CORRECTS it, found two ways:
|
|
1421
|
+
// • edge — an outgoing "superseded by"/"closed by" arrow (drawn by capture or
|
|
1422
|
+
// a confirmed reconcile) whose successor still has text;
|
|
1423
|
+
// • cue — a LIVE correction-cue card that lexically overlaps it ≥ `at`, ANY
|
|
1424
|
+
// area (the un-edged pair the capture-time supersede missed).
|
|
1425
|
+
// The caller injects the corrector FIRST (labeled) and reduces the stale hit to
|
|
1426
|
+
// a headline — the stale text never stands alone. Pure + cheap: correction-cue
|
|
1427
|
+
// cards are rare and the hit list is ≤topK.
|
|
1428
|
+
export function correctionOverlaysFor(struct, cards, { at = CORRECTION_SUPERSEDE_AT } = {}) {
|
|
1429
|
+
const out = new Map();
|
|
1430
|
+
if (!struct || !Array.isArray(struct.cards) || !Array.isArray(cards) || !cards.length) return out;
|
|
1431
|
+
const byId = new Map(struct.cards.map(c => [c.id, c]));
|
|
1432
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1433
|
+
const successorOf = new Map();
|
|
1434
|
+
for (const cn of struct.connections || []) {
|
|
1435
|
+
if (cn.label === 'superseded by' || cn.label === 'closed by') successorOf.set(cn.fromId, cn.toId);
|
|
1436
|
+
}
|
|
1437
|
+
const cues = struct.cards.filter(c => c.type !== 'container' && !isArchived(c) && (c.text || '').trim() && hasCorrectionCue(c.text));
|
|
1438
|
+
for (const card of cards) {
|
|
1439
|
+
if (!card || !card.id) continue;
|
|
1440
|
+
const succ = successorOf.has(card.id) ? byId.get(successorOf.get(card.id)) : null;
|
|
1441
|
+
if (succ && (succ.text || '').trim()) { out.set(card.id, { kind: 'edge', by: succ }); continue; }
|
|
1442
|
+
if (hasCorrectionCue(card.text)) continue; // the hit IS a correction — nothing to overlay
|
|
1443
|
+
const cTok = tokenSet(card.text);
|
|
1444
|
+
let best = null, bestS = 0;
|
|
1445
|
+
for (const cue of cues) {
|
|
1446
|
+
if (cue.id === card.id) continue;
|
|
1447
|
+
const s = cueMatch(cTok, stripCueMeta(tokenSet(cue.text)), at);
|
|
1448
|
+
if (s > bestS) { bestS = s; best = cue; }
|
|
1449
|
+
}
|
|
1450
|
+
if (best && bestS > 0) out.set(card.id, { kind: 'cue', by: best, overlap: Math.round(bestS * 100) / 100 });
|
|
1451
|
+
}
|
|
1452
|
+
return out;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1008
1455
|
// ── Auto-skill classifier (skills emerge from the flow, not just the '+' marker) ─
|
|
1009
1456
|
// A REUSABLE skill (how-to / gotcha / convention) reads as a GENERAL RULE that
|
|
1010
1457
|
// applies next time — distinct from a one-time decision ("we shipped X"). This is
|
|
@@ -1024,9 +1471,12 @@ export function looksLikeSkill(text) {
|
|
|
1024
1471
|
}
|
|
1025
1472
|
|
|
1026
1473
|
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;
|
|
1474
|
+
const SUPERSEDE_AT = 0.6, RESOLVE_AT = 0.3, UPDATE_AT = 0.45, CLOSE_COVER_AT = 0.6, QUESTION_MERGE_AT = 0.6;
|
|
1028
1475
|
let work = buffer;
|
|
1029
|
-
|
|
1476
|
+
// corrections[] lists cross-area/low-bar supersedes driven by a correction
|
|
1477
|
+
// cue, so the surface (brain_note result / capture stderr) can say WHAT was
|
|
1478
|
+
// archived and how to undo — the confirmation channel for the widened match.
|
|
1479
|
+
const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0, closed: 0, merged: 0, corrections: [] };
|
|
1030
1480
|
|
|
1031
1481
|
// Pass 1 — resolutions + supersede marking operate on EXISTING cards.
|
|
1032
1482
|
if (resolutions.length || cards.length || updates.length) {
|
|
@@ -1093,24 +1543,36 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1093
1543
|
};
|
|
1094
1544
|
canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
|
|
1095
1545
|
|
|
1096
|
-
// RESOLVE (✓ markers) — best live match in the area
|
|
1546
|
+
// RESOLVE (✓ markers) — best live match in the area, PLUS its near-tie
|
|
1547
|
+
// twins: a rephrased duplicate ❓ scores within a hair of its sibling, and
|
|
1548
|
+
// resolving only the first left the twin open forever (it kept surfacing
|
|
1549
|
+
// in every brief as still-to-do). Precision-kept: the set is the best
|
|
1550
|
+
// match ± 0.1, never everything above the loose 0.3 floor. ❓ preferred.
|
|
1097
1551
|
const milestonesFallback = [];
|
|
1098
1552
|
for (const r of resolutions) {
|
|
1099
1553
|
const rTok = tokenSet(r.text);
|
|
1100
|
-
|
|
1554
|
+
const cands = [];
|
|
1101
1555
|
for (const c of liveTextCards()) {
|
|
1102
1556
|
if (r.area && (c.area || '').toLowerCase() !== r.area.toLowerCase()) continue;
|
|
1557
|
+
if (/🛠/.test(c.text)) continue; // skills are standing reference — a ✓ must never archive one (mirror the supersede guard)
|
|
1103
1558
|
const s = overlapScore(rTok, tokenSet(c.text)) + (/❓|🎯/.test(c.text) ? 0.15 : 0);
|
|
1104
|
-
if (s >
|
|
1559
|
+
if (s > 0) cands.push({ c, s });
|
|
1105
1560
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1561
|
+
cands.sort((a, b) => b.s - a.s);
|
|
1562
|
+
const bestScore = cands.length ? cands[0].s : 0;
|
|
1563
|
+
const set = bestScore >= RESOLVE_AT
|
|
1564
|
+
? cands.filter(x => x.s >= Math.max(RESOLVE_AT, bestScore - 0.1)).slice(0, 3)
|
|
1565
|
+
: [];
|
|
1566
|
+
if (set.length) {
|
|
1567
|
+
for (const { c: best } of set) {
|
|
1568
|
+
await rewriteCard(best.id, j => {
|
|
1569
|
+
j.content = `${j.content}\n✅ ${today}: ${r.text}`;
|
|
1570
|
+
j.borderColor = 'rgba(16,185,129,0.35)';
|
|
1571
|
+
});
|
|
1572
|
+
await archiveCard(best.id);
|
|
1573
|
+
best.text += ` ✅ ${r.text}`; // keep in-memory struct honest for later matching
|
|
1574
|
+
stats.resolved++;
|
|
1575
|
+
}
|
|
1114
1576
|
} else {
|
|
1115
1577
|
milestonesFallback.push({ text: (r.area ? `${r.area}: ` : '') + `🏁 ${r.text}`, area: r.area, borderColor: 'rgba(59,130,246,0.8)' });
|
|
1116
1578
|
}
|
|
@@ -1146,6 +1608,34 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1146
1608
|
}
|
|
1147
1609
|
}
|
|
1148
1610
|
|
|
1611
|
+
// MERGE-ON-CAPTURE for duplicate open questions — a rephrased ❓ that
|
|
1612
|
+
// heavily overlaps an EXISTING live ❓ updates that card in place (fresh
|
|
1613
|
+
// wording + createdAt) instead of stacking a twin the close-pass would
|
|
1614
|
+
// later miss. (Supersede deliberately skips ? cards, so without this
|
|
1615
|
+
// twins could never merge at capture at all.)
|
|
1616
|
+
for (let i = cards.length - 1; i >= 0; i--) {
|
|
1617
|
+
const card = cards[i];
|
|
1618
|
+
if (!/❓/.test(card.text) || /🏁|🛠/.test(card.text)) continue;
|
|
1619
|
+
const nTok = tokenSet(card.text);
|
|
1620
|
+
let best = null, bestScore = 0;
|
|
1621
|
+
for (const c of liveTextCards()) {
|
|
1622
|
+
if (!/❓/.test(c.text)) continue;
|
|
1623
|
+
const s = overlapScore(nTok, tokenSet(c.text));
|
|
1624
|
+
if (s > bestScore) { bestScore = s; best = c; }
|
|
1625
|
+
}
|
|
1626
|
+
if (best && bestScore >= QUESTION_MERGE_AT) {
|
|
1627
|
+
await rewriteCard(best.id, j => {
|
|
1628
|
+
j.content = String(card.text);
|
|
1629
|
+
j.createdAt = now;
|
|
1630
|
+
if (card.createdVia) j.createdVia = String(card.createdVia);
|
|
1631
|
+
if (Array.isArray(card.evidence) && card.evidence.length) j.evidence = card.evidence;
|
|
1632
|
+
});
|
|
1633
|
+
best.text = String(card.text);
|
|
1634
|
+
cards.splice(i, 1);
|
|
1635
|
+
stats.merged++;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1149
1639
|
// SUPERSEDE — pre-mark old cards that a NEW decision replaces. The arrow
|
|
1150
1640
|
// to the new card is drawn in pass 2 (after the new ids exist), matched
|
|
1151
1641
|
// back by remembering which old card each new card displaced.
|
|
@@ -1153,22 +1643,36 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1153
1643
|
if (/❓|🎯|🏁|🛠/.test(card.text)) continue; // only plain decisions supersede (not questions/goals/milestones/skills)
|
|
1154
1644
|
const nTok = tokenSet(card.text);
|
|
1155
1645
|
const area = (card.area || '').toLowerCase();
|
|
1646
|
+
// A correction-cue note ("CORRECTION: … was WRONG") declares it
|
|
1647
|
+
// replaces something — widen the search to ALL areas and lower the
|
|
1648
|
+
// bar: a cross-area reworded correction could never fire the
|
|
1649
|
+
// same-area 0.6 path by construction, which is exactly how stale
|
|
1650
|
+
// cards outlived their corrections in the field.
|
|
1651
|
+
const isCorrection = hasCorrectionCue(card.text);
|
|
1652
|
+
const nTokCmp = isCorrection ? stripCueMeta(nTok) : nTok; // cue meta words dilute the denominator
|
|
1156
1653
|
let best = null, bestScore = 0;
|
|
1157
1654
|
for (const c of liveTextCards()) {
|
|
1158
|
-
if (area && (c.area || '').toLowerCase() !== area) continue;
|
|
1655
|
+
if (!isCorrection && area && (c.area || '').toLowerCase() !== area) continue;
|
|
1159
1656
|
if (/🛠/.test(c.text)) continue; // never auto-archive a 🛠️ skill via a decision's supersede — skills are standing reference (correct with ~)
|
|
1160
|
-
|
|
1657
|
+
// cueMatch returns 0 unless it clears the widened bar (ratio OR
|
|
1658
|
+
// absolute subject mass) — so for corrections, any non-zero fires.
|
|
1659
|
+
const s = isCorrection ? cueMatch(nTokCmp, tokenSet(c.text), CORRECTION_SUPERSEDE_AT) : overlapScore(nTok, tokenSet(c.text));
|
|
1161
1660
|
if (s > bestScore) { bestScore = s; best = c; }
|
|
1162
1661
|
}
|
|
1163
|
-
if (best && bestScore >= SUPERSEDE_AT) {
|
|
1662
|
+
if (best && (isCorrection ? bestScore > 0 : bestScore >= SUPERSEDE_AT)) {
|
|
1164
1663
|
await rewriteCard(best.id, j => {
|
|
1165
1664
|
j.content = `↩︎ superseded ${today}\n${j.content}`;
|
|
1166
1665
|
j.borderColor = 'rgba(120,120,135,0.5)';
|
|
1167
1666
|
});
|
|
1168
1667
|
await archiveCard(best.id);
|
|
1668
|
+
const wasCross = isCorrection && (bestScore < SUPERSEDE_AT || (area && (best.area || '').toLowerCase() !== area));
|
|
1169
1669
|
best.text = `↩︎ ${best.text}`;
|
|
1170
1670
|
card.__supersedes = best.id;
|
|
1171
1671
|
stats.superseded++;
|
|
1672
|
+
// Surface the widened match for confirmation: the caller tells the
|
|
1673
|
+
// agent what was archived and how to undo (restore from Archive /
|
|
1674
|
+
// re-run with ~) — the widened bar acts WITH a visible receipt.
|
|
1675
|
+
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
1676
|
}
|
|
1173
1677
|
}
|
|
1174
1678
|
|
|
@@ -1184,25 +1688,41 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1184
1688
|
if (!target) continue;
|
|
1185
1689
|
const wantTitle = target.replace(/^\[\[/, '').replace(/\]\]$/, '').trim().toLowerCase();
|
|
1186
1690
|
const tTok = tokenSet(target);
|
|
1187
|
-
|
|
1691
|
+
// Collect EVERY live card the close-target covers — near-duplicate ❓
|
|
1692
|
+
// twins score together, and the old first-match-and-break resolved one
|
|
1693
|
+
// while its twin stayed "open" in every brief forever. Capped for
|
|
1694
|
+
// safety: a close-target is deliberate, so >4 matches means it was too
|
|
1695
|
+
// generic to trust beyond the strongest few.
|
|
1696
|
+
const matches = [];
|
|
1188
1697
|
for (const c of liveTextCards()) {
|
|
1698
|
+
if (/🛠/.test(c.text)) continue; // skills are standing reference — a closes: must never archive one (mirror the supersede guard)
|
|
1189
1699
|
const ct = (c.title || '').trim().toLowerCase();
|
|
1190
|
-
// Title fast-path: exact / prefix
|
|
1191
|
-
// target
|
|
1192
|
-
|
|
1700
|
+
// Title fast-path: exact / prefix (≥6 chars), or the card title
|
|
1701
|
+
// CONTAINS the target — the contains variant needs a LONGER target
|
|
1702
|
+
// (≥10) because a short generic word ("sandbox") appears in many
|
|
1703
|
+
// unrelated titles and the multi-close below would sweep them all.
|
|
1704
|
+
if (ct && wantTitle.length >= 6 && (ct === wantTitle || ct.startsWith(wantTitle) || wantTitle.startsWith(ct))) { matches.push({ c, cov: 1 }); continue; }
|
|
1705
|
+
if (ct && wantTitle.length >= 10 && ct.includes(wantTitle)) { matches.push({ c, cov: 1 }); continue; }
|
|
1193
1706
|
// Else target-coverage (≥2 tokens, no floor): a short deliberate
|
|
1194
1707
|
// 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
|
|
1708
|
+
if (tTok.size >= 2) { const cov = coverageOf(tTok, tokenSet(c.text)); if (cov >= CLOSE_COVER_AT) matches.push({ c, cov }); }
|
|
1196
1709
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1710
|
+
matches.sort((a, b) => b.cov - a.cov);
|
|
1711
|
+
// >4 matches means the target was too GENERIC to trust a sweep —
|
|
1712
|
+
// fall back to the single best match (the pre-1.17 behavior) rather
|
|
1713
|
+
// than archive four semi-related cards in iteration order.
|
|
1714
|
+
const chosen = matches.length > 4 ? matches.slice(0, 1) : matches;
|
|
1715
|
+
if (!chosen.length) continue;
|
|
1716
|
+
const ship = String(card.text).replace(/\s+/g, ' ').replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, '').trim().slice(0, 80);
|
|
1717
|
+
card.__closesIds = [];
|
|
1718
|
+
for (const { c: best } of chosen) {
|
|
1199
1719
|
await rewriteCard(best.id, j => {
|
|
1200
1720
|
j.content = `${j.content}\n✅ ${today}: closed by → ${ship}`;
|
|
1201
1721
|
j.borderColor = 'rgba(16,185,129,0.35)';
|
|
1202
1722
|
});
|
|
1203
1723
|
await archiveCard(best.id);
|
|
1204
1724
|
best.text = `✅ ${best.text}`;
|
|
1205
|
-
card.
|
|
1725
|
+
card.__closesIds.push(best.id);
|
|
1206
1726
|
stats.closed++;
|
|
1207
1727
|
}
|
|
1208
1728
|
}
|
|
@@ -1238,11 +1758,13 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1238
1758
|
const titleIndex = struct.cards
|
|
1239
1759
|
.filter(c => (c.title || '').trim())
|
|
1240
1760
|
.map(c => ({ id: c.id, t: c.title.trim().toLowerCase() }));
|
|
1761
|
+
const newIds = new Set();
|
|
1241
1762
|
for (const card of cards) {
|
|
1242
1763
|
const created = findNew(card.text);
|
|
1243
1764
|
if (!created) continue;
|
|
1765
|
+
newIds.add(created.id);
|
|
1244
1766
|
if (card.__supersedes) addConn(card.__supersedes, created.id, 'superseded by', undefined);
|
|
1245
|
-
|
|
1767
|
+
for (const cid of (card.__closesIds || [])) addConn(cid, created.id, 'closed by', undefined);
|
|
1246
1768
|
for (const link of (created.links || [])) {
|
|
1247
1769
|
const want = String(link).trim().toLowerCase();
|
|
1248
1770
|
if (!want) continue;
|
|
@@ -1250,6 +1772,21 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1250
1772
|
if (target) addConn(created.id, target.id, undefined, 'relates_to');
|
|
1251
1773
|
}
|
|
1252
1774
|
}
|
|
1775
|
+
// Structural auto-link for the NEW cards — the graph used to form only
|
|
1776
|
+
// when someone explicitly ran brain_connect (never, in practice: 66%
|
|
1777
|
+
// orphans in the field). Run the cheap tag/[[title]] proposer over the
|
|
1778
|
+
// post-append struct and keep only edges touching a just-added card
|
|
1779
|
+
// (≤2 per card via the proposer's own cap), labeled 'auto' so they're
|
|
1780
|
+
// distinguishable from deliberate arrows. Best-effort: an auto-link
|
|
1781
|
+
// failure must never fail a capture.
|
|
1782
|
+
if (newIds.size) {
|
|
1783
|
+
try {
|
|
1784
|
+
for (const e of proposeStructuralConnections(struct, { maxPerCard: 2 })) {
|
|
1785
|
+
if (!newIds.has(e.fromId) && !newIds.has(e.toId)) continue;
|
|
1786
|
+
addConn(e.fromId, e.toId, 'auto', 'relates_to');
|
|
1787
|
+
}
|
|
1788
|
+
} catch { /* auto-linking is opportunistic */ }
|
|
1789
|
+
}
|
|
1253
1790
|
work = await finalizeBrainZip(zip, canvas, manifest, now);
|
|
1254
1791
|
}
|
|
1255
1792
|
|
|
@@ -1265,6 +1802,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
1265
1802
|
// to Archive, and arrowed → the synthesis. Nothing is deleted (archived verbatim).
|
|
1266
1803
|
const GARDEN_KEEP_NEWEST = 8; // per area, never consolidate the newest N
|
|
1267
1804
|
const GARDEN_MIN_AGE_DAYS = 14; // only cards older than this are candidates
|
|
1805
|
+
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
1806
|
const GARDEN_MIN_CANDIDATES = 3; // don't bother merging fewer than this
|
|
1269
1807
|
const GARDEN_MAX_DEGREE = 1; // SMART guard: protect load-bearing cards —
|
|
1270
1808
|
// only consolidate cards with ≤ this many connections (orphans + leaves). A
|
|
@@ -1311,9 +1849,11 @@ export function selectGardenCandidates(struct, { keepNewest = GARDEN_KEEP_NEWEST
|
|
|
1311
1849
|
const children = struct.cards
|
|
1312
1850
|
.filter(c => c.type === 'text' && c.parentId === ctn.id && (c.text || '').trim() && !/⤵|↩|✅|🛠/.test(c.text)) // 🛠️ skills are standing reference — never consolidate them away
|
|
1313
1851
|
.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
1852
|
+
const autoCutoff = now - GARDEN_AUTO_MIN_AGE_DAYS * 86_400_000;
|
|
1853
|
+
const isAuto = (c) => (c.tags || []).some(t => String(t).toLowerCase().replace(/^#/, '') === 'auto');
|
|
1314
1854
|
const old = children
|
|
1315
1855
|
.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
|
|
1856
|
+
.filter(c => (c.createdAt || 0) < (isAuto(c) ? autoCutoff : cutoff) && (degree.get(c.id) || 0) <= maxDegree); // dormant: old AND peripheral (#auto ages faster)
|
|
1317
1857
|
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
1858
|
}
|
|
1319
1859
|
return out;
|