@lh8ppl/claude-memory-kit 0.2.0 → 0.2.2
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 +6 -2
- package/bin/cmk-capture-prompt.mjs +17 -17
- package/bin/cmk-capture-turn.mjs +22 -21
- package/bin/cmk-compress-session.mjs +2 -2
- package/bin/cmk-inject-context.mjs +11 -11
- package/bin/cmk-observe-edit.mjs +17 -16
- package/package.json +1 -1
- package/src/auto-extract.mjs +257 -5
- package/src/compress-session.mjs +89 -26
- package/src/graduation.mjs +1 -1
- package/src/inject-context.mjs +5 -1
- package/src/install.mjs +12 -4
- package/src/lazy-compress.mjs +58 -9
- package/src/rich-fact.mjs +46 -0
- package/src/subcommands.mjs +1 -19
- package/template/.gitignore.fragment +3 -3
- package/template/CLAUDE.md.template +2 -2
- package/template/docs/journey/journey-log.md.template +1 -1
package/README.md
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
7
|
+
- **Cross-project persona — the wedge (v0.2)** — when you state how you work *everywhere* ("always use uv, never pip", "from now on run the linter before committing"), the per-turn auto-extract promotes it into your **user tier** (`~/.claude-memory-kit/`) **that turn**. So a brand-new project **cold-opens already knowing your style** — layered structure, your tooling, your testing discipline — with no hand-curation and no waiting. Carry it between your own machines with `cmk persona export`/`import`, or pin a single fact across projects with `cmk lessons promote`.
|
|
7
8
|
- **Frozen snapshot at session start** — MEMORY.md + USER.md + SOUL.md + INDEX.md + today's session log inject once at the first tool call, so Claude sees your context every session without you re-telling it.
|
|
8
|
-
- **Auto-extract on every assistant turn** — a background `claude --print` subagent reads each turn and saves durable facts (
|
|
9
|
+
- **Auto-extract on every assistant turn** — a background `claude --print` subagent reads each turn and saves durable facts to memory. Durable project knowledge (setup/config, conventions, workflows, tool quirks) becomes a **rich Why/How fact file** (structured + searchable); lighter signals stay terse `MEMORY.md` bullets. Runs automatically, so the rich tier survives even when the model uses Claude Code's built-in memory instead. No manual writes needed.
|
|
9
10
|
- **Explicit capture when you want it** — say "remember this" / "from now on" / "we decided" / "forget X" (the `memory-write` skill), or run `cmk remember "<fact>"`. Both dedup, screen for secrets, abstract machine paths to `~`, and write silently.
|
|
10
11
|
- **Search + MCP** — `cmk search "<term>"` (keyword/hybrid over facts + scratchpads); `cmk mcp` exposes the same to Claude Code as tools.
|
|
11
|
-
- **Bounded by compression** — session → daily → weekly Haiku rollups (cron or lazy-on-read) keep the snapshot small as history grows.
|
|
12
|
+
- **Bounded by compression** — session → daily → weekly Haiku rollups (cron or lazy-on-read) keep the snapshot small as history grows. The session-buffer rollup self-heals at session start too, so memory stays bounded even if you never cleanly close the window.
|
|
12
13
|
- **Per-project, in-repo** — `context/` lives inside your project and travels with `git clone`. Each project keeps its own memory.
|
|
13
14
|
- **9 health checks** — `cmk doctor` validates install, hook wiring, distill freshness, INDEX consistency, cron registration, and stale locks.
|
|
14
15
|
|
|
@@ -53,6 +54,9 @@ Most-used commands (full list via `cmk --help`):
|
|
|
53
54
|
| `cmk roll --scope now\|today\|recent` | Manually trigger a compression pipeline |
|
|
54
55
|
| `cmk register-crons [--dry-run] [--unregister]` | Register daily + weekly jobs with cron / launchd / Task Scheduler |
|
|
55
56
|
| `cmk forget <id>` | Tombstone a fact (preserves audit trail) |
|
|
57
|
+
| `cmk lessons promote <id> [--to USER.md\|HABITS.md]` | Promote one captured fact to your cross-project **user tier** (the safe path — sanitized, secret-screened, audited) so it applies in **every** project |
|
|
58
|
+
| `cmk disable-native-memory` / `enable-native-memory` | Opt out of Claude Code's built-in Auto Memory so the kit is your single, lean memory layer (committable — travels with `git clone`) |
|
|
59
|
+
| `cmk persona generate` | Run cross-project persona synthesis on demand (instead of waiting for the weekly pass) |
|
|
56
60
|
| `cmk persona export <file>` / `import <file>` | Carry your cross-project persona (the user tier) to another of **your** machines — export to one portable bundle, import on the other (overwrites with backup + rollback). The persona stays private (never committed to a project) |
|
|
57
61
|
| `cmk import-anthropic-memory [--dry-run] [--yes]` | Merge bullets from Anthropic's native auto-memory into MEMORY.md |
|
|
58
62
|
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
// transcript, emit {"continue": true}. Always exit 0 — a hook that errors
|
|
12
12
|
// would interrupt the user mid-prompt.
|
|
13
13
|
|
|
14
|
-
import { readFileSync } from 'node:fs';
|
|
15
14
|
import { dirname, join } from 'node:path';
|
|
16
15
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
16
|
|
|
@@ -19,35 +18,36 @@ function emitContinue() {
|
|
|
19
18
|
process.stdout.write('{"continue": true}');
|
|
20
19
|
}
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
emitContinue();
|
|
27
|
-
process.exit(0);
|
|
28
|
-
}
|
|
21
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
22
|
+
const __dirname = dirname(__filename);
|
|
23
|
+
const readHookStdinPath = join(__dirname, '..', 'src', 'read-hook-stdin.mjs');
|
|
24
|
+
const modulePath = join(__dirname, '..', 'src', 'capture-prompt.mjs');
|
|
29
25
|
|
|
30
|
-
let
|
|
26
|
+
let readHookStdin;
|
|
27
|
+
let capturePrompt;
|
|
31
28
|
try {
|
|
32
|
-
|
|
29
|
+
({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
|
|
30
|
+
({ capturePrompt } = await import(pathToFileURL(modulePath).href));
|
|
33
31
|
} catch (err) {
|
|
34
32
|
process.stderr.write(
|
|
35
|
-
`cmk-capture-prompt: failed to
|
|
33
|
+
`cmk-capture-prompt: failed to load modules: ${err?.message ?? err}\n`,
|
|
36
34
|
);
|
|
37
35
|
emitContinue();
|
|
38
36
|
process.exit(0);
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
// Drain the hook payload — but NOT on an interactive TTY (a manual run):
|
|
40
|
+
// a blocking stdin read would hang forever on a console that never sends EOF, before
|
|
41
|
+
// any body runs (Task 101; DECISION-LOG 2026-06-06). readHookStdin returns ''
|
|
42
|
+
// for a TTY so a manual invocation finishes instead of hanging.
|
|
43
|
+
const rawInput = readHookStdin({ isTTY: process.stdin.isTTY });
|
|
44
44
|
|
|
45
|
-
let
|
|
45
|
+
let payload;
|
|
46
46
|
try {
|
|
47
|
-
(
|
|
47
|
+
payload = rawInput.trim() === '' ? {} : JSON.parse(rawInput);
|
|
48
48
|
} catch (err) {
|
|
49
49
|
process.stderr.write(
|
|
50
|
-
`cmk-capture-prompt: failed to
|
|
50
|
+
`cmk-capture-prompt: failed to parse stdin JSON: ${err?.message ?? err}\n`,
|
|
51
51
|
);
|
|
52
52
|
emitContinue();
|
|
53
53
|
process.exit(0);
|
package/bin/cmk-capture-turn.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// append to transcripts, spawn detached auto-extract, emit
|
|
14
14
|
// {"continue": true}, exit 0 within ~50ms (NFR-1). Always exit 0.
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
17
|
import { dirname, join } from 'node:path';
|
|
18
18
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
19
19
|
|
|
@@ -21,27 +21,9 @@ function emitContinue() {
|
|
|
21
21
|
process.stdout.write('{"continue": true}');
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
let raw = '';
|
|
25
|
-
try {
|
|
26
|
-
raw = readFileSync(0, 'utf8');
|
|
27
|
-
} catch {
|
|
28
|
-
emitContinue();
|
|
29
|
-
process.exit(0);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
let payload;
|
|
33
|
-
try {
|
|
34
|
-
payload = raw.trim() === '' ? {} : JSON.parse(raw);
|
|
35
|
-
} catch (err) {
|
|
36
|
-
process.stderr.write(
|
|
37
|
-
`cmk-capture-turn: failed to parse stdin JSON: ${err?.message ?? err}\n`,
|
|
38
|
-
);
|
|
39
|
-
emitContinue();
|
|
40
|
-
process.exit(0);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
44
25
|
const __dirname = dirname(__filename);
|
|
26
|
+
const readHookStdinPath = join(__dirname, '..', 'src', 'read-hook-stdin.mjs');
|
|
45
27
|
const modulePath = join(__dirname, '..', 'src', 'capture-turn.mjs');
|
|
46
28
|
|
|
47
29
|
// Auto-extract path: env override → sibling cmk-auto-extract.mjs (ships
|
|
@@ -53,12 +35,31 @@ const autoExtractPath =
|
|
|
53
35
|
? join(__dirname, 'cmk-auto-extract.mjs')
|
|
54
36
|
: null);
|
|
55
37
|
|
|
38
|
+
let readHookStdin;
|
|
56
39
|
let captureTurn;
|
|
57
40
|
try {
|
|
41
|
+
({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
|
|
58
42
|
({ captureTurn } = await import(pathToFileURL(modulePath).href));
|
|
59
43
|
} catch (err) {
|
|
60
44
|
process.stderr.write(
|
|
61
|
-
`cmk-capture-turn: failed to load
|
|
45
|
+
`cmk-capture-turn: failed to load modules: ${err?.message ?? err}\n`,
|
|
46
|
+
);
|
|
47
|
+
emitContinue();
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Drain the hook payload — but NOT on an interactive TTY (a manual run):
|
|
52
|
+
// a blocking stdin read would hang forever on a console that never sends EOF, before
|
|
53
|
+
// any body runs (Task 101; DECISION-LOG 2026-06-06). readHookStdin returns ''
|
|
54
|
+
// for a TTY so a manual invocation finishes instead of hanging.
|
|
55
|
+
const raw = readHookStdin({ isTTY: process.stdin.isTTY });
|
|
56
|
+
|
|
57
|
+
let payload;
|
|
58
|
+
try {
|
|
59
|
+
payload = raw.trim() === '' ? {} : JSON.parse(raw);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
process.stderr.write(
|
|
62
|
+
`cmk-capture-turn: failed to parse stdin JSON: ${err?.message ?? err}\n`,
|
|
62
63
|
);
|
|
63
64
|
emitContinue();
|
|
64
65
|
process.exit(0);
|
|
@@ -43,8 +43,8 @@ try {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// Drain the hook payload so Claude Code's pipe closes cleanly — but NOT when
|
|
46
|
-
// stdin is an interactive TTY (a manual run):
|
|
47
|
-
// forever on a console that never sends EOF,
|
|
46
|
+
// stdin is an interactive TTY (a manual run): a blocking stdin read would hang
|
|
47
|
+
// forever on a console that never sends EOF, before any of the body
|
|
48
48
|
// runs (DECISION-LOG 2026-06-06). The payload is discarded; we read state from
|
|
49
49
|
// disk. readHookStdin returns '' for a TTY so a manual invocation finishes.
|
|
50
50
|
readHookStdin({ isTTY: process.stdin.isTTY });
|
|
@@ -14,19 +14,13 @@
|
|
|
14
14
|
// unconditionally — a throwing SessionStart hook would interrupt
|
|
15
15
|
// session start, worse than an empty additionalContext.
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
18
|
import { dirname, join } from 'node:path';
|
|
19
19
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
20
|
|
|
21
|
-
// Drain stdin so callers blocking on EPIPE don't hang.
|
|
22
|
-
try {
|
|
23
|
-
readFileSync(0, 'utf8');
|
|
24
|
-
} catch {
|
|
25
|
-
// stdin not connected; fine.
|
|
26
|
-
}
|
|
27
|
-
|
|
28
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
29
22
|
const __dirname = dirname(__filename);
|
|
23
|
+
const readHookStdinPath = join(__dirname, '..', 'src', 'read-hook-stdin.mjs');
|
|
30
24
|
const modulePath = join(__dirname, '..', 'src', 'inject-context.mjs');
|
|
31
25
|
|
|
32
26
|
// Resolve the sibling lazy-compress bin (ships in this same bin/ dir) so
|
|
@@ -39,14 +33,14 @@ const compressLazyPath =
|
|
|
39
33
|
? join(__dirname, 'cmk-compress-lazy.mjs')
|
|
40
34
|
: null);
|
|
41
35
|
|
|
36
|
+
let readHookStdin;
|
|
42
37
|
let injectContext;
|
|
43
38
|
try {
|
|
39
|
+
({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
|
|
44
40
|
({ injectContext } = await import(pathToFileURL(modulePath).href));
|
|
45
41
|
} catch (err) {
|
|
46
42
|
process.stderr.write(
|
|
47
|
-
`cmk-inject-context: failed to load
|
|
48
|
-
err?.message ?? String(err)
|
|
49
|
-
}\n`,
|
|
43
|
+
`cmk-inject-context: failed to load modules: ${err?.message ?? String(err)}\n`,
|
|
50
44
|
);
|
|
51
45
|
process.stdout.write(
|
|
52
46
|
JSON.stringify({
|
|
@@ -59,6 +53,12 @@ try {
|
|
|
59
53
|
process.exit(0);
|
|
60
54
|
}
|
|
61
55
|
|
|
56
|
+
// Drain stdin so callers blocking on EPIPE don't hang — but NOT on an
|
|
57
|
+
// interactive TTY (a manual run): a blocking stdin read would hang forever on a
|
|
58
|
+
// console that never sends EOF (Task 101; DECISION-LOG 2026-06-06). The payload
|
|
59
|
+
// is discarded; readHookStdin returns '' for a TTY so a manual run finishes.
|
|
60
|
+
readHookStdin({ isTTY: process.stdin.isTTY });
|
|
61
|
+
|
|
62
62
|
try {
|
|
63
63
|
const r = injectContext({ cwd: process.cwd(), compressLazyPath });
|
|
64
64
|
process.stdout.write(JSON.stringify(r.hookOutput));
|
package/bin/cmk-observe-edit.mjs
CHANGED
|
@@ -11,37 +11,38 @@
|
|
|
11
11
|
// must never surface in the user's session. The append is
|
|
12
12
|
// fire-and-forget by design.
|
|
13
13
|
|
|
14
|
-
import { readFileSync } from 'node:fs';
|
|
15
14
|
import { dirname, join } from 'node:path';
|
|
16
15
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
process.exit(0);
|
|
23
|
-
}
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = dirname(__filename);
|
|
19
|
+
const readHookStdinPath = join(__dirname, '..', 'src', 'read-hook-stdin.mjs');
|
|
20
|
+
const modulePath = join(__dirname, '..', 'src', 'observe-edit.mjs');
|
|
24
21
|
|
|
25
|
-
let
|
|
22
|
+
let readHookStdin;
|
|
23
|
+
let observeEdit;
|
|
26
24
|
try {
|
|
27
|
-
|
|
25
|
+
({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
|
|
26
|
+
({ observeEdit } = await import(pathToFileURL(modulePath).href));
|
|
28
27
|
} catch (err) {
|
|
29
28
|
process.stderr.write(
|
|
30
|
-
`cmk-observe-edit: failed to
|
|
29
|
+
`cmk-observe-edit: failed to load modules: ${err?.message ?? err}\n`,
|
|
31
30
|
);
|
|
32
31
|
process.exit(0);
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
// Drain the hook payload — but NOT on an interactive TTY (a manual run):
|
|
35
|
+
// a blocking stdin read would hang forever on a console that never sends EOF, before
|
|
36
|
+
// any body runs (Task 101; DECISION-LOG 2026-06-06). readHookStdin returns ''
|
|
37
|
+
// for a TTY so a manual invocation finishes instead of hanging.
|
|
38
|
+
const raw = readHookStdin({ isTTY: process.stdin.isTTY });
|
|
38
39
|
|
|
39
|
-
let
|
|
40
|
+
let payload;
|
|
40
41
|
try {
|
|
41
|
-
(
|
|
42
|
+
payload = raw.trim() === '' ? {} : JSON.parse(raw);
|
|
42
43
|
} catch (err) {
|
|
43
44
|
process.stderr.write(
|
|
44
|
-
`cmk-observe-edit: failed to
|
|
45
|
+
`cmk-observe-edit: failed to parse stdin JSON: ${err?.message ?? err}\n`,
|
|
45
46
|
);
|
|
46
47
|
process.exit(0);
|
|
47
48
|
}
|
package/package.json
CHANGED
package/src/auto-extract.mjs
CHANGED
|
@@ -48,8 +48,11 @@ import {
|
|
|
48
48
|
appendFileSync,
|
|
49
49
|
} from 'node:fs';
|
|
50
50
|
import { join, dirname } from 'node:path';
|
|
51
|
+
import { createHash } from 'node:crypto';
|
|
51
52
|
import { generateId } from '@lh8ppl/cmk-canonicalize';
|
|
52
53
|
import { memoryWrite } from './memory-write.mjs';
|
|
54
|
+
import { writeFact } from './write-fact.mjs';
|
|
55
|
+
import { buildRichFactBody, slugifyFact } from './rich-fact.mjs';
|
|
53
56
|
import { HaikuTimeoutError } from './compressor.mjs';
|
|
54
57
|
import { pidIsAlive } from './lock-discipline.mjs';
|
|
55
58
|
import { nowIso } from './audit-log.mjs';
|
|
@@ -284,6 +287,21 @@ export function buildExtractionInstructions() {
|
|
|
284
287
|
'',
|
|
285
288
|
'Note: assistant-origin candidates are auto-demoted one trust level before routing (HIGH → MEDIUM → LOW → discarded). This is intentional — assistant inferences need user review. Emit your honest trust assessment; the routing layer handles demotion.',
|
|
286
289
|
'',
|
|
290
|
+
'ALSO — rich fact files (durable project KNOWLEDGE). This is a SEPARATE output from the terse TRUST_ lines. When a turn reveals a durable, substantive piece of project knowledge worth a FULL record — a setup/configuration fact (trigger 3), a project convention (trigger 4), a completed multi-step workflow worth recording (trigger 5), or a tool quirk/workaround (trigger 6) — emit a BEGIN_FACT block (below) INSTEAD OF a terse TRUST_ line for it. Keep terse TRUST_ lines for the LIGHTER signals: user corrections and discovered preferences (triggers 1–2) and active threads. Emit each fact EITHER as a rich BEGIN_FACT block OR as a terse TRUST_ line — NEVER both.',
|
|
291
|
+
'Format (one block per durable fact):',
|
|
292
|
+
' BEGIN_FACT',
|
|
293
|
+
' type: project',
|
|
294
|
+
' title: <short Title-Case headline, ≤ 80 chars>',
|
|
295
|
+
' body: <what is true; if it has parts, give a short labelled markdown breakdown over multiple lines, NOT one vague sentence>',
|
|
296
|
+
' why: <why it is true / why it matters — the rationale a future session needs>',
|
|
297
|
+
' how: <how the next session should apply it>',
|
|
298
|
+
' END_FACT',
|
|
299
|
+
'Rules for BEGIN_FACT blocks:',
|
|
300
|
+
' - body may span multiple lines (markdown bullets are encouraged when the knowledge has parts — make the saved fact genuinely useful to a future session, at least as detailed as a careful hand-written note). Write it as plain markdown on the lines after `body:` — do NOT use a YAML block scalar (`|` or `>`).',
|
|
301
|
+
' - title AND body are required; why/how are strongly preferred but optional. type defaults to project.',
|
|
302
|
+
' - Do NOT invent facts; synthesize only what the turn shows. Never put a secret, token, password, or key in a block.',
|
|
303
|
+
' - These facts are saved automatically (no review step), so be selective: only genuinely durable knowledge, at most a few per turn.',
|
|
304
|
+
'',
|
|
287
305
|
'ALSO — cross-project doctrine. This is a REQUIRED, PER-FACT pass, separate from the TRUST_ lines above. Re-scan the SAME turn for EVERY fact that expresses how this user works in ALL their projects (tooling habits, how they structure their work, communication / process style — NOT specifics that belong to this ONE project, like a particular value, name, or detail that would not carry to their other projects). **For EACH such cross-project fact, emit its OWN PERSONA CANDIDATE line — one line per fact. If the turn states THREE cross-project rules, emit THREE PERSONA CANDIDATE lines. Never collapse several rules into one line, and never skip a rule because the turn is busy or already has TRUST_ lines.** Format (one line per cross-project fact):',
|
|
288
306
|
' PERSONA CANDIDATE | target=<HABITS.md|LESSONS.md|USER.md> | section=<Section> | confidence=<high|medium|low> | <one-line restatement>',
|
|
289
307
|
' - HABITS.md → sections: Iteration Cadence | Destructive Operations | Communication Style',
|
|
@@ -310,7 +328,11 @@ function buildExtractionPrompt({ userTurn, assistantTurn, dedupContext }) {
|
|
|
310
328
|
return sections.join('\n');
|
|
311
329
|
}
|
|
312
330
|
|
|
313
|
-
|
|
331
|
+
// Exported for the live-Haiku smoke (spawn-smoke-auto-extract-rich.test.js),
|
|
332
|
+
// which asserts the enriched prompt still elicits parseable terse OR rich
|
|
333
|
+
// output from real Haiku. The terse format is the extraction prompt's contract,
|
|
334
|
+
// same as parseRichFacts above.
|
|
335
|
+
export function parseCandidates(haikuOutput) {
|
|
314
336
|
if (!haikuOutput || typeof haikuOutput !== 'string') return [];
|
|
315
337
|
const lines = haikuOutput.split('\n');
|
|
316
338
|
const candidates = [];
|
|
@@ -328,6 +350,127 @@ function parseCandidates(haikuOutput) {
|
|
|
328
350
|
return candidates;
|
|
329
351
|
}
|
|
330
352
|
|
|
353
|
+
// --- Rich-fact parser (Task 103) ------------------------------------
|
|
354
|
+
|
|
355
|
+
// Durable project KNOWLEDGE (the six triggers' config / convention / workflow /
|
|
356
|
+
// quirk facts) is emitted by Haiku as a fenced block, parsed here into the
|
|
357
|
+
// fields writeFact() needs. Lives next to parseCandidates + buildExtraction-
|
|
358
|
+
// Instructions — the format and its parser stay together (same as the terse
|
|
359
|
+
// TRUST_ surface). See design §6.4.
|
|
360
|
+
//
|
|
361
|
+
// BEGIN_FACT
|
|
362
|
+
// type: project
|
|
363
|
+
// title: <short title>
|
|
364
|
+
// body: <summary; MAY continue as markdown bullets on following lines>
|
|
365
|
+
// why: <rationale>
|
|
366
|
+
// how: <how to apply>
|
|
367
|
+
// END_FACT
|
|
368
|
+
//
|
|
369
|
+
// A field's value continues across lines until the next recognized key or the
|
|
370
|
+
// block close — so `body` can hold a multi-line structured breakdown (the
|
|
371
|
+
// native-parity bar). type defaults to 'project' when absent/invalid; a block
|
|
372
|
+
// missing title OR body is skipped (writeFact requires both).
|
|
373
|
+
const RICH_FACT_VALID_TYPES = new Set(['user', 'feedback', 'project', 'reference']);
|
|
374
|
+
const RICH_FACT_KEYS = new Set(['type', 'title', 'body', 'why', 'how']);
|
|
375
|
+
// Defensive per-field cap so a runaway block can't write an unbounded fact body.
|
|
376
|
+
const RICH_FACT_FIELD_CAP = 4000;
|
|
377
|
+
|
|
378
|
+
// Match a `key: value` field line. String-based (not a regex) — deterministically
|
|
379
|
+
// linear, no backtracking surface. Semantics: the key must be at the START of
|
|
380
|
+
// the line (no leading whitespace, mirroring an `^key` anchor), with optional
|
|
381
|
+
// whitespace before the colon. Returns {key, value} or null (a continuation /
|
|
382
|
+
// non-key line, e.g. a `- bullet:` inside a body).
|
|
383
|
+
function matchRichFactKey(line) {
|
|
384
|
+
const idx = line.indexOf(':');
|
|
385
|
+
if (idx <= 0) return null;
|
|
386
|
+
const keyPart = line.slice(0, idx);
|
|
387
|
+
if (keyPart.trimStart().length !== keyPart.length) return null; // leading ws → not a key
|
|
388
|
+
const key = keyPart.trimEnd().toLowerCase();
|
|
389
|
+
if (!RICH_FACT_KEYS.has(key)) return null;
|
|
390
|
+
return { key, value: line.slice(idx + 1).trimStart() };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// A YAML block-scalar indicator as a field's entire first-line value (`|`,
|
|
394
|
+
// `|-`, `>`, `>+`, `|2`, …). Live Haiku formats a multi-line body as `body: |`
|
|
395
|
+
// then indents the content — we must not keep the literal `|` or the indent.
|
|
396
|
+
const BLOCK_SCALAR_RE = /^[|>][+-]?\d*$/;
|
|
397
|
+
|
|
398
|
+
// Normalize a parsed field value: drop a leading block-scalar indicator line,
|
|
399
|
+
// then dedent (strip the common leading whitespace the block scalar adds). A
|
|
400
|
+
// plain single-line value passes through untouched.
|
|
401
|
+
function cleanFieldValue(raw) {
|
|
402
|
+
const lines = (raw ?? '').split('\n');
|
|
403
|
+
if (lines.length && BLOCK_SCALAR_RE.test(lines[0].trim())) lines.shift();
|
|
404
|
+
const indents = lines
|
|
405
|
+
.filter((l) => l.trim() !== '')
|
|
406
|
+
.map((l) => (l.match(/^[ \t]*/)?.[0].length ?? 0));
|
|
407
|
+
const minIndent = indents.length ? Math.min(...indents) : 0;
|
|
408
|
+
return lines.map((l) => l.slice(minIndent)).join('\n').trim();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function parseRichFactBlock(blockLines) {
|
|
412
|
+
const fields = {};
|
|
413
|
+
let currentKey = null;
|
|
414
|
+
for (const line of blockLines) {
|
|
415
|
+
const m = matchRichFactKey(line);
|
|
416
|
+
if (m) {
|
|
417
|
+
currentKey = m.key;
|
|
418
|
+
fields[currentKey] = m.value; // first-line value (may be '' or a `|` scalar)
|
|
419
|
+
} else if (currentKey) {
|
|
420
|
+
// Continuation of the current field — multi-line body / why / how.
|
|
421
|
+
fields[currentKey] += '\n' + line;
|
|
422
|
+
}
|
|
423
|
+
// A non-key line before any key is ignored.
|
|
424
|
+
}
|
|
425
|
+
const title = cleanFieldValue(fields.title);
|
|
426
|
+
const body = cleanFieldValue(fields.body);
|
|
427
|
+
if (!title || !body) return null; // writeFact requires both
|
|
428
|
+
let type = cleanFieldValue(fields.type).toLowerCase();
|
|
429
|
+
if (!RICH_FACT_VALID_TYPES.has(type)) type = 'project';
|
|
430
|
+
const why = cleanFieldValue(fields.why);
|
|
431
|
+
const how = cleanFieldValue(fields.how);
|
|
432
|
+
return {
|
|
433
|
+
type,
|
|
434
|
+
title: title.slice(0, RICH_FACT_FIELD_CAP),
|
|
435
|
+
body: body.slice(0, RICH_FACT_FIELD_CAP),
|
|
436
|
+
why: why ? why.slice(0, RICH_FACT_FIELD_CAP) : '',
|
|
437
|
+
how: how ? how.slice(0, RICH_FACT_FIELD_CAP) : '',
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Exported for direct unit-testing (cli-rich-fact.test.js) — the BEGIN_FACT
|
|
442
|
+
// format is the extraction prompt's contract, pinned independently of a live
|
|
443
|
+
// Haiku call.
|
|
444
|
+
export function parseRichFacts(haikuOutput) {
|
|
445
|
+
if (!haikuOutput || typeof haikuOutput !== 'string') return [];
|
|
446
|
+
const lines = haikuOutput.split('\n');
|
|
447
|
+
const facts = [];
|
|
448
|
+
let i = 0;
|
|
449
|
+
while (i < lines.length) {
|
|
450
|
+
if (lines[i].trim().toUpperCase() !== 'BEGIN_FACT') {
|
|
451
|
+
i++;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
// Collect block lines until END_FACT, the next BEGIN_FACT (missing close —
|
|
455
|
+
// don't let it swallow the following block), or end-of-output.
|
|
456
|
+
i++;
|
|
457
|
+
const blockLines = [];
|
|
458
|
+
while (i < lines.length) {
|
|
459
|
+
const marker = lines[i].trim().toUpperCase();
|
|
460
|
+
if (marker === 'END_FACT') {
|
|
461
|
+
i++;
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
if (marker === 'BEGIN_FACT') break; // close here; leave i for the outer loop
|
|
465
|
+
blockLines.push(lines[i]);
|
|
466
|
+
i++;
|
|
467
|
+
}
|
|
468
|
+
const fact = parseRichFactBlock(blockLines);
|
|
469
|
+
if (fact) facts.push(fact);
|
|
470
|
+
}
|
|
471
|
+
return facts;
|
|
472
|
+
}
|
|
473
|
+
|
|
331
474
|
// Demote assistant-origin candidates one trust level. User-origin
|
|
332
475
|
// candidates pass through unchanged — they're authoritative.
|
|
333
476
|
// Order: must run BEFORE applyRetainOverride so the override beats
|
|
@@ -456,6 +599,45 @@ function routeMedium({ candidate, projectRoot, ts }) {
|
|
|
456
599
|
return { action: 'queued', id, path: reviewPath };
|
|
457
600
|
}
|
|
458
601
|
|
|
602
|
+
// Route a rich fact to the project fact store via writeFact() (Task 103).
|
|
603
|
+
//
|
|
604
|
+
// Direct-to-fact-store (NOT the review queue the terse medium-trust path uses):
|
|
605
|
+
// the point of Task 103 is AUTOMATIC native-parity capture — native writes its
|
|
606
|
+
// fact files with no approval step, so parity requires the same. The fact store
|
|
607
|
+
// is searchable-but-not-full-trust-injected, writeFact already screens every
|
|
608
|
+
// write (home-path sanitize + Poison_Guard + schema + INDEX/reindex), and a
|
|
609
|
+
// later explicit `cmk remember` (trust:high) supersedes. See design §6.4.
|
|
610
|
+
//
|
|
611
|
+
// trust:medium / write_source:auto-extract marks it as a Haiku synthesis
|
|
612
|
+
// (proposal-grade), below the explicit-high tier. The body is built by the SAME
|
|
613
|
+
// rich-fact.mjs helper the explicit path uses, so an auto-extracted fact reads
|
|
614
|
+
// identically to a `cmk remember --why/--how` one.
|
|
615
|
+
function routeRichFact({ candidate, projectRoot, ts }) {
|
|
616
|
+
const body = buildRichFactBody({
|
|
617
|
+
text: candidate.body,
|
|
618
|
+
why: candidate.why,
|
|
619
|
+
how: candidate.how,
|
|
620
|
+
});
|
|
621
|
+
return writeFact({
|
|
622
|
+
tier: 'P',
|
|
623
|
+
type: candidate.type,
|
|
624
|
+
slug: slugifyFact(candidate.title),
|
|
625
|
+
title: candidate.title,
|
|
626
|
+
body,
|
|
627
|
+
writeSource: 'auto-extract',
|
|
628
|
+
trust: 'medium',
|
|
629
|
+
sourceFile: 'auto-extract',
|
|
630
|
+
sourceLine: 1,
|
|
631
|
+
// Content fingerprint for the provenance field — NOT a security context.
|
|
632
|
+
// Matches the kit's sha1-of-content convention (write-fact.mjs caller in
|
|
633
|
+
// subcommands.runRememberRich, memory-write.mjs); writeFact dedups by the
|
|
634
|
+
// content-addressed id, this is just source_sha1. // NOSONAR
|
|
635
|
+
sourceSha1: createHash('sha1').update(body).digest('hex'), // NOSONAR
|
|
636
|
+
createdAt: ts,
|
|
637
|
+
projectRoot,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
459
641
|
// --- NDJSON extract.log ---------------------------------------------
|
|
460
642
|
|
|
461
643
|
function writeExtractLogEntry({ projectRoot, ts, entry }) {
|
|
@@ -668,6 +850,22 @@ export async function runAutoExtract({
|
|
|
668
850
|
candidates = applyRetainOverride(candidates, retainSegments);
|
|
669
851
|
candidates = dedupByCanonicalId(candidates);
|
|
670
852
|
|
|
853
|
+
// Task 103 — rich fact synthesis on the native-immune Stop-hook path. The
|
|
854
|
+
// SAME Haiku output may carry BEGIN_FACT blocks (durable project KNOWLEDGE)
|
|
855
|
+
// alongside the terse TRUST_ lines; route them to the fact store via
|
|
856
|
+
// writeFact (richer + searchable). No second LLM call — same outputText.
|
|
857
|
+
const richFacts = parseRichFacts(haikuResult.outputText);
|
|
858
|
+
// XOR safety net: the prompt asks Haiku to emit a fact as EITHER a rich
|
|
859
|
+
// block OR a terse line, never both. If it does both for the same fact, the
|
|
860
|
+
// rich block wins — drop any terse candidate whose canonical id matches a
|
|
861
|
+
// rich fact's body, so it isn't ALSO written as a MEMORY.md bullet. (Keyed
|
|
862
|
+
// on the rich fact's raw `body` headline vs the terse `text` — the prompt
|
|
863
|
+
// enforces the semantic XOR; this catches the exact-restatement case.)
|
|
864
|
+
if (richFacts.length > 0) {
|
|
865
|
+
const richIds = new Set(richFacts.map((f) => generateId('P', f.body)));
|
|
866
|
+
candidates = candidates.filter((c) => !richIds.has(generateId('P', c.text)));
|
|
867
|
+
}
|
|
868
|
+
|
|
671
869
|
// Task 61 — inline cross-project promotion. The SAME Haiku output may
|
|
672
870
|
// carry PERSONA CANDIDATE lines (cross-project doctrine); promote them to
|
|
673
871
|
// the user tier THIS run (vs the weekly auto-persona janitor). No second
|
|
@@ -719,10 +917,11 @@ export async function runAutoExtract({
|
|
|
719
917
|
}
|
|
720
918
|
: {};
|
|
721
919
|
|
|
722
|
-
if (candidates.length === 0 && !personaLanded) {
|
|
920
|
+
if (candidates.length === 0 && richFacts.length === 0 && !personaLanded) {
|
|
723
921
|
const entry = {
|
|
724
922
|
...baseEntry,
|
|
725
923
|
...personaLogFields,
|
|
924
|
+
rich_facts_written: 0,
|
|
726
925
|
success: true,
|
|
727
926
|
skipped_reason: 'nothing_durable',
|
|
728
927
|
duration_ms: Date.now() - t0,
|
|
@@ -735,6 +934,7 @@ export async function runAutoExtract({
|
|
|
735
934
|
duration_ms: entry.duration_ms,
|
|
736
935
|
logPath,
|
|
737
936
|
candidates: [],
|
|
937
|
+
richFacts: [],
|
|
738
938
|
persona,
|
|
739
939
|
};
|
|
740
940
|
}
|
|
@@ -787,9 +987,57 @@ export async function runAutoExtract({
|
|
|
787
987
|
}
|
|
788
988
|
}
|
|
789
989
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
)
|
|
990
|
+
// 6b. Route rich facts to the fact store (Task 103). Each writeFact is
|
|
991
|
+
// isolated in try/catch — a Poison_Guard / schema / collision rejection
|
|
992
|
+
// (or an unexpected throw) must NOT take down terse routing or the
|
|
993
|
+
// persona pass, exactly like the inline persona isolation above. A
|
|
994
|
+
// 'created' counts toward observation_count; a 'skipped' (content
|
|
995
|
+
// duplicate) is a no-op success that doesn't re-count; anything else is
|
|
996
|
+
// 'rejected' with its category for analytics (Door 4).
|
|
997
|
+
const richWrites = [];
|
|
998
|
+
for (const fact of richFacts) {
|
|
999
|
+
try {
|
|
1000
|
+
const r = routeRichFact({ candidate: fact, projectRoot, ts });
|
|
1001
|
+
let written;
|
|
1002
|
+
if (r?.action === 'created') written = 'fact';
|
|
1003
|
+
else if (r?.action === 'skipped') written = 'fact-duplicate';
|
|
1004
|
+
else written = 'rejected';
|
|
1005
|
+
const rec = { ...fact, written, result: r };
|
|
1006
|
+
if (written === 'rejected') {
|
|
1007
|
+
rec.rejected_category = r?.errorCategory ?? 'unknown';
|
|
1008
|
+
// Trace the drop (§6.5 don't-lose-without-trace), mirroring the terse
|
|
1009
|
+
// low-discard trace — a rejected rich fact is otherwise invisible once
|
|
1010
|
+
// the detached process exits. TITLE ONLY, never the body: a
|
|
1011
|
+
// poison_guard rejection means the body may carry a secret (the
|
|
1012
|
+
// redacted excerpt is already in poison-guard.log). One NDJSON entry
|
|
1013
|
+
// per rejection (Door 4).
|
|
1014
|
+
writeExtractLogEntry({
|
|
1015
|
+
projectRoot,
|
|
1016
|
+
ts,
|
|
1017
|
+
entry: {
|
|
1018
|
+
event: 'rich_fact_rejected',
|
|
1019
|
+
reason: 'rich_fact_rejected',
|
|
1020
|
+
rejected_category: rec.rejected_category,
|
|
1021
|
+
title: fact.title.slice(0, LOW_DISCARD_EXCERPT_MAX),
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
richWrites.push(rec);
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
richWrites.push({
|
|
1028
|
+
...fact,
|
|
1029
|
+
written: 'rejected',
|
|
1030
|
+
rejected_category: 'exception',
|
|
1031
|
+
error: err?.message ?? String(err),
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
const richFactsWritten = richWrites.filter((w) => w.written === 'fact').length;
|
|
1036
|
+
|
|
1037
|
+
const observation_count =
|
|
1038
|
+
writes.filter(
|
|
1039
|
+
(w) => w.written === 'memory' || w.written === 'review' || w.written === 'conflict',
|
|
1040
|
+
).length + richFactsWritten;
|
|
793
1041
|
|
|
794
1042
|
// Persona-only turn: no project candidate landed, but cross-project
|
|
795
1043
|
// doctrine promoted to the user tier this run. That IS a durable
|
|
@@ -799,6 +1047,7 @@ export async function runAutoExtract({
|
|
|
799
1047
|
const entry = {
|
|
800
1048
|
...baseEntry,
|
|
801
1049
|
...personaLogFields,
|
|
1050
|
+
rich_facts_written: richFactsWritten,
|
|
802
1051
|
success: true,
|
|
803
1052
|
skipped_reason: 'nothing_durable',
|
|
804
1053
|
duration_ms: Date.now() - t0,
|
|
@@ -811,6 +1060,7 @@ export async function runAutoExtract({
|
|
|
811
1060
|
duration_ms: entry.duration_ms,
|
|
812
1061
|
logPath,
|
|
813
1062
|
candidates: writes,
|
|
1063
|
+
richFacts: richWrites,
|
|
814
1064
|
persona,
|
|
815
1065
|
};
|
|
816
1066
|
}
|
|
@@ -818,6 +1068,7 @@ export async function runAutoExtract({
|
|
|
818
1068
|
const entry = {
|
|
819
1069
|
...baseEntry,
|
|
820
1070
|
...personaLogFields,
|
|
1071
|
+
rich_facts_written: richFactsWritten,
|
|
821
1072
|
success: true,
|
|
822
1073
|
observation_count,
|
|
823
1074
|
duration_ms: Date.now() - t0,
|
|
@@ -829,6 +1080,7 @@ export async function runAutoExtract({
|
|
|
829
1080
|
duration_ms: entry.duration_ms,
|
|
830
1081
|
logPath,
|
|
831
1082
|
candidates: writes,
|
|
1083
|
+
richFacts: richWrites,
|
|
832
1084
|
persona,
|
|
833
1085
|
};
|
|
834
1086
|
} finally {
|
package/src/compress-session.mjs
CHANGED
|
@@ -30,7 +30,8 @@ import {
|
|
|
30
30
|
readFileSync,
|
|
31
31
|
writeFileSync,
|
|
32
32
|
appendFileSync,
|
|
33
|
-
|
|
33
|
+
renameSync,
|
|
34
|
+
unlinkSync,
|
|
34
35
|
} from 'node:fs';
|
|
35
36
|
import { join, dirname } from 'node:path';
|
|
36
37
|
import { nowIso } from './audit-log.mjs';
|
|
@@ -46,6 +47,10 @@ const DEFAULT_MAX_OUTPUT_BYTES = 4096;
|
|
|
46
47
|
|
|
47
48
|
const NOW_MD_RELATIVE = ['context', 'sessions', 'now.md'];
|
|
48
49
|
const SESSIONS_DIR_RELATIVE = ['context', 'sessions'];
|
|
50
|
+
// Task 106 (§16.27): the live buffer is CLAIMED by an atomic rename to this
|
|
51
|
+
// suffix before compression, so concurrent PostToolUse/capture-turn appends
|
|
52
|
+
// land on a fresh now.md without racing the truncate.
|
|
53
|
+
const ROLLING_SUFFIX = '.rolling-';
|
|
49
54
|
|
|
50
55
|
// Compression prompt (design §8.4). Written from scratch per the
|
|
51
56
|
// licensing posture in SOURCES.md (claude-remember's prompts are not
|
|
@@ -126,13 +131,74 @@ function dateFromIso(ts) {
|
|
|
126
131
|
return ts.slice(0, 10);
|
|
127
132
|
}
|
|
128
133
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
// Task 106 (§16.27 file-rename pattern). ATOMICALLY claim the live buffer:
|
|
135
|
+
// rename now.md → now.md.rolling-{ts}, then read the claimed copy. The rename is
|
|
136
|
+
// atomic on POSIX (rename(2)) + NTFS (MoveFileEx), so a concurrent appender
|
|
137
|
+
// (PostToolUse/capture-turn) that fires DURING the ~5–10s Haiku call lands on a
|
|
138
|
+
// fresh now.md with zero contention — its content is never inside the
|
|
139
|
+
// read→clear window the old `read then truncate(0)` left open. Returns the
|
|
140
|
+
// claimed buffer + the rolling path (null when now.md is absent / the rename
|
|
141
|
+
// raced, which the caller treats as an empty buffer).
|
|
142
|
+
//
|
|
143
|
+
// Bonus property — the rename also SERIALIZES concurrent rolls. compressSession
|
|
144
|
+
// is gated by the 120s cooldown, but the marker is only touched on success, so
|
|
145
|
+
// two callers (a SessionEnd + the Task 105 SessionStart-lazy roll) can both pass
|
|
146
|
+
// the cooldown gate and reach here. Only ONE renameSync wins; the other gets
|
|
147
|
+
// ENOENT (now.md already claimed) → returns an empty buffer → skips. No lock
|
|
148
|
+
// needed; the atomic rename IS the mutex.
|
|
149
|
+
function claimNowBuffer(projectRoot, ts) {
|
|
150
|
+
const nowPath = readNowMdPath(projectRoot);
|
|
151
|
+
if (!existsSync(nowPath)) return { buffer: '', rollingPath: null };
|
|
152
|
+
const rollingPath = nowPath + ROLLING_SUFFIX + String(ts).replace(/[:.]/g, '-');
|
|
153
|
+
try {
|
|
154
|
+
renameSync(nowPath, rollingPath);
|
|
155
|
+
} catch {
|
|
156
|
+
// now.md vanished or the rename lost a race — nothing to roll.
|
|
157
|
+
return { buffer: '', rollingPath: null };
|
|
158
|
+
}
|
|
159
|
+
let buffer = '';
|
|
160
|
+
try {
|
|
161
|
+
buffer = readFileSync(rollingPath, 'utf8');
|
|
162
|
+
} catch {
|
|
163
|
+
buffer = '';
|
|
164
|
+
}
|
|
165
|
+
return { buffer, rollingPath };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Success path: the claimed buffer is safely compressed into today-{date}.md.
|
|
169
|
+
// Drop the rolling file. now.md is owned by the (new) session's appenders now —
|
|
170
|
+
// we do NOT recreate or touch it, so a concurrent append is never clobbered.
|
|
171
|
+
function discardRolling(rollingPath) {
|
|
172
|
+
if (!rollingPath) return;
|
|
132
173
|
try {
|
|
133
|
-
|
|
174
|
+
unlinkSync(rollingPath);
|
|
134
175
|
} catch {
|
|
135
|
-
|
|
176
|
+
// best-effort; a leaked rolling file is inert (the next roll claims now.md,
|
|
177
|
+
// not now.md.rolling-*) and harmless beyond disk noise.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Error/timeout path: the claimed buffer was NOT compressed — restore it so the
|
|
182
|
+
// next roll retries it (the old impl's "leave now.md intact" contract). Prepend
|
|
183
|
+
// it to anything a concurrent session appended to the fresh now.md (the claimed
|
|
184
|
+
// content is OLDER, so it leads), preserving both with no truncate. Best-effort:
|
|
185
|
+
// if the restore write fails, the rolling file stays as a recovery breadcrumb.
|
|
186
|
+
function restoreRolling(projectRoot, rollingPath) {
|
|
187
|
+
if (!rollingPath || !existsSync(rollingPath)) return;
|
|
188
|
+
const nowPath = readNowMdPath(projectRoot);
|
|
189
|
+
try {
|
|
190
|
+
const claimed = readFileSync(rollingPath, 'utf8');
|
|
191
|
+
const current = existsSync(nowPath) ? readFileSync(nowPath, 'utf8') : '';
|
|
192
|
+
// Guarantee a newline boundary between the claimed (older) buffer and any
|
|
193
|
+
// concurrent appends. String op, not a regex — a trailing-anchored `\n*$`
|
|
194
|
+
// trips static-analysis's ReDoS heuristic (same convention as slugify in
|
|
195
|
+
// rich-fact.mjs / graduation.mjs).
|
|
196
|
+
const sep = claimed.endsWith('\n') ? '' : '\n';
|
|
197
|
+
const merged = current ? claimed + sep + current : claimed;
|
|
198
|
+
writeFileSync(nowPath, merged, 'utf8');
|
|
199
|
+
unlinkSync(rollingPath);
|
|
200
|
+
} catch {
|
|
201
|
+
// best-effort — see above
|
|
136
202
|
}
|
|
137
203
|
}
|
|
138
204
|
|
|
@@ -146,18 +212,6 @@ function appendToTodayMd({ projectRoot, date, body }) {
|
|
|
146
212
|
return path;
|
|
147
213
|
}
|
|
148
214
|
|
|
149
|
-
function truncateNowMd(projectRoot) {
|
|
150
|
-
const p = readNowMdPath(projectRoot);
|
|
151
|
-
if (!existsSync(p)) return;
|
|
152
|
-
try {
|
|
153
|
-
truncateSync(p, 0);
|
|
154
|
-
} catch {
|
|
155
|
-
// Best-effort. If truncate fails (perm error etc.), the next
|
|
156
|
-
// session compresses a slightly-larger buffer — not a data-loss
|
|
157
|
-
// event.
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
215
|
function writeCompressLogEntry({ projectRoot, date, entry }) {
|
|
162
216
|
const path = compressLogPath(projectRoot, date);
|
|
163
217
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -231,9 +285,13 @@ export async function compressSession({
|
|
|
231
285
|
};
|
|
232
286
|
}
|
|
233
287
|
|
|
234
|
-
// 2.
|
|
235
|
-
|
|
288
|
+
// 2. CLAIM the live buffer by atomic rename (Task 106 / §16.27), then read it;
|
|
289
|
+
// no-op if empty (tasks.md 22.1). Claiming before the Haiku call is what
|
|
290
|
+
// closes the race — a concurrent append during compression lands on a
|
|
291
|
+
// fresh now.md, never inside a read→truncate window.
|
|
292
|
+
const { buffer, rollingPath } = claimNowBuffer(projectRoot, ts);
|
|
236
293
|
if (buffer.trim() === '') {
|
|
294
|
+
discardRolling(rollingPath); // drop the (empty) claimed file if one was renamed
|
|
237
295
|
const duration_ms = Date.now() - t0;
|
|
238
296
|
const entry = {
|
|
239
297
|
ts,
|
|
@@ -257,13 +315,14 @@ export async function compressSession({
|
|
|
257
315
|
const input_bytes = Buffer.byteLength(buffer, 'utf8');
|
|
258
316
|
const instructions = buildCompressionInstructions(maxOutputBytes);
|
|
259
317
|
|
|
260
|
-
// 3. Invoke backend. On throw:
|
|
318
|
+
// 3. Invoke backend. On throw: RESTORE the claimed buffer to now.md (22.5) so
|
|
319
|
+
// the next session-end retries it — the file-rename analogue of the old
|
|
320
|
+
// "leave now.md intact".
|
|
261
321
|
//
|
|
262
322
|
// Subprocess timeout: 50_000 ms. Sits under the 60s SessionEnd
|
|
263
323
|
// hook ceiling (design §5.1) so on timeout the catch + log write
|
|
264
|
-
// complete BEFORE Claude Code kills the parent
|
|
265
|
-
//
|
|
266
|
-
// on the success path), so the next session-end retries naturally.
|
|
324
|
+
// complete BEFORE Claude Code kills the parent — including the
|
|
325
|
+
// restoreRolling call, so the buffer is never stranded in the rolling file.
|
|
267
326
|
// See design §8.5 for the composition rationale.
|
|
268
327
|
let result;
|
|
269
328
|
try {
|
|
@@ -285,6 +344,8 @@ export async function compressSession({
|
|
|
285
344
|
const errorCategory = err instanceof HaikuTimeoutError
|
|
286
345
|
? ERROR_CATEGORIES.HAIKU_TIMEOUT
|
|
287
346
|
: ERROR_CATEGORIES.COMPRESS_FAILED;
|
|
347
|
+
// The claimed buffer wasn't compressed — put it back so it isn't lost.
|
|
348
|
+
restoreRolling(projectRoot, rollingPath);
|
|
288
349
|
const duration_ms = Date.now() - t0;
|
|
289
350
|
const entry = {
|
|
290
351
|
ts,
|
|
@@ -316,8 +377,10 @@ export async function compressSession({
|
|
|
316
377
|
body: output,
|
|
317
378
|
});
|
|
318
379
|
|
|
319
|
-
// 5.
|
|
320
|
-
|
|
380
|
+
// 5. The claimed buffer is safely in today-{date}.md — drop the rolling file
|
|
381
|
+
// (Task 106/§16.27). now.md is untouched: any turn the new session appended
|
|
382
|
+
// while we compressed stays put.
|
|
383
|
+
discardRolling(rollingPath);
|
|
321
384
|
|
|
322
385
|
// 6. Touch cooldown marker so the next caller within 120s skips.
|
|
323
386
|
touchCooldownMarker({ projectRoot, now: ts });
|
package/src/graduation.mjs
CHANGED
|
@@ -35,7 +35,7 @@ const VALID_WRITE_SOURCES = new Set([
|
|
|
35
35
|
|
|
36
36
|
function slugify(s) {
|
|
37
37
|
// Collapse non-alphanumerics to single dashes, cap, trim edges (string ops,
|
|
38
|
-
// no trailing-dash quantifier — matches
|
|
38
|
+
// no trailing-dash quantifier — matches rich-fact.slugifyFact's ReDoS-safe
|
|
39
39
|
// shape).
|
|
40
40
|
let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40);
|
|
41
41
|
if (base.startsWith('-')) base = base.slice(1);
|
package/src/inject-context.mjs
CHANGED
|
@@ -740,7 +740,11 @@ export function injectContext({
|
|
|
740
740
|
try {
|
|
741
741
|
const verdict = detectStaleness({ projectRoot, now: ts });
|
|
742
742
|
lazyTrigger = { verdict: verdict.action, reason: verdict.reason };
|
|
743
|
-
if (
|
|
743
|
+
if (
|
|
744
|
+
verdict.action === 'stale-now' ||
|
|
745
|
+
verdict.action === 'stale-daily' ||
|
|
746
|
+
verdict.action === 'stale-weekly'
|
|
747
|
+
) {
|
|
744
748
|
const spawner = typeof testSpawnLazy === 'function' ? testSpawnLazy : spawnLazyCompress;
|
|
745
749
|
const spawnResult = spawner(projectRoot, compressLazyPath);
|
|
746
750
|
lazyTrigger = { ...lazyTrigger, ...spawnResult };
|
package/src/install.mjs
CHANGED
|
@@ -51,9 +51,17 @@ const CLI_SRC_DIR = dirname(__filename);
|
|
|
51
51
|
const REPO_ROOT_DEV = resolve(CLI_SRC_DIR, '..', '..', '..');
|
|
52
52
|
const CLI_PKG_DIR = resolve(CLI_SRC_DIR, '..');
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// The start marker carries the install version (matching the CLAUDE.md block,
|
|
55
|
+
// which is load-bearing for upgrade detection). The replace-regex in
|
|
56
|
+
// injectGitignore ignores the version, so it's cosmetic for idempotency — but
|
|
57
|
+
// it must not show a stale hardcode (was `v0.1.0` in every install). Built per
|
|
58
|
+
// install from the kit version; see gitignoreStartMarker().
|
|
55
59
|
const GITIGNORE_END = '# claude-memory-kit:gitignore:end';
|
|
56
60
|
|
|
61
|
+
function gitignoreStartMarker(version) {
|
|
62
|
+
return `# claude-memory-kit:gitignore:start v${version}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
/**
|
|
58
66
|
* Read the kit version from the cli package's package.json.
|
|
59
67
|
* Used as the default version for the CLAUDE.md marker.
|
|
@@ -216,12 +224,12 @@ function installTier(srcDir, destDir, { created, skipped, errors, vars }) {
|
|
|
216
224
|
* Build the canonical .gitignore managed block from template/.gitignore.fragment.
|
|
217
225
|
* Adds start/end markers around the fragment so we can refresh in place.
|
|
218
226
|
*/
|
|
219
|
-
function buildGitignoreBlock(templateDir) {
|
|
227
|
+
function buildGitignoreBlock(templateDir, version = getKitVersion()) {
|
|
220
228
|
const fragmentPath = join(templateDir, '.gitignore.fragment');
|
|
221
229
|
const fragment = existsSync(fragmentPath)
|
|
222
230
|
? readFileSync(fragmentPath, 'utf8').trim()
|
|
223
231
|
: 'context.local/\ncontext/.index/\ncontext/.locks/';
|
|
224
|
-
return `${
|
|
232
|
+
return `${gitignoreStartMarker(version)}\n${fragment}\n${GITIGNORE_END}\n`;
|
|
225
233
|
}
|
|
226
234
|
|
|
227
235
|
/**
|
|
@@ -318,7 +326,7 @@ export async function install(options = {}) {
|
|
|
318
326
|
});
|
|
319
327
|
}
|
|
320
328
|
|
|
321
|
-
const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir));
|
|
329
|
+
const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir, version));
|
|
322
330
|
|
|
323
331
|
// CLAUDE.md loader block — Task 4. Read the block content from the kit's
|
|
324
332
|
// template/ and inject (or refresh) it inside marker delimiters. Never
|
package/src/lazy-compress.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
existsSync,
|
|
30
30
|
mkdirSync,
|
|
31
31
|
readdirSync,
|
|
32
|
+
readFileSync,
|
|
32
33
|
statSync,
|
|
33
34
|
writeFileSync,
|
|
34
35
|
unlinkSync,
|
|
@@ -42,11 +43,13 @@ import {
|
|
|
42
43
|
} from './cooldown.mjs';
|
|
43
44
|
import { dailyDistill } from './daily-distill.mjs';
|
|
44
45
|
import { weeklyCurate } from './weekly-curate.mjs';
|
|
46
|
+
import { compressSession } from './compress-session.mjs';
|
|
45
47
|
|
|
46
48
|
const DEFAULT_DAILY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
47
49
|
const DEFAULT_WEEKLY_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
48
50
|
const SESSIONS_REL = ['context', 'sessions'];
|
|
49
51
|
const LOCKS_REL = ['context', '.locks'];
|
|
52
|
+
const NOW_MD_REL = ['context', 'sessions', 'now.md'];
|
|
50
53
|
const RECENT_MD_REL = ['context', 'sessions', 'recent.md'];
|
|
51
54
|
const CRON_SENTINEL_REL = ['context', '.locks', 'cron-registered'];
|
|
52
55
|
const LAZY_LOG_REL = ['context', '.locks', 'lazy-compress.log'];
|
|
@@ -100,6 +103,25 @@ function listTodayFiles(projectRoot) {
|
|
|
100
103
|
return matches;
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
// Task 105 (D-75): does now.md carry prior-session content? The now→today
|
|
107
|
+
// roll (compressSession) fires only at SessionEnd, and Claude Code fires
|
|
108
|
+
// SessionEnd ONLY on a clean window-close — so a never-cleanly-closed session
|
|
109
|
+
// leaves now.md growing unbounded with no today-*.md/recent.md built. We detect
|
|
110
|
+
// a non-empty now.md at SessionStart and let the lazy worker roll it. At
|
|
111
|
+
// SessionStart now.md can only hold PRIOR-session turns (this session's
|
|
112
|
+
// capture-turn writes haven't fired yet), so non-empty ⇒ stale. Emptiness must
|
|
113
|
+
// match compressSession's own `buffer.trim() === ''` check so the spawn verdict
|
|
114
|
+
// and the actual roll agree (else we'd spawn for a roll that immediately skips).
|
|
115
|
+
function nowMdHasContent(projectRoot) {
|
|
116
|
+
const p = join(projectRoot, ...NOW_MD_REL);
|
|
117
|
+
if (!existsSync(p)) return false;
|
|
118
|
+
try {
|
|
119
|
+
return readFileSync(p, 'utf8').trim() !== '';
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
103
125
|
function recentMdMtimeMs(projectRoot) {
|
|
104
126
|
const p = join(projectRoot, ...RECENT_MD_REL);
|
|
105
127
|
if (!existsSync(p)) return null;
|
|
@@ -113,9 +135,11 @@ function recentMdMtimeMs(projectRoot) {
|
|
|
113
135
|
/**
|
|
114
136
|
* Cheap inline staleness check. Runs in <5ms — one stat + a few existsSync.
|
|
115
137
|
*
|
|
116
|
-
* Verdict semantics:
|
|
138
|
+
* Verdict semantics (precedence: cron > no-context-dir > now > weekly > daily > fresh):
|
|
117
139
|
* - 'cron-active' : sentinel exists; cron will handle staleness. No-op.
|
|
118
140
|
* - 'no-context-dir': context/sessions/ doesn't exist. No-op (kit not installed).
|
|
141
|
+
* - 'stale-now' : now.md carries prior-session content (Task 105/D-75) — the
|
|
142
|
+
* now→today roll the SessionEnd hook would have done.
|
|
119
143
|
* - 'stale-weekly' : ANY today-*.md older than 7d exists. Weekly curate needed.
|
|
120
144
|
* - 'stale-daily' : no OLD today files, but recent.md is missing OR older than dailyTtlMs.
|
|
121
145
|
* - 'fresh' : recent.md exists + younger than dailyTtlMs AND no OLD today files.
|
|
@@ -141,6 +165,16 @@ export function detectStaleness({
|
|
|
141
165
|
return { action: 'no-context-dir', reason: 'sessions-dir-missing' };
|
|
142
166
|
}
|
|
143
167
|
|
|
168
|
+
// Task 105 (D-75): a non-empty now.md is the now→today roll the SessionEnd
|
|
169
|
+
// hook would have done. It takes PRECEDENCE over daily/weekly because it's
|
|
170
|
+
// the FIRST pipeline level (now → today → recent → archive) — roll it this
|
|
171
|
+
// SessionStart; the today→recent + weekly levels cascade on subsequent
|
|
172
|
+
// SessionStarts once now.md is drained. (cron-active above still wins — a
|
|
173
|
+
// registered cron owns the whole pipeline.)
|
|
174
|
+
if (nowMdHasContent(projectRoot)) {
|
|
175
|
+
return { action: 'stale-now', reason: 'now-md-has-prior-session-content' };
|
|
176
|
+
}
|
|
177
|
+
|
|
144
178
|
const ts = now ?? nowIso();
|
|
145
179
|
const nowMs = new Date(ts).getTime();
|
|
146
180
|
const files = listTodayFiles(projectRoot);
|
|
@@ -281,12 +315,24 @@ export async function runLazyCompress({
|
|
|
281
315
|
return { action: 'skipped', reason: verdict.reason, duration_ms };
|
|
282
316
|
}
|
|
283
317
|
|
|
284
|
-
// verdict.action is 'stale-daily' or 'stale-weekly'.
|
|
285
|
-
// Delegate to the
|
|
286
|
-
// already gated above; the inner call shouldn't gate a second time on
|
|
287
|
-
//
|
|
318
|
+
// verdict.action is 'stale-now', 'stale-daily', or 'stale-weekly'.
|
|
319
|
+
// Delegate to the matching pipeline stage, passing cooldownMs=0 because we
|
|
320
|
+
// already gated above; the inner call shouldn't gate a second time on the
|
|
321
|
+
// same marker. Task 105: 'stale-now' rolls now.md → today-*.md via
|
|
322
|
+
// compressSession (the level the SessionEnd hook owns); the today→recent +
|
|
323
|
+
// weekly levels cascade on the next SessionStart once now.md is drained.
|
|
288
324
|
let result;
|
|
289
|
-
|
|
325
|
+
let delegatedTo;
|
|
326
|
+
if (verdict.action === 'stale-now') {
|
|
327
|
+
delegatedTo = 'compress-session';
|
|
328
|
+
result = await compressSession({
|
|
329
|
+
projectRoot,
|
|
330
|
+
backend,
|
|
331
|
+
now: ts,
|
|
332
|
+
cooldownMs: 0,
|
|
333
|
+
});
|
|
334
|
+
} else if (verdict.action === 'stale-weekly') {
|
|
335
|
+
delegatedTo = 'weekly-curate';
|
|
290
336
|
result = await weeklyCurate({
|
|
291
337
|
projectRoot,
|
|
292
338
|
backend,
|
|
@@ -294,6 +340,7 @@ export async function runLazyCompress({
|
|
|
294
340
|
cooldownMs: 0,
|
|
295
341
|
});
|
|
296
342
|
} else {
|
|
343
|
+
delegatedTo = 'daily-distill';
|
|
297
344
|
result = await dailyDistill({
|
|
298
345
|
projectRoot,
|
|
299
346
|
backend,
|
|
@@ -310,17 +357,19 @@ export async function runLazyCompress({
|
|
|
310
357
|
scope: 'lazy-compress',
|
|
311
358
|
action: result?.action ?? 'unknown',
|
|
312
359
|
verdict: verdict.action,
|
|
313
|
-
delegated_to:
|
|
360
|
+
delegated_to: delegatedTo,
|
|
314
361
|
duration_ms,
|
|
315
362
|
success: result?.action !== 'error',
|
|
316
363
|
...(result?.errorCategory ? { error_category: result.errorCategory } : {}),
|
|
364
|
+
// compress-session reports its error via error_category (snake) — pass it
|
|
365
|
+
// through too so the lazy log captures either shape.
|
|
366
|
+
...(result?.error_category ? { error_category: result.error_category } : {}),
|
|
317
367
|
},
|
|
318
368
|
});
|
|
319
369
|
return {
|
|
320
370
|
...result,
|
|
321
371
|
verdict: verdict.action,
|
|
322
|
-
delegatedTo
|
|
323
|
-
verdict.action === 'stale-weekly' ? 'weekly-curate' : 'daily-distill',
|
|
372
|
+
delegatedTo,
|
|
324
373
|
duration_ms,
|
|
325
374
|
};
|
|
326
375
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Rich-fact body + slug shaping — the single source of truth for HOW a rich
|
|
2
|
+
// fact's file body and filename slug are built (Task 103).
|
|
3
|
+
//
|
|
4
|
+
// Extracted from subcommands.mjs so the TWO rich-capture paths build identical
|
|
5
|
+
// fact files (the shared-modules / no-drift rule, CLAUDE.md §1.3):
|
|
6
|
+
// 1. explicit — `cmk remember --why/--how` → runRememberRich (subcommands.mjs)
|
|
7
|
+
// 2. automatic — the Stop-hook auto-extract synthesizing rich facts on the
|
|
8
|
+
// native-immune path (auto-extract.mjs, Task 103)
|
|
9
|
+
// Both call writeFact() with a body produced here, so an auto-extracted fact
|
|
10
|
+
// reads the same as an explicitly-captured one.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a slug for a rich fact's filename from its title.
|
|
14
|
+
*
|
|
15
|
+
* Collapse every run of non-alphanumerics to a single '-' (so dashes are never
|
|
16
|
+
* doubled), cap at 60 chars, then trim a leading/trailing dash without a regex
|
|
17
|
+
* quantifier (static analysis flags trailing `-+$` as ReDoS-prone; a single
|
|
18
|
+
* dash is all that can remain after the collapse, so string ops suffice).
|
|
19
|
+
*
|
|
20
|
+
* @param {string} s - the source text (typically the fact title).
|
|
21
|
+
* @returns {string} a `[a-z0-9][a-z0-9_-]*`-safe slug, or 'fact' if empty.
|
|
22
|
+
*/
|
|
23
|
+
export function slugifyFact(s) {
|
|
24
|
+
let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 60);
|
|
25
|
+
if (base.startsWith('-')) base = base.slice(1);
|
|
26
|
+
if (base.endsWith('-')) base = base.slice(0, -1);
|
|
27
|
+
return base || 'fact';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Assemble the rich fact body in the v0.1.1 shape: headline + Why + How.
|
|
32
|
+
* The headline/body may itself be multi-line markdown (a structured breakdown);
|
|
33
|
+
* Why/How are appended as labelled blocks only when present.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} opts
|
|
36
|
+
* @param {string} opts.text - the headline / body (may be multi-line markdown).
|
|
37
|
+
* @param {string} [opts.why] - the rationale → `**Why:**` block.
|
|
38
|
+
* @param {string} [opts.how] - how to apply → `**How to apply:**` block.
|
|
39
|
+
* @returns {string} the assembled markdown body for writeFact().
|
|
40
|
+
*/
|
|
41
|
+
export function buildRichFactBody({ text, why, how }) {
|
|
42
|
+
const parts = [String(text).trim()];
|
|
43
|
+
if (why && String(why).trim()) parts.push(`**Why:** ${String(why).trim()}`);
|
|
44
|
+
if (how && String(how).trim()) parts.push(`**How to apply:** ${String(how).trim()}`);
|
|
45
|
+
return parts.join('\n\n');
|
|
46
|
+
}
|
package/src/subcommands.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { autoPersona } from './auto-persona.mjs';
|
|
|
27
27
|
import { exportPersona, importPersona } from './persona-portability.mjs';
|
|
28
28
|
import { setNativeAutoMemory, nativeMemoryInstallNote } from './native-memory.mjs';
|
|
29
29
|
import { writeFact } from './write-fact.mjs';
|
|
30
|
+
import { buildRichFactBody, slugifyFact } from './rich-fact.mjs';
|
|
30
31
|
import { createHash } from 'node:crypto';
|
|
31
32
|
import { runLazyCompress } from './lazy-compress.mjs';
|
|
32
33
|
import { runDoctor } from './doctor.mjs';
|
|
@@ -356,25 +357,6 @@ function runSearch(queryParts, options) {
|
|
|
356
357
|
*/
|
|
357
358
|
// Task 63 (F1): a slug derived from the title — lowercased, non-alphanumerics
|
|
358
359
|
// collapsed to '-', trimmed, capped. Always passes writeFact's SLUG_PATTERN.
|
|
359
|
-
function slugifyFact(s) {
|
|
360
|
-
// Collapse every run of non-alphanumerics to a single '-' (so dashes are
|
|
361
|
-
// never doubled), cap, then trim a leading/trailing dash without a regex
|
|
362
|
-
// quantifier (static analysis flags trailing `-+$` as ReDoS-prone; a single
|
|
363
|
-
// dash is all that can remain after the collapse, so string ops suffice).
|
|
364
|
-
let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 60);
|
|
365
|
-
if (base.startsWith('-')) base = base.slice(1);
|
|
366
|
-
if (base.endsWith('-')) base = base.slice(0, -1);
|
|
367
|
-
return base || 'fact';
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Assemble the rich fact body in the v0.1.1 shape: headline + Why + How.
|
|
371
|
-
function buildRichFactBody({ text, why, how }) {
|
|
372
|
-
const parts = [String(text).trim()];
|
|
373
|
-
if (why && String(why).trim()) parts.push(`**Why:** ${String(why).trim()}`);
|
|
374
|
-
if (how && String(how).trim()) parts.push(`**How to apply:** ${String(how).trim()}`);
|
|
375
|
-
return parts.join('\n\n');
|
|
376
|
-
}
|
|
377
|
-
|
|
378
360
|
/**
|
|
379
361
|
* `cmk remember --why … --how … --type … --title …` (Task 63 / F1) — RICH
|
|
380
362
|
* capture. Writes a real granular fact file (frontmatter + Why/How/links) via
|
|
@@ -12,7 +12,7 @@ context/.index/
|
|
|
12
12
|
context/.locks/
|
|
13
13
|
|
|
14
14
|
# Diagnostic NDJSON logs (observability only; carry raw turn excerpts —
|
|
15
|
-
# e.g.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# e.g. discarded-low-trust traces — that are NOT secret-screened, so they
|
|
16
|
+
# must never be committed). The durable memory lives in the scratchpads +
|
|
17
|
+
# fact files, not these logs.
|
|
18
18
|
context/sessions/*.extract.log
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This project uses **claude-memory-kit** for per-project, in-repo memory that survives session boundaries. Memory lives in `context/` (committed) and `context.local/` (gitignored). Cross-project memory lives at `~/.claude-memory-kit/` (or `$MEMORY_KIT_USER_DIR`).
|
|
4
4
|
|
|
5
|
-
>
|
|
5
|
+
> This block is the runtime contract for the kit. `cmk doctor` reports which layers are active in your install. Docs: <https://github.com/LH8PPL/claude-memory-kit>
|
|
6
6
|
|
|
7
7
|
### Where memory lives
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ Precedence at session start: local > project > user (most-specific wins, others
|
|
|
22
22
|
|
|
23
23
|
### Health checks (when `cmk doctor` is live)
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
The `cmk doctor` health checks verify each layer is wired correctly: install integrity, hook registration, transcript capture freshness, INDEX accuracy, cron registration, semantic search backend, native Auto Memory coexistence, and stale locks. Full design + decision records: <https://github.com/LH8PPL/claude-memory-kit>.
|
|
26
26
|
|
|
27
27
|
### Recalling memory (for Claude)
|
|
28
28
|
|
|
@@ -23,7 +23,7 @@ How to update it:
|
|
|
23
23
|
|
|
24
24
|
For inspiration:
|
|
25
25
|
See claude-memory-kit's own journey log as a worked example:
|
|
26
|
-
https://github.com/LH8PPL/claude-memory-kit/
|
|
26
|
+
https://github.com/LH8PPL/claude-memory-kit/tree/main/docs/journey
|
|
27
27
|
|
|
28
28
|
This template uses HTML comments (like this one) to coach the
|
|
29
29
|
user through each section. The comments are stripped from
|