klypix-mcp 1.100.0 → 1.101.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/README.md +23 -10
- package/bin/klypix-worker.mjs +18 -3
- package/package.json +1 -1
- package/src/agent-presence.mjs +31 -5
- package/src/codex-brain-hook.mjs +27 -0
- package/src/global-brain-hook.mjs +2 -1
package/README.md
CHANGED
|
@@ -597,16 +597,29 @@ and the person opens Codex (or a fresh Claude Code) and says *continue*. Until 1
|
|
|
597
597
|
handoff was whatever the person pasted. Now there are two layers, and the person does not have
|
|
598
598
|
to ask for either.
|
|
599
599
|
|
|
600
|
-
**Automatic
|
|
601
|
-
remembers sessions for two weeks
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
the
|
|
600
|
+
**Automatic — whichever agent stopped.** Each session's last state is recorded on the lane that
|
|
601
|
+
already remembers sessions for two weeks, by whatever that host offers:
|
|
602
|
+
|
|
603
|
+
| Host | Recorded automatically |
|
|
604
|
+
|---|---|
|
|
605
|
+
| Claude Code (hooks) | last prompt, the tail of the last reply, files edited, its *own* commits |
|
|
606
|
+
| Codex (hooks) | last prompt, the tail of the last reply, files edited, its *own* commits |
|
|
607
|
+
| Any MCP host — Cursor, VS Code, Windsurf, Gemini CLI, … | the task and files it declared with `brain_sync` |
|
|
608
|
+
|
|
609
|
+
*Own* commits are the shas that session's `git commit` printed, never a branch range, because
|
|
610
|
+
twenty sessions can share one checkout. A session is listed when it stopped in the last 24 hours
|
|
611
|
+
without completing its task — quiet for ten minutes, or closed — and left a record. A session that
|
|
612
|
+
recorded nothing is never listed, so finished chats do not pile up.
|
|
613
|
+
|
|
614
|
+
The next session on the project, in any agent, sees **⏸️ Work other sessions may have left
|
|
615
|
+
unfinished** at its first `brain_sync` when its task is to continue something ("continue what Codex
|
|
616
|
+
was doing on the invoice" puts that session first) or touches a file one of them touched. It sees the
|
|
617
|
+
same list at session start. Each record carries its status, files, commits, last words and resume
|
|
618
|
+
command. It says, in the text and in the JSON (`state`, `working:false`, `holdsFiles:false`), that
|
|
619
|
+
the session is not running, holds no claim on any file, and that a "do not touch" note it left no
|
|
620
|
+
longer applies: the list is history, not a lock. The agent takes a record over with
|
|
621
|
+
`brain_handoff {action:"take", session:"<id>"}` so peers see it is taken, then marks it `done`. This
|
|
622
|
+
layer is machine-local, like the rest of the lane.
|
|
610
623
|
|
|
611
624
|
**Deliberate.** When a session knows it is stopping — the person asks for a handoff, the context or
|
|
612
625
|
usage limit is near, the day ends, it is blocked, it is switching tool — it calls
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -1418,13 +1418,28 @@ server.registerTool('brain_sync', {
|
|
|
1418
1418
|
const wantsContinue = HANDOFF_CONTINUE_RE.test(String(intent || ''));
|
|
1419
1419
|
const normPath = (p) => String(p || '').replace(/\\/g, '/').toLowerCase();
|
|
1420
1420
|
const mine = new Set((Array.isArray(files) ? files : []).map(normPath));
|
|
1421
|
-
const
|
|
1422
|
-
|
|
1421
|
+
const overlaps = (r) => Array.isArray(r.files) && r.files.some((p) => mine.has(normPath(p)));
|
|
1422
|
+
// "continue what codex was doing on the invoice": rank by what the
|
|
1423
|
+
// request names — the agent, words of the task, last words, files,
|
|
1424
|
+
// commit subjects — then by recency, so the one meant leads the list.
|
|
1425
|
+
const words = new Set(String(intent || '').toLowerCase().match(/[\p{L}\p{N}_-]{3,}/gu) || []);
|
|
1426
|
+
for (const w of ['continue', 'resume', 'what', 'was', 'were', 'doing', 'the', 'and', 'left', 'off', 'where', 'session', 'other', 'work', 'pick', 'carry', 'keep', 'going', 'finish', 'take', 'over', 'handoff', 'hand']) words.delete(w);
|
|
1427
|
+
const relevance = (r) => {
|
|
1428
|
+
const hay = [r.client === 'claude-code' ? 'claude' : r.client, r.intent, r.lastPrompt, r.lastWords, ...(r.files || []), ...(r.commits || []).map((c) => c.subject)].join(' ').toLowerCase();
|
|
1429
|
+
let n = 0;
|
|
1430
|
+
for (const w of words) if (hay.includes(w)) n++;
|
|
1431
|
+
return n + (overlaps(r) ? 3 : 0);
|
|
1432
|
+
};
|
|
1433
|
+
const lane = (h?.structured?.unfinished || [])
|
|
1434
|
+
.filter((r) => wantsContinue || overlaps(r))
|
|
1435
|
+
.map((r) => ({ r, score: relevance(r) }))
|
|
1436
|
+
.sort((a, b) => b.score - a.score || Number(b.r.lastSeen || 0) - Number(a.r.lastSeen || 0))
|
|
1437
|
+
.map(({ r }) => r);
|
|
1423
1438
|
if (!h?.isError && (cards.length || lane.length)) {
|
|
1424
1439
|
const now = Date.now();
|
|
1425
1440
|
handoffText = [
|
|
1426
1441
|
formatHandoffCards(cards, { now, max: 5 }),
|
|
1427
|
-
formatUnfinishedWork(lane, { now, max: 4 }),
|
|
1442
|
+
formatUnfinishedWork(lane, { now, max: 4, ranked: wantsContinue && words.size > 0 }),
|
|
1428
1443
|
].filter(Boolean).join('\n\n');
|
|
1429
1444
|
handoffData = {
|
|
1430
1445
|
cards: cards.length,
|
package/package.json
CHANGED
package/src/agent-presence.mjs
CHANGED
|
@@ -535,12 +535,28 @@ export function normalizeWork(raw) {
|
|
|
535
535
|
out.takenBy = { id: str(raw.takenBy.id, 160), client: str(raw.takenBy.client, 40) || 'unknown', at: ms(raw.takenBy.at) };
|
|
536
536
|
}
|
|
537
537
|
if (ms(raw.doneAt)) { out.doneAt = ms(raw.doneAt); if (raw.doneBy) out.doneBy = str(raw.doneBy, 160); }
|
|
538
|
+
// 1.99.1 (universal): a task DECLARED through brain_sync — any MCP host,
|
|
539
|
+
// hooks or not — stamps when it was declared, so a Cursor / Gemini / VS Code
|
|
540
|
+
// session that stopped mid-task is still found by the next session.
|
|
541
|
+
if (ms(raw.declaredAt)) out.declaredAt = ms(raw.declaredAt);
|
|
538
542
|
return Object.keys(out).length ? out : null;
|
|
539
543
|
}
|
|
540
544
|
export function mergeWork(previous, patch) {
|
|
541
545
|
if (patch === undefined) return normalizeWork(previous);
|
|
542
546
|
if (patch === null) return null;
|
|
543
|
-
|
|
547
|
+
const prev = normalizeWork(previous) || {};
|
|
548
|
+
const next = patch && typeof patch === 'object' ? { ...patch } : {};
|
|
549
|
+
// `addCommits` APPENDS (Codex records one commit per tool result; the
|
|
550
|
+
// Claude Stop hook replaces the full list with `commits`), newest last,
|
|
551
|
+
// de-duplicated by sha, capped like `commits`.
|
|
552
|
+
if (Array.isArray(next.addCommits)) {
|
|
553
|
+
const seen = new Set();
|
|
554
|
+
next.commits = [...(prev.commits || []), ...next.addCommits]
|
|
555
|
+
.filter((c) => c && c.sha && !seen.has(String(c.sha).slice(0, 7)) && seen.add(String(c.sha).slice(0, 7)))
|
|
556
|
+
.slice(-WORK_COMMITS_MAX);
|
|
557
|
+
delete next.addCommits;
|
|
558
|
+
}
|
|
559
|
+
return normalizeWork({ ...prev, ...next });
|
|
544
560
|
}
|
|
545
561
|
const pruneDirectory = (rows, now) => (Array.isArray(rows) ? rows : [])
|
|
546
562
|
.filter((row) => row && recipientKey(row.id) && now - Number(row.lastSeen || 0) < DIRECTORY_FRESH_MS)
|
|
@@ -1186,7 +1202,14 @@ export function upsertSession({
|
|
|
1186
1202
|
...(statusDigestHash !== undefined ? { statusDigestHash: String(statusDigestHash || '').slice(0, 16) } : {}),
|
|
1187
1203
|
// ADDITIVE unfinished-work state: merged only when this touch carries a
|
|
1188
1204
|
// patch; the `...previous` spread keeps it verbatim otherwise.
|
|
1189
|
-
...(
|
|
1205
|
+
...(() => {
|
|
1206
|
+
// A brain_sync-DECLARED task (any MCP host) is itself a work record:
|
|
1207
|
+
// stamp declaredAt beside whatever patch this touch carries.
|
|
1208
|
+
const declared = intentSource === 'declared' && intentChanged && nextIntent ? { declaredAt: now } : null;
|
|
1209
|
+
if (work === undefined && !declared) return {};
|
|
1210
|
+
const merged = mergeWork(previous.work, { ...(work && typeof work === 'object' ? work : {}), ...(declared || {}) });
|
|
1211
|
+
return merged ? { work: merged } : {};
|
|
1212
|
+
})(),
|
|
1190
1213
|
// ADDITIVE host status: stamped only when this touch carries one, kept
|
|
1191
1214
|
// verbatim otherwise, so a heartbeat never turns "working" into unknown.
|
|
1192
1215
|
...(nextHostStatus
|
|
@@ -2914,7 +2937,10 @@ export const UNFINISHED_QUIET_MS = 10 * 60 * 1000;
|
|
|
2914
2937
|
// and every record says, in text and JSON, that it is not running and holds
|
|
2915
2938
|
// no claim on any file.
|
|
2916
2939
|
export const UNFINISHED_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
2917
|
-
|
|
2940
|
+
// Evidence of real work, from ANY host: a reply tail (Claude and Codex Stop
|
|
2941
|
+
// hooks), own commits (Claude transcript, Codex tool results), a handoff card
|
|
2942
|
+
// (brain_handoff), or a task declared through brain_sync (every MCP host).
|
|
2943
|
+
export const hasWorkEvidence = (work) => Boolean(work && (work.lastWords || (Array.isArray(work.commits) && work.commits.length) || work.handoffCardId || work.declaredAt));
|
|
2918
2944
|
export function clientWord(client) {
|
|
2919
2945
|
const key = String(client || '').toLowerCase();
|
|
2920
2946
|
if (key === 'claude-code' || key === 'claude') return 'Claude Code';
|
|
@@ -3026,11 +3052,11 @@ export function stampSessionWork({ brainPath, sessionId, patch, home, now = Date
|
|
|
3026
3052
|
// one record per unfinished session, newest first, then how to take it over.
|
|
3027
3053
|
// Quoted text is another session's record and is labelled as such — never as
|
|
3028
3054
|
// the human's instructions; markers inside it were neutralized at store time.
|
|
3029
|
-
export function formatUnfinishedWork(records, { now = Date.now(), max = 5, cardTitles = null } = {}) {
|
|
3055
|
+
export function formatUnfinishedWork(records, { now = Date.now(), max = 5, cardTitles = null, ranked = false } = {}) {
|
|
3030
3056
|
const list = Array.isArray(records) ? records : [];
|
|
3031
3057
|
if (!list.length) return '';
|
|
3032
3058
|
const flat = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
|
3033
|
-
const lines = [`⏸️ Work other sessions may have left unfinished (${list.length}, stopped in the last 24h, newest first). None of them is running: they hold NO claim on any file, and a "do not touch" or WIP note one of them left no longer applies. Use this only if the human asks you to continue their work:`];
|
|
3059
|
+
const lines = [`⏸️ Work other sessions may have left unfinished (${list.length}, stopped in the last 24h, ${ranked ? 'most relevant first' : 'newest first'}). None of them is running: they hold NO claim on any file, and a "do not touch" or WIP note one of them left no longer applies. Use this only if the human asks you to continue their work:`];
|
|
3034
3060
|
for (const r of list.slice(0, max)) {
|
|
3035
3061
|
const who = `${clientWord(r.client)} ${String(r.id).slice(0, 8)}`;
|
|
3036
3062
|
const bits = [r.state === 'ended' ? 'ended' : `stopped (${r.status})`, `last active ${agoLabel(r.ageMs)}`];
|
package/src/codex-brain-hook.mjs
CHANGED
|
@@ -371,6 +371,8 @@ async function statusDigestContext({ brainPath, sessionId, prompt, sessions, cwd
|
|
|
371
371
|
// ⏸️ Unfinished work other sessions left — the engine's block (agent-presence
|
|
372
372
|
// listUnfinishedWork + formatUnfinishedWork), read-only, never throws.
|
|
373
373
|
const CONTINUE_PROMPT_RE = /\b(continue|resume|pick (it |this |that )?up|carry on|keep going|where (were|did) we|what('s| is| was) (next|left|remaining|pending)|unfinished|left off|hand[- ]?off|handed (over|off)|take over|finish (what|the work|it|this))\b/i;
|
|
374
|
+
const HANDOFF_ASK_PROMPT_RE = /\b(hand[- ]?off|hand (it|this|the work) (over|off)|before (we|i|you) (hit|reach|approach|run out of) (the )?(limit|context|window|quota)|(context|usage|token|rate) (limit|window|quota)|out of context|switch(ing)? to (claude|another (agent|session|tool)))\b/i;
|
|
375
|
+
const CODEX_HANDOFF_HINT = '🤝 KLYPIX: to hand this session\'s work to the next one (any agent — Claude Code or Codex), call brain_handoff with the task, what is done, what is next, the files and how to verify; the next session on this project sees it at its first brain_sync and takes it with {action:"take"}. Your last reply and commits are recorded automatically at each turn end regardless — but a deliberate handoff says what you know. Never make the human paste a summary into the next session.';
|
|
374
376
|
function unfinishedWorkBlock(brainPath, sessionId) {
|
|
375
377
|
try {
|
|
376
378
|
return formatUnfinishedWork(listUnfinishedWork({ brainPath, selfId: sessionId }));
|
|
@@ -390,8 +392,31 @@ function codexWorkPatch({ event, humanTurn, prompt, input }) {
|
|
|
390
392
|
const text = typeof words === 'string' ? words.trim() : '';
|
|
391
393
|
if (text) { patch.lastWords = text.length > 700 ? '…' + text.slice(-699) : text; patch.lastWordsAt = Date.now(); }
|
|
392
394
|
}
|
|
395
|
+
// 1.99.1 (universal): this session's OWN commits, from the output git
|
|
396
|
+
// printed for a commit it ran ("[branch abc1234] subject") — never a branch
|
|
397
|
+
// range, which would credit a peer's commits in a shared checkout.
|
|
398
|
+
if (event === 'PostToolUse') {
|
|
399
|
+
const commits = commitsFromToolResult(input);
|
|
400
|
+
if (commits.length) patch.addCommits = commits;
|
|
401
|
+
}
|
|
393
402
|
return Object.keys(patch).length ? patch : null;
|
|
394
403
|
}
|
|
404
|
+
function toolOutputText(input) {
|
|
405
|
+
const r = input.tool_response ?? input.toolResponse ?? input.tool_output ?? input.toolOutput;
|
|
406
|
+
if (typeof r === 'string') return r;
|
|
407
|
+
if (!r || typeof r !== 'object') return '';
|
|
408
|
+
const parts = [r.output, r.stdout, r.aggregated_output, r.formatted_output, r.text, r.content];
|
|
409
|
+
return parts.map((p) => (typeof p === 'string' ? p : Array.isArray(p) ? p.map((x) => (typeof x === 'string' ? x : x?.text || '')).join('\n') : '')).filter(Boolean).join('\n');
|
|
410
|
+
}
|
|
411
|
+
export function commitsFromToolResult(input) {
|
|
412
|
+
const ti = input.tool_input || input.toolInput || {};
|
|
413
|
+
const cmd = typeof ti === 'string' ? ti : [ti.command, ti.cmd, Array.isArray(ti.command) ? ti.command.join(' ') : ''].filter((v) => typeof v === 'string').join(' ');
|
|
414
|
+
if (!/\bgit\b[^\n]*\bcommit\b/.test(cmd)) return [];
|
|
415
|
+
const out = toolOutputText(input);
|
|
416
|
+
const commits = [];
|
|
417
|
+
for (const m of out.matchAll(/^\[[^\]\n]{1,80}?\s([0-9a-f]{7,40})\]\s+(.+)$/gm)) commits.push({ sha: m[1].slice(0, 12), subject: m[2].trim().slice(0, 120) });
|
|
418
|
+
return commits.slice(0, 6);
|
|
419
|
+
}
|
|
395
420
|
|
|
396
421
|
async function compactTaskContext(projectDir, prompt, files) {
|
|
397
422
|
if (!prompt) return '';
|
|
@@ -533,6 +558,8 @@ async function main() {
|
|
|
533
558
|
stampReceivedMessages(messages, Date.now(), undefined, sessionId),
|
|
534
559
|
// Only on a continue-shaped human prompt — SessionStart printed it once.
|
|
535
560
|
...(prompt && CONTINUE_PROMPT_RE.test(prompt) ? [unfinishedWorkBlock(brainPath, sessionId)] : []),
|
|
561
|
+
// The human asked for a handoff or named the limit (Codex has one too).
|
|
562
|
+
...(prompt && HANDOFF_ASK_PROMPT_RE.test(prompt) ? [CODEX_HANDOFF_HINT] : []),
|
|
536
563
|
], event);
|
|
537
564
|
return;
|
|
538
565
|
}
|
|
@@ -1086,6 +1086,7 @@ function workNorm(raw) {
|
|
|
1086
1086
|
if (raw.handoffCardId) out.handoffCardId = str(raw.handoffCardId, 64);
|
|
1087
1087
|
if (raw.takenBy && typeof raw.takenBy === 'object' && raw.takenBy.id) out.takenBy = { id: str(raw.takenBy.id, 160), client: str(raw.takenBy.client, 40) || 'unknown', at: ms(raw.takenBy.at) };
|
|
1088
1088
|
if (ms(raw.doneAt)) { out.doneAt = ms(raw.doneAt); if (raw.doneBy) out.doneBy = str(raw.doneBy, 160); }
|
|
1089
|
+
if (ms(raw.declaredAt)) out.declaredAt = ms(raw.declaredAt); // 1.99.1 twin: a brain_sync-declared task (any MCP host)
|
|
1089
1090
|
return Object.keys(out).length ? out : null;
|
|
1090
1091
|
}
|
|
1091
1092
|
const workMerge = (prev, patch) => (patch === undefined ? workNorm(prev) : patch === null ? null : workNorm({ ...(workNorm(prev) || {}), ...(patch && typeof patch === 'object' ? patch : {}) }));
|
|
@@ -1470,7 +1471,7 @@ const UNFINISHED_QUIET_MS = 10 * 60 * 1000;
|
|
|
1470
1471
|
// 1.99.1 twin of agent-presence UNFINISHED_WINDOW_MS / hasWorkEvidence: only a
|
|
1471
1472
|
// session that stopped in the last 24h AND left a 1.99+ work record is listed.
|
|
1472
1473
|
const UNFINISHED_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
1473
|
-
const hasWorkEvidence = (w) => Boolean(w && (w.lastWords || (Array.isArray(w.commits) && w.commits.length) || w.handoffCardId));
|
|
1474
|
+
const hasWorkEvidence = (w) => Boolean(w && (w.lastWords || (Array.isArray(w.commits) && w.commits.length) || w.handoffCardId || w.declaredAt));
|
|
1474
1475
|
const CONTINUE_RE = /\b(continue|resume|pick (it |this |that )?up|carry on|keep going|where (were|did) we|what('s| is| was) (next|left|remaining|pending)|unfinished|left off|hand[- ]?off|handed (over|off)|take over|finish (what|the work|it|this))\b/i;
|
|
1475
1476
|
const HANDOFF_ASK_RE = /\b(hand[- ]?off|hand (it|this|the work) (over|off)|before (we|i|you) (hit|reach|approach|run out of) (the )?(limit|context|window|quota)|(context|usage|token) (limit|window|quota)|out of context|switch(ing)? to (codex|another (agent|session|tool)))\b/i;
|
|
1476
1477
|
function unfinishedFooter(sid, { max = 4 } = {}) {
|