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.
Files changed (46) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +1 -0
  2. package/.claude/guidance/shipped/moflo-core-guidance.md +1 -0
  3. package/.claude/guidance/shipped/moflo-skills-reference.md +108 -0
  4. package/.claude/guidance/shipped/moflo-yaml-reference.md +13 -0
  5. package/.claude/skills/commune/SKILL.md +140 -0
  6. package/.claude/skills/divine/SKILL.md +130 -0
  7. package/.claude/skills/meditate/SKILL.md +122 -0
  8. package/README.md +39 -3
  9. package/bin/index-all.mjs +2 -1
  10. package/bin/index-reference.mjs +221 -0
  11. package/bin/lib/file-sync.mjs +50 -1
  12. package/bin/lib/hook-io.mjs +63 -0
  13. package/bin/lib/index-fingerprint.mjs +0 -0
  14. package/bin/lib/internal-skills.mjs +16 -0
  15. package/bin/lib/meditate.mjs +497 -0
  16. package/bin/lib/pii-scrub.mjs +119 -0
  17. package/bin/lib/reference-docs.mjs +218 -0
  18. package/bin/lib/session-continuity.mjs +372 -0
  19. package/bin/lib/shipped-scripts.json +36 -0
  20. package/bin/lib/shipped-scripts.mjs +33 -0
  21. package/bin/lib/yaml-upgrader.mjs +62 -0
  22. package/bin/meditate-capture.mjs +123 -0
  23. package/bin/meditate-distill.mjs +121 -0
  24. package/bin/session-continuity.mjs +206 -0
  25. package/bin/session-start-launcher.mjs +140 -60
  26. package/dist/src/cli/config/moflo-config.js +18 -0
  27. package/dist/src/cli/init/executor.js +11 -17
  28. package/dist/src/cli/init/moflo-init.js +21 -19
  29. package/dist/src/cli/init/moflo-yaml-template.js +21 -0
  30. package/dist/src/cli/init/settings-generator.js +23 -1
  31. package/dist/src/cli/init/shipped-scripts.js +39 -0
  32. package/dist/src/cli/memory/bridge-core.js +20 -0
  33. package/dist/src/cli/memory/bridge-entries.js +8 -2
  34. package/dist/src/cli/memory/memory-bridge.js +6 -2
  35. package/dist/src/cli/memory/memory-initializer.js +6 -2
  36. package/dist/src/cli/services/hook-block-hash.js +9 -1
  37. package/dist/src/cli/services/hook-wiring.js +38 -0
  38. package/dist/src/cli/transfer/anonymization/index.js +146 -40
  39. package/dist/src/cli/transfer/deploy-seraphine.js +1 -1
  40. package/dist/src/cli/transfer/export.js +2 -2
  41. package/dist/src/cli/transfer/store/publish.js +1 -1
  42. package/dist/src/cli/version.js +1 -1
  43. package/package.json +2 -2
  44. package/scripts/post-install-bootstrap.mjs +22 -42
  45. package/dist/src/cli/hooks/llm/index.js +0 -11
  46. 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
+ }