klypix-mcp 1.31.1 → 1.32.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/package.json +2 -2
- package/src/global-brain-hook.mjs +27 -2
- package/src/klypix-format.mjs +125 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0",
|
|
4
4
|
"description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Cursor, Cline, any model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"node": ">=18"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/canvas-view.mjs"
|
|
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 && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@modelcontextprotocol/ext-apps": "^1.7.4",
|
|
@@ -1502,13 +1502,38 @@ async function promptRetrieve(lib) {
|
|
|
1502
1502
|
if (struct) {
|
|
1503
1503
|
try {
|
|
1504
1504
|
const digest = lib.areaStatusDigest(struct, { maxAreas: 12 });
|
|
1505
|
+
// The digest alone is per-area COUNTS ("· 8 open ·") — it never
|
|
1506
|
+
// names a single open card, yet this block also clears freshHits,
|
|
1507
|
+
// so a status prompt used to arrive with the instruction "answer
|
|
1508
|
+
// from THIS" and nothing to answer FROM. An agent then reports
|
|
1509
|
+
// whatever the session brief happened to show, which is itself a
|
|
1510
|
+
// truncated tier — the 2026-07-25 field incident, where 8 live
|
|
1511
|
+
// opens (including a founder-ranked #1 bug) were invisible on
|
|
1512
|
+
// both surfaces at once. Prefer the full computed view — digest
|
|
1513
|
+
// AND every open card, honestly overflow-marked — and keep the
|
|
1514
|
+
// bare digest only as the fallback for an older engine.
|
|
1515
|
+
let body = null;
|
|
1516
|
+
if (typeof lib.statusContextToMarkdown === 'function') {
|
|
1517
|
+
try {
|
|
1518
|
+
// Sized so a real brain fits BOTH a full area digest and
|
|
1519
|
+
// every open at readable width (~140 chars) — this block
|
|
1520
|
+
// IS the answer to the question that triggered it, it is
|
|
1521
|
+
// hash-deduped per session, and it replaces freshHits
|
|
1522
|
+
// rather than adding to them. Paying ~1.2k tokens once
|
|
1523
|
+
// per status conversation beats answering it wrong.
|
|
1524
|
+
const md = lib.statusContextToMarkdown(struct, { budgetChars: 5200 });
|
|
1525
|
+
// Drop its own H1; the hook's stronger header replaces it.
|
|
1526
|
+
if (md && md.trim()) body = md.split('\n').slice(1).join('\n').trimEnd();
|
|
1527
|
+
} catch { body = null; }
|
|
1528
|
+
}
|
|
1529
|
+
const content = body || digest.join('\n');
|
|
1505
1530
|
if (digest.length) {
|
|
1506
|
-
const h = crypto.createHash('sha1').update(
|
|
1531
|
+
const h = crypto.createHash('sha1').update(content).digest('hex').slice(0, 12);
|
|
1507
1532
|
let lane = null; try { lane = readSessions().find(s => s.id === sid) || null; } catch { /* */ }
|
|
1508
1533
|
if (lane && lane.statusDigestHash === h) {
|
|
1509
1534
|
statusMd = '## 📊 Current state — unchanged since the digest shown earlier this session (`brain_ask` gives the full computed status view).';
|
|
1510
1535
|
} else {
|
|
1511
|
-
statusMd = ['## 📊 Computed current state (status-shaped question detected — answer from THIS + `brain_ask`, never from memory of past sessions)',
|
|
1536
|
+
statusMd = ['## 📊 Computed current state (status-shaped question detected — answer from THIS + `brain_ask`, never from memory of past sessions)', content].join('\n');
|
|
1512
1537
|
try { touchSession(sid, { statusDigestHash: h }); } catch { /* best-effort */ }
|
|
1513
1538
|
}
|
|
1514
1539
|
freshHits = [];
|
package/src/klypix-format.mjs
CHANGED
|
@@ -1114,6 +1114,47 @@ export async function arrangeBrain(buffer, opts = {}) {
|
|
|
1114
1114
|
return { buffer: work, stats };
|
|
1115
1115
|
}
|
|
1116
1116
|
|
|
1117
|
+
// ── Lifecycle classification — ONE definition, every surface ────────────────
|
|
1118
|
+
// A capture marker writes the lifecycle glyph into LINE 1 ("Area: ❓ text").
|
|
1119
|
+
// PRECEDENCE: when line 1 already carries a state glyph, line 1 DECIDES — so a
|
|
1120
|
+
// 🏁 milestone whose BODY quotes "❓/🎯" stays a milestone instead of ranking as
|
|
1121
|
+
// an open question forever (the glyph-in-prose trap; 6 live false-positives on
|
|
1122
|
+
// the KLYPIX brain, four of them the same shipped 1.30.0 card re-read as "still
|
|
1123
|
+
// to do"). v1.31.1 stripped glyphs at CAPTURE time for engine-authored text —
|
|
1124
|
+
// that could not help cards already written, nor a human card that legitimately
|
|
1125
|
+
// quotes a glyph. This is the read-side half, and it needs no rewrite of stored
|
|
1126
|
+
// text (a rewrite's failure mode is silent: an over-matching strip turns a real
|
|
1127
|
+
// open card invisible with nothing to notice).
|
|
1128
|
+
//
|
|
1129
|
+
// Two deliberate constraints, both measured before shipping:
|
|
1130
|
+
// 1. FALLBACK — if line 1 carries no state glyph at all we scan the whole
|
|
1131
|
+
// text exactly as before, so a brain that writes its marker under a title
|
|
1132
|
+
// line is classified identically. Zero regression by construction.
|
|
1133
|
+
// 2. 🛠 IS EXEMPT — a skill is ADDITIVE (a milestone can also carry a standing
|
|
1134
|
+
// rule) and skills never age out, so 🛠 keeps whole-text matching. Applying
|
|
1135
|
+
// precedence to it demoted 7 live skill cards to milestones, silently
|
|
1136
|
+
// retiring 7 standing rules. Precedence governs the ❓/🎯-vs-🏁 STATE
|
|
1137
|
+
// distinction only.
|
|
1138
|
+
// Measured on the KLYPIX brain: open 26→20 (6 false-positives, 0 lost),
|
|
1139
|
+
// skills 97→97, milestones 303→309, 0 cards left with no lifecycle at all.
|
|
1140
|
+
const STATE_GLYPH = /❓|🎯|🏁/;
|
|
1141
|
+
const OPEN_GLYPH = /❓|🎯/;
|
|
1142
|
+
const SKILL_GLYPH = /🛠/;
|
|
1143
|
+
const MILE_GLYPH = /🏁/;
|
|
1144
|
+
const RESOLVED_GLYPH = /↩|✅|⤵/;
|
|
1145
|
+
// The span line 1 governs when it carries a state glyph; else the whole card.
|
|
1146
|
+
const lifecycleScope = (text) => {
|
|
1147
|
+
const t = String(text || '');
|
|
1148
|
+
const l1 = t.split('\n', 1)[0];
|
|
1149
|
+
return STATE_GLYPH.test(l1) ? l1 : t;
|
|
1150
|
+
};
|
|
1151
|
+
export const isSkillCard = (c) => SKILL_GLYPH.test(String(c?.text || ''));
|
|
1152
|
+
export const isOpenCard = (c) => !isSkillCard(c) && OPEN_GLYPH.test(lifecycleScope(c?.text));
|
|
1153
|
+
export const isMilestoneCard = (c) => !isSkillCard(c) && !isOpenCard(c) && MILE_GLYPH.test(lifecycleScope(c?.text));
|
|
1154
|
+
// "Open AND not already resolved in prose" — the status surfaces carry this
|
|
1155
|
+
// extra guard so a ✅/↩/⤵-stamped card is never reported as plainly still-open.
|
|
1156
|
+
export const isUnresolvedOpenCard = (c) => isOpenCard(c) && !RESOLVED_GLYPH.test(String(c?.text || ''));
|
|
1157
|
+
|
|
1117
1158
|
// ── Per-area status digest (2026-07-23 field incident) ───────────────────────
|
|
1118
1159
|
// ONE computed current-state line per ACTIVE area: newest 🏁 headline + open
|
|
1119
1160
|
// count. This is the fact whose tier-eviction let a stale "remaining:" claim
|
|
@@ -1138,8 +1179,8 @@ export function areaStatusDigest(struct, { activeDays = 30, maxAreas = 20, now =
|
|
|
1138
1179
|
for (const [area, cs] of byArea) {
|
|
1139
1180
|
const newest = Math.max(...cs.map(c => c.createdAt || 0));
|
|
1140
1181
|
if (newest < cutoff) continue; // dormant area — not "current state"
|
|
1141
|
-
const miles = cs.filter(
|
|
1142
|
-
const opens = cs.filter(
|
|
1182
|
+
const miles = cs.filter(isMilestoneCard).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
1183
|
+
const opens = cs.filter(isOpenCard);
|
|
1143
1184
|
const m = miles[0];
|
|
1144
1185
|
const mileTxt = m ? `last 🏁 ${day(m.createdAt)} “${cut(flat(m.text).replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, ''), 70)}”` : 'no 🏁 yet';
|
|
1145
1186
|
rows.push({ newest, line: `- ${area} — ${mileTxt} · ${opens.length} open · latest ${day(newest)}` });
|
|
@@ -1174,9 +1215,9 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
1174
1215
|
// Standing reference, NOT a point-in-time event: always shown, never
|
|
1175
1216
|
// recency-decayed, and excluded from open/milestones/recent so a skill never
|
|
1176
1217
|
// masquerades as (or ages out like) a decision.
|
|
1177
|
-
const skills = rest.filter(
|
|
1178
|
-
const open = rest.filter(
|
|
1179
|
-
const miles = rest.filter(
|
|
1218
|
+
const skills = rest.filter(isSkillCard);
|
|
1219
|
+
const open = rest.filter(isOpenCard);
|
|
1220
|
+
const miles = rest.filter(isMilestoneCard);
|
|
1180
1221
|
const plain = rest.filter(c => !open.includes(c) && !miles.includes(c) && !skills.includes(c));
|
|
1181
1222
|
const recent = plain.filter(c => c.createdAt >= cutoff).sort((a, b) => b.createdAt - a.createdAt).slice(0, maxRecent);
|
|
1182
1223
|
const archivedCount = texts.length - live.length;
|
|
@@ -1237,16 +1278,29 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
1237
1278
|
}
|
|
1238
1279
|
if (open.length) {
|
|
1239
1280
|
push('', `## Open questions & goals (${open.length}${overdueById.size ? `, ${overdueById.size} ⏰ overdue` : ''})`);
|
|
1240
|
-
// Overdue first, then
|
|
1241
|
-
//
|
|
1242
|
-
|
|
1281
|
+
// Overdue first, then OLDEST first. This tier used to render every open
|
|
1282
|
+
// in FULL TEXT while every other tier rendered 160-char headlines, so a
|
|
1283
|
+
// handful of verbose cards spent the whole allowance: 14,401 chars of
|
|
1284
|
+
// opens against a 7,425-char tier meant 15 of 27 were dropped — and
|
|
1285
|
+
// sorted newest-first, the dropped 15 were ALWAYS the oldest, i.e. the
|
|
1286
|
+
// long-deferred items a "what is still open?" question is mostly about.
|
|
1287
|
+
// Now: the newest few keep their detail, the rest get a headline, and
|
|
1288
|
+
// the whole list fits. Age-ascending makes eviction safe permanently —
|
|
1289
|
+
// if the brain triples and something must go, it is the newest (already
|
|
1290
|
+
// shown in Recent decisions), never the item nobody has looked at.
|
|
1291
|
+
const sorted = open.slice().sort((a, b) => (overdueById.has(b.id) ? 1 : 0) - (overdueById.has(a.id) ? 1 : 0) || (a.createdAt || 0) - (b.createdAt || 0));
|
|
1292
|
+
// Detail tier mirrors the Recent-decisions idiom: a few in depth, the
|
|
1293
|
+
// rest as scannable headlines. Completeness beats depth for a list whose
|
|
1294
|
+
// job is to say what EXISTS — full text is one MCP read away.
|
|
1295
|
+
const detailOpen = new Set(sorted.slice(-detailRecent).map(c => c.id)); // newest few keep full detail
|
|
1243
1296
|
let shown = 0;
|
|
1244
1297
|
for (const c of sorted) {
|
|
1245
1298
|
if (shown >= 40 || used > BUDGET_CHARS * 0.55) break;
|
|
1246
|
-
|
|
1299
|
+
const body = (overdueById.has(c.id) || detailOpen.has(c.id)) ? excerpt(c) : headline(c, 200);
|
|
1300
|
+
push(`- ${fr(c)}${body}${odBadge(c)}`);
|
|
1247
1301
|
shown++;
|
|
1248
1302
|
}
|
|
1249
|
-
if (shown < open.length) push(`- …and ${open.length - shown} more open item(s) — ask the brain (brain_ask) rather than assuming this list is complete.`);
|
|
1303
|
+
if (shown < open.length) push(`- ⚠️ …and ${open.length - shown} more open item(s) — ask the brain (brain_ask) rather than assuming this list is complete.`);
|
|
1250
1304
|
}
|
|
1251
1305
|
if (skills.length) {
|
|
1252
1306
|
push('', '## 🛠️ Skills — how we do things here (reusable; applies every session)');
|
|
@@ -1299,6 +1353,9 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
1299
1353
|
if (miles.length) {
|
|
1300
1354
|
push('', '## Milestones');
|
|
1301
1355
|
for (const c of miles.sort((a, b) => b.createdAt - a.createdAt).slice(0, maxMilestones)) push(`- ${fr(c)}${headline(c)}`);
|
|
1356
|
+
// Same honesty rule as every other tier: this printed 8 of 302 with no
|
|
1357
|
+
// notice at all, so the newest-8 read as "the milestones".
|
|
1358
|
+
if (miles.length > maxMilestones) push(`- …and ${miles.length - maxMilestones} older milestone(s) — search the brain.`);
|
|
1302
1359
|
}
|
|
1303
1360
|
let shownRecent = 0;
|
|
1304
1361
|
if (recent.length) {
|
|
@@ -1327,6 +1384,7 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
1327
1384
|
if (struct.connections.length && used <= BUDGET_CHARS) {
|
|
1328
1385
|
push('', '## Connections');
|
|
1329
1386
|
for (const cn of struct.connections.slice(0, maxConnections)) push(`- ${cn.from} → ${cn.to}${cn.label || cn.relationship ? ` (${cn.label || cn.relationship})` : ''}`);
|
|
1387
|
+
if (struct.connections.length > maxConnections) push(`- …and ${struct.connections.length - maxConnections} more connection(s) — search the brain.`);
|
|
1330
1388
|
}
|
|
1331
1389
|
const hidden = [];
|
|
1332
1390
|
const unshown = plain.length - shownRecent;
|
|
@@ -1357,8 +1415,8 @@ export function structToUltraBrief(struct, { freshness = null, briefPath = '.cla
|
|
|
1357
1415
|
const isFocus = (c) => /(^|\s)focus\b/i.test(c.area || '');
|
|
1358
1416
|
const live = texts.filter(c => !isArchived(c));
|
|
1359
1417
|
const focus = live.filter(isFocus);
|
|
1360
|
-
const open = live.filter(c =>
|
|
1361
|
-
const skills = live.filter(c =>
|
|
1418
|
+
const open = live.filter(c => isOpenCard(c) && !isFocus(c));
|
|
1419
|
+
const skills = live.filter(c => isSkillCard(c) && !isFocus(c));
|
|
1362
1420
|
const conflicts = (struct.connections || []).filter(c => c.relationship === 'conflicts_with');
|
|
1363
1421
|
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
1364
1422
|
const fr = (c) => (freshness && freshness[c.id]) ? freshness[c.id] + ' ' : '';
|
|
@@ -1627,7 +1685,10 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
|
|
|
1627
1685
|
// the question). Deterministic O(cards) assembly, no embeddings: per-area
|
|
1628
1686
|
// digest + open items (overdue first, with correction/fulfillment flags) +
|
|
1629
1687
|
// likely-fulfilled hints + newest milestones. Rendered ABOVE the ranked hits.
|
|
1630
|
-
|
|
1688
|
+
// maxOpen defaults to NO cap: the open list is the answer to a status question,
|
|
1689
|
+
// so it is sized to fit (per-card width adapts) rather than sliced. Callers can
|
|
1690
|
+
// still pass a cap explicitly.
|
|
1691
|
+
export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChars = 4200 } = {}) {
|
|
1631
1692
|
if (!struct || !Array.isArray(struct.cards)) return '';
|
|
1632
1693
|
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
1633
1694
|
const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
|
|
@@ -1635,17 +1696,38 @@ export function statusContextToMarkdown(struct, { maxOpen = 12, budgetChars = 32
|
|
|
1635
1696
|
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
|
|
1636
1697
|
const out = [];
|
|
1637
1698
|
let used = 0;
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1699
|
+
// A truncation notice must NEVER be subject to the budget it is warning
|
|
1700
|
+
// about. It used to be: the "…and N more" line was emitted through the same
|
|
1701
|
+
// guarded push as the content, so once the budget was spent the notice was
|
|
1702
|
+
// dropped too — and a one-third-complete list rendered as a complete one,
|
|
1703
|
+
// under a header that tells the agent to answer from this section first.
|
|
1704
|
+
// (2026-07-25 field incident: 9 of 27 opens shown, no notice, answer wrong.)
|
|
1705
|
+
// Structural lines — headers, counts, overflow notices — bypass the budget.
|
|
1706
|
+
const pushAlways = (l) => { out.push(l); used += l.length + 1; };
|
|
1707
|
+
const cut = (t, n) => { let s = String(t).slice(0, n); if (/[\uD800-\uDBFF]$/.test(s)) s = s.slice(0, -1); return String(t).length > n ? s.trimEnd() + '…' : s; };
|
|
1708
|
+
pushAlways('# Computed current state (status mode)');
|
|
1709
|
+
pushAlways('_A status-shaped question answers from THIS computed section first — the ranked cards below are supporting context only. A card marked ⏳ or ⚠️ must not be reported as plainly still-open._');
|
|
1710
|
+
pushAlways('');
|
|
1643
1711
|
// Resolved-but-not-archived cards (✅/↩/⤵ in text) are NOT open — every
|
|
1644
1712
|
// sibling lifecycle matcher carries this guard (review parity fix).
|
|
1645
|
-
const opens = live.filter(
|
|
1713
|
+
const opens = live.filter(isUnresolvedOpenCard);
|
|
1714
|
+
// OPENS ARE THE PRIORITY TIER — reserve their share BEFORE the area digest
|
|
1715
|
+
// spends it. The digest used to run first and unbounded at 14 areas, eating
|
|
1716
|
+
// 1,886 of 3,200 chars (59%) before a single open card printed, so `maxOpen`
|
|
1717
|
+
// never even bound: the budget cut the list at 9 first. Derive the area cap
|
|
1718
|
+
// from what's actually left instead of a fixed number — areaStatusDigest
|
|
1719
|
+
// prints its own honest "…and N more active area(s)" line when it trims.
|
|
1720
|
+
const openReserve = opens.length ? Math.floor(budgetChars * 0.6) : 0;
|
|
1721
|
+
const maxAreas = Math.max(4, Math.min(14, Math.floor((budgetChars - openReserve - used) / 100)));
|
|
1722
|
+
for (const l of areaStatusDigest(struct, { maxAreas })) pushAlways(l);
|
|
1646
1723
|
if (opens.length) {
|
|
1647
1724
|
const overdueById = findOverdueOpenCards(struct).byId;
|
|
1648
|
-
|
|
1725
|
+
// Overdue first, then OLDEST first. Age IS an open item's urgency signal,
|
|
1726
|
+
// and the newest opens already appear in the brief's Recent tier — so if
|
|
1727
|
+
// anything must be cut it should be the newest, never the long-deferred.
|
|
1728
|
+
// (The brief evicted oldest-first: every dropped card was older than
|
|
1729
|
+
// every printed one, hiding a founder-ranked #1 bug for two weeks.)
|
|
1730
|
+
const sorted = opens.slice().sort((a, b) => (overdueById.has(b.id) ? 1 : 0) - (overdueById.has(a.id) ? 1 : 0) || (a.createdAt || 0) - (b.createdAt || 0));
|
|
1649
1731
|
// Slice FIRST, overlay the slice — running correction/fulfillment
|
|
1650
1732
|
// overlays across ALL opens was a cards×cards-shaped pass per status
|
|
1651
1733
|
// question (review scale finding).
|
|
@@ -1653,24 +1735,33 @@ export function statusContextToMarkdown(struct, { maxOpen = 12, budgetChars = 32
|
|
|
1653
1735
|
const fulfills = fulfillmentOverlaysFor(struct, top);
|
|
1654
1736
|
let overlays = new Map();
|
|
1655
1737
|
try { overlays = correctionOverlaysFor(struct, top); } catch { /* best-effort */ }
|
|
1656
|
-
|
|
1657
|
-
|
|
1738
|
+
pushAlways('');
|
|
1739
|
+
pushAlways(`## Open (${opens.length})`);
|
|
1740
|
+
// COMPLETENESS OVER DEPTH: scale each line to fit rather than dropping
|
|
1741
|
+
// items. A 60-char headline still proves an item EXISTS and can be
|
|
1742
|
+
// pulled in full; a missing line asserts it doesn't. Only if every open
|
|
1743
|
+
// at the floor width still overflows do we cut — and then we say so.
|
|
1744
|
+
const room = Math.max(0, budgetChars - used - 200); // 200 ≈ milestones tail
|
|
1745
|
+
// Budget the PREFIX too ("- [Area] ⏰OVERDUE ⏳likely-fulfilled "), or the
|
|
1746
|
+
// section overruns by prefix×cards — measured 17% over before this term.
|
|
1747
|
+
const PREFIX = 30;
|
|
1748
|
+
const perCard = Math.max(60, Math.min(140, Math.floor(room / Math.max(1, top.length)) - PREFIX));
|
|
1658
1749
|
let shown = 0;
|
|
1659
1750
|
for (const c of top) {
|
|
1660
1751
|
if (used > budgetChars) break;
|
|
1661
1752
|
const flags = `${overdueById.has(c.id) ? ' ⏰OVERDUE' : ''}${overlays.has(c.id) ? ' ⚠️CORRECTED' : ''}${fulfills.has(c.id) ? ' ⏳likely-fulfilled' : ''}`;
|
|
1662
|
-
|
|
1753
|
+
pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ${cut(flat(c.text), perCard)}`);
|
|
1663
1754
|
shown++;
|
|
1664
1755
|
}
|
|
1665
|
-
if (shown < opens.length)
|
|
1756
|
+
if (shown < opens.length) pushAlways(`- ⚠️ …and ${opens.length - shown} more open item(s) — this list is NOT complete; call brain_ask before reporting what remains.`);
|
|
1666
1757
|
}
|
|
1667
|
-
const miles = live.filter(
|
|
1758
|
+
const miles = live.filter(isMilestoneCard).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
1668
1759
|
if (miles.length) {
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
for (const m of miles.slice(0, 3))
|
|
1760
|
+
pushAlways('');
|
|
1761
|
+
pushAlways('## Newest milestones');
|
|
1762
|
+
for (const m of miles.slice(0, 3)) pushAlways(`- [${flat(m.area) || '?'}] ${day(m.createdAt)} ${cut(flat(m.text), 130)}`);
|
|
1672
1763
|
}
|
|
1673
|
-
|
|
1764
|
+
pushAlways('');
|
|
1674
1765
|
return out.join('\n') + '\n';
|
|
1675
1766
|
}
|
|
1676
1767
|
|
|
@@ -1978,7 +2069,7 @@ export function brainInsights(struct, { staleDays = 21, topHubs = 6 } = {}) {
|
|
|
1978
2069
|
if (cn.toId) deg.set(cn.toId, (deg.get(cn.toId) || 0) + 1);
|
|
1979
2070
|
}
|
|
1980
2071
|
const headline = (c) => String(c.text || '').replace(/\s+/g, ' ').trim().replace(/^(.*?)([.!?](\s|$)|$)/, '$1').slice(0, 120);
|
|
1981
|
-
const isQuestion =
|
|
2072
|
+
const isQuestion = isOpenCard; // ❓ open question + 🎯 goal both read as "open" (line-1 precedence)
|
|
1982
2073
|
const hubs = live
|
|
1983
2074
|
.map(c => ({ id: c.id, area: c.area, degree: deg.get(c.id) || 0, headline: headline(c) }))
|
|
1984
2075
|
.filter(x => x.degree > 0)
|
|
@@ -2233,7 +2324,7 @@ export function challengeBrain(struct, claim, { semantic = null, k = 8, now = Da
|
|
|
2233
2324
|
const liveCards = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
|
|
2234
2325
|
res.checked.liveCards = liveCards.length;
|
|
2235
2326
|
res.checked.cueCards = liveCards.filter(c => hasCorrectionCue(c.text)).length;
|
|
2236
|
-
res.checked.openQuestions = liveCards.filter(
|
|
2327
|
+
res.checked.openQuestions = liveCards.filter(isOpenCard).length;
|
|
2237
2328
|
if (!text) { res.shortClaim = true; return res; }
|
|
2238
2329
|
|
|
2239
2330
|
const claimQTok = stripCueMeta(new Set(queryTokens(text)));
|
|
@@ -3392,10 +3483,9 @@ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
|
|
|
3392
3483
|
const empty = { gaps: [], total: 0 };
|
|
3393
3484
|
if (!struct || !Array.isArray(struct.cards)) return empty;
|
|
3394
3485
|
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
3395
|
-
const isOpen = (c) => /❓|🎯/.test(c.text);
|
|
3396
3486
|
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c) && !/↩|✅/.test(c.text));
|
|
3397
|
-
const opens = live.filter(
|
|
3398
|
-
const miles = live.filter(
|
|
3487
|
+
const opens = live.filter(isOpenCard);
|
|
3488
|
+
const miles = live.filter(isMilestoneCard);
|
|
3399
3489
|
if (!opens.length || !miles.length) return empty;
|
|
3400
3490
|
const out = [];
|
|
3401
3491
|
for (const o of opens) {
|
|
@@ -3451,7 +3541,7 @@ export function findOverdueOpenCards(struct, { now = Date.now() } = {}) {
|
|
|
3451
3541
|
for (const c of struct.cards) {
|
|
3452
3542
|
if (c.type === 'container' || !(c.text || '').trim() || isArchived(c)) continue;
|
|
3453
3543
|
if (/↩|✅/.test(c.text)) continue; // already superseded/resolved
|
|
3454
|
-
if (
|
|
3544
|
+
if (!isOpenCard(c)) continue; // open questions & goals only
|
|
3455
3545
|
const d = parseDeadline(c.text);
|
|
3456
3546
|
// Overdue gate is LENIENT (end of the named day, d.ts) — a card isn't overdue
|
|
3457
3547
|
// at 00:01 on its deadline day. The DISPLAY count is a whole-calendar-day
|