@onenomad/engram-mcp 1.1.0 → 2.0.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 +32 -32
- package/dist/auth/login.d.ts +107 -68
- package/dist/auth/login.js +227 -216
- package/dist/auth/login.js.map +1 -1
- package/dist/consolidator.js +519 -519
- package/dist/context-pressure.js +91 -91
- package/dist/handoff.d.ts +53 -53
- package/dist/handoff.js +156 -156
- package/dist/server.js +204 -49
- package/dist/server.js.map +1 -1
- package/dist/source-dedup.d.ts +86 -86
- package/dist/source-dedup.js +147 -147
- package/dist/update-metadata.d.ts +29 -29
- package/dist/update-metadata.js +51 -51
- package/dist/wal.d.ts +95 -95
- package/dist/wal.js +295 -295
- package/package.json +1 -1
package/dist/context-pressure.js
CHANGED
|
@@ -1,92 +1,92 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CONTEXT PRESSURE — self-nudge helper.
|
|
3
|
-
*
|
|
4
|
-
* Engram can't actually read the agent's token budget, but it can return a
|
|
5
|
-
* structured checklist that reminds the agent *what* to do when context is
|
|
6
|
-
* getting heavy. The agent calls this periodically (or after big tool outputs)
|
|
7
|
-
* and gets back a deterministic prompt-injection telling it to write a
|
|
8
|
-
* handoff note and invoke /compact.
|
|
9
|
-
*
|
|
10
|
-
* This exists because self-pacing compaction is a discipline problem — the
|
|
11
|
-
* agent knows it should do it, but needs a loud, structured reminder.
|
|
12
|
-
*/
|
|
13
|
-
const PLAN_OK = [
|
|
14
|
-
'No action required. Continue working.',
|
|
15
|
-
'Save any new facts, preferences, or decisions to
|
|
16
|
-
];
|
|
17
|
-
const PLAN_WARM = [
|
|
18
|
-
'Save any unsaved facts/preferences/decisions to
|
|
19
|
-
'Update session state with current task/decisions via
|
|
20
|
-
'Continue working but keep tool outputs lean.',
|
|
21
|
-
];
|
|
22
|
-
const PLAN_HOT = [
|
|
23
|
-
'IMMEDIATELY call
|
|
24
|
-
'Save unsaved facts to
|
|
25
|
-
'After the handoff is written, invoke /compact yourself — do not wait for the system.',
|
|
26
|
-
];
|
|
27
|
-
const PLAN_CRITICAL = [
|
|
28
|
-
'STOP all other work.',
|
|
29
|
-
'Call
|
|
30
|
-
'Save any unsaved facts to
|
|
31
|
-
'Tell the user the context is near-full and ask permission to /compact or end the session. If no response, compact anyway — losing the handoff is worse than a surprise compact.',
|
|
32
|
-
];
|
|
33
|
-
// When the agent reports a natural phase boundary (task done, pivoting focus,
|
|
34
|
-
// finishing a subsystem), eat the cache miss now. The pivot will thrash the
|
|
35
|
-
// cache anyway — better to compact with fresh memories and a handoff in hand
|
|
36
|
-
// than ride a bloated window into the next phase.
|
|
37
|
-
const PLAN_PHASE_BOUNDARY = [
|
|
38
|
-
'Natural phase boundary detected. This is the right moment to compact — pivots thrash the cache anyway.',
|
|
39
|
-
'Call
|
|
40
|
-
'Save any unsaved facts from the completed phase via
|
|
41
|
-
'Invoke /compact yourself before starting the next phase. Do not carry verbose tool outputs from the finished work into the new one.',
|
|
42
|
-
];
|
|
43
|
-
export function assessPressure(level, reason = '', phaseBoundary = false) {
|
|
44
|
-
// Phase boundary overrides ok/warm — compact proactively regardless of level.
|
|
45
|
-
// At hot/critical the phase boundary adds urgency but the existing plan is already strict.
|
|
46
|
-
if (phaseBoundary && (level === 'ok' || level === 'warm')) {
|
|
47
|
-
return {
|
|
48
|
-
level,
|
|
49
|
-
phaseBoundary: true,
|
|
50
|
-
reason,
|
|
51
|
-
actionPlan: PLAN_PHASE_BOUNDARY,
|
|
52
|
-
reminder: 'Phase boundary. Write handoff, save memories, /compact before pivoting.',
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
switch (level) {
|
|
56
|
-
case 'ok':
|
|
57
|
-
return {
|
|
58
|
-
level,
|
|
59
|
-
phaseBoundary,
|
|
60
|
-
reason,
|
|
61
|
-
actionPlan: PLAN_OK,
|
|
62
|
-
reminder: 'Context healthy. Keep persisting memories as they emerge.',
|
|
63
|
-
};
|
|
64
|
-
case 'warm':
|
|
65
|
-
return {
|
|
66
|
-
level,
|
|
67
|
-
phaseBoundary,
|
|
68
|
-
reason,
|
|
69
|
-
actionPlan: PLAN_WARM,
|
|
70
|
-
reminder: 'Context warming. Persist memories now; keep outputs lean.',
|
|
71
|
-
};
|
|
72
|
-
case 'hot':
|
|
73
|
-
return {
|
|
74
|
-
level,
|
|
75
|
-
phaseBoundary,
|
|
76
|
-
reason,
|
|
77
|
-
actionPlan: phaseBoundary
|
|
78
|
-
? [...PLAN_PHASE_BOUNDARY, ...PLAN_HOT]
|
|
79
|
-
: PLAN_HOT,
|
|
80
|
-
reminder: 'Context HOT. Write handoff note, then /compact. Do not wait.',
|
|
81
|
-
};
|
|
82
|
-
case 'critical':
|
|
83
|
-
return {
|
|
84
|
-
level,
|
|
85
|
-
phaseBoundary,
|
|
86
|
-
reason,
|
|
87
|
-
actionPlan: PLAN_CRITICAL,
|
|
88
|
-
reminder: 'CRITICAL: window near full. Write handoff NOW or lose state.',
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* CONTEXT PRESSURE — self-nudge helper.
|
|
3
|
+
*
|
|
4
|
+
* Engram can't actually read the agent's token budget, but it can return a
|
|
5
|
+
* structured checklist that reminds the agent *what* to do when context is
|
|
6
|
+
* getting heavy. The agent calls this periodically (or after big tool outputs)
|
|
7
|
+
* and gets back a deterministic prompt-injection telling it to write a
|
|
8
|
+
* handoff note and invoke /compact.
|
|
9
|
+
*
|
|
10
|
+
* This exists because self-pacing compaction is a discipline problem — the
|
|
11
|
+
* agent knows it should do it, but needs a loud, structured reminder.
|
|
12
|
+
*/
|
|
13
|
+
const PLAN_OK = [
|
|
14
|
+
'No action required. Continue working.',
|
|
15
|
+
'Save any new facts, preferences, or decisions to engram-ingest as they emerge.',
|
|
16
|
+
];
|
|
17
|
+
const PLAN_WARM = [
|
|
18
|
+
'Save any unsaved facts/preferences/decisions to engram-ingest now — do not batch.',
|
|
19
|
+
'Update session state with current task/decisions via engram-session.',
|
|
20
|
+
'Continue working but keep tool outputs lean.',
|
|
21
|
+
];
|
|
22
|
+
const PLAN_HOT = [
|
|
23
|
+
'IMMEDIATELY call engram-handoff-write with a full "where we left off" snapshot.',
|
|
24
|
+
'Save unsaved facts to engram-ingest.',
|
|
25
|
+
'After the handoff is written, invoke /compact yourself — do not wait for the system.',
|
|
26
|
+
];
|
|
27
|
+
const PLAN_CRITICAL = [
|
|
28
|
+
'STOP all other work.',
|
|
29
|
+
'Call engram-handoff-write RIGHT NOW — reason: "context-pressure". Include currentTask, nextSteps, fileRefs, openQuestions.',
|
|
30
|
+
'Save any unsaved facts to engram-ingest.',
|
|
31
|
+
'Tell the user the context is near-full and ask permission to /compact or end the session. If no response, compact anyway — losing the handoff is worse than a surprise compact.',
|
|
32
|
+
];
|
|
33
|
+
// When the agent reports a natural phase boundary (task done, pivoting focus,
|
|
34
|
+
// finishing a subsystem), eat the cache miss now. The pivot will thrash the
|
|
35
|
+
// cache anyway — better to compact with fresh memories and a handoff in hand
|
|
36
|
+
// than ride a bloated window into the next phase.
|
|
37
|
+
const PLAN_PHASE_BOUNDARY = [
|
|
38
|
+
'Natural phase boundary detected. This is the right moment to compact — pivots thrash the cache anyway.',
|
|
39
|
+
'Call engram-handoff-write with reason="compact". Include currentTask (the phase just finished), completed, nextSteps (the phase about to start), fileRefs, decisions.',
|
|
40
|
+
'Save any unsaved facts from the completed phase via engram-ingest.',
|
|
41
|
+
'Invoke /compact yourself before starting the next phase. Do not carry verbose tool outputs from the finished work into the new one.',
|
|
42
|
+
];
|
|
43
|
+
export function assessPressure(level, reason = '', phaseBoundary = false) {
|
|
44
|
+
// Phase boundary overrides ok/warm — compact proactively regardless of level.
|
|
45
|
+
// At hot/critical the phase boundary adds urgency but the existing plan is already strict.
|
|
46
|
+
if (phaseBoundary && (level === 'ok' || level === 'warm')) {
|
|
47
|
+
return {
|
|
48
|
+
level,
|
|
49
|
+
phaseBoundary: true,
|
|
50
|
+
reason,
|
|
51
|
+
actionPlan: PLAN_PHASE_BOUNDARY,
|
|
52
|
+
reminder: 'Phase boundary. Write handoff, save memories, /compact before pivoting.',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
switch (level) {
|
|
56
|
+
case 'ok':
|
|
57
|
+
return {
|
|
58
|
+
level,
|
|
59
|
+
phaseBoundary,
|
|
60
|
+
reason,
|
|
61
|
+
actionPlan: PLAN_OK,
|
|
62
|
+
reminder: 'Context healthy. Keep persisting memories as they emerge.',
|
|
63
|
+
};
|
|
64
|
+
case 'warm':
|
|
65
|
+
return {
|
|
66
|
+
level,
|
|
67
|
+
phaseBoundary,
|
|
68
|
+
reason,
|
|
69
|
+
actionPlan: PLAN_WARM,
|
|
70
|
+
reminder: 'Context warming. Persist memories now; keep outputs lean.',
|
|
71
|
+
};
|
|
72
|
+
case 'hot':
|
|
73
|
+
return {
|
|
74
|
+
level,
|
|
75
|
+
phaseBoundary,
|
|
76
|
+
reason,
|
|
77
|
+
actionPlan: phaseBoundary
|
|
78
|
+
? [...PLAN_PHASE_BOUNDARY, ...PLAN_HOT]
|
|
79
|
+
: PLAN_HOT,
|
|
80
|
+
reminder: 'Context HOT. Write handoff note, then /compact. Do not wait.',
|
|
81
|
+
};
|
|
82
|
+
case 'critical':
|
|
83
|
+
return {
|
|
84
|
+
level,
|
|
85
|
+
phaseBoundary,
|
|
86
|
+
reason,
|
|
87
|
+
actionPlan: PLAN_CRITICAL,
|
|
88
|
+
reminder: 'CRITICAL: window near full. Write handoff NOW or lose state.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
92
|
//# sourceMappingURL=context-pressure.js.map
|
package/dist/handoff.d.ts
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HANDOFF NOTES — "where we left off" lifeline for cross-session continuity.
|
|
3
|
-
*
|
|
4
|
-
* Unlike diary entries (free-form journal) or session-state (ephemeral scratchpad),
|
|
5
|
-
* handoffs are *structured* resume-from-here snapshots written immediately before
|
|
6
|
-
* context compaction or session end. If the context window fills before compaction
|
|
7
|
-
* runs, the user abandons the chat — the handoff is the ONLY way to continue in
|
|
8
|
-
* a fresh session without re-explaining everything.
|
|
9
|
-
*
|
|
10
|
-
* Schema is opinionated on purpose: a fresh agent can pick up from any field
|
|
11
|
-
* without hunting through prose.
|
|
12
|
-
*/
|
|
13
|
-
export interface HandoffNote {
|
|
14
|
-
/** ISO timestamp of when this handoff was written */
|
|
15
|
-
timestamp: string;
|
|
16
|
-
/** Optional human-friendly checkpoint name (e.g. "engram-named-checkpoints"). Allows list-and-pick resume across many saved sessions. */
|
|
17
|
-
name?: string;
|
|
18
|
-
/** Session or conversation identifier */
|
|
19
|
-
sessionId: string | null;
|
|
20
|
-
/** Why the handoff was written: compact, session-end, manual, context-pressure */
|
|
21
|
-
reason: 'compact' | 'session-end' | 'manual' | 'context-pressure';
|
|
22
|
-
/** One-sentence description of the active task */
|
|
23
|
-
currentTask: string;
|
|
24
|
-
/** What's already been completed in this session */
|
|
25
|
-
completed: string[];
|
|
26
|
-
/** The very next concrete action(s) to take on resume */
|
|
27
|
-
nextSteps: string[];
|
|
28
|
-
/** Unresolved questions, blockers, or decisions awaiting user input */
|
|
29
|
-
openQuestions: string[];
|
|
30
|
-
/** File paths (ideally path:line) the next agent needs to look at */
|
|
31
|
-
fileRefs: string[];
|
|
32
|
-
/** Key decisions made this session that shape future work */
|
|
33
|
-
decisions: string[];
|
|
34
|
-
/** Anything else the next agent MUST know — hidden constraints, quirks, gotchas */
|
|
35
|
-
notes: string;
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Write a handoff note. Persists BOTH JSON (machine-readable) and markdown (human-readable).
|
|
39
|
-
*/
|
|
40
|
-
export declare function writeHandoff(dataDir: string, note: Omit<HandoffNote, 'timestamp'>): HandoffNote;
|
|
41
|
-
export declare function readHandoff(dataDir: string, identifier?: string): HandoffNote | null;
|
|
42
|
-
export interface HandoffListEntry {
|
|
43
|
-
stamp: string;
|
|
44
|
-
timestamp: string;
|
|
45
|
-
reason: string;
|
|
46
|
-
currentTask: string;
|
|
47
|
-
name?: string;
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* List handoff checkpoints, newest first. Includes the optional `name` so a
|
|
51
|
-
* caller can present a list-and-pick UI keyed on either stamp or name.
|
|
52
|
-
*/
|
|
53
|
-
export declare function listHandoffs(dataDir: string, limit?: number): HandoffListEntry[];
|
|
1
|
+
/**
|
|
2
|
+
* HANDOFF NOTES — "where we left off" lifeline for cross-session continuity.
|
|
3
|
+
*
|
|
4
|
+
* Unlike diary entries (free-form journal) or session-state (ephemeral scratchpad),
|
|
5
|
+
* handoffs are *structured* resume-from-here snapshots written immediately before
|
|
6
|
+
* context compaction or session end. If the context window fills before compaction
|
|
7
|
+
* runs, the user abandons the chat — the handoff is the ONLY way to continue in
|
|
8
|
+
* a fresh session without re-explaining everything.
|
|
9
|
+
*
|
|
10
|
+
* Schema is opinionated on purpose: a fresh agent can pick up from any field
|
|
11
|
+
* without hunting through prose.
|
|
12
|
+
*/
|
|
13
|
+
export interface HandoffNote {
|
|
14
|
+
/** ISO timestamp of when this handoff was written */
|
|
15
|
+
timestamp: string;
|
|
16
|
+
/** Optional human-friendly checkpoint name (e.g. "engram-named-checkpoints"). Allows list-and-pick resume across many saved sessions. */
|
|
17
|
+
name?: string;
|
|
18
|
+
/** Session or conversation identifier */
|
|
19
|
+
sessionId: string | null;
|
|
20
|
+
/** Why the handoff was written: compact, session-end, manual, context-pressure */
|
|
21
|
+
reason: 'compact' | 'session-end' | 'manual' | 'context-pressure';
|
|
22
|
+
/** One-sentence description of the active task */
|
|
23
|
+
currentTask: string;
|
|
24
|
+
/** What's already been completed in this session */
|
|
25
|
+
completed: string[];
|
|
26
|
+
/** The very next concrete action(s) to take on resume */
|
|
27
|
+
nextSteps: string[];
|
|
28
|
+
/** Unresolved questions, blockers, or decisions awaiting user input */
|
|
29
|
+
openQuestions: string[];
|
|
30
|
+
/** File paths (ideally path:line) the next agent needs to look at */
|
|
31
|
+
fileRefs: string[];
|
|
32
|
+
/** Key decisions made this session that shape future work */
|
|
33
|
+
decisions: string[];
|
|
34
|
+
/** Anything else the next agent MUST know — hidden constraints, quirks, gotchas */
|
|
35
|
+
notes: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Write a handoff note. Persists BOTH JSON (machine-readable) and markdown (human-readable).
|
|
39
|
+
*/
|
|
40
|
+
export declare function writeHandoff(dataDir: string, note: Omit<HandoffNote, 'timestamp'>): HandoffNote;
|
|
41
|
+
export declare function readHandoff(dataDir: string, identifier?: string): HandoffNote | null;
|
|
42
|
+
export interface HandoffListEntry {
|
|
43
|
+
stamp: string;
|
|
44
|
+
timestamp: string;
|
|
45
|
+
reason: string;
|
|
46
|
+
currentTask: string;
|
|
47
|
+
name?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* List handoff checkpoints, newest first. Includes the optional `name` so a
|
|
51
|
+
* caller can present a list-and-pick UI keyed on either stamp or name.
|
|
52
|
+
*/
|
|
53
|
+
export declare function listHandoffs(dataDir: string, limit?: number): HandoffListEntry[];
|
package/dist/handoff.js
CHANGED
|
@@ -1,157 +1,157 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
function handoffDir(dataDir) {
|
|
4
|
-
return join(dataDir, 'handoffs');
|
|
5
|
-
}
|
|
6
|
-
function stampFilename() {
|
|
7
|
-
// YYYY-MM-DD_HH-MM-SS — safe for filenames, chronologically sortable
|
|
8
|
-
return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').split('-').slice(0, 6).join('-');
|
|
9
|
-
}
|
|
10
|
-
function handoffJsonPath(dataDir, stamp) {
|
|
11
|
-
return join(handoffDir(dataDir), `${stamp}.json`);
|
|
12
|
-
}
|
|
13
|
-
function handoffMdPath(dataDir, stamp) {
|
|
14
|
-
return join(handoffDir(dataDir), `${stamp}.md`);
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* Write a handoff note. Persists BOTH JSON (machine-readable) and markdown (human-readable).
|
|
18
|
-
*/
|
|
19
|
-
export function writeHandoff(dataDir, note) {
|
|
20
|
-
const dir = handoffDir(dataDir);
|
|
21
|
-
if (!existsSync(dir))
|
|
22
|
-
mkdirSync(dir, { recursive: true });
|
|
23
|
-
const timestamp = new Date().toISOString();
|
|
24
|
-
const full = { ...note, timestamp };
|
|
25
|
-
const stamp = stampFilename();
|
|
26
|
-
// Atomic write for the JSON+MD pair. Two non-atomic writeFileSync
|
|
27
|
-
// calls in a row could leave a JSON file with no markdown sibling
|
|
28
|
-
// (or vice versa) on crash, breaking the pairing readHandoff
|
|
29
|
-
// relies on. Stage both as .tmp first, then rename both -- minimizes
|
|
30
|
-
// the crash window to the gap between two consecutive renameSync
|
|
31
|
-
// calls (sub-millisecond). True cross-file atomicity isn't
|
|
32
|
-
// expressible in POSIX; this is the best practical approximation.
|
|
33
|
-
const jsonPath = handoffJsonPath(dataDir, stamp);
|
|
34
|
-
const mdPath = handoffMdPath(dataDir, stamp);
|
|
35
|
-
writeFileSync(`${jsonPath}.tmp`, JSON.stringify(full, null, 2), 'utf-8');
|
|
36
|
-
writeFileSync(`${mdPath}.tmp`, formatHandoffMarkdown(full), 'utf-8');
|
|
37
|
-
renameSync(`${jsonPath}.tmp`, jsonPath);
|
|
38
|
-
renameSync(`${mdPath}.tmp`, mdPath);
|
|
39
|
-
return full;
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Read the most recent handoff, or a specific one by stamp or name.
|
|
43
|
-
*
|
|
44
|
-
* Identifier resolution order:
|
|
45
|
-
* 1. No identifier → latest timestamped handoff
|
|
46
|
-
* 2. Identifier matches stamp regex → load by stamp
|
|
47
|
-
* 3. Otherwise → scan handoff JSONs for `name` field match (newest match wins)
|
|
48
|
-
*/
|
|
49
|
-
// Timestamped handoff filenames look like "2026-04-22_14-32-05-123Z" (what
|
|
50
|
-
// stampFilename() produces). The rolling `session-checkpoint.json` written
|
|
51
|
-
// by engram_stop_hook.sh does NOT match this shape, so it won't shadow real
|
|
52
|
-
// handoffs when readHandoff() picks the latest.
|
|
53
|
-
const STAMP_RE = /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/;
|
|
54
|
-
export function readHandoff(dataDir, identifier) {
|
|
55
|
-
const dir = handoffDir(dataDir);
|
|
56
|
-
if (!existsSync(dir))
|
|
57
|
-
return null;
|
|
58
|
-
if (!identifier) {
|
|
59
|
-
const allJson = readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
60
|
-
const timestamped = allJson.filter(f => STAMP_RE.test(f)).sort().reverse();
|
|
61
|
-
const pick = timestamped[0] ?? allJson.sort().reverse()[0];
|
|
62
|
-
if (!pick)
|
|
63
|
-
return null;
|
|
64
|
-
return loadHandoffFile(handoffJsonPath(dataDir, pick.replace(/\.json$/, '')));
|
|
65
|
-
}
|
|
66
|
-
if (STAMP_RE.test(identifier)) {
|
|
67
|
-
return loadHandoffFile(handoffJsonPath(dataDir, identifier));
|
|
68
|
-
}
|
|
69
|
-
// Name lookup — scan newest first, return first hit.
|
|
70
|
-
const stamps = readdirSync(dir)
|
|
71
|
-
.filter(f => f.endsWith('.json'))
|
|
72
|
-
.map(f => f.replace(/\.json$/, ''))
|
|
73
|
-
.sort()
|
|
74
|
-
.reverse();
|
|
75
|
-
for (const stamp of stamps) {
|
|
76
|
-
const note = loadHandoffFile(handoffJsonPath(dataDir, stamp));
|
|
77
|
-
if (note?.name === identifier)
|
|
78
|
-
return note;
|
|
79
|
-
}
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
function loadHandoffFile(path) {
|
|
83
|
-
if (!existsSync(path))
|
|
84
|
-
return null;
|
|
85
|
-
try {
|
|
86
|
-
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* List handoff checkpoints, newest first. Includes the optional `name` so a
|
|
94
|
-
* caller can present a list-and-pick UI keyed on either stamp or name.
|
|
95
|
-
*/
|
|
96
|
-
export function listHandoffs(dataDir, limit = 10) {
|
|
97
|
-
const dir = handoffDir(dataDir);
|
|
98
|
-
if (!existsSync(dir))
|
|
99
|
-
return [];
|
|
100
|
-
const stamps = readdirSync(dir)
|
|
101
|
-
.filter(f => f.endsWith('.json'))
|
|
102
|
-
.map(f => f.replace(/\.json$/, ''))
|
|
103
|
-
.sort()
|
|
104
|
-
.reverse()
|
|
105
|
-
.slice(0, limit);
|
|
106
|
-
const results = [];
|
|
107
|
-
for (const stamp of stamps) {
|
|
108
|
-
try {
|
|
109
|
-
const note = JSON.parse(readFileSync(handoffJsonPath(dataDir, stamp), 'utf-8'));
|
|
110
|
-
results.push({
|
|
111
|
-
stamp,
|
|
112
|
-
timestamp: note.timestamp,
|
|
113
|
-
reason: note.reason,
|
|
114
|
-
currentTask: note.currentTask,
|
|
115
|
-
...(note.name ? { name: note.name } : {}),
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
// Skip malformed
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return results;
|
|
123
|
-
}
|
|
124
|
-
function formatHandoffMarkdown(note) {
|
|
125
|
-
const lines = [
|
|
126
|
-
`# Handoff — ${note.name ?? note.timestamp}`,
|
|
127
|
-
'',
|
|
128
|
-
note.name ? `**Name:** ${note.name}` : '',
|
|
129
|
-
`**Reason:** ${note.reason}`,
|
|
130
|
-
`**Timestamp:** ${note.timestamp}`,
|
|
131
|
-
note.sessionId ? `**Session:** ${note.sessionId}` : '',
|
|
132
|
-
'',
|
|
133
|
-
'## Current Task',
|
|
134
|
-
note.currentTask || '_unspecified_',
|
|
135
|
-
'',
|
|
136
|
-
];
|
|
137
|
-
if (note.completed.length) {
|
|
138
|
-
lines.push('## Completed', ...note.completed.map(c => `- ${c}`), '');
|
|
139
|
-
}
|
|
140
|
-
if (note.nextSteps.length) {
|
|
141
|
-
lines.push('## Next Steps', ...note.nextSteps.map(s => `- ${s}`), '');
|
|
142
|
-
}
|
|
143
|
-
if (note.openQuestions.length) {
|
|
144
|
-
lines.push('## Open Questions', ...note.openQuestions.map(q => `- ${q}`), '');
|
|
145
|
-
}
|
|
146
|
-
if (note.fileRefs.length) {
|
|
147
|
-
lines.push('## File Refs', ...note.fileRefs.map(f => `- ${f}`), '');
|
|
148
|
-
}
|
|
149
|
-
if (note.decisions.length) {
|
|
150
|
-
lines.push('## Decisions', ...note.decisions.map(d => `- ${d}`), '');
|
|
151
|
-
}
|
|
152
|
-
if (note.notes.trim()) {
|
|
153
|
-
lines.push('## Notes', note.notes.trim(), '');
|
|
154
|
-
}
|
|
155
|
-
return lines.filter((l, i, a) => !(l === '' && a[i - 1] === '')).join('\n');
|
|
156
|
-
}
|
|
1
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
function handoffDir(dataDir) {
|
|
4
|
+
return join(dataDir, 'handoffs');
|
|
5
|
+
}
|
|
6
|
+
function stampFilename() {
|
|
7
|
+
// YYYY-MM-DD_HH-MM-SS — safe for filenames, chronologically sortable
|
|
8
|
+
return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').split('-').slice(0, 6).join('-');
|
|
9
|
+
}
|
|
10
|
+
function handoffJsonPath(dataDir, stamp) {
|
|
11
|
+
return join(handoffDir(dataDir), `${stamp}.json`);
|
|
12
|
+
}
|
|
13
|
+
function handoffMdPath(dataDir, stamp) {
|
|
14
|
+
return join(handoffDir(dataDir), `${stamp}.md`);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Write a handoff note. Persists BOTH JSON (machine-readable) and markdown (human-readable).
|
|
18
|
+
*/
|
|
19
|
+
export function writeHandoff(dataDir, note) {
|
|
20
|
+
const dir = handoffDir(dataDir);
|
|
21
|
+
if (!existsSync(dir))
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
const timestamp = new Date().toISOString();
|
|
24
|
+
const full = { ...note, timestamp };
|
|
25
|
+
const stamp = stampFilename();
|
|
26
|
+
// Atomic write for the JSON+MD pair. Two non-atomic writeFileSync
|
|
27
|
+
// calls in a row could leave a JSON file with no markdown sibling
|
|
28
|
+
// (or vice versa) on crash, breaking the pairing readHandoff
|
|
29
|
+
// relies on. Stage both as .tmp first, then rename both -- minimizes
|
|
30
|
+
// the crash window to the gap between two consecutive renameSync
|
|
31
|
+
// calls (sub-millisecond). True cross-file atomicity isn't
|
|
32
|
+
// expressible in POSIX; this is the best practical approximation.
|
|
33
|
+
const jsonPath = handoffJsonPath(dataDir, stamp);
|
|
34
|
+
const mdPath = handoffMdPath(dataDir, stamp);
|
|
35
|
+
writeFileSync(`${jsonPath}.tmp`, JSON.stringify(full, null, 2), 'utf-8');
|
|
36
|
+
writeFileSync(`${mdPath}.tmp`, formatHandoffMarkdown(full), 'utf-8');
|
|
37
|
+
renameSync(`${jsonPath}.tmp`, jsonPath);
|
|
38
|
+
renameSync(`${mdPath}.tmp`, mdPath);
|
|
39
|
+
return full;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read the most recent handoff, or a specific one by stamp or name.
|
|
43
|
+
*
|
|
44
|
+
* Identifier resolution order:
|
|
45
|
+
* 1. No identifier → latest timestamped handoff
|
|
46
|
+
* 2. Identifier matches stamp regex → load by stamp
|
|
47
|
+
* 3. Otherwise → scan handoff JSONs for `name` field match (newest match wins)
|
|
48
|
+
*/
|
|
49
|
+
// Timestamped handoff filenames look like "2026-04-22_14-32-05-123Z" (what
|
|
50
|
+
// stampFilename() produces). The rolling `session-checkpoint.json` written
|
|
51
|
+
// by engram_stop_hook.sh does NOT match this shape, so it won't shadow real
|
|
52
|
+
// handoffs when readHandoff() picks the latest.
|
|
53
|
+
const STAMP_RE = /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/;
|
|
54
|
+
export function readHandoff(dataDir, identifier) {
|
|
55
|
+
const dir = handoffDir(dataDir);
|
|
56
|
+
if (!existsSync(dir))
|
|
57
|
+
return null;
|
|
58
|
+
if (!identifier) {
|
|
59
|
+
const allJson = readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
60
|
+
const timestamped = allJson.filter(f => STAMP_RE.test(f)).sort().reverse();
|
|
61
|
+
const pick = timestamped[0] ?? allJson.sort().reverse()[0];
|
|
62
|
+
if (!pick)
|
|
63
|
+
return null;
|
|
64
|
+
return loadHandoffFile(handoffJsonPath(dataDir, pick.replace(/\.json$/, '')));
|
|
65
|
+
}
|
|
66
|
+
if (STAMP_RE.test(identifier)) {
|
|
67
|
+
return loadHandoffFile(handoffJsonPath(dataDir, identifier));
|
|
68
|
+
}
|
|
69
|
+
// Name lookup — scan newest first, return first hit.
|
|
70
|
+
const stamps = readdirSync(dir)
|
|
71
|
+
.filter(f => f.endsWith('.json'))
|
|
72
|
+
.map(f => f.replace(/\.json$/, ''))
|
|
73
|
+
.sort()
|
|
74
|
+
.reverse();
|
|
75
|
+
for (const stamp of stamps) {
|
|
76
|
+
const note = loadHandoffFile(handoffJsonPath(dataDir, stamp));
|
|
77
|
+
if (note?.name === identifier)
|
|
78
|
+
return note;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
function loadHandoffFile(path) {
|
|
83
|
+
if (!existsSync(path))
|
|
84
|
+
return null;
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* List handoff checkpoints, newest first. Includes the optional `name` so a
|
|
94
|
+
* caller can present a list-and-pick UI keyed on either stamp or name.
|
|
95
|
+
*/
|
|
96
|
+
export function listHandoffs(dataDir, limit = 10) {
|
|
97
|
+
const dir = handoffDir(dataDir);
|
|
98
|
+
if (!existsSync(dir))
|
|
99
|
+
return [];
|
|
100
|
+
const stamps = readdirSync(dir)
|
|
101
|
+
.filter(f => f.endsWith('.json'))
|
|
102
|
+
.map(f => f.replace(/\.json$/, ''))
|
|
103
|
+
.sort()
|
|
104
|
+
.reverse()
|
|
105
|
+
.slice(0, limit);
|
|
106
|
+
const results = [];
|
|
107
|
+
for (const stamp of stamps) {
|
|
108
|
+
try {
|
|
109
|
+
const note = JSON.parse(readFileSync(handoffJsonPath(dataDir, stamp), 'utf-8'));
|
|
110
|
+
results.push({
|
|
111
|
+
stamp,
|
|
112
|
+
timestamp: note.timestamp,
|
|
113
|
+
reason: note.reason,
|
|
114
|
+
currentTask: note.currentTask,
|
|
115
|
+
...(note.name ? { name: note.name } : {}),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Skip malformed
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return results;
|
|
123
|
+
}
|
|
124
|
+
function formatHandoffMarkdown(note) {
|
|
125
|
+
const lines = [
|
|
126
|
+
`# Handoff — ${note.name ?? note.timestamp}`,
|
|
127
|
+
'',
|
|
128
|
+
note.name ? `**Name:** ${note.name}` : '',
|
|
129
|
+
`**Reason:** ${note.reason}`,
|
|
130
|
+
`**Timestamp:** ${note.timestamp}`,
|
|
131
|
+
note.sessionId ? `**Session:** ${note.sessionId}` : '',
|
|
132
|
+
'',
|
|
133
|
+
'## Current Task',
|
|
134
|
+
note.currentTask || '_unspecified_',
|
|
135
|
+
'',
|
|
136
|
+
];
|
|
137
|
+
if (note.completed.length) {
|
|
138
|
+
lines.push('## Completed', ...note.completed.map(c => `- ${c}`), '');
|
|
139
|
+
}
|
|
140
|
+
if (note.nextSteps.length) {
|
|
141
|
+
lines.push('## Next Steps', ...note.nextSteps.map(s => `- ${s}`), '');
|
|
142
|
+
}
|
|
143
|
+
if (note.openQuestions.length) {
|
|
144
|
+
lines.push('## Open Questions', ...note.openQuestions.map(q => `- ${q}`), '');
|
|
145
|
+
}
|
|
146
|
+
if (note.fileRefs.length) {
|
|
147
|
+
lines.push('## File Refs', ...note.fileRefs.map(f => `- ${f}`), '');
|
|
148
|
+
}
|
|
149
|
+
if (note.decisions.length) {
|
|
150
|
+
lines.push('## Decisions', ...note.decisions.map(d => `- ${d}`), '');
|
|
151
|
+
}
|
|
152
|
+
if (note.notes.trim()) {
|
|
153
|
+
lines.push('## Notes', note.notes.trim(), '');
|
|
154
|
+
}
|
|
155
|
+
return lines.filter((l, i, a) => !(l === '' && a[i - 1] === '')).join('\n');
|
|
156
|
+
}
|
|
157
157
|
//# sourceMappingURL=handoff.js.map
|