@lh8ppl/claude-memory-kit 0.4.0 → 0.4.1
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 +135 -54
- package/bin/cmk-approve-permission.mjs +62 -0
- package/bin/cmk-daily-distill.mjs +14 -0
- package/bin/cmk-inject-context.mjs +12 -0
- package/bin/cmk-weekly-curate.mjs +12 -0
- package/package.json +3 -2
- package/src/approve-permission.mjs +92 -0
- package/src/auto-extract.mjs +17 -10
- package/src/auto-persona.mjs +7 -3
- package/src/compaction-state.mjs +204 -0
- package/src/doctor.mjs +42 -1
- package/src/inject-context.mjs +8 -15
- package/src/install.mjs +37 -4
- package/src/lazy-compress.mjs +43 -110
- package/src/register-crons.mjs +31 -0
- package/src/settings-hooks.mjs +58 -1
- package/src/tier-paths.mjs +47 -13
package/README.md
CHANGED
|
@@ -5,97 +5,178 @@
|
|
|
5
5
|
</picture>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
<p align="center">
|
|
9
|
+
<strong>Persistent, per-project memory for <a href="https://docs.claude.com/en/docs/claude-code">Claude Code</a> — plain markdown, committed with your code, recalled by meaning.</strong>
|
|
10
|
+
</p>
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://www.npmjs.com/package/@lh8ppl/claude-memory-kit"><img src="https://img.shields.io/npm/v/@lh8ppl/claude-memory-kit?label=npm&color=blue" alt="npm"></a>
|
|
14
|
+
<a href="https://github.com/LH8PPL/claude-memory-kit/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-yellow" alt="License: MIT"></a>
|
|
15
|
+
<img src="https://img.shields.io/badge/Node.js-bundled%20%C2%B7%20none%20required-brightgreen" alt="Node.js bundled, none required">
|
|
16
|
+
<a href="https://github.com/LH8PPL/claude-memory-kit/actions/workflows/ci.yml"><img src="https://github.com/LH8PPL/claude-memory-kit/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
17
|
+
</p>
|
|
11
18
|
|
|
12
|
-
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="https://img.shields.io/badge/Windows-supported-0078D6?logo=windows&logoColor=white" alt="Windows supported">
|
|
21
|
+
<img src="https://img.shields.io/badge/macOS-supported-000000?logo=apple&logoColor=white" alt="macOS supported">
|
|
22
|
+
<img src="https://img.shields.io/badge/Linux-supported-FCC624?logo=linux&logoColor=black" alt="Linux supported">
|
|
23
|
+
</p>
|
|
13
24
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- **Per-project, in-repo** — `context/` lives inside your project and travels with `git clone`. Each project keeps its own memory.
|
|
24
|
-
- **9 health checks** — `cmk doctor` validates hook wiring, distill freshness, transcript firing, INDEX consistency, cron registration, native-memory coexistence, stale locks, native-binding health (npm 12 readiness), and version drift (a project scaffold behind your installed `cmk` after an update) — each failure with a repair command.
|
|
25
|
+
<p align="center">
|
|
26
|
+
<img src="https://img.shields.io/badge/Claude_Code-supported-6E56CF" alt="Claude Code supported">
|
|
27
|
+
<img src="https://img.shields.io/badge/Kiro-supported-6E56CF" alt="Kiro supported">
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
Claude forgets everything when a session ends — so every new chat you re-explain who you are, what you're building, and how you like things done. **claude-memory-kit** fixes that: it quietly captures your decisions, preferences, and project context, then hands them back at the start of every session. Everything is plain text inside your project, and it travels with the code — `git clone` brings the memory along.
|
|
31
|
+
|
|
32
|
+
> [!NOTE]
|
|
33
|
+
> **Not a developer?** If you can open a project in Claude Code, you're set — let Claude run the setup for you (see [Quickstart](#quickstart)).
|
|
25
34
|
|
|
26
|
-
##
|
|
35
|
+
## How it feels
|
|
27
36
|
|
|
28
|
-
|
|
37
|
+
You open Claude Code on a project you haven't touched in weeks. Before you say anything, Claude already knows your stack, your conventions, and what you decided last time:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
claude-memory-kit: 23 fact(s) in context, 2 captured in the last 24h, 1 conflict pending
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
You work. It learns — automatically, no buttons. Next session, it remembers this one too.
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **Remembers across sessions** — a frozen snapshot of your project + persona injects once at session start, so Claude leads with what it knows instead of re-deriving it from code.
|
|
48
|
+
- **Captures automatically, prompt-free** — a background pass reads each turn and saves durable facts as searchable notes. No "save" button. When you *do* say "remember this," the kit auto-approves its **own** tools and skills so the save happens with no "Allow?" prompt — nothing else is touched.
|
|
49
|
+
- **Recalls by meaning** — ask in your own words ("where do credentials go") and get the right fact even with zero keyword overlap. Fully local, zero API calls — **R@5 0.941 / paraphrase 1.000** ([benchmarks](#benchmarks)).
|
|
50
|
+
- **Learns how you work, everywhere** — state a habit once ("always use uv, never pip") and a brand-new project cold-opens already knowing it.
|
|
51
|
+
- **Stays private + bounded** — secrets are screened before any write, machine paths are abstracted to `~`, and rolling compression keeps memory small as history grows.
|
|
52
|
+
- **Guards against accidental deletion** — a hook **blocks** a destructive command (`rm`, `git reset --hard`, …) the moment it targets a memory path, before it runs.
|
|
53
|
+
- **Works across your agents** — the same memory brain on **Claude Code** and **[Kiro](https://kiro.dev)** (IDE + `kiro-cli`). A project's `context/` is shared, so memory you build in one is there in the other.
|
|
54
|
+
- **Per-project, in your repo** — `context/` lives in your project and travels with `git clone`. Each project keeps its own memory.
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
|
|
58
|
+
> [!IMPORTANT]
|
|
59
|
+
> Pick **one** route — both wire the same hooks and are complete on their own.
|
|
29
60
|
|
|
30
61
|
### Route A — npm (recommended)
|
|
31
62
|
|
|
63
|
+
Install the CLI once, then run `cmk install` in each project — pick your agent:
|
|
64
|
+
|
|
32
65
|
```bash
|
|
33
66
|
npm install -g @lh8ppl/claude-memory-kit
|
|
34
67
|
cd ~/my-project
|
|
35
|
-
cmk install # scaffolds context/ + the memory-write + memory-search skills AND wires the lifecycle hooks into .claude/settings.json
|
|
36
|
-
cmk install --with-semantic # (optional) local semantic recall — one-time ~260 MB, search defaults to hybrid
|
|
37
|
-
cmk install --ide kiro # (optional) target Kiro instead — wires the IDE + kiro-cli surfaces (MCP + steering + AGENTS.md + skills + hooks)
|
|
38
|
-
cmk register-crons # (optional) scheduled background compression — otherwise self-heals lazily
|
|
39
|
-
cmk import-claude-md --yes # (optional) seed memory from an existing CLAUDE.md / .cursorrules (--dry-run previews)
|
|
40
|
-
cmk doctor # verify, then restart Claude Code
|
|
41
68
|
```
|
|
42
69
|
|
|
43
|
-
|
|
70
|
+
**Claude Code:**
|
|
44
71
|
|
|
45
|
-
|
|
72
|
+
```bash
|
|
73
|
+
cmk install # scaffold context/ + wire hooks (one step)
|
|
74
|
+
cmk install --with-semantic # optional: local semantic recall (~260 MB, once)
|
|
75
|
+
cmk doctor # verify, then restart Claude Code
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Kiro** (IDE + `kiro-cli` — see [the Kiro guide](https://github.com/LH8PPL/claude-memory-kit/blob/main/docs/KIRO.md)):
|
|
46
79
|
|
|
47
|
-
|
|
80
|
+
```bash
|
|
81
|
+
cmk install --ide kiro # wire Kiro end-to-end
|
|
82
|
+
cmk install --ide kiro --with-semantic # …with local semantic recall
|
|
83
|
+
cmk doctor # verify, then restart Kiro
|
|
84
|
+
```
|
|
48
85
|
|
|
49
|
-
|
|
86
|
+
`cmk install` is the whole entry point: it scaffolds `context/`, drops the memory skills, wires the lifecycle hooks, and registers the MCP server so the agent can drive memory as tools — no `/plugin` step needed. A project can carry **both** agents — run both installs; they share one `context/`.
|
|
50
87
|
|
|
51
|
-
|
|
88
|
+
> [!TIP]
|
|
89
|
+
> Prefer not to touch the terminal? Open the project in Claude Code and say *"install claude-memory-kit and set it up here."* Claude runs the commands; you just approve them. **Restart Claude Code once** afterward (`/exit`, then `claude`) so the hooks load.
|
|
52
90
|
|
|
53
|
-
|
|
91
|
+
### Route B — Claude Code plugin
|
|
54
92
|
|
|
55
93
|
```text
|
|
56
94
|
/plugin marketplace add LH8PPL/claude-memory-kit # add this repo as a plugin source (once per machine)
|
|
57
|
-
/plugin install claude-memory-kit # install
|
|
58
|
-
cd ~/my-project
|
|
59
|
-
/claude-memory-kit:bootstrap # scaffold this project's
|
|
95
|
+
/plugin install claude-memory-kit # install hooks + skills (once per machine)
|
|
96
|
+
cd ~/my-project
|
|
97
|
+
/claude-memory-kit:bootstrap # scaffold this project's memory (once per project)
|
|
60
98
|
```
|
|
61
99
|
|
|
62
|
-
The
|
|
100
|
+
The plugin bundles the hooks + skills, so it's complete without the npm CLI. Add the CLI later only if you want `cmk search` / `cmk doctor` / cron.
|
|
101
|
+
|
|
102
|
+
> [!NOTE]
|
|
103
|
+
> **Updating** has two parts on both routes: update the machinery, then re-stamp each project (`cmk install` again, or `/claude-memory-kit:bootstrap`). `cmk doctor` flags any project that's behind so you don't have to remember.
|
|
104
|
+
|
|
105
|
+
## How it works
|
|
106
|
+
|
|
107
|
+
`context/` is the source of truth — plain markdown, committed with your code. A regenerable SQLite + FTS5 index (plus an optional local embedder) powers search. Memory lives in three tiers:
|
|
108
|
+
|
|
109
|
+
| Tier | Location | Scope | What lives here |
|
|
110
|
+
| --- | --- | --- | --- |
|
|
111
|
+
| **Project** | `<repo>/context/` | committed — travels with `clone` | Decisions, conventions, file purposes |
|
|
112
|
+
| **Local** | `<repo>/context.local/` | gitignored, per-machine | Machine paths, local tool versions |
|
|
113
|
+
| **User** | `~/.claude-memory-kit/` | cross-project, per-person | Persona, cross-project lessons |
|
|
114
|
+
|
|
115
|
+
Project memory follows the **repo** (teammates get it on clone). Your persona follows **you** — machine-local, never committed. Carry it between your own machines with `cmk persona export` / `import`.
|
|
63
116
|
|
|
64
117
|
## CLI
|
|
65
118
|
|
|
66
|
-
|
|
119
|
+
You rarely type these yourself — Claude drives the same operations as tools mid-conversation through the kit's **MCP server** (full tool reference: **[docs/MCP.md](https://github.com/LH8PPL/claude-memory-kit/blob/main/docs/MCP.md)**). The commands:
|
|
67
120
|
|
|
68
121
|
| Command | Purpose |
|
|
69
122
|
| --- | --- |
|
|
70
|
-
| `cmk install` | Scaffold
|
|
71
|
-
| `cmk
|
|
123
|
+
| `cmk install [--with-semantic] [--ide claude-code\|kiro]` | Scaffold + wire hooks + register the MCP server (complete entry point) |
|
|
124
|
+
| `cmk uninstall [--ide claude-code\|kiro]` | Remove one agent's wiring — conservative, never deletes `context/` |
|
|
125
|
+
| `cmk doctor` | Run HC-1..HC-10 health checks; surface a repair command per failure |
|
|
72
126
|
| `cmk repair --hooks` / `--locks` / `--index` / `--all` | Idempotent self-repair |
|
|
73
|
-
| `cmk search "<query>" [--mode keyword\|semantic\|hybrid] [--scope facts\|transcripts\|decisions]` | Search memory
|
|
74
|
-
| `cmk
|
|
127
|
+
| `cmk search "<query>" [--mode keyword\|semantic\|hybrid] [--scope facts\|transcripts\|decisions]` | Search memory by meaning (hybrid default after `--with-semantic`); `--scope transcripts` = raw session record; `--scope decisions` = the decision journal (history / "what did we reject") |
|
|
128
|
+
| `cmk remember "<fact>"` | Capture a fact explicitly (deduped, secret-screened, path-abstracted). `--from-file fact.json` for backtick/quote-heavy rich facts |
|
|
129
|
+
| `cmk get <id…>` / `cmk timeline <id>` / `cmk cite <id>` / `cmk recent-activity` | Read the index back — full fact bodies + provenance, context around an observation, a citation link, recent changes (the CLI side of the `mk_*` MCP read tools) |
|
|
130
|
+
| `cmk forget <id>` | Tombstone a fact — gone from `cmk search` immediately (audit trail preserved) |
|
|
131
|
+
| `cmk lessons promote <id> [--to USER.md\|HABITS.md]` | Promote one project fact to your cross-project **user tier** so it applies in **every** project |
|
|
75
132
|
| `cmk roll --scope now\|today\|recent` | Manually trigger a compression pipeline |
|
|
76
|
-
| `cmk register-crons [--dry-run] [--unregister]` | Register daily + weekly jobs
|
|
77
|
-
| `cmk
|
|
78
|
-
| `cmk
|
|
79
|
-
| `cmk
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
133
|
+
| `cmk register-crons [--dry-run] [--unregister]` | Register daily + weekly compression jobs (cron / launchd / Task Scheduler) |
|
|
134
|
+
| `cmk disable-native-memory` / `enable-native-memory` | Opt out of Claude Code's built-in Auto Memory so the kit is your single, lean layer |
|
|
135
|
+
| `cmk persona generate` · `export <file>` · `import <file>` | Synthesize your cross-project persona on demand; carry it to another of **your** machines (private — never committed) |
|
|
136
|
+
| `cmk import-claude-md [file]` · `import-anthropic-memory` | Seed memory from an existing `CLAUDE.md` / `.cursorrules` / `AGENTS.md`, or merge Anthropic's native auto-memory bullets (`--dry-run` previews) |
|
|
137
|
+
|
|
138
|
+
Full reference with examples: **[docs/CLI.md](https://github.com/LH8PPL/claude-memory-kit/blob/main/docs/CLI.md)** or `cmk --help`.
|
|
139
|
+
|
|
140
|
+
## Working with Kiro
|
|
141
|
+
|
|
142
|
+
[Kiro](https://kiro.dev) (the AWS agentic IDE + `kiro-cli`) is a first-class target — `cmk install --ide kiro` wires it end-to-end for both the IDE and the terminal, and a project's `context/` is shared with Claude Code. The full setup, surface table, and dual-agent notes are in **[the Kiro guide](https://github.com/LH8PPL/claude-memory-kit/blob/main/docs/KIRO.md)**.
|
|
143
|
+
|
|
144
|
+
## Uninstalling
|
|
145
|
+
|
|
146
|
+
`cmk uninstall` is **conservative** — it removes only the kit's managed wiring for one agent and **never deletes your `context/` memory** (your data) or anything outside the kit's markers.
|
|
147
|
+
|
|
148
|
+
**Claude Code:**
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
cmk uninstall # remove the CLAUDE.md block + hooks
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Kiro:**
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
cmk uninstall --ide kiro # remove the .kiro/ blocks + skills + IDE hooks + AGENTS.md + ~/.kiro CLI agent
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
On a dual-agent project, uninstall one and the other keeps working. To remove the memory data too, delete `context/` (and `context.local/`) yourself — the kit won't do it for you.
|
|
161
|
+
|
|
162
|
+
## Benchmarks
|
|
163
|
+
|
|
164
|
+
Recall quality is **measured, not claimed** — `npm run bench:recall` runs a LongMemEval-style harness through the kit's real write / index / search paths.
|
|
165
|
+
|
|
166
|
+
| Pipeline | R@5 | Paraphrase recall | API calls |
|
|
167
|
+
| --- | --- | --- | --- |
|
|
168
|
+
| Keyword (FTS5 one-shot) | 0.176 | 0.000 | 0 |
|
|
169
|
+
| Agentic keyword (iterative + LLM reformulation) | 0.529 | 0.300 | 1/query |
|
|
170
|
+
| **Semantic (sqlite-vec + local bge-base, the default)** | **0.941** | **1.000** | **0** |
|
|
171
|
+
|
|
172
|
+
Keyword search structurally misses natural-language questions; the embedded semantic backend closes the paraphrase gap entirely — locally, with no API calls.
|
|
84
173
|
|
|
85
174
|
## Requirements
|
|
86
175
|
|
|
87
176
|
- Node.js ≥ 20
|
|
88
|
-
- Claude Code (for the hook-driven auto-memory loop)
|
|
177
|
+
- Claude Code (for the hook-driven auto-memory loop) — or [Kiro](https://kiro.dev)
|
|
89
178
|
- Optional: `cmk install --with-semantic` for semantic/hybrid recall (installs the local `@huggingface/transformers` embedder, ~260 MB once — no API, no Python)
|
|
90
179
|
|
|
91
|
-
## Three-tier model
|
|
92
|
-
|
|
93
|
-
| Tier | Location | Scope |
|
|
94
|
-
| --- | --- | --- |
|
|
95
|
-
| **P** (project) | `<repo>/context/` | committed to git, travels with `clone` |
|
|
96
|
-
| **L** (local) | `<repo>/context.local/` | gitignored, per-machine |
|
|
97
|
-
| **U** (user) | `~/.claude-memory-kit/` | cross-project per-user |
|
|
98
|
-
|
|
99
180
|
## Documentation
|
|
100
181
|
|
|
101
182
|
Full docs, architecture, and design live in the repository:
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PermissionRequest hook handler — the prompt-free auto-approver (Task 172).
|
|
3
|
+
//
|
|
4
|
+
// Wired by `cmk install` as a Claude Code PermissionRequest hook (matchers
|
|
5
|
+
// "mcp__cmk__.*" and "Skill"). Reads the permission request on stdin and, if it
|
|
6
|
+
// is for one of the kit's OWN surfaces (an `mcp__cmk__<tool>` MCP tool or a
|
|
7
|
+
// scaffolded kit skill), prints the allow decision to stdout so Claude Code
|
|
8
|
+
// approves it without a prompt. For anything else it prints nothing and exits 0,
|
|
9
|
+
// leaving CC's normal permission flow untouched.
|
|
10
|
+
//
|
|
11
|
+
// Why this exists: CC 2.1.x stopped honouring the kit's `permissions.allow`
|
|
12
|
+
// MCP rules + skill `allowed-tools` for these prompts (anthropics/claude-code
|
|
13
|
+
// #17499, #18837→#14956). The PermissionRequest hook is the documented,
|
|
14
|
+
// working mechanism (proven live, v041l).
|
|
15
|
+
//
|
|
16
|
+
// Output contract (code.claude.com/docs/en/hooks-guide):
|
|
17
|
+
// stdout = {"hookSpecificOutput":{"hookEventName":"PermissionRequest",
|
|
18
|
+
// "decision":{"behavior":"allow"}}} → auto-approve
|
|
19
|
+
// stdout empty + exit 0 → no opinion (CC asks as usual)
|
|
20
|
+
// Fail-SILENT: any load/parse/logic error prints nothing and exits 0 — a broken
|
|
21
|
+
// auto-approver must never wedge the session OR wrongly approve; it just stops
|
|
22
|
+
// auto-approving and the user sees the normal prompt.
|
|
23
|
+
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
28
|
+
const __dirname = dirname(__filename);
|
|
29
|
+
const readHookStdinPath = join(__dirname, '..', 'src', 'read-hook-stdin.mjs');
|
|
30
|
+
const modulePath = join(__dirname, '..', 'src', 'approve-permission.mjs');
|
|
31
|
+
|
|
32
|
+
let readHookStdin;
|
|
33
|
+
let evaluatePermissionRequest;
|
|
34
|
+
try {
|
|
35
|
+
({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
|
|
36
|
+
({ evaluatePermissionRequest } = await import(pathToFileURL(modulePath).href));
|
|
37
|
+
} catch {
|
|
38
|
+
process.exit(0); // fail-silent: no opinion
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Drain the hook payload — but not on an interactive TTY (a manual run), where a
|
|
42
|
+
// blocking stdin read would hang forever (the Task-101 lesson).
|
|
43
|
+
const raw = readHookStdin({ isTTY: process.stdin.isTTY });
|
|
44
|
+
|
|
45
|
+
let payload;
|
|
46
|
+
try {
|
|
47
|
+
payload = raw.trim() === '' ? {} : JSON.parse(raw);
|
|
48
|
+
} catch {
|
|
49
|
+
process.exit(0); // fail-silent on unparseable input
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let decision;
|
|
53
|
+
try {
|
|
54
|
+
decision = evaluatePermissionRequest(payload);
|
|
55
|
+
} catch {
|
|
56
|
+
process.exit(0); // fail-silent on any logic error
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (decision) {
|
|
60
|
+
process.stdout.write(JSON.stringify(decision));
|
|
61
|
+
}
|
|
62
|
+
process.exit(0);
|
|
@@ -26,12 +26,15 @@ const __dirname = dirname(__filename);
|
|
|
26
26
|
// relative to bin/ → ../src/ for both modules.
|
|
27
27
|
const dailyDistillModulePath = join(__dirname, '..', 'src', 'daily-distill.mjs');
|
|
28
28
|
const compressorModulePath = join(__dirname, '..', 'src', 'compressor.mjs');
|
|
29
|
+
const compactionStateModulePath = join(__dirname, '..', 'src', 'compaction-state.mjs');
|
|
29
30
|
|
|
30
31
|
let dailyDistill;
|
|
31
32
|
let HaikuViaAnthropicApi;
|
|
33
|
+
let recordCronHeartbeat;
|
|
32
34
|
try {
|
|
33
35
|
({ dailyDistill } = await import(pathToFileURL(dailyDistillModulePath).href));
|
|
34
36
|
({ HaikuViaAnthropicApi } = await import(pathToFileURL(compressorModulePath).href));
|
|
37
|
+
({ recordCronHeartbeat } = await import(pathToFileURL(compactionStateModulePath).href));
|
|
35
38
|
} catch (err) {
|
|
36
39
|
process.stderr.write(
|
|
37
40
|
`cmk-daily-distill: failed to load modules: ${err?.message ?? err}\n`,
|
|
@@ -52,6 +55,17 @@ const envRoot = process.env.CMK_PROJECT_DIR && process.env.CMK_PROJECT_DIR.lengt
|
|
|
52
55
|
: null;
|
|
53
56
|
const projectRoot = argvRoot ?? envRoot ?? process.cwd();
|
|
54
57
|
|
|
58
|
+
// Task 167 (D-207): record the cron HEARTBEAT on every fire — BEFORE the distill
|
|
59
|
+
// work, so a run that crashes mid-distill still proves "the cron is alive" (the
|
|
60
|
+
// anacron model: a run HAPPENED, regardless of outcome). This is the durable
|
|
61
|
+
// liveness signal the lazy-roll gate keys off (by age); without it a registered
|
|
62
|
+
// cron would read "dead" after 48h even while firing nightly. Best-effort.
|
|
63
|
+
try {
|
|
64
|
+
recordCronHeartbeat?.({ projectRoot });
|
|
65
|
+
} catch {
|
|
66
|
+
// never let a heartbeat write failure abort the distill
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
try {
|
|
56
70
|
const backend = new HaikuViaAnthropicApi();
|
|
57
71
|
const r = await dailyDistill({ projectRoot, backend });
|
|
@@ -59,6 +59,18 @@ try {
|
|
|
59
59
|
// is discarded; readHookStdin returns '' for a TTY so a manual run finishes.
|
|
60
60
|
readHookStdin({ isTTY: process.stdin.isTTY });
|
|
61
61
|
|
|
62
|
+
// Task 167 NOTE (D-207, the live-test revision): an earlier design (Q4) tried a
|
|
63
|
+
// SYNCHRONOUS now.md drain HERE, before injectContext, to make THIS session read
|
|
64
|
+
// the rolled state. The live test proved that doesn't fit: a real now→today Haiku
|
|
65
|
+
// roll takes ~18–37s, but the SessionStart hook ceiling is 30s — so the
|
|
66
|
+
// synchronous drain reliably timed out and fell back to the detached path anyway.
|
|
67
|
+
// The research (claude-mem/mem0/Letta) confirms the event-driven peers compact at
|
|
68
|
+
// session END (the Stop hook, no user waiting), NOT session start. So the kit
|
|
69
|
+
// keeps the now→today roll on the DETACHED SessionStart path (spawned inside
|
|
70
|
+
// injectContext) + the SessionEnd compress-session hook — and the real fix is the
|
|
71
|
+
// cron-liveness gate (167.A), which stops a dead cron from suppressing that roll.
|
|
72
|
+
// now.md heals next session via the detached roll and never compounds.
|
|
73
|
+
|
|
62
74
|
try {
|
|
63
75
|
const r = injectContext({ cwd: process.cwd(), compressLazyPath });
|
|
64
76
|
process.stdout.write(JSON.stringify(r.hookOutput));
|
|
@@ -20,12 +20,15 @@ const __dirname = dirname(__filename);
|
|
|
20
20
|
|
|
21
21
|
const weeklyCurateModulePath = join(__dirname, '..', 'src', 'weekly-curate.mjs');
|
|
22
22
|
const compressorModulePath = join(__dirname, '..', 'src', 'compressor.mjs');
|
|
23
|
+
const compactionStateModulePath = join(__dirname, '..', 'src', 'compaction-state.mjs');
|
|
23
24
|
|
|
24
25
|
let weeklyCurate;
|
|
25
26
|
let HaikuViaAnthropicApi;
|
|
27
|
+
let recordCronHeartbeat;
|
|
26
28
|
try {
|
|
27
29
|
({ weeklyCurate } = await import(pathToFileURL(weeklyCurateModulePath).href));
|
|
28
30
|
({ HaikuViaAnthropicApi } = await import(pathToFileURL(compressorModulePath).href));
|
|
31
|
+
({ recordCronHeartbeat } = await import(pathToFileURL(compactionStateModulePath).href));
|
|
29
32
|
} catch (err) {
|
|
30
33
|
process.stderr.write(
|
|
31
34
|
`cmk-weekly-curate: failed to load modules: ${err?.message ?? err}\n`,
|
|
@@ -49,6 +52,15 @@ const projectRoot = argvRoot ?? envRoot ?? process.cwd();
|
|
|
49
52
|
// project-only (backward-compatible).
|
|
50
53
|
const userDir = join(homedir(), '.claude-memory-kit');
|
|
51
54
|
|
|
55
|
+
// Task 167 (D-207): record the cron heartbeat on every fire (anacron model —
|
|
56
|
+
// proves the cron is alive regardless of curate outcome). The lazy-roll gate
|
|
57
|
+
// keys off its age. Best-effort; never abort the curate on a heartbeat failure.
|
|
58
|
+
try {
|
|
59
|
+
recordCronHeartbeat?.({ projectRoot });
|
|
60
|
+
} catch {
|
|
61
|
+
// best-effort
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
try {
|
|
53
65
|
const backend = new HaikuViaAnthropicApi();
|
|
54
66
|
const r = await weeklyCurate({ projectRoot, userDir, backend });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lh8ppl/claude-memory-kit",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "cmk — the CLI for claude-memory-kit. Per-project, in-repo memory system for Claude Code.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"cmk-observe-edit": "./bin/cmk-observe-edit.mjs",
|
|
14
14
|
"cmk-capture-turn": "./bin/cmk-capture-turn.mjs",
|
|
15
15
|
"cmk-compress-session": "./bin/cmk-compress-session.mjs",
|
|
16
|
-
"cmk-guard-memory": "./bin/cmk-guard-memory.mjs"
|
|
16
|
+
"cmk-guard-memory": "./bin/cmk-guard-memory.mjs",
|
|
17
|
+
"cmk-approve-permission": "./bin/cmk-approve-permission.mjs"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
19
20
|
"bin/",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// PermissionRequest auto-approve logic — the prompt-free fix (Task 172).
|
|
2
|
+
//
|
|
3
|
+
// Background (the v0.4.1 cut-gate, 2026-06-27). Claude Code 2.1.x tightened
|
|
4
|
+
// permission matching so that neither the kit's `permissions.allow` MCP rules
|
|
5
|
+
// (`mcp__cmk__*` + the specific names, Task 171) NOR a skill's `allowed-tools`
|
|
6
|
+
// grant reliably suppress the per-tool MCP approval prompt or the "Use skill?"
|
|
7
|
+
// prompt — see anthropics/claude-code#17499 (allowed-tools for MCP tools is
|
|
8
|
+
// undocumented/unreliable) and #18837 → #14956 (allowed-tools not enforced).
|
|
9
|
+
// The popup is a CLAUDE CODE change, not a kit regression: the skill + allow-list
|
|
10
|
+
// config has been byte-stable since Task 108/117.
|
|
11
|
+
//
|
|
12
|
+
// The DOCUMENTED, working mechanism is a `PermissionRequest` hook
|
|
13
|
+
// (code.claude.com/docs/en/hooks-guide#auto-approve-specific-permission-prompts):
|
|
14
|
+
// it fires when CC is about to show a permission dialog, and a hook that writes
|
|
15
|
+
// `{"behavior":"allow"}` answers it on the user's behalf — proven live (v041l):
|
|
16
|
+
// the dialog flashes then auto-dismisses, the tool runs, no click required.
|
|
17
|
+
//
|
|
18
|
+
// This module decides — given the hook payload — whether to auto-approve. It
|
|
19
|
+
// approves ONLY the kit's OWN surfaces (its MCP tools + its scaffolded skills),
|
|
20
|
+
// never anything else. Two-layer safety: the wired matcher is already narrow
|
|
21
|
+
// (`mcp__cmk__.*` / `Skill`), and this self-check is the second layer, so even a
|
|
22
|
+
// loose matcher can't make the hook approve a non-kit tool. Returns `null` for
|
|
23
|
+
// anything not kit-owned → the hook stays silent → CC's normal permission flow
|
|
24
|
+
// runs unchanged.
|
|
25
|
+
|
|
26
|
+
// The kit's own scaffolded skills (template/.claude/skills/<name>). Keep in sync
|
|
27
|
+
// with the Skill(...) entries in settings-hooks.mjs KIT_ALLOW.
|
|
28
|
+
const KIT_SKILLS = Object.freeze(['memory-write', 'memory-search']);
|
|
29
|
+
|
|
30
|
+
// The allow decision shape CC expects on stdout for a PermissionRequest hook.
|
|
31
|
+
export const ALLOW_DECISION = Object.freeze({
|
|
32
|
+
hookSpecificOutput: {
|
|
33
|
+
hookEventName: 'PermissionRequest',
|
|
34
|
+
decision: { behavior: 'allow' },
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True iff `toolName` is one of the kit's own MCP tools (any `mcp__cmk__<tool>`).
|
|
40
|
+
*/
|
|
41
|
+
function isKitMcpTool(toolName) {
|
|
42
|
+
return typeof toolName === 'string' && toolName.startsWith('mcp__cmk__');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True iff this is an invocation of one of the kit's own scaffolded skills.
|
|
47
|
+
* The Skill tool surfaces the skill name either as the tool name's suffix
|
|
48
|
+
* (`Skill(memory-write)`) or in `tool_input.name`/`tool_input.skill` — accept
|
|
49
|
+
* either shape so a CC payload-format change doesn't silently stop matching.
|
|
50
|
+
*
|
|
51
|
+
* Security boundary: the `tool_input.name`/`skill` fallback is consulted ONLY
|
|
52
|
+
* when `tool_name` identifies the Skill tool (`Skill` or `Skill(...)`). Without
|
|
53
|
+
* that gate, a non-Skill request whose `tool_input` merely happened to carry
|
|
54
|
+
* `{name:"memory-write"}` (e.g. a `Bash` call) would match — defeating the
|
|
55
|
+
* second layer of defence-in-depth (the matcher is the first). We never read
|
|
56
|
+
* `tool_input` for a tool that isn't the Skill tool.
|
|
57
|
+
*/
|
|
58
|
+
function isKitSkill(toolName, toolInput) {
|
|
59
|
+
if (typeof toolName !== 'string') return false;
|
|
60
|
+
// The documented tool-name form for a skill invocation: `Skill(<name>)`.
|
|
61
|
+
for (const skill of KIT_SKILLS) {
|
|
62
|
+
if (toolName === `Skill(${skill})`) return true;
|
|
63
|
+
}
|
|
64
|
+
// Only trust the tool_input name shape for an actual Skill-tool request.
|
|
65
|
+
// (A bare `tool_name === "<skill>"` is NOT matched — it isn't a documented
|
|
66
|
+
// CC shape and would risk approving any unrelated tool that happened to share
|
|
67
|
+
// the name.)
|
|
68
|
+
if (toolName === 'Skill' || toolName.startsWith('Skill(')) {
|
|
69
|
+
const named = toolInput && (toolInput.name ?? toolInput.skill ?? toolInput.skillName);
|
|
70
|
+
if (typeof named === 'string' && KIT_SKILLS.includes(named)) return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Decide whether to auto-approve a PermissionRequest for a kit-owned surface.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} payload - the hook payload from stdin
|
|
79
|
+
* ({ tool_name, tool_input, ... }).
|
|
80
|
+
* @returns {object|null} the ALLOW_DECISION object to print on stdout, or null
|
|
81
|
+
* when the request is NOT for a kit surface (the hook emits nothing → CC's
|
|
82
|
+
* normal permission flow proceeds).
|
|
83
|
+
*/
|
|
84
|
+
export function evaluatePermissionRequest(payload) {
|
|
85
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
86
|
+
const toolName = payload.tool_name;
|
|
87
|
+
const toolInput = payload.tool_input;
|
|
88
|
+
if (isKitMcpTool(toolName) || isKitSkill(toolName, toolInput)) {
|
|
89
|
+
return ALLOW_DECISION;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
package/src/auto-extract.mjs
CHANGED
|
@@ -858,18 +858,25 @@ export async function runAutoExtract({
|
|
|
858
858
|
// ceiling is free. Live-test finding (2026-06-01, live-test-4 baseline).
|
|
859
859
|
timeoutMs: 90_000,
|
|
860
860
|
});
|
|
861
|
-
// Touch the cooldown marker
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
// block
|
|
867
|
-
//
|
|
861
|
+
// Touch the cooldown marker after a SUCCESSFUL Haiku call — the
|
|
862
|
+
// "we spent the budget" signal compress-session.mjs reads to skip its
|
|
863
|
+
// own Haiku call within 120s of ours.
|
|
864
|
+
//
|
|
865
|
+
// **Original behavior (pre-Task-167, preserved as decision-trail):** the
|
|
866
|
+
// catch block below ALSO touched the cooldown ("spent the budget,
|
|
867
|
+
// succeeded OR failed"), so a failing Haiku here blocked compress-session
|
|
868
|
+
// for 120s — on the theory that a failure shouldn't let the next caller
|
|
869
|
+
// re-spend the budget on the same broken call.
|
|
870
|
+
// **Task 167.F change (D-207, Q5):** the cooldown now fires on SUCCESS
|
|
871
|
+
// ONLY. A FAILED call did NOT successfully spend the budget — blocking the
|
|
872
|
+
// next NEEDED compress for 120s after a *transient* failure is the wrong
|
|
873
|
+
// gate (it was the SECONDARY cause of the 410 KB now.md bloat: after the
|
|
874
|
+
// dead cron, failed-call cooldowns kept the roll skipped). Correctness >
|
|
875
|
+
// cost — a transient failure must be free to retry.
|
|
868
876
|
touchCooldownMarker({ projectRoot, now: ts });
|
|
869
877
|
} catch (err) {
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
touchCooldownMarker({ projectRoot, now: ts });
|
|
878
|
+
// Task 167.F: do NOT touch the cooldown on failure (see the success-path
|
|
879
|
+
// comment above) — a failed call must not block the next compress's retry.
|
|
873
880
|
// Route on the error TYPE — distinguishes "took too long"
|
|
874
881
|
// (HAIKU_TIMEOUT) from "subprocess exited non-zero"
|
|
875
882
|
// (HAIKU_FAILED). Using `instanceof HaikuTimeoutError`
|
package/src/auto-persona.mjs
CHANGED
|
@@ -336,12 +336,16 @@ export async function autoPersona(opts = {}) {
|
|
|
336
336
|
// ceiling, so it passes a generous value — the explicit command can wait.
|
|
337
337
|
timeoutMs,
|
|
338
338
|
});
|
|
339
|
-
// Spent a Haiku call — refresh the shared cooldown marker so the
|
|
340
|
-
// gated caller backs off. (touch even on cooldownMs:0 cycles: the call
|
|
339
|
+
// Spent a Haiku call SUCCESSFULLY — refresh the shared cooldown marker so the
|
|
340
|
+
// next gated caller backs off. (touch even on cooldownMs:0 cycles: the call
|
|
341
341
|
// happened, so the marker should reflect it for any LATER gated caller.)
|
|
342
342
|
touchCooldownMarker({ projectRoot, now: ts });
|
|
343
343
|
} catch (err) {
|
|
344
|
-
|
|
344
|
+
// Task 167.F (D-207, Q5): do NOT touch the cooldown on FAILURE — a failed
|
|
345
|
+
// Haiku call did not successfully spend the budget, and blocking the next
|
|
346
|
+
// needed compress for 120s after a transient failure is the wrong gate
|
|
347
|
+
// (correctness > cost; a transient failure must be free to retry). Original
|
|
348
|
+
// pre-167 behavior touched here too; preserved as decision-trail.
|
|
345
349
|
return errorResult({
|
|
346
350
|
category: ERROR_CATEGORIES.COMPRESS_FAILED,
|
|
347
351
|
errors: [err?.message ?? String(err)],
|