moflo 4.10.20 → 4.10.21
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/.claude/guidance/shipped/moflo-cli-reference.md +1 -0
- package/.claude/guidance/shipped/moflo-core-guidance.md +1 -0
- package/.claude/guidance/shipped/moflo-skills-reference.md +108 -0
- package/.claude/guidance/shipped/moflo-yaml-reference.md +13 -0
- package/.claude/skills/commune/SKILL.md +140 -0
- package/.claude/skills/divine/SKILL.md +130 -0
- package/.claude/skills/meditate/SKILL.md +122 -0
- package/README.md +39 -3
- package/bin/index-all.mjs +2 -1
- package/bin/index-reference.mjs +221 -0
- package/bin/lib/file-sync.mjs +50 -1
- package/bin/lib/hook-io.mjs +63 -0
- package/bin/lib/index-fingerprint.mjs +0 -0
- package/bin/lib/internal-skills.mjs +16 -0
- package/bin/lib/meditate.mjs +497 -0
- package/bin/lib/pii-scrub.mjs +119 -0
- package/bin/lib/reference-docs.mjs +218 -0
- package/bin/lib/session-continuity.mjs +372 -0
- package/bin/lib/shipped-scripts.json +36 -0
- package/bin/lib/shipped-scripts.mjs +33 -0
- package/bin/lib/yaml-upgrader.mjs +62 -0
- package/bin/meditate-capture.mjs +123 -0
- package/bin/meditate-distill.mjs +121 -0
- package/bin/session-continuity.mjs +206 -0
- package/bin/session-start-launcher.mjs +140 -60
- package/dist/src/cli/config/moflo-config.js +18 -0
- package/dist/src/cli/init/executor.js +11 -17
- package/dist/src/cli/init/moflo-init.js +21 -19
- package/dist/src/cli/init/moflo-yaml-template.js +21 -0
- package/dist/src/cli/init/settings-generator.js +23 -1
- package/dist/src/cli/init/shipped-scripts.js +39 -0
- package/dist/src/cli/memory/bridge-core.js +20 -0
- package/dist/src/cli/memory/bridge-entries.js +8 -2
- package/dist/src/cli/memory/memory-bridge.js +6 -2
- package/dist/src/cli/memory/memory-initializer.js +6 -2
- package/dist/src/cli/services/hook-block-hash.js +9 -1
- package/dist/src/cli/services/hook-wiring.js +38 -0
- package/dist/src/cli/transfer/anonymization/index.js +146 -40
- package/dist/src/cli/transfer/deploy-seraphine.js +1 -1
- package/dist/src/cli/transfer/export.js +2 -2
- package/dist/src/cli/transfer/store/publish.js +1 -1
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
- package/scripts/post-install-bootstrap.mjs +22 -42
- package/dist/src/cli/hooks/llm/index.js +0 -11
- package/dist/src/cli/hooks/llm/llm-hooks.js +0 -382
|
@@ -22,6 +22,31 @@ import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
|
22
22
|
* also add the same block here so existing users get it on their next session.
|
|
23
23
|
*/
|
|
24
24
|
export const REQUIRED_SECTIONS = [
|
|
25
|
+
{
|
|
26
|
+
key: 'session_continuity',
|
|
27
|
+
block: `# Passive session-continuity — pick up where you left off across sessions.
|
|
28
|
+
# capture: silently record a compact "where you left off" digest at turn-end.
|
|
29
|
+
# inject: surface the single most-relevant recent digest at session-start
|
|
30
|
+
# (relevance-gated by branch / changed files / recency, so an unrelated
|
|
31
|
+
# session shows nothing). Add "<private>" to a message to skip capturing
|
|
32
|
+
# that session. Set either to false to opt out.
|
|
33
|
+
session_continuity:
|
|
34
|
+
capture: true
|
|
35
|
+
inject: true
|
|
36
|
+
max_age_hours: 72 # ignore digests older than this when injecting
|
|
37
|
+
`,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: 'auto_meditate',
|
|
41
|
+
block: `# Auto-meditate (#1198) — the automatic counterpart to /meditate. When enabled,
|
|
42
|
+
# moflo recognizes durable lessons in the LIVE session (a tiny answer-first note
|
|
43
|
+
# on course-corrections / errors / decisions) and distills them into long-term
|
|
44
|
+
# memory at the next session-start via a cheap headless Haiku pass — deduped.
|
|
45
|
+
# Ships ON; set false to opt out.
|
|
46
|
+
auto_meditate:
|
|
47
|
+
enabled: true
|
|
48
|
+
`,
|
|
49
|
+
},
|
|
25
50
|
{
|
|
26
51
|
key: 'sandbox',
|
|
27
52
|
block: `# Spell step sandboxing (OS-level process isolation for bash steps)
|
|
@@ -37,6 +62,38 @@ sandbox:
|
|
|
37
62
|
},
|
|
38
63
|
];
|
|
39
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Registry of top-level config sections RENAMED across a moflo version. On
|
|
67
|
+
* upgrade, an existing `<from>:` block in the user's moflo.yaml is renamed in
|
|
68
|
+
* place to `<to>:` — preserving the user's values (e.g. an opt-out) — so they
|
|
69
|
+
* keep their setting under the new key instead of a fresh default block being
|
|
70
|
+
* appended while the stale old one lingers.
|
|
71
|
+
*/
|
|
72
|
+
export const RENAMED_SECTIONS = [
|
|
73
|
+
// Auto-meditate rebrand — feature #1198 was renamed from "auto-reflect".
|
|
74
|
+
{ from: 'auto_reflect', to: 'auto_meditate' },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Rename any registered top-level sections present under their OLD key to the
|
|
79
|
+
* NEW key, in place. Only the top-level key line is rewritten; the block body
|
|
80
|
+
* (the user's values) is preserved untouched. No-op when the old key is absent
|
|
81
|
+
* or the new key already exists. Returns the list of `from→to` renames applied.
|
|
82
|
+
*/
|
|
83
|
+
export function renameYamlSections(yamlPath, registry = RENAMED_SECTIONS) {
|
|
84
|
+
if (!existsSync(yamlPath)) return [];
|
|
85
|
+
let text = readFileSync(yamlPath, 'utf-8');
|
|
86
|
+
const applied = [];
|
|
87
|
+
for (const { from, to } of registry) {
|
|
88
|
+
if (hasTopLevelSection(text, from) && !hasTopLevelSection(text, to)) {
|
|
89
|
+
text = text.replace(new RegExp(`^${from}(\\s*:)`, 'm'), `${to}$1`);
|
|
90
|
+
applied.push(`${from}→${to}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (applied.length > 0) writeFileSync(yamlPath, text, 'utf-8');
|
|
94
|
+
return applied;
|
|
95
|
+
}
|
|
96
|
+
|
|
40
97
|
/**
|
|
41
98
|
* Return true if the YAML text already defines the given top-level key.
|
|
42
99
|
* Matches `^<key>:` at column 0 on any line, which is how YAML roots look.
|
|
@@ -66,6 +123,11 @@ export function missingSections(yamlText, registry = REQUIRED_SECTIONS) {
|
|
|
66
123
|
export function ensureYamlSections(yamlPath, registry = REQUIRED_SECTIONS) {
|
|
67
124
|
if (!existsSync(yamlPath)) return [];
|
|
68
125
|
|
|
126
|
+
// Migrate any renamed sections in place BEFORE computing what's missing, so a
|
|
127
|
+
// renamed key (e.g. auto_reflect → auto_meditate) is recognised as present and
|
|
128
|
+
// not re-appended as a fresh default block alongside the stale old one.
|
|
129
|
+
renameYamlSections(yamlPath);
|
|
130
|
+
|
|
69
131
|
const original = readFileSync(yamlPath, 'utf-8');
|
|
70
132
|
const toAppend = registry.filter((entry) => !hasTopLevelSection(original, entry.key));
|
|
71
133
|
if (toAppend.length === 0) return [];
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Auto-meditate Stage 1 — CAPTURE (#1198). One script, two hook entry points:
|
|
4
|
+
*
|
|
5
|
+
* detect (UserPromptSubmit) — mechanically score the user's prompt + recent
|
|
6
|
+
* transcript tail for a strong signal (course-correction / error→fix /
|
|
7
|
+
* decision). On a hit, and within rate limits, print an answer-first
|
|
8
|
+
* directive asking the LIVE model — which has full context at the
|
|
9
|
+
* moment of insight — to append ONE <meditate-capture> line IF a durable
|
|
10
|
+
* lesson emerged. Stdout from a UserPromptSubmit hook is added to the
|
|
11
|
+
* model's context (same mechanism as .claude/helpers/prompt-hook.mjs).
|
|
12
|
+
*
|
|
13
|
+
* scrape (Stop) — read the transcript tail, extract <meditate-capture> tags
|
|
14
|
+
* from the last ASSISTANT message(s), and append the raw one-liners to
|
|
15
|
+
* the JSON ledger (.moflo/meditate-ledger.json). No moflo.db write, no
|
|
16
|
+
* dedup yet — the bounded Haiku distill (bin/meditate-distill.mjs) does
|
|
17
|
+
* that later, writer-safe, via memory_store.
|
|
18
|
+
*
|
|
19
|
+
* Both modes early-return cheaply when auto-meditate is OFF or CLAUDE_CODE_HEADLESS
|
|
20
|
+
* is set — the distill's own headless session must never re-enter capture (#860).
|
|
21
|
+
* A hook must NEVER throw: every path is wrapped and degrades to a silent exit 0.
|
|
22
|
+
*
|
|
23
|
+
* Invoked by:
|
|
24
|
+
* node .claude/scripts/meditate-capture.mjs meditate-detect (UserPromptSubmit)
|
|
25
|
+
* node .claude/scripts/meditate-capture.mjs meditate-scrape (Stop)
|
|
26
|
+
*
|
|
27
|
+
* Cross-platform (Rule #1): Node fs/path primitives only; no shell.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { findProjectRoot } from './lib/moflo-paths.mjs';
|
|
31
|
+
import { readHookStdin, readFileTail } from './lib/hook-io.mjs';
|
|
32
|
+
import {
|
|
33
|
+
TRANSCRIPT_TAIL_BYTES,
|
|
34
|
+
readMeditateConfig,
|
|
35
|
+
isHeadless,
|
|
36
|
+
detectSignal,
|
|
37
|
+
recentTranscriptTurn,
|
|
38
|
+
buildCaptureDirective,
|
|
39
|
+
injectionAllowed,
|
|
40
|
+
readMeditateState,
|
|
41
|
+
recordInjection,
|
|
42
|
+
extractCapturesFromTranscript,
|
|
43
|
+
appendLedgerEntries,
|
|
44
|
+
} from './lib/meditate.mjs';
|
|
45
|
+
|
|
46
|
+
// Scrape needs to reliably catch the tag at the END of the assistant's reply,
|
|
47
|
+
// which can be a single large JSONL line — read a generous tail. Detect only
|
|
48
|
+
// needs recent error/decision markers, so the smaller shared window suffices.
|
|
49
|
+
const SCRAPE_TAIL_BYTES = 262_144;
|
|
50
|
+
|
|
51
|
+
function warn(msg) {
|
|
52
|
+
try { process.stderr.write(`moflo: meditate-capture ${msg}\n`); } catch { /* never throw from a hook */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Shared preamble: resolve root + config, applying the OFF / headless guards.
|
|
56
|
+
* Returns null when the caller should early-exit. */
|
|
57
|
+
function gate() {
|
|
58
|
+
if (isHeadless(process.env)) return null; // #860 — never run inside the distill's own session
|
|
59
|
+
const projectRoot = findProjectRoot();
|
|
60
|
+
if (!readMeditateConfig(projectRoot).enabled) return null; // off (opt-out)
|
|
61
|
+
return { projectRoot };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function detect() {
|
|
65
|
+
const ctx = gate();
|
|
66
|
+
if (!ctx) return;
|
|
67
|
+
const { projectRoot } = ctx;
|
|
68
|
+
|
|
69
|
+
const input = await readHookStdin();
|
|
70
|
+
const prompt = (input.prompt || input.user_prompt || '').trim();
|
|
71
|
+
// No real user prompt (system reminder / task-notification re-invocation) →
|
|
72
|
+
// there's no "moment" to attach a directive to. Skip — this is the dominant
|
|
73
|
+
// false-fire source the live dogfood surfaced.
|
|
74
|
+
if (!prompt) return;
|
|
75
|
+
|
|
76
|
+
// Cheapest path first: corrections + in-prompt decisions need only the prompt.
|
|
77
|
+
// Only when the prompt alone yields nothing do we read the transcript and scope
|
|
78
|
+
// to the MOST RECENT turn (post-last-user-message) — scanning the full tail
|
|
79
|
+
// re-fires on stale error/decision markers from earlier in the session.
|
|
80
|
+
let signal = detectSignal(prompt, '');
|
|
81
|
+
if (!signal.hit) {
|
|
82
|
+
const tail = readFileTail(input.transcript_path || input.transcriptPath, TRANSCRIPT_TAIL_BYTES);
|
|
83
|
+
if (tail) signal = detectSignal(prompt, recentTranscriptTurn(tail));
|
|
84
|
+
}
|
|
85
|
+
if (!signal.hit) return;
|
|
86
|
+
|
|
87
|
+
const sessionId = input.session_id || input.sessionId || null;
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
const state = readMeditateState(projectRoot);
|
|
90
|
+
if (!injectionAllowed(state, sessionId, now)) return; // rate-limited
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
process.stdout.write(buildCaptureDirective(signal.kind) + '\n');
|
|
94
|
+
} catch {
|
|
95
|
+
return; // broken stdout — don't record an injection that didn't land
|
|
96
|
+
}
|
|
97
|
+
recordInjection(projectRoot, sessionId, now, state);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function scrape() {
|
|
101
|
+
const ctx = gate();
|
|
102
|
+
if (!ctx) return;
|
|
103
|
+
const { projectRoot } = ctx;
|
|
104
|
+
|
|
105
|
+
const input = await readHookStdin();
|
|
106
|
+
const tail = readFileTail(input.transcript_path || input.transcriptPath, SCRAPE_TAIL_BYTES);
|
|
107
|
+
if (!tail) return;
|
|
108
|
+
|
|
109
|
+
const lessons = extractCapturesFromTranscript(tail);
|
|
110
|
+
if (lessons.length === 0) return;
|
|
111
|
+
|
|
112
|
+
const sessionId = input.session_id || input.sessionId || null;
|
|
113
|
+
appendLedgerEntries(projectRoot, lessons.map((lesson) => ({ lesson, kind: 'captured', sessionId })));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const sub = process.argv[2];
|
|
117
|
+
if (sub === 'meditate-detect') {
|
|
118
|
+
detect().catch((err) => warn(`detect failed (${err && err.message ? err.message : String(err)})`));
|
|
119
|
+
} else if (sub === 'meditate-scrape') {
|
|
120
|
+
scrape().catch((err) => warn(`scrape failed (${err && err.message ? err.message : String(err)})`));
|
|
121
|
+
} else {
|
|
122
|
+
warn(`unknown subcommand "${sub || ''}" (expected: meditate-detect | meditate-scrape)`);
|
|
123
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Auto-meditate Stage 2 — DISTILL (#1198). Fired DETACHED by the session-start
|
|
4
|
+
* launcher (never inline — the launcher's contract is spawn-and-exit). Runs ONE
|
|
5
|
+
* bounded headless Haiku `claude --print` that executes #1187's /meditate
|
|
6
|
+
* distillation over the ledger one-liners (NOT the transcript): dedup-search
|
|
7
|
+
* `learnings` (≥0.80 → update same key), store via memory_store. Because the
|
|
8
|
+
* headless session calls memory_store itself, writes route through the daemon
|
|
9
|
+
* single-writer chokepoint (#981) — writer-safe, no raw cross-process db write.
|
|
10
|
+
*
|
|
11
|
+
* Haiku is a FORMATTER, not a judge: the durability gate already passed upstream
|
|
12
|
+
* in the live model when the lesson was captured, so this unattended pass cannot
|
|
13
|
+
* pollute `learnings` with junk it invented.
|
|
14
|
+
*
|
|
15
|
+
* GUARDS:
|
|
16
|
+
* - off (auto_meditate.enabled: false) — exit immediately. Default is ON.
|
|
17
|
+
* - CLAUDE_CODE_HEADLESS — exit immediately (defensive; the launcher already
|
|
18
|
+
* won't fire us in headless, but self-guarding prevents any infinite spawn).
|
|
19
|
+
* - empty ledger — exit without spawning anything.
|
|
20
|
+
*
|
|
21
|
+
* Only entries in the snapshot we hand to Haiku are marked distilled on success,
|
|
22
|
+
* so captures that arrive DURING the run survive for the next pass.
|
|
23
|
+
*
|
|
24
|
+
* Test seam: MOFLO_MEDITATE_DISTILL_NODE_STUB — when set to a path, the model
|
|
25
|
+
* call is `node <stub> --print <prompt>` instead of `claude --print <prompt>`,
|
|
26
|
+
* so the spawn is exercisable cross-platform without a real Claude CLI.
|
|
27
|
+
*
|
|
28
|
+
* Invoked by: node .claude/scripts/meditate-distill.mjs
|
|
29
|
+
*
|
|
30
|
+
* Cross-platform (Rule #1): Node child_process (arg arrays, no shell) + fs/path.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { spawn } from 'child_process';
|
|
34
|
+
import { appendFileSync, mkdirSync } from 'fs';
|
|
35
|
+
import { resolve, dirname } from 'path';
|
|
36
|
+
import { findProjectRoot } from './lib/moflo-paths.mjs';
|
|
37
|
+
import {
|
|
38
|
+
HAIKU_MODEL_ID,
|
|
39
|
+
readMeditateConfig,
|
|
40
|
+
isHeadless,
|
|
41
|
+
readLedger,
|
|
42
|
+
pendingEntries,
|
|
43
|
+
buildDistillPrompt,
|
|
44
|
+
markLedgerDistilled,
|
|
45
|
+
} from './lib/meditate.mjs';
|
|
46
|
+
|
|
47
|
+
/** Cap candidates per pass — keeps the Haiku prompt small (cheap, fast). Extra
|
|
48
|
+
* pending entries roll into the next session's pass. */
|
|
49
|
+
const DISTILL_BATCH_MAX = 25;
|
|
50
|
+
/** Hard ceiling on the headless run; killed past this. */
|
|
51
|
+
const DISTILL_TIMEOUT_MS = 120_000;
|
|
52
|
+
|
|
53
|
+
function log(projectRoot, line) {
|
|
54
|
+
try {
|
|
55
|
+
const p = resolve(projectRoot, '.moflo', 'meditate-distill.log');
|
|
56
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
57
|
+
appendFileSync(p, `[${new Date().toISOString()}] ${line}\n`, 'utf-8');
|
|
58
|
+
} catch { /* logging is best-effort — never throw */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Run the bounded headless distillation. Resolves { success, output }. */
|
|
62
|
+
function runHeadless(projectRoot, prompt) {
|
|
63
|
+
return new Promise((res) => {
|
|
64
|
+
const stub = process.env.MOFLO_MEDITATE_DISTILL_NODE_STUB;
|
|
65
|
+
const cmd = stub ? process.execPath : 'claude';
|
|
66
|
+
const args = stub ? [stub, '--print', prompt] : ['--print', prompt];
|
|
67
|
+
|
|
68
|
+
const env = {
|
|
69
|
+
...process.env,
|
|
70
|
+
CLAUDE_CODE_HEADLESS: 'true', // mark the child so its own hooks no-op (#860)
|
|
71
|
+
ANTHROPIC_MODEL: HAIKU_MODEL_ID, // cheap formatter model
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let child;
|
|
75
|
+
try {
|
|
76
|
+
child = spawn(cmd, args, { cwd: projectRoot, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
77
|
+
} catch (err) {
|
|
78
|
+
res({ success: false, output: '', error: err && err.message ? err.message : String(err) });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let out = '';
|
|
83
|
+
let done = false;
|
|
84
|
+
const finish = (r) => { if (done) return; done = true; clearTimeout(timer); res(r); };
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
87
|
+
finish({ success: false, output: out, error: `timed out after ${DISTILL_TIMEOUT_MS}ms` });
|
|
88
|
+
}, DISTILL_TIMEOUT_MS);
|
|
89
|
+
|
|
90
|
+
child.stdout?.on('data', (d) => { out += d.toString(); });
|
|
91
|
+
child.stderr?.on('data', (d) => { out += d.toString(); });
|
|
92
|
+
child.on('error', (err) => finish({ success: false, output: out, error: err && err.message ? err.message : String(err) }));
|
|
93
|
+
child.on('close', (code) => finish({ success: code === 0, output: out }));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function main() {
|
|
98
|
+
if (isHeadless(process.env)) return; // #860 self-guard
|
|
99
|
+
const projectRoot = findProjectRoot();
|
|
100
|
+
if (!readMeditateConfig(projectRoot).enabled) return; // off (opt-out)
|
|
101
|
+
|
|
102
|
+
const pending = pendingEntries(readLedger(projectRoot));
|
|
103
|
+
if (pending.length === 0) return; // nothing to do — no spawn
|
|
104
|
+
|
|
105
|
+
const batch = pending.slice(0, DISTILL_BATCH_MAX);
|
|
106
|
+
const prompt = buildDistillPrompt(batch);
|
|
107
|
+
|
|
108
|
+
const result = await runHeadless(projectRoot, prompt);
|
|
109
|
+
if (result.success) {
|
|
110
|
+
const marked = markLedgerDistilled(projectRoot, batch.map((e) => e.id));
|
|
111
|
+
const summary = String(result.output || '').trim().split('\n').filter(Boolean).pop() || '(no summary)';
|
|
112
|
+
log(projectRoot, `distilled ${batch.length} candidate(s), marked ${marked} — haiku: ${summary.slice(0, 200)}`);
|
|
113
|
+
} else {
|
|
114
|
+
// Leave the ledger untouched so the next session retries.
|
|
115
|
+
log(projectRoot, `distill failed (${result.error || 'non-zero exit'}) — ${batch.length} candidate(s) retained for retry`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main().catch((err) => {
|
|
120
|
+
try { process.stderr.write(`moflo: meditate-distill failed (${err && err.message ? err.message : String(err)})\n`); } catch { /* ignore */ }
|
|
121
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Passive session-continuity CAPTURE (#1185).
|
|
4
|
+
*
|
|
5
|
+
* Wired to Claude Code's Stop hook (fires at the end of each assistant turn).
|
|
6
|
+
* On each fire it assembles a compact "where you left off" digest from data
|
|
7
|
+
* moflo already has — git state, recent `learnings`, the session goal — scrubs
|
|
8
|
+
* any secrets, and writes ONE record per session into the `.moflo/continuity/`
|
|
9
|
+
* JSON store. The matching injection runs at session-start inside
|
|
10
|
+
* session-start-launcher.mjs.
|
|
11
|
+
*
|
|
12
|
+
* Why a JSON store and not moflo.db: capture fires while the daemon is live, and
|
|
13
|
+
* the moflo.db writer audit requires cross-process DB writers to route through
|
|
14
|
+
* the daemon chokepoint. A dedicated JSON store sidesteps that — these digests
|
|
15
|
+
* are operational state, not semantic memory. We still READ moflo.db read-only
|
|
16
|
+
* for recent learnings (safe under WAL; reads never clobber).
|
|
17
|
+
*
|
|
18
|
+
* Why Stop (per-turn) and not only a clean SessionEnd: capturing on every turn
|
|
19
|
+
* makes the digest crash-robust — the latest state survives even if the session
|
|
20
|
+
* never exits cleanly ("closed my laptop"). A throttle keeps the real work to at
|
|
21
|
+
* most once per THROTTLE_MS so per-turn cost stays negligible across consumers.
|
|
22
|
+
*
|
|
23
|
+
* Invoked by: node .claude/scripts/session-continuity.mjs capture
|
|
24
|
+
*
|
|
25
|
+
* Cross-platform (Rule #1): Node fs/path/child_process primitives only.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { existsSync, readFileSync, writeFileSync, statSync, openSync, readSync, closeSync, mkdirSync } from 'fs';
|
|
29
|
+
import { resolve, dirname } from 'path';
|
|
30
|
+
import { findProjectRoot } from './lib/moflo-paths.mjs';
|
|
31
|
+
import { openBackend } from './lib/get-backend.mjs';
|
|
32
|
+
import { scrubSecrets } from './lib/pii-scrub.mjs';
|
|
33
|
+
import { readHookStdin } from './lib/hook-io.mjs';
|
|
34
|
+
import {
|
|
35
|
+
readContinuityConfig,
|
|
36
|
+
readGitState,
|
|
37
|
+
hasPrivateOptOut,
|
|
38
|
+
assembleDigestContent,
|
|
39
|
+
buildDigestMetadata,
|
|
40
|
+
writeDigest,
|
|
41
|
+
rotateDigests,
|
|
42
|
+
} from './lib/session-continuity.mjs';
|
|
43
|
+
|
|
44
|
+
const THROTTLE_MS = 30_000; // skip the work if we captured <30s ago this session
|
|
45
|
+
const LEARNINGS_SAMPLE = 5; // how many recent learnings to fold in as "decisions"
|
|
46
|
+
|
|
47
|
+
function warn(msg) {
|
|
48
|
+
try { process.stderr.write(`moflo: session-continuity ${msg}\n`); } catch { /* never throw from a hook */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** First user message (the session goal) from a transcript JSONL head. */
|
|
52
|
+
function extractFirstUserGoal(headText) {
|
|
53
|
+
for (const line of headText.split('\n')) {
|
|
54
|
+
const t = line.trim();
|
|
55
|
+
if (!t) continue;
|
|
56
|
+
let obj;
|
|
57
|
+
try { obj = JSON.parse(t); } catch { continue; }
|
|
58
|
+
const role = obj?.message?.role ?? obj?.role;
|
|
59
|
+
if (role !== 'user') continue;
|
|
60
|
+
let content = obj?.message?.content ?? obj?.content;
|
|
61
|
+
if (Array.isArray(content)) {
|
|
62
|
+
content = content.map((b) => (typeof b === 'string' ? b : b?.text || '')).join(' ');
|
|
63
|
+
}
|
|
64
|
+
if (typeof content !== 'string') continue;
|
|
65
|
+
const firstLine = content.replace(/\s+/g, ' ').trim();
|
|
66
|
+
// Skip slash-command / hook-noise openers — they aren't a goal statement.
|
|
67
|
+
if (!firstLine || firstLine.startsWith('<')) continue;
|
|
68
|
+
return firstLine.slice(0, 200);
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Bounded transcript read: goal from the head, `<private>` opt-out scan over
|
|
74
|
+
* head+tail. Large transcripts are read at the edges only. */
|
|
75
|
+
function readTranscriptInfo(path) {
|
|
76
|
+
const info = { goal: null, optOut: false };
|
|
77
|
+
try {
|
|
78
|
+
if (!path || !existsSync(path)) return info;
|
|
79
|
+
const size = statSync(path).size;
|
|
80
|
+
let head;
|
|
81
|
+
let tail;
|
|
82
|
+
if (size <= 1_048_576) {
|
|
83
|
+
head = tail = readFileSync(path, 'utf-8');
|
|
84
|
+
} else {
|
|
85
|
+
const fd = openSync(path, 'r');
|
|
86
|
+
try {
|
|
87
|
+
const hb = Buffer.alloc(32_768);
|
|
88
|
+
readSync(fd, hb, 0, hb.length, 0);
|
|
89
|
+
head = hb.toString('utf-8');
|
|
90
|
+
const tlen = 131_072;
|
|
91
|
+
const tb = Buffer.alloc(tlen);
|
|
92
|
+
readSync(fd, tb, 0, tlen, Math.max(0, size - tlen));
|
|
93
|
+
tail = tb.toString('utf-8');
|
|
94
|
+
} finally {
|
|
95
|
+
closeSync(fd);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
info.optOut = hasPrivateOptOut(head) || hasPrivateOptOut(tail);
|
|
99
|
+
info.goal = extractFirstUserGoal(head);
|
|
100
|
+
} catch {
|
|
101
|
+
// Transcript is best-effort context; never block capture on it.
|
|
102
|
+
}
|
|
103
|
+
return info;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Throttle gate — true if we should skip capturing this turn. */
|
|
107
|
+
function shouldThrottle(statePath, sessionId, now) {
|
|
108
|
+
try {
|
|
109
|
+
if (!existsSync(statePath)) return false;
|
|
110
|
+
const s = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
111
|
+
return s.sessionId === sessionId && typeof s.lastCaptureMs === 'number' && now - s.lastCaptureMs < THROTTLE_MS;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeThrottleState(statePath, sessionId, now) {
|
|
118
|
+
try {
|
|
119
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
120
|
+
writeFileSync(statePath, JSON.stringify({ sessionId, lastCaptureMs: now }), 'utf-8');
|
|
121
|
+
} catch { /* non-fatal */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Recent learnings → short "decision" bullets. Read-only moflo.db access; safe
|
|
125
|
+
* under WAL and never blocks the daemon. Best-effort: any failure → []. */
|
|
126
|
+
async function readRecentDecisions(projectRoot, limit) {
|
|
127
|
+
let db;
|
|
128
|
+
try {
|
|
129
|
+
db = await openBackend(projectRoot, { create: false, readOnly: true });
|
|
130
|
+
} catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const out = [];
|
|
134
|
+
try {
|
|
135
|
+
const stmt = db.prepare(
|
|
136
|
+
`SELECT content FROM memory_entries WHERE namespace='learnings' AND status='active' ORDER BY created_at DESC LIMIT ?`,
|
|
137
|
+
);
|
|
138
|
+
stmt.bind([limit]);
|
|
139
|
+
while (stmt.step()) {
|
|
140
|
+
const row = stmt.getAsObject();
|
|
141
|
+
const firstLine = String(row.content || '')
|
|
142
|
+
.split('\n')
|
|
143
|
+
.map((l) => l.trim())
|
|
144
|
+
.find((l) => l && !l.startsWith('#') && !l.startsWith('---'));
|
|
145
|
+
if (firstLine) out.push(firstLine.slice(0, 120));
|
|
146
|
+
}
|
|
147
|
+
stmt.free();
|
|
148
|
+
} catch {
|
|
149
|
+
// learnings table may not exist yet, or DB mid-repair — optional source.
|
|
150
|
+
} finally {
|
|
151
|
+
try { if (typeof db.close === 'function') db.close(); } catch { /* already closed */ }
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function capture() {
|
|
157
|
+
const projectRoot = findProjectRoot();
|
|
158
|
+
const cfg = readContinuityConfig(projectRoot);
|
|
159
|
+
if (!cfg.capture) return;
|
|
160
|
+
|
|
161
|
+
const input = await readHookStdin();
|
|
162
|
+
const sessionId = input.session_id || input.sessionId || null;
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
|
|
165
|
+
const statePath = resolve(projectRoot, '.moflo', 'continuity-state.json');
|
|
166
|
+
if (shouldThrottle(statePath, sessionId, now)) return;
|
|
167
|
+
|
|
168
|
+
const transcript = readTranscriptInfo(input.transcript_path || input.transcriptPath);
|
|
169
|
+
if (transcript.optOut) {
|
|
170
|
+
// User asked this session not to be remembered — record nothing, but stamp
|
|
171
|
+
// the throttle so we don't re-scan the transcript every turn.
|
|
172
|
+
writeThrottleState(statePath, sessionId, now);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const gitState = readGitState(projectRoot);
|
|
177
|
+
const decisions = await readRecentDecisions(projectRoot, LEARNINGS_SAMPLE);
|
|
178
|
+
|
|
179
|
+
const content = scrubSecrets(
|
|
180
|
+
assembleDigestContent({ goal: transcript.goal, decisions, gitState }),
|
|
181
|
+
);
|
|
182
|
+
if (!content.trim()) {
|
|
183
|
+
writeThrottleState(statePath, sessionId, now);
|
|
184
|
+
return; // nothing substantive to remember
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const metadata = buildDigestMetadata({ gitState, sessionId, endedAt: now });
|
|
188
|
+
const key = sessionId
|
|
189
|
+
? `session-${sessionId}`
|
|
190
|
+
: `session-${gitState.branch || 'nobranch'}-${new Date(now).toISOString().slice(0, 10)}`;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
writeDigest(projectRoot, key, { content, metadata });
|
|
194
|
+
rotateDigests(projectRoot);
|
|
195
|
+
writeThrottleState(statePath, sessionId, now);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
warn(`persist failed (${err && err.message ? err.message : String(err)})`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const sub = process.argv[2];
|
|
202
|
+
if (sub === 'capture') {
|
|
203
|
+
capture().catch((err) => warn(`failed (${err && err.message ? err.message : String(err)})`));
|
|
204
|
+
} else {
|
|
205
|
+
warn(`unknown subcommand "${sub || ''}" (expected: capture)`);
|
|
206
|
+
}
|