klypix-mcp 1.18.1 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/klypix-mcp.mjs +18 -5
- package/package.json +2 -2
- package/src/agent-rules.mjs +1 -1
- package/src/global-brain-hook.mjs +43 -11
- package/src/klypix-core.mjs +77 -4
- package/src/klypix-format.mjs +248 -5
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
24
24
|
import {
|
|
25
25
|
resolveVault, getEmbedder, buildKlypixMap, cardSchema, connSchema,
|
|
26
26
|
opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
|
|
27
|
-
opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage,
|
|
27
|
+
opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage, opBrainAsk,
|
|
28
28
|
} from '../src/klypix-core.mjs';
|
|
29
29
|
|
|
30
30
|
// Real package version for the MCP handshake (was hardcoded '1.0.0', which
|
|
@@ -116,6 +116,17 @@ server.registerTool('search_all_brains', {
|
|
|
116
116
|
},
|
|
117
117
|
}, async ({ query, as_of }) => toContent(await opSearchAllBrains({ vault: VAULT, query, as_of, log })));
|
|
118
118
|
|
|
119
|
+
server.registerTool('brain_ask', {
|
|
120
|
+
title: 'Ask the project brain a question (whole-brain, correction-aware answer)',
|
|
121
|
+
description: 'Answer a natural-language question from the WHOLE project brain — "what did we decide about X?", "where did the auth work land?", "why did we drop Y?". Ranks every card (semantic on-device + lexical hybrid), INCLUDES superseded/archived history (flagged, so you can see how a decision changed), and attaches each stale card\'s live CORRECTION so the answer reflects the current truth, not an outdated card. Returns a synthesis-ready context (full cards + provenance + lifecycle) for you to turn into a direct, cited answer — it does not itself write prose. Prefer this over search_canvases when the user asks a QUESTION (not a keyword lookup). Optional as_of (YYYY-MM-DD) answers "what was true then". Defaults to the project brain ("brain").',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
question: z.string().describe('The natural-language question to answer from the brain.'),
|
|
124
|
+
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
|
125
|
+
as_of: z.string().optional().describe('Optional YYYY-MM-DD: answer as of that date (superseded cards count as live if they were current then).'),
|
|
126
|
+
k: z.number().optional().describe('Max cards to surface for synthesis (default 10, capped 20).'),
|
|
127
|
+
},
|
|
128
|
+
}, async ({ question, canvas, as_of, k }) => toContent(await opBrainAsk({ vault: VAULT, canvas, question, as_of, k, log })));
|
|
129
|
+
|
|
119
130
|
server.registerTool('brain_insights', {
|
|
120
131
|
title: 'What matters in a brain — hubs, orphans, stale questions',
|
|
121
132
|
description: 'Structural read of a brain.klypix: the most-connected "hub" cards (load-bearing decisions), orphaned decisions (no connections — maybe forgotten), stale open questions (aging & unresolved), and area sizes. Use to answer "what matters here / what am I forgetting / what should I review?" — read it at the start of a planning session, or before tidying.',
|
|
@@ -127,22 +138,24 @@ server.registerTool('brain_insights', {
|
|
|
127
138
|
|
|
128
139
|
server.registerTool('brain_connect', {
|
|
129
140
|
title: 'Connect related-but-unlinked brain cards (densify the graph)',
|
|
130
|
-
description: 'Finds genuinely related cards that AREN\'T linked yet and proposes connections — semantic similarity when the on-device model is installed, else shared tags + [[mentions]]. Dry-run by default (review the suggestions); pass apply:true to draw them (ADDITIVE — never deletes; the human can remove any arrow). Use after brain_insights flags many orphans, to turn a flat list into a real knowledge graph.',
|
|
141
|
+
description: 'Finds genuinely related cards that AREN\'T linked yet and proposes connections — semantic similarity when the on-device model is installed, else shared tags + [[mentions]]. Dry-run by default (review the suggestions); pass apply:true to draw them (ADDITIVE — never deletes; the human can remove any arrow). Use after brain_insights flags many orphans, to turn a flat list into a real knowledge graph. To DISMISS a brain_reconcile false-positive contradiction, pass pairs:[{fromId,toId}] (the ids are in the reconcile output) with relationship:"not_contradiction" — a persisted dismissal that stops that pair ever resurfacing as a candidate.',
|
|
131
142
|
inputSchema: {
|
|
132
143
|
canvas: z.string().optional().describe('Canvas filename/path. Defaults to the project brain ("brain").'),
|
|
133
144
|
apply: z.boolean().optional().describe('false (default) = suggest only; true = draw the connections.'),
|
|
134
145
|
max: z.number().optional().describe('Max connections to propose/draw (default 24).'),
|
|
135
146
|
threshold: z.number().optional().describe('Min semantic similarity 0–1 to link (default 0.45). Higher = fewer, tighter links.'),
|
|
147
|
+
pairs: z.array(z.object({ fromId: z.string(), toId: z.string() })).optional().describe('Explicit card-id pairs to connect (bypasses auto-proposal). Use to dismiss a reconcile false-positive: pass the two card ids with relationship:"not_contradiction".'),
|
|
148
|
+
relationship: z.string().optional().describe('Relationship for explicit `pairs` (e.g. "not_contradiction" to permanently dismiss a contradiction candidate, or "relates_to", "depends_on", "supports").'),
|
|
136
149
|
},
|
|
137
|
-
}, async ({ canvas, apply, max, threshold }) => toContent(await opBrainConnect({ vault: VAULT, canvas, apply, max, threshold, log })));
|
|
150
|
+
}, async ({ canvas, apply, max, threshold, pairs, relationship }) => toContent(await opBrainConnect({ vault: VAULT, canvas, apply, max, threshold, pairs, relationship, log })));
|
|
138
151
|
|
|
139
152
|
server.registerTool('brain_reconcile', {
|
|
140
153
|
title: 'Reconcile the brain — contradictions between cards + unrecorded migrations',
|
|
141
|
-
description: 'Truth maintenance
|
|
154
|
+
description: 'Truth maintenance. (1) CONTRADICTIONS: finds same-subject live card pairs where one carries an explicit correction cue (uppercase "CORRECTION", "was WRONG", "OBSOLETE" — that side is the presumed truth) or the two use opposite polarity words (deferred↔wired, broken↔fixed, dead↔live), i.e. stale facts whose correction never got linked — candidates only, YOU confirm each: retire the stale card via brain_note ✓. Dismiss a FALSE positive (either kind) by connecting the two ids with brain_connect pairs + relationship:"not_contradiction" — persisted, so it never resurfaces. (2) MIGRATIONS: lists committed migration files (Supabase / Rails / Prisma / Knex / generic) that NO brain card references, so an applied-but-unnarrated rollout can be recorded. (3) LEGACY: pre-v1.15 raw-bash ship cards to tidy. Reads ONLY the filesystem — never the database, never the network — and changes nothing. Run it periodically, or when recall surfaces something you believe is stale.',
|
|
142
155
|
inputSchema: {
|
|
143
156
|
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
|
144
157
|
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").'),
|
|
158
|
+
mode: z.enum(['all', 'contradictions', 'migrations', 'legacy']).optional().describe('Which pass to run (default "all"): contradictions · migrations · legacy (pre-v1.15 raw-bash ship cards to tidy).'),
|
|
146
159
|
},
|
|
147
160
|
}, async ({ canvas, root, mode }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root, mode })));
|
|
148
161
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "An open, local-first, agent-neutral canvas file your AI reads and writes over MCP — works with Claude, Cursor, Cline, any model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"node": ">=18"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs"
|
|
56
|
+
"test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/src/agent-rules.mjs
CHANGED
|
@@ -55,7 +55,7 @@ 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
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).
|
|
58
|
-
- with the \`klypix-canvas\` MCP server: call \`search_canvases\`
|
|
58
|
+
- with the \`klypix-canvas\` MCP server: to **answer a question** from the brain ("what did we decide about X?", "where did Y land?"), call \`brain_ask\` — it ranks the whole brain, includes superseded history, and surfaces the current truth for any corrected card. Use \`search_canvases\` for a raw keyword lookup, \`read_canvas\` (canvas: \`"brain"\`) for the whole thing, or \`brain_insights\` for the load-bearing cards.
|
|
59
59
|
- or via CLI: \`npx klypix-read brain.klypix\`
|
|
60
60
|
|
|
61
61
|
**When you make a real decision, finding, or milestone — capture it HERE** so it persists for the next session/agent:
|
|
@@ -307,6 +307,12 @@ function touchSession(sid, patch = {}) {
|
|
|
307
307
|
// the per-prompt recall renders a re-hit as one headline instead of
|
|
308
308
|
// re-paying the full card (a ~600-word card was injected 3× before).
|
|
309
309
|
injected: patch.injected !== undefined ? patch.injected : (prev.injected ?? []),
|
|
310
|
+
// Sibling ledger for LARGE cards (>1KB) with a much deeper cap: the
|
|
311
|
+
// 100-entry `injected` set evicts old ids in a long session, so a big
|
|
312
|
+
// card injected early could re-inflate full-text after ~100 other
|
|
313
|
+
// cards scrolled it out. Big cards are rare, so this cap effectively
|
|
314
|
+
// never evicts one — enforcing "no >1KB card full-text twice".
|
|
315
|
+
injectedBig: patch.injectedBig !== undefined ? patch.injectedBig : (prev.injectedBig ?? []),
|
|
310
316
|
startedAt: prev.startedAt || now, lastSeen: now,
|
|
311
317
|
});
|
|
312
318
|
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
@@ -436,9 +442,16 @@ function messageFooter(sid, tp) {
|
|
|
436
442
|
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(d2));
|
|
437
443
|
} catch { /* */ } finally { if (got) releaseLock(SESSIONS_LOCK); }
|
|
438
444
|
if (!show.length) return '';
|
|
445
|
+
// De-dupe identical message TEXT within one delivery: the same note can reach
|
|
446
|
+
// the lane twice (posted via both the 🧠 MSG marker and the brain_message MCP
|
|
447
|
+
// twin, or re-sent) as two distinct ids — all are already acked above, so
|
|
448
|
+
// dropping the repeats here shows each note once without ever re-surfacing it.
|
|
449
|
+
const seenTxt = new Set();
|
|
450
|
+
const showUniq = show.filter(m => { const k = String(m.text || '').replace(/\s+/g, ' ').trim().toLowerCase(); if (!k || seenTxt.has(k)) return false; seenTxt.add(k); return true; });
|
|
451
|
+
if (!showUniq.length) return '';
|
|
439
452
|
const ago = (ts) => { const mm = Math.max(0, Math.round((now - (ts || now)) / 60000)); return mm <= 0 ? 'just now' : `${mm}m ago`; };
|
|
440
453
|
const out = ['', '## 📨 Message(s) from another session in this project (delivered once — act on or reply to them)'];
|
|
441
|
-
for (const m of
|
|
454
|
+
for (const m of showUniq.slice(0, 6)) out.push(`- from ${String(m.from || '?').slice(0, 8)} · ${ago(m.ts)}: ${String(m.text).replace(/\s+/g, ' ').trim().slice(0, 400)}`);
|
|
442
455
|
out.push('Reply with `🧠 MSG [<their-id or all>]: <text>` — it reaches them on their next prompt.');
|
|
443
456
|
return '\n' + out.join('\n');
|
|
444
457
|
}
|
|
@@ -1236,15 +1249,30 @@ async function promptRetrieve(lib) {
|
|
|
1236
1249
|
if (struct && typeof lib.correctionOverlaysFor === 'function') {
|
|
1237
1250
|
try { overlays = lib.correctionOverlaysFor(struct, freshHits.map(h => h.card)); } catch { /* best-effort */ }
|
|
1238
1251
|
}
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1252
|
+
// Awaits-merge decay: a hit saying "PR #N awaits merge" gets a merged-overlay
|
|
1253
|
+
// when a harvested ship event already recorded #N MERGED (deterministic, no
|
|
1254
|
+
// retirement — the fact is right, only its status decayed).
|
|
1255
|
+
let mergeOv = new Map();
|
|
1256
|
+
if (struct && typeof lib.mergeOverlaysFor === 'function') {
|
|
1257
|
+
try { mergeOv = lib.mergeOverlaysFor(struct, freshHits.map(h => h.card)); } catch { /* best-effort */ }
|
|
1258
|
+
}
|
|
1259
|
+
const mergeTag = (id) => { const m = mergeOv.get(id); return m ? `\n ↳ ⚠️ PR #${m.num} is since MERGED${m.date ? ` (ship event ${m.date})` : ''} — this "awaits merge" note is stale; nothing to do.` : ''; };
|
|
1260
|
+
// Per-session injection dedup: a card already shown full-text this session
|
|
1261
|
+
// renders as one headline, not another ~600 words of context. LARGE cards
|
|
1262
|
+
// (>1KB) are tracked in a separate, deep-capped ledger so the 100-entry
|
|
1263
|
+
// `injected` set's eviction can never re-inflate one (the observed 3× bug).
|
|
1264
|
+
const BIG_CHARS = 1000;
|
|
1241
1265
|
let me = null; try { me = readSessions().find(s => s.id === sid) || null; } catch { /* */ }
|
|
1242
1266
|
const injected = new Set((me && Array.isArray(me.injected)) ? me.injected : []);
|
|
1267
|
+
const injectedBig = new Set((me && Array.isArray(me.injectedBig)) ? me.injectedBig : []);
|
|
1268
|
+
const isBig = (c) => flat(c.text).length > BIG_CHARS;
|
|
1269
|
+
const wasInjected = (c) => injected.has(c.id) || (isBig(c) && injectedBig.has(c.id));
|
|
1243
1270
|
const shownNow = new Set();
|
|
1244
1271
|
lines.push(semMode === 'sem-hit'
|
|
1245
1272
|
? "# Related prior decisions (semantic match — no exact keyword overlap; full brain via the klypix-canvas MCP)"
|
|
1246
1273
|
: "# Relevant prior decisions from this project's brain (task-matched; full brain via the klypix-canvas MCP)");
|
|
1247
|
-
const newlyInjected = [];
|
|
1274
|
+
const newlyInjected = [], newlyBig = [];
|
|
1275
|
+
const noteInjected = (c) => { newlyInjected.push(c.id); if (isBig(c)) newlyBig.push(c.id); };
|
|
1248
1276
|
for (const h of freshHits) {
|
|
1249
1277
|
if (shownNow.has(h.card.id)) continue; // already rendered this turn (e.g. as a corrector)
|
|
1250
1278
|
const ov = overlays.get(h.card.id);
|
|
@@ -1256,11 +1284,11 @@ async function promptRetrieve(lib) {
|
|
|
1256
1284
|
let correctorLine = false;
|
|
1257
1285
|
if (!shownNow.has(ov.by.id)) {
|
|
1258
1286
|
correctorLine = true;
|
|
1259
|
-
if (
|
|
1287
|
+
if (wasInjected(ov.by)) {
|
|
1260
1288
|
lines.push(`- ⚠️ CORRECTED — current (already shown this session): ${head(ov.by, 110)}`);
|
|
1261
1289
|
} else {
|
|
1262
1290
|
lines.push(`- ⚠️ CORRECTED — current: ${flat(ov.by.text)}`);
|
|
1263
|
-
|
|
1291
|
+
noteInjected(ov.by);
|
|
1264
1292
|
}
|
|
1265
1293
|
shownNow.add(ov.by.id);
|
|
1266
1294
|
}
|
|
@@ -1268,16 +1296,20 @@ async function promptRetrieve(lib) {
|
|
|
1268
1296
|
shownNow.add(h.card.id);
|
|
1269
1297
|
continue;
|
|
1270
1298
|
}
|
|
1271
|
-
if (
|
|
1272
|
-
lines.push(`- (already shown this session) ${head(h.card, 110)}`);
|
|
1299
|
+
if (wasInjected(h.card)) {
|
|
1300
|
+
lines.push(`- (already shown this session) ${head(h.card, 110)}${mergeTag(h.card.id)}`);
|
|
1273
1301
|
shownNow.add(h.card.id);
|
|
1274
1302
|
continue;
|
|
1275
1303
|
}
|
|
1276
|
-
lines.push(`- ${flat(h.card.text)}`);
|
|
1304
|
+
lines.push(`- ${flat(h.card.text)}${mergeTag(h.card.id)}`);
|
|
1277
1305
|
shownNow.add(h.card.id);
|
|
1278
|
-
|
|
1306
|
+
noteInjected(h.card);
|
|
1307
|
+
}
|
|
1308
|
+
if (newlyInjected.length) {
|
|
1309
|
+
const patch = { injected: [...new Set([...injected, ...newlyInjected])].slice(-100) };
|
|
1310
|
+
if (newlyBig.length) patch.injectedBig = [...new Set([...injectedBig, ...newlyBig])].slice(-400);
|
|
1311
|
+
touchSession(sid, patch);
|
|
1279
1312
|
}
|
|
1280
|
-
if (newlyInjected.length) touchSession(sid, { injected: [...new Set([...injected, ...newlyInjected])].slice(-100) });
|
|
1281
1313
|
}
|
|
1282
1314
|
const parts = [];
|
|
1283
1315
|
if (lines.length) parts.push(lines.join('\n'));
|
package/src/klypix-core.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite,
|
|
27
27
|
findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
|
|
28
28
|
selectGardenCandidates, applyGarden, detectContradictions,
|
|
29
|
+
rankForQuestion, questionContextToMarkdown, findLegacyShipCards,
|
|
29
30
|
} from './klypix-format.mjs';
|
|
30
31
|
|
|
31
32
|
// ── Card / connection input shape (single source for every face) ─────────────
|
|
@@ -369,6 +370,40 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
|
|
|
369
370
|
return { blocks: [text(`# Cross-project matches for "${query}" (${scored.length} hits in ${brains.length} brains, top ${top.length} · ${mode}${asOfNote})\n\n${lines.join('\n')}`)] };
|
|
370
371
|
}
|
|
371
372
|
|
|
373
|
+
// ── brain_ask — answer a natural-language question over the WHOLE brain ───────
|
|
374
|
+
// "What did we decide about X?" / "Where did the auth work land?" The daily-use
|
|
375
|
+
// surface: hybrid retrieval (semantic on-device + lexical) over every card
|
|
376
|
+
// (including archived history, flagged), correction-aware (a stale hit carries its
|
|
377
|
+
// live correction), assembled into a SYNTHESIS-READY context the calling agent
|
|
378
|
+
// turns into a direct, cited answer. The engine never calls an LLM (pure retrieval
|
|
379
|
+
// + assembly) — same "engine selects, model writes" contract as brain_connect.
|
|
380
|
+
export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log = () => {} }) {
|
|
381
|
+
const q = String(question || '').trim();
|
|
382
|
+
if (!q) return err('brain_ask needs a question.');
|
|
383
|
+
const t = brainTarget(vault, canvas);
|
|
384
|
+
if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
|
|
385
|
+
if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
|
|
386
|
+
const asOfTs = as_of ? Date.parse(as_of) : null;
|
|
387
|
+
if (as_of && Number.isNaN(asOfTs)) return err(`Bad as_of date: "${as_of}" — use YYYY-MM-DD.`);
|
|
388
|
+
let struct;
|
|
389
|
+
try { ({ struct } = await parseKlypix(fs.readFileSync(t.file))); } catch (e) { return err(`Read failed: ${e.message}`); }
|
|
390
|
+
const stamp = brainStamp(t.file, struct, t.how);
|
|
391
|
+
// Semantic blend (best-effort, time-bounded): embed the question + the brain's
|
|
392
|
+
// cards on-device, hand rankForQuestion a Map<cardId, cosine>. A missing/warming
|
|
393
|
+
// model degrades cleanly to pure lexical.
|
|
394
|
+
let semantic = null, mode = 'lexical';
|
|
395
|
+
try {
|
|
396
|
+
const pipe = await Promise.race([getEmbedder(log), new Promise(r => setTimeout(() => r(null), 20_000))]);
|
|
397
|
+
if (pipe) {
|
|
398
|
+
const [qv] = await embedTexts(pipe, [q]);
|
|
399
|
+
const vecs = await vectorsForBrain(pipe, t.file, struct.cards);
|
|
400
|
+
if (qv && vecs && vecs.size) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); mode = 'semantic+lexical (on-device)'; }
|
|
401
|
+
}
|
|
402
|
+
} catch { semantic = null; mode = 'lexical (semantic warming — retry for semantic ranking)'; }
|
|
403
|
+
const result = rankForQuestion(struct, q, { semantic, k: Math.max(1, Math.min(20, k || 10)), as_of: asOfTs != null ? as_of : null });
|
|
404
|
+
return { blocks: [text(stamp + questionContextToMarkdown(q, result, { mode, as_of: asOfTs != null ? as_of : null }))] };
|
|
405
|
+
}
|
|
406
|
+
|
|
372
407
|
export async function opBrainInsights({ vault, canvas, staleDays }) {
|
|
373
408
|
const t = brainTarget(vault, canvas);
|
|
374
409
|
if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
|
|
@@ -427,14 +462,30 @@ export async function opBrainReconcile({ vault, canvas, root, mode = 'all' }) {
|
|
|
427
462
|
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
428
463
|
const lines = pairs.map((p, i) =>
|
|
429
464
|
`${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
|
|
465
|
+
+ ` · likely STALE [${p.stale.area || '?'}] (id ${p.stale.id}) ${flat(p.stale.text).slice(0, 180)}\n`
|
|
466
|
+
+ ` · likely CURRENT [${p.fresh.area || '?'}] (id ${p.fresh.id}) ${flat(p.fresh.text).slice(0, 180)}`);
|
|
467
|
+
sections.push(`# ⚔️ ${pairs.length} contradiction candidate(s) — confirm, then reconcile\n_Candidates only — nothing was changed. For each REAL contradiction: retire the stale card with \`brain_note\` marker \`✓\` (text = what it resolved to), or record a correction-cue decision ("CORRECTION: …", uppercase) — capture auto-supersedes it across areas. Dismissing a FALSE positive (either kind — polarity OR correction-cue): \`brain_connect\` with \`pairs:[{fromId, toId}]\` and \`relationship:"not_contradiction"\` using the ids above — the dismissal is persisted, so that pair never resurfaces here again._\n\n${lines.join('\n')}`);
|
|
433
468
|
} else if (mode === 'contradictions') {
|
|
434
469
|
sections.push('✓ No contradiction candidates — no live card pair shows a correction cue or a polarity flip over the same subject.');
|
|
435
470
|
}
|
|
436
471
|
}
|
|
437
472
|
|
|
473
|
+
// (1b) LEGACY SHIP CARDS — pre-v1.15 auto-captured "merged PR — auto-captured
|
|
474
|
+
// (`gh …`)" cards with raw shell text / path-scraped junk PR numbers. Already
|
|
475
|
+
// excluded from repeat-matching (so they no longer cry wolf); this surfaces them
|
|
476
|
+
// for optional one-time cleanup. Suggestion-only — retire each with a ✓ marker.
|
|
477
|
+
if (mode === 'all' || mode === 'legacy') {
|
|
478
|
+
const { cards: legacy, total } = findLegacyShipCards(struct);
|
|
479
|
+
if (legacy.length) {
|
|
480
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
481
|
+
const lines = legacy.map(c => `- (id ${c.id}) [${c.area || '?'}] ${flat(c.text).slice(0, 140)}`);
|
|
482
|
+
const more = total > legacy.length ? `\n\n…and ${total - legacy.length} more.` : '';
|
|
483
|
+
sections.push(`# 🧹 ${total} legacy raw-bash ship card(s) — optional cleanup\n_Pre-v1.15 auto-capture residue (raw shell command / path-scraped PR numbers). They are ALREADY excluded from repeat-detection, so this is cosmetic hygiene, not a correctness fix. To tidy: retire each with \`brain_note\` marker \`✓\`, or leave them — they no longer trigger false repeat warnings._\n\n${lines.join('\n')}${more}`);
|
|
484
|
+
} else if (mode === 'legacy') {
|
|
485
|
+
sections.push('✓ No legacy raw-bash ship cards — every ship card is a clean fact.');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
438
489
|
// (2) MIGRATIONS — the brain reconciled against committed external state.
|
|
439
490
|
// Migrations live in the CODE repo (usually beside brain.klypix), not in a
|
|
440
491
|
// separate canvas vault — so default the root to the brain file's folder.
|
|
@@ -495,7 +546,7 @@ export async function opBrainGarden({ vault, canvas, apply = false, syntheses })
|
|
|
495
546
|
}
|
|
496
547
|
}
|
|
497
548
|
|
|
498
|
-
export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, log = () => {} }) {
|
|
549
|
+
export async function opBrainConnect({ vault, canvas, apply = false, max = 24, threshold = 0.45, pairs = null, relationship = null, label = null, log = () => {} }) {
|
|
499
550
|
const tgt = brainTarget(vault, canvas);
|
|
500
551
|
if (tgt.ambiguous) return ambiguousBrainErr(tgt.ambiguous);
|
|
501
552
|
if (!tgt.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}.`);
|
|
@@ -507,6 +558,28 @@ export async function opBrainConnect({ vault, canvas, apply = false, max = 24, t
|
|
|
507
558
|
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !/^archive$/i.test(c.area || ''));
|
|
508
559
|
const linked = new Set(struct.connections.map(c => [c.fromId, c.toId].sort().join('|')));
|
|
509
560
|
|
|
561
|
+
// ── Explicit-pairs mode ─────────────────────────────────────────────────────
|
|
562
|
+
// Draw EXACTLY the given card-id pairs with a chosen relationship, bypassing the
|
|
563
|
+
// auto-proposer. This is the persisted DISMISS path for a reconcile false
|
|
564
|
+
// positive: pass relationship:"not_contradiction" and detectContradictions will
|
|
565
|
+
// treat the pair as settled forever (the escape hatch a correction-cue false
|
|
566
|
+
// positive — which has no stale card to retire — previously lacked).
|
|
567
|
+
if (Array.isArray(pairs) && pairs.length) {
|
|
568
|
+
const rel = typeof relationship === 'string' && relationship ? relationship : 'relates_to';
|
|
569
|
+
const lbl = (typeof label === 'string' && label) ? label : (rel === 'not_contradiction' ? 'not a contradiction' : undefined);
|
|
570
|
+
const explicit = pairs
|
|
571
|
+
.filter(p => p && p.fromId && p.toId && byId.has(p.fromId) && byId.has(p.toId) && p.fromId !== p.toId)
|
|
572
|
+
.map(p => ({ fromId: p.fromId, toId: p.toId, relationship: rel, ...(lbl ? { label: lbl } : {}) }));
|
|
573
|
+
if (!explicit.length) return err('pairs needs [{fromId, toId}, …] where both ids are real cards in this brain (see the ids in brain_reconcile output).');
|
|
574
|
+
const render2 = (e) => `- ${flat(byId.get(e.fromId)?.text)} ↔ ${flat(byId.get(e.toId)?.text)} (${e.relationship}${e.label ? `: ${e.label}` : ''})`;
|
|
575
|
+
if (!apply) return { blocks: [text(`# ${explicit.length} explicit connection(s) to draw\n_Re-run with apply:true to draw them.${rel === 'not_contradiction' ? ' These pairs will then be permanently dismissed as contradiction candidates.' : ''}_\n\n${explicit.map(render2).join('\n')}`)] };
|
|
576
|
+
try {
|
|
577
|
+
const { buffer, added } = await addBrainConnections(fs.readFileSync(file), explicit);
|
|
578
|
+
await atomicWrite(file, buffer);
|
|
579
|
+
return { blocks: [text(`✓ Drew ${added} connection(s)${rel === 'not_contradiction' ? ' — these pair(s) are now dismissed and will NOT resurface as brain_reconcile contradiction candidates' : ''}.\n\n${explicit.slice(0, added).map(render2).join('\n')}`)] };
|
|
580
|
+
} catch (e) { return err(`Apply failed (brain unchanged): ${e.message}`); }
|
|
581
|
+
}
|
|
582
|
+
|
|
510
583
|
let edges = [];
|
|
511
584
|
let mode = 'structural (shared tags + [[mentions]])';
|
|
512
585
|
const pipe = await Promise.race([getEmbedder(log), new Promise(r => setTimeout(() => r(null), 20_000))]);
|
package/src/klypix-format.mjs
CHANGED
|
@@ -127,7 +127,11 @@ export async function parseKlypix(buffer) {
|
|
|
127
127
|
return { struct, zip, assetPaths, isV4, canvas, manifest };
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
// 'not_contradiction' is a DISMISSAL edge, not a topical relation: an agent draws
|
|
131
|
+
// it between two cards a reconcile pass flagged as a FALSE contradiction, and
|
|
132
|
+
// detectContradictions then treats the pair as settled forever (the persisted
|
|
133
|
+
// dismiss path the correction-cue false-positive previously lacked).
|
|
134
|
+
const REL = new Set(['leads_to', 'depends_on', 'relates_to', 'conflicts_with', 'supports', 'questions', 'costs', 'blocks', 'not_contradiction']);
|
|
131
135
|
|
|
132
136
|
/**
|
|
133
137
|
* Build a real .klypix v4 file (nodebuffer) from a simple spec:
|
|
@@ -827,13 +831,17 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
827
831
|
const out = [];
|
|
828
832
|
const push = (...lines) => { for (const l of lines) { out.push(l); used += l.length + 1; } };
|
|
829
833
|
|
|
834
|
+
// Overdue open cards (self-declared deadline passed) — badged inline so a
|
|
835
|
+
// stale-dated reminder is flagged the next session instead of decaying silently.
|
|
836
|
+
const overdueById = findOverdueOpenCards(struct).byId;
|
|
837
|
+
const odBadge = (c) => { const o = overdueById.get(c.id); return o ? ` · ⏰ OVERDUE — deadline ${o.date} passed ${o.daysOverdue}d ago; verify or close (✓)` : ''; };
|
|
830
838
|
push(`# ${struct.title} — brain brief`);
|
|
831
839
|
push(`*${struct.format} · ${struct.counts.cards} cards · ${struct.counts.connections} connections · tiered brief (focus + open + last ${recentDays}d headlines); full cards via klypix-canvas MCP search*`);
|
|
832
840
|
if (focus.length) {
|
|
833
841
|
push('', '## 📌 Human focus (cards the human placed here — act on these first)');
|
|
834
|
-
for (const c of focus) push(`- ${fr(c)}${flat(c.text)}`);
|
|
842
|
+
for (const c of focus) push(`- ${fr(c)}${flat(c.text)}${odBadge(c)}`);
|
|
835
843
|
}
|
|
836
|
-
if (open.length) { push('', '## Open questions & goals'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}`); }
|
|
844
|
+
if (open.length) { push('', '## Open questions & goals'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}${odBadge(c)}`); }
|
|
837
845
|
if (skills.length) {
|
|
838
846
|
push('', '## 🛠️ Skills — how we do things here (reusable; applies every session)');
|
|
839
847
|
for (const c of skills.slice(0, maxSkills)) push(`- ${fr(c)}${flat(c.text)}`);
|
|
@@ -942,9 +950,13 @@ export function structToUltraBrief(struct, { freshness = null, briefPath = '.cla
|
|
|
942
950
|
for (const c of conflicts.slice(0, 4)) { if (!pushIf(`- ${clip(c.from, 70)} ⚔️ ${clip(c.to, 70)}`)) break; shown++; }
|
|
943
951
|
if (shown < conflicts.length) pushIf(`- …and ${conflicts.length - shown} more conflict(s) — in the full brief.`);
|
|
944
952
|
}
|
|
945
|
-
|
|
953
|
+
// Overdue opens lead (and get a ⏰ prefix) so a passed deadline is never the
|
|
954
|
+
// line that falls off the bottom of the preview-sized budget.
|
|
955
|
+
const overdueById = findOverdueOpenCards(struct).byId;
|
|
956
|
+
const openSorted = open.slice().sort((a, b) => (overdueById.has(b.id) ? 1 : 0) - (overdueById.has(a.id) ? 1 : 0));
|
|
957
|
+
if (open.length && pushIf('') && pushIf(`## Open questions & goals (${open.length}${overdueById.size ? `, ${overdueById.size} ⏰ overdue` : ''})`)) {
|
|
946
958
|
let shown = 0;
|
|
947
|
-
for (const c of
|
|
959
|
+
for (const c of openSorted) { if (!pushIf(`- ${overdueById.has(c.id) ? '⏰ OVERDUE ' : ''}${fr(c)}${head(c)}`)) break; shown++; }
|
|
948
960
|
if (shown < open.length) pushIf(`- …and ${open.length - shown} more — in the full brief.`);
|
|
949
961
|
}
|
|
950
962
|
const areas = struct.cards.filter(c => c.type === 'container' && !/^archive$/i.test(c.title || ''))
|
|
@@ -1006,6 +1018,118 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
|
|
|
1006
1018
|
return scored.filter(s => s.score >= minScore).slice(0, topK);
|
|
1007
1019
|
}
|
|
1008
1020
|
|
|
1021
|
+
// ── Ask-the-brain — whole-brain, correction-aware retrieval for a question ───
|
|
1022
|
+
// The surface a human actually uses ("what did we decide about X?", "where did
|
|
1023
|
+
// the auth work land?"). Distinct from the per-prompt recall hook (which injects
|
|
1024
|
+
// a few RELATED cards into every prompt) and search_canvases (raw substring): this
|
|
1025
|
+
// RANKS the whole brain against a natural-language question and assembles a
|
|
1026
|
+
// SYNTHESIS-READY context for the calling agent to answer from — the engine stays
|
|
1027
|
+
// model-free (mirrors brain_connect/garden: engine selects, model writes prose).
|
|
1028
|
+
// Three things make it an ANSWER path, not a card dump:
|
|
1029
|
+
// 1. Hybrid-ready — lexical always (the shared scorer's weights + length-norm),
|
|
1030
|
+
// semantic blended when the caller (core, with the on-device embedder) passes
|
|
1031
|
+
// a sim map; on a lexical miss the semantic floor still surfaces paraphrases.
|
|
1032
|
+
// 2. History-aware — INCLUDES archived/superseded cards (penalized + flagged), so
|
|
1033
|
+
// "what did we decide" can show the arc (decided A → changed to B), and an
|
|
1034
|
+
// optional as_of answers "what was true then".
|
|
1035
|
+
// 3. Truth-aware — every stale hit carries its live CORRECTION (the P1 machinery),
|
|
1036
|
+
// so the agent answers from the correction, never the outdated card alone.
|
|
1037
|
+
// Pure + node-runnable. `semantic` is Map<cardId, 0..1> or null.
|
|
1038
|
+
const deathDateOfCard = (text) => { const m = /(?:↩︎ superseded|↩ superseded|✅) (\d{4}-\d{2}-\d{2})/.exec(String(text)); return m ? Date.parse(m[1]) : null; };
|
|
1039
|
+
export function rankForQuestion(struct, question, { semantic = null, k = 10, as_of = null, now = Date.now(), semFloor = 0.30, recentDays = 30 } = {}) {
|
|
1040
|
+
const tokens = queryTokens(question);
|
|
1041
|
+
if (!struct || !Array.isArray(struct.cards) || (!tokens.length && !semantic)) return { hits: [], total: 0, tokens };
|
|
1042
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1043
|
+
const asOfTs = as_of ? Date.parse(as_of) : null;
|
|
1044
|
+
const timeTravel = asOfTs != null && Number.isFinite(asOfTs);
|
|
1045
|
+
const cutoff = now - recentDays * 86_400_000;
|
|
1046
|
+
const scored = [];
|
|
1047
|
+
for (const c of struct.cards) {
|
|
1048
|
+
if (c.type === 'container' || !(c.text || '').trim()) continue;
|
|
1049
|
+
const arch = isArchived(c);
|
|
1050
|
+
if (timeTravel) {
|
|
1051
|
+
if ((c.createdAt || 0) > asOfTs) continue; // didn't exist yet
|
|
1052
|
+
if (arch) {
|
|
1053
|
+
// A card archived NOW: keep it ONLY if it demonstrably outlived
|
|
1054
|
+
// as_of (its retirement is stamped LATER) — then it was the live
|
|
1055
|
+
// truth then. If it died by as_of, or carries NO dated stamp (we
|
|
1056
|
+
// can't prove it was still live), exclude it — precision-first, so
|
|
1057
|
+
// a "what was true then" answer never asserts a since-dead fact.
|
|
1058
|
+
const died = deathDateOfCard(c.text);
|
|
1059
|
+
if (died == null || died <= asOfTs) continue;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const titleW = wordsOf(c.title);
|
|
1063
|
+
const bodyW = wordsOf(c.text);
|
|
1064
|
+
const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
|
|
1065
|
+
const lenNorm = Math.min(1, 6 / Math.max(6, Math.log2(bodyW.size || 1)));
|
|
1066
|
+
let lex = 0;
|
|
1067
|
+
for (const tok of tokens) {
|
|
1068
|
+
if (titleW.has(tok)) lex += 3;
|
|
1069
|
+
else if (tagStems.has(tok)) lex += 3;
|
|
1070
|
+
else if (bodyW.has(tok)) lex += lenNorm;
|
|
1071
|
+
}
|
|
1072
|
+
const sem = semantic ? (semantic.get(c.id) ?? null) : null;
|
|
1073
|
+
if (lex <= 0 && (sem == null || sem < semFloor)) continue; // no lexical AND no strong semantic → skip
|
|
1074
|
+
// In time-travel a surviving card WAS live at as_of, so it is NOT stale
|
|
1075
|
+
// history — don't demote or flag it as archived (that status is a present
|
|
1076
|
+
// fact). Outside time-travel, archived cards are demoted but never excluded
|
|
1077
|
+
// (history matters for "what did we…").
|
|
1078
|
+
const effArch = timeTravel ? false : arch;
|
|
1079
|
+
let score = sem != null ? sem * 10 + Math.min(lex, 6) * 0.5 : lex;
|
|
1080
|
+
if (!timeTravel && (c.createdAt || 0) >= cutoff) score += 0.5;
|
|
1081
|
+
if (/🛠/.test(c.text)) score += 1; // standing skills
|
|
1082
|
+
if (effArch) score -= 1.5;
|
|
1083
|
+
scored.push({ card: c, score, sem, archived: effArch });
|
|
1084
|
+
}
|
|
1085
|
+
scored.sort((a, b) => b.score - a.score || (b.card.createdAt || 0) - (a.card.createdAt || 0));
|
|
1086
|
+
const top = scored.slice(0, k);
|
|
1087
|
+
// Correction overlays on the surfaced hits — a stale card gets its live
|
|
1088
|
+
// corrector so the agent answers from the truth (edge or cue; P1 machinery).
|
|
1089
|
+
// NOT in time-travel: a correction is a PRESENT fact; importing a future
|
|
1090
|
+
// corrector into a "what was true then" answer would contaminate it (a
|
|
1091
|
+
// 2026-05 correction leaking into a 2026-02 query). Then-live cards stand
|
|
1092
|
+
// as they were.
|
|
1093
|
+
let overlays = new Map();
|
|
1094
|
+
if (!timeTravel) { try { overlays = correctionOverlaysFor(struct, top.map(h => h.card)); } catch { /* best-effort */ } }
|
|
1095
|
+
const hits = top.map(h => ({ ...h, correction: overlays.get(h.card.id) || null }));
|
|
1096
|
+
return { hits, total: scored.length, tokens };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Assemble the ranked hits into a SYNTHESIS-READY markdown context: a header that
|
|
1100
|
+
// instructs the agent to answer the question directly (cite cards, honor
|
|
1101
|
+
// corrections, admit gaps), then each hit full-text with provenance + lifecycle +
|
|
1102
|
+
// its correction. Char-budgeted so a huge brain can't blow the tool result.
|
|
1103
|
+
export function questionContextToMarkdown(question, result, { mode = 'lexical', as_of = null, budgetChars = 9000 } = {}) {
|
|
1104
|
+
const { hits, total } = result;
|
|
1105
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
1106
|
+
const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
|
|
1107
|
+
if (!hits.length) {
|
|
1108
|
+
return `# No brain cards answer: “${flat(question)}”\n`
|
|
1109
|
+
+ `Searched the whole brain (${mode}) and found nothing relevant. Tell the user the brain doesn't cover this yet — don't guess. If you learn the answer this session, capture it: \`🧠 BRAIN [Area]: <decision>\`.\n`;
|
|
1110
|
+
}
|
|
1111
|
+
const out = [];
|
|
1112
|
+
out.push(`# Answer “${flat(question)}” from these ${hits.length} brain card(s)${as_of ? ` (as of ${as_of})` : ''} — ${total} matched, ${mode} ranking`);
|
|
1113
|
+
out.push('_Synthesize a DIRECT answer from the cards below, then cite the ones you used by [Area]+date. Where a card is marked ⚠️ CORRECTED, answer from the correction, NOT the stale card. Include superseded/archived cards only to show how a decision CHANGED. If the cards don\'t actually answer the question, say so — don\'t pad._');
|
|
1114
|
+
out.push('');
|
|
1115
|
+
let used = out.join('\n').length;
|
|
1116
|
+
let shown = 0;
|
|
1117
|
+
for (const h of hits) {
|
|
1118
|
+
const c = h.card;
|
|
1119
|
+
const status = h.archived ? ' · ⛔ archived/superseded' : '';
|
|
1120
|
+
const rel = h.sem != null ? ` · sim ${h.sem.toFixed(2)}` : '';
|
|
1121
|
+
let block = `## [${flat(c.area) || 'Notes'}] ${day(c.createdAt)}${status}${rel}\n${flat(c.text)}`;
|
|
1122
|
+
if (h.correction) {
|
|
1123
|
+
block += `\n\n ⚠️ CORRECTED — this card is STALE; the current truth is:\n ${flat(h.correction.by.text).slice(0, 600)}`;
|
|
1124
|
+
}
|
|
1125
|
+
if (used + block.length + 2 > budgetChars && shown > 0) { out.push(`\n_…and ${hits.length - shown} more matched card(s) omitted for length — narrow the question or use search for the rest._`); break; }
|
|
1126
|
+
out.push(block, '');
|
|
1127
|
+
used += block.length + 2;
|
|
1128
|
+
shown++;
|
|
1129
|
+
}
|
|
1130
|
+
return out.join('\n') + '\n';
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1009
1133
|
// ── External-state reconcile — migration omission tripwire ───────────────────
|
|
1010
1134
|
// The brain is a NARRATION-capture system: a fact exists only if someone wrote a
|
|
1011
1135
|
// 🧠 marker or a rationale-bearing commit body. Applying a DB migration to prod
|
|
@@ -1081,6 +1205,35 @@ const REPEAT_VERB_STOP = new Set([
|
|
|
1081
1205
|
// PR#), or a kebab/snake identifier. A #file-/#dir- tag-stem match counts as an
|
|
1082
1206
|
// entity at match time regardless of shape (tags are capture-stamped anchors).
|
|
1083
1207
|
const isEntityToken = (t) => /\d/.test(t) || t.includes('-') || t.includes('_');
|
|
1208
|
+
// ── Legacy raw-bash ship cards (pre-v1.15 auto-capture residue) ──────────────
|
|
1209
|
+
// Before the v1.15 ship-capture rewrite, auto-harvest dumped the RAW shell command
|
|
1210
|
+
// into the card ("🏁 merged PR #850 — auto-captured (`cd /c/Users/…/8db42`)") and
|
|
1211
|
+
// scraped stray path digits as PR numbers ("merged PR #238886"). These are dense
|
|
1212
|
+
// with generic ship verbs + junk numbers, so the possible-repeat warner kept
|
|
1213
|
+
// matching them and crying wolf — training agents to ignore repeat warnings. They
|
|
1214
|
+
// are (a) excluded from detectRepeatWork below and (b) surfaced by findLegacyShip-
|
|
1215
|
+
// Cards for a one-time cleanup. Detection is SIGNATURE-based (not just "old"), so a
|
|
1216
|
+
// CLEAN ship card ("Ship: 🏁 merged PR #286") is never touched.
|
|
1217
|
+
export const isLegacyRawShipCard = (text) => {
|
|
1218
|
+
const t = String(text || '');
|
|
1219
|
+
if (!/🏁|\bmerged\b|\brelease|\bpublish|\bshipped\b|\btagged\b/i.test(t)) return false; // ship-shaped only
|
|
1220
|
+
return /auto-captured/i.test(t) // the explicit pre-v1.15 stamp
|
|
1221
|
+
|| /`[^`]*\b(?:cd|gh|git|npm|node|npx)\b[^`]*`/i.test(t) // a raw shell command left inside the card
|
|
1222
|
+
|| /(?:\/c\/users\/|[a-z]:[\\/]+users[\\/]|\/(?:home|users|mnt|tmp)\/)/i.test(t) // a filesystem-path fragment
|
|
1223
|
+
|| /\b(?:PR|pull request|#)\s*#?\d{6,}\b/i.test(t); // a 6+ digit "PR number" = scraped path digits, not a real PR
|
|
1224
|
+
};
|
|
1225
|
+
// One-time cleanup surface: the legacy raw-bash ship cards still live in a brain.
|
|
1226
|
+
// Suggestion-only (retire each with a ✓ marker) — they're already excluded from
|
|
1227
|
+
// repeat-matching, so this is cosmetic hygiene, not a correctness fix.
|
|
1228
|
+
export function findLegacyShipCards(struct, { max = 20 } = {}) {
|
|
1229
|
+
const empty = { cards: [], total: 0 };
|
|
1230
|
+
if (!struct || !Array.isArray(struct.cards)) return empty;
|
|
1231
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1232
|
+
const hits = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim()
|
|
1233
|
+
&& !isArchived(c) && !/↩|✅/.test(c.text) && isLegacyRawShipCard(c.text));
|
|
1234
|
+
return { cards: hits.slice(0, max), total: hits.length };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1084
1237
|
export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTokens = 2 } = {}) {
|
|
1085
1238
|
const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
|
|
1086
1239
|
if (tokens.length < minTokens || !struct || !Array.isArray(struct.cards)) return [];
|
|
@@ -1091,6 +1244,7 @@ export function detectRepeatWork(struct, query, { topK = 2, minScore = 5, minTok
|
|
|
1091
1244
|
if (c.type === 'container' || !(c.text || '').trim()) continue;
|
|
1092
1245
|
const kind = kindOf(c.text);
|
|
1093
1246
|
if (!kind) continue; // only COMPLETED-work cards qualify
|
|
1247
|
+
if (isLegacyRawShipCard(c.text)) continue; // pre-v1.15 raw-bash residue — never a repeat candidate (nonsense scraped PR numbers)
|
|
1094
1248
|
// Score the first MEANINGFUL line, not a marker stamp: supersede/resolve
|
|
1095
1249
|
// prepend "↩︎ superseded <date>" / lead with "✅ …", which would otherwise
|
|
1096
1250
|
// become the title and hide the real content from title-weighted matching.
|
|
@@ -1195,6 +1349,12 @@ export function detectContradictions(struct, { minOverlap = 0.45, topK = 12 } =
|
|
|
1195
1349
|
for (const e of struct.connections || []) {
|
|
1196
1350
|
const k1 = e.fromId + '|' + e.toId, k2 = e.toId + '|' + e.fromId;
|
|
1197
1351
|
if (e.label === 'superseded by' || e.label === 'closed by' || e.relationship === 'conflicts_with') { settled.add(k1); settled.add(k2); }
|
|
1352
|
+
// Explicit "not a contradiction" dismissal — settles BOTH a cue pair and a
|
|
1353
|
+
// polarity pair. This is the persisted escape hatch for a correction-cue
|
|
1354
|
+
// FALSE positive: it had no stale card to retire, so a plain link never
|
|
1355
|
+
// cleared it and it re-reported on every run forever. A deliberate
|
|
1356
|
+
// not_contradiction edge (brain_connect pairs:…) now permanently dismisses it.
|
|
1357
|
+
if (e.relationship === 'not_contradiction' || e.label === 'not a contradiction') { settled.add(k1); settled.add(k2); }
|
|
1198
1358
|
if (e.label !== 'auto') { linked.add(k1); linked.add(k2); }
|
|
1199
1359
|
}
|
|
1200
1360
|
const out = [];
|
|
@@ -1465,6 +1625,50 @@ export function correctionOverlaysFor(struct, cards, { at = CORRECTION_SUPERSEDE
|
|
|
1465
1625
|
return out;
|
|
1466
1626
|
}
|
|
1467
1627
|
|
|
1628
|
+
// ── Awaits-merge decay — the deterministic twin of the correction overlay ────
|
|
1629
|
+
// A milestone written minutes before the human merges ("PR #332 awaits founder
|
|
1630
|
+
// merge") stays stale forever, even though ship-event auto-capture DOES record
|
|
1631
|
+
// "merged PR #332" — nothing linked the two. Matching a "PR #N" + awaits-cue card
|
|
1632
|
+
// against a harvested "merged PR #N" is EXACT-STRING, zero-inference work. Given
|
|
1633
|
+
// the cards recall is about to inject, return for each one carrying an unmet
|
|
1634
|
+
// merge that the ship event contradicts a merged-overlay {num, by, date}. The
|
|
1635
|
+
// caller annotates the card (never retires it — the fact is right, only its
|
|
1636
|
+
// status decayed). Pure + cheap. PR numbers are capped at 5 digits — a 6+ digit
|
|
1637
|
+
// "#N" is a path-scraped junk number (see isLegacyRawShipCard), never a real PR.
|
|
1638
|
+
const PR_MERGE_AWAIT_RE = /\b(?:await(?:s|ing)?|pending|not\s+yet|yet\s+to\s+be|to\s+be|unmerged|needs?)\b[^.\n]{0,24}?\bmerg/i;
|
|
1639
|
+
const PR_MERGED_RE = /\bmerged\b[^.\n]{0,16}?(?:PR|pull\s+request|#)\s*#?(\d{1,5})\b/i;
|
|
1640
|
+
const PR_REFS_RE = /\b(?:PR|pull\s+request|#)\s*#?(\d{1,5})\b/ig;
|
|
1641
|
+
function prNumbersIn(text) {
|
|
1642
|
+
const out = new Set(); const s = String(text || '');
|
|
1643
|
+
PR_REFS_RE.lastIndex = 0; let m;
|
|
1644
|
+
while ((m = PR_REFS_RE.exec(s))) out.add(m[1]);
|
|
1645
|
+
return out;
|
|
1646
|
+
}
|
|
1647
|
+
export function mergeOverlaysFor(struct, cards) {
|
|
1648
|
+
const out = new Map();
|
|
1649
|
+
if (!struct || !Array.isArray(struct.cards) || !Array.isArray(cards) || !cards.length) return out;
|
|
1650
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1651
|
+
// Index PR number → the NEWEST live card that says it merged (ship event or hand marker).
|
|
1652
|
+
const mergedBy = new Map();
|
|
1653
|
+
for (const c of struct.cards) {
|
|
1654
|
+
if (c.type === 'container' || isArchived(c) || !(c.text || '').trim()) continue;
|
|
1655
|
+
const mm = PR_MERGED_RE.exec(c.text); if (!mm) continue;
|
|
1656
|
+
const prev = mergedBy.get(mm[1]);
|
|
1657
|
+
if (!prev || (c.createdAt || 0) >= (prev.createdAt || 0)) mergedBy.set(mm[1], c);
|
|
1658
|
+
}
|
|
1659
|
+
if (!mergedBy.size) return out;
|
|
1660
|
+
for (const card of cards) {
|
|
1661
|
+
if (!card || !card.id || !(card.text || '').trim()) continue;
|
|
1662
|
+
if (PR_MERGED_RE.test(card.text)) continue; // the hit IS a merge note — nothing to overlay
|
|
1663
|
+
if (!PR_MERGE_AWAIT_RE.test(card.text)) continue; // must actually claim it's awaiting a merge
|
|
1664
|
+
for (const num of prNumbersIn(card.text)) {
|
|
1665
|
+
const by = mergedBy.get(num);
|
|
1666
|
+
if (by && by.id !== card.id) { out.set(card.id, { kind: 'merged', num, by, date: by.createdAt ? new Date(by.createdAt).toISOString().slice(0, 10) : null }); break; }
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
return out;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1468
1672
|
// ── Auto-skill classifier (skills emerge from the flow, not just the '+' marker) ─
|
|
1469
1673
|
// A REUSABLE skill (how-to / gotcha / convention) reads as a GENERAL RULE that
|
|
1470
1674
|
// applies next time — distinct from a one-time decision ("we shipped X"). This is
|
|
@@ -2012,6 +2216,45 @@ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
|
|
|
2012
2216
|
return { gaps: out.slice(0, max), total: out.length };
|
|
2013
2217
|
}
|
|
2014
2218
|
|
|
2219
|
+
// ── Open-question deadline awareness ─────────────────────────────────────────
|
|
2220
|
+
// An open card can carry a self-declared deadline ("Rotate NPM_TOKEN before
|
|
2221
|
+
// ~2026-07-03"). Once that day passes the card is OVERDUE — but nothing surfaced
|
|
2222
|
+
// it, so it decayed silently and served the same stale reminder a day late. This
|
|
2223
|
+
// parses an EXPLICIT, cue-anchored ISO date only (a bare date elsewhere in the
|
|
2224
|
+
// card — "shipped 2026-06-30" — is NOT a deadline), so a false overdue flag can't
|
|
2225
|
+
// fire. Deadline = end of the named UTC day. Used to badge overdue opens in both
|
|
2226
|
+
// brief tiers (the "flag it in the next session's brief" acceptance criterion).
|
|
2227
|
+
const DEADLINE_RE = /\b(?:before|by|due(?:\s+(?:date|by|on))?|deadline|until|no\s+later\s+than|not\s+later\s+than|eod|end\s+of(?:\s+day)?)\b[^\n.]{0,32}?~?\s*(\d{4}-\d{2}-\d{2})\b/i;
|
|
2228
|
+
export function parseDeadline(text) {
|
|
2229
|
+
const m = DEADLINE_RE.exec(String(text || ''));
|
|
2230
|
+
if (!m) return null;
|
|
2231
|
+
const ts = Date.parse(m[1] + 'T23:59:59Z'); // due at the END of the named day (UTC)
|
|
2232
|
+
return Number.isFinite(ts) ? { date: m[1], ts } : null;
|
|
2233
|
+
}
|
|
2234
|
+
// Live open (❓/🎯) cards whose parsed deadline has passed, newest-overdue-last so
|
|
2235
|
+
// the most-overdue lead. Injectable `now` for hermetic tests. Skips already
|
|
2236
|
+
// resolved/superseded cards (they carry ✅/↩︎) and skills (standing reference).
|
|
2237
|
+
export function findOverdueOpenCards(struct, { now = Date.now() } = {}) {
|
|
2238
|
+
const empty = { overdue: [], total: 0, byId: new Map() };
|
|
2239
|
+
if (!struct || !Array.isArray(struct.cards)) return empty;
|
|
2240
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
2241
|
+
const dayMs = (iso) => Date.parse(iso + 'T00:00:00Z');
|
|
2242
|
+
const nowDay = dayMs(new Date(now).toISOString().slice(0, 10));
|
|
2243
|
+
const out = [];
|
|
2244
|
+
for (const c of struct.cards) {
|
|
2245
|
+
if (c.type === 'container' || !(c.text || '').trim() || isArchived(c)) continue;
|
|
2246
|
+
if (/↩|✅/.test(c.text)) continue; // already superseded/resolved
|
|
2247
|
+
if (!/❓|🎯/.test(c.text) || /🛠/.test(c.text)) continue; // open questions & goals only
|
|
2248
|
+
const d = parseDeadline(c.text);
|
|
2249
|
+
// Overdue gate is LENIENT (end of the named day, d.ts) — a card isn't overdue
|
|
2250
|
+
// at 00:01 on its deadline day. The DISPLAY count is a whole-calendar-day
|
|
2251
|
+
// difference, so "before 2026-07-03" seen on 2026-07-04 reads "passed 1d ago".
|
|
2252
|
+
if (d && d.ts < now) out.push({ card: c, date: d.date, daysOverdue: Math.max(0, Math.round((nowDay - dayMs(d.date)) / 86_400_000)) });
|
|
2253
|
+
}
|
|
2254
|
+
out.sort((a, b) => b.daysOverdue - a.daysOverdue);
|
|
2255
|
+
return { overdue: out, total: out.length, byId: new Map(out.map(o => [o.card.id, o])) };
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2015
2258
|
// ── Deliberate note → capture input ──────────────────────────────────────────
|
|
2016
2259
|
// Turn ONE structured note into captureIntoBrain's input shape — the deliberate
|
|
2017
2260
|
// twin of the Stop hook's transcript marker parser. This is what lets an ON-DEMAND
|