@lh8ppl/claude-memory-kit 0.1.2 → 0.2.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 +12 -5
- package/bin/cmk-auto-extract.mjs +13 -0
- package/bin/cmk-compress-session.mjs +31 -17
- package/bin/cmk-inject-context.mjs +12 -2
- package/bin/cmk-weekly-curate.mjs +14 -2
- package/package.json +3 -2
- package/src/audit-log.mjs +6 -0
- package/src/auto-drain.mjs +59 -0
- package/src/auto-extract.mjs +117 -6
- package/src/auto-persona.mjs +544 -0
- package/src/bullet-lookup.mjs +59 -0
- package/src/capture-turn.mjs +54 -0
- package/src/compress-session.mjs +6 -8
- package/src/compressor.mjs +19 -4
- package/src/conflict-queue.mjs +8 -1
- package/src/daily-distill.mjs +19 -11
- package/src/doctor.mjs +74 -23
- package/src/forget.mjs +14 -0
- package/src/graduate-session.mjs +65 -0
- package/src/graduation.mjs +179 -0
- package/src/inject-context.mjs +206 -59
- package/src/install.mjs +52 -7
- package/src/lessons-promote.mjs +137 -0
- package/src/memory-write.mjs +2 -2
- package/src/native-memory.mjs +98 -0
- package/src/persona-portability.mjs +253 -0
- package/src/provenance.mjs +23 -5
- package/src/read-hook-stdin.mjs +47 -0
- package/src/register-crons.mjs +17 -8
- package/src/scratchpad.mjs +247 -19
- package/src/session-end-tasks.mjs +127 -0
- package/src/settings-hooks.mjs +33 -3
- package/src/subcommands.mjs +339 -16
- package/src/weekly-curate.mjs +53 -6
- package/src/write-fact.mjs +14 -0
- package/template/.claude/skills/memory-write/SKILL.md +47 -88
- package/template/.gitignore.fragment +6 -0
- package/template/CLAUDE.md.template +15 -9
- package/template/local/machine-paths.md.template +1 -12
- package/template/local/overrides.md.template +1 -11
- package/template/project/MEMORY.md.template +5 -26
- package/template/project/SOUL.md.template +1 -10
- package/template/user/fragments/INDEX.md.template +1 -1
- package/template/.claude/hooks/pre-tool-memory.js +0 -78
- package/template/.claude/hooks/transcript-capture.js +0 -69
- package/template/.claude/settings.json +0 -27
- package/template/support/scripts/auto-extract-memory.sh +0 -102
- package/template/support/scripts/refresh-distill-timestamp.py +0 -35
- package/template/support/scripts/register-crons.py +0 -242
- package/template/support/scripts/run-daily-distill.sh +0 -67
- package/template/support/scripts/run-weekly-curate.sh +0 -58
package/README.md
CHANGED
|
@@ -4,9 +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
9
|
- **Auto-extract on every assistant turn** — a background `claude --print` subagent reads each turn and saves durable facts (decisions, preferences, environment) to memory. 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.
|
|
11
|
+
- **Search + MCP** — `cmk search "<term>"` (keyword/hybrid over facts + scratchpads); `cmk mcp` exposes the same to Claude Code as tools.
|
|
12
|
+
- **Bounded by compression** — session → daily → weekly Haiku rollups (cron or lazy-on-read) keep the snapshot small as history grows.
|
|
10
13
|
- **Per-project, in-repo** — `context/` lives inside your project and travels with `git clone`. Each project keeps its own memory.
|
|
11
14
|
- **9 health checks** — `cmk doctor` validates install, hook wiring, distill freshness, INDEX consistency, cron registration, and stale locks.
|
|
12
15
|
|
|
@@ -19,11 +22,11 @@ Each route is complete on its own. **Don't run both** — they wire the same hoo
|
|
|
19
22
|
```bash
|
|
20
23
|
npm install -g @lh8ppl/claude-memory-kit
|
|
21
24
|
cd ~/my-project
|
|
22
|
-
cmk install # scaffolds context/ AND wires the lifecycle hooks into .claude/settings.json
|
|
25
|
+
cmk install # scaffolds context/ + the memory-write skill AND wires the lifecycle hooks into .claude/settings.json
|
|
23
26
|
cmk doctor # verify, then restart Claude Code
|
|
24
27
|
```
|
|
25
28
|
|
|
26
|
-
`cmk install` is a complete entry point: it scaffolds `context/` and writes the 5 lifecycle hooks (PATH-resolved, cross-OS) into the project's `.claude/settings.json`. No separate `/plugin` step needed. Use `cmk install --no-hooks` for a scaffold-only install.
|
|
29
|
+
`cmk install` is a complete entry point: it scaffolds `context/`, drops the `memory-write` skill into `.claude/skills/` (committed — travels with `git clone`), and writes the 5 lifecycle hooks (PATH-resolved, cross-OS) into the project's `.claude/settings.json`. No separate `/plugin` step needed. Use `cmk install --no-hooks` for a scaffold-only install.
|
|
27
30
|
|
|
28
31
|
> Installing the package globally adds the `cmk` CLI **and** the installer. It's the `cmk install` *subcommand* that wires the hooks — not the bare `npm install`.
|
|
29
32
|
|
|
@@ -44,13 +47,17 @@ Most-used commands (full list via `cmk --help`):
|
|
|
44
47
|
|
|
45
48
|
| Command | Purpose |
|
|
46
49
|
| --- | --- |
|
|
47
|
-
| `cmk install` | Scaffold `context/` + `.gitignore` + CLAUDE.md block + wire hooks (`--no-hooks` for scaffold-only) |
|
|
50
|
+
| `cmk install` | Scaffold `context/` + the `memory-write` skill + `.gitignore` + CLAUDE.md block + wire hooks (`--no-hooks` for scaffold-only) |
|
|
48
51
|
| `cmk doctor` | Run HC-1..HC-9 health checks, surface repair commands |
|
|
49
52
|
| `cmk repair --hooks` / `--locks` / `--index` / `--all` | Idempotent self-repair |
|
|
50
53
|
| `cmk search "<query>" [--mode keyword\|semantic\|hybrid]` | Search accumulated memory (keyword default) |
|
|
51
54
|
| `cmk roll --scope now\|today\|recent` | Manually trigger a compression pipeline |
|
|
52
55
|
| `cmk register-crons [--dry-run] [--unregister]` | Register daily + weekly jobs with cron / launchd / Task Scheduler |
|
|
53
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) |
|
|
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) |
|
|
54
61
|
| `cmk import-anthropic-memory [--dry-run] [--yes]` | Merge bullets from Anthropic's native auto-memory into MEMORY.md |
|
|
55
62
|
|
|
56
63
|
## Requirements
|
|
@@ -74,4 +81,4 @@ Full docs, architecture, and design live in the repository:
|
|
|
74
81
|
|
|
75
82
|
## License
|
|
76
83
|
|
|
77
|
-
MIT ©
|
|
84
|
+
MIT © the maintainer
|
package/bin/cmk-auto-extract.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { dirname, join } from 'node:path';
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
18
19
|
|
|
19
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
20
21
|
const __dirname = dirname(__filename);
|
|
@@ -29,12 +30,15 @@ if (!turnFile) {
|
|
|
29
30
|
|
|
30
31
|
const autoExtractModulePath = join(__dirname, '..', 'src', 'auto-extract.mjs');
|
|
31
32
|
const compressorModulePath = join(__dirname, '..', 'src', 'compressor.mjs');
|
|
33
|
+
const tierPathsModulePath = join(__dirname, '..', 'src', 'tier-paths.mjs');
|
|
32
34
|
|
|
33
35
|
let runAutoExtract;
|
|
34
36
|
let HaikuViaAnthropicApi;
|
|
37
|
+
let resolveTierRoot;
|
|
35
38
|
try {
|
|
36
39
|
({ runAutoExtract } = await import(pathToFileURL(autoExtractModulePath).href));
|
|
37
40
|
({ HaikuViaAnthropicApi } = await import(pathToFileURL(compressorModulePath).href));
|
|
41
|
+
({ resolveTierRoot } = await import(pathToFileURL(tierPathsModulePath).href));
|
|
38
42
|
} catch (err) {
|
|
39
43
|
process.stderr.write(
|
|
40
44
|
`cmk-auto-extract: failed to load modules: ${err?.message ?? err}\n`,
|
|
@@ -42,6 +46,14 @@ try {
|
|
|
42
46
|
process.exit(0);
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
// Task 61 — inline cross-project promotion: pass the user-tier dir so
|
|
50
|
+
// cross-project doctrine promotes immediately. Resolve the base via the
|
|
51
|
+
// shared tier-paths resolver (never re-derive ~/.claude-memory-kit inline —
|
|
52
|
+
// CLAUDE.md shared-modules rule), and only pass it when the user tier
|
|
53
|
+
// actually exists (skip gracefully on a fresh machine, like autoPersona).
|
|
54
|
+
const userDirBase = resolveTierRoot({ tier: 'U' });
|
|
55
|
+
const userDir = existsSync(userDirBase) ? userDirBase : undefined;
|
|
56
|
+
|
|
45
57
|
try {
|
|
46
58
|
const haikuBackend = new HaikuViaAnthropicApi();
|
|
47
59
|
const r = await runAutoExtract({
|
|
@@ -49,6 +61,7 @@ try {
|
|
|
49
61
|
projectRoot,
|
|
50
62
|
haikuBackend,
|
|
51
63
|
sessionId: process.env.CMK_SESSION_ID,
|
|
64
|
+
userDir,
|
|
52
65
|
});
|
|
53
66
|
process.stderr.write(
|
|
54
67
|
`cmk-auto-extract: ${r.action} (observations: ${r.observation_count ?? 0}, ms: ${r.duration_ms ?? 0})\n`,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// {"continue": true}. Always exit 0 — a crashed SessionEnd hook would
|
|
12
12
|
// block the user from closing their terminal.
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
17
|
|
|
@@ -19,24 +19,20 @@ function emitContinue() {
|
|
|
19
19
|
process.stdout.write('{"continue": true}');
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
let rawInput = '';
|
|
23
|
-
try {
|
|
24
|
-
rawInput = readFileSync(0, 'utf8');
|
|
25
|
-
} catch {
|
|
26
|
-
// stdin not connected — fine; SessionEnd still proceeds.
|
|
27
|
-
}
|
|
28
|
-
void rawInput;
|
|
29
|
-
|
|
30
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
31
23
|
const __dirname = dirname(__filename);
|
|
32
24
|
|
|
33
|
-
const
|
|
25
|
+
const readHookStdinPath = join(__dirname, '..', 'src', 'read-hook-stdin.mjs');
|
|
26
|
+
const sessionEndTasksModulePath = join(__dirname, '..', 'src', 'session-end-tasks.mjs');
|
|
34
27
|
const compressorModulePath = join(__dirname, '..', 'src', 'compressor.mjs');
|
|
35
28
|
|
|
36
|
-
let
|
|
29
|
+
let readHookStdin;
|
|
30
|
+
let runSessionEndTasks;
|
|
31
|
+
let summarizeSessionEnd;
|
|
37
32
|
let HaikuViaAnthropicApi;
|
|
38
33
|
try {
|
|
39
|
-
({
|
|
34
|
+
({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
|
|
35
|
+
({ runSessionEndTasks, summarizeSessionEnd } = await import(pathToFileURL(sessionEndTasksModulePath).href));
|
|
40
36
|
({ HaikuViaAnthropicApi } = await import(pathToFileURL(compressorModulePath).href));
|
|
41
37
|
} catch (err) {
|
|
42
38
|
process.stderr.write(
|
|
@@ -46,15 +42,33 @@ try {
|
|
|
46
42
|
process.exit(0);
|
|
47
43
|
}
|
|
48
44
|
|
|
45
|
+
// Drain the hook payload so Claude Code's pipe closes cleanly — but NOT when
|
|
46
|
+
// stdin is an interactive TTY (a manual run): readFileSync(0) would block
|
|
47
|
+
// forever on a console that never sends EOF, hanging before any of the body
|
|
48
|
+
// runs (DECISION-LOG 2026-06-06). The payload is discarded; we read state from
|
|
49
|
+
// disk. readHookStdin returns '' for a TTY so a manual invocation finishes.
|
|
50
|
+
readHookStdin({ isTTY: process.stdin.isTTY });
|
|
51
|
+
|
|
49
52
|
const projectRoot = process.env.CMK_PROJECT_DIR ?? process.cwd();
|
|
50
53
|
|
|
51
54
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
);
|
|
55
|
+
// Task 86b + D-42: run compressSession and the dedicated autoPersona classifier
|
|
56
|
+
// CONCURRENTLY (they have disjoint inputs/outputs, so they don't race), keeping
|
|
57
|
+
// the SessionEnd wall-clock at max(~50s) under the 60s hook ceiling instead of
|
|
58
|
+
// the sequential sum (~100s). See session-end-tasks.mjs for the full rationale.
|
|
59
|
+
const userDir = process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
|
|
60
|
+
const outcomes = await runSessionEndTasks({
|
|
61
|
+
projectRoot,
|
|
62
|
+
userDir,
|
|
63
|
+
makeBackend: () => new HaikuViaAnthropicApi(),
|
|
64
|
+
});
|
|
65
|
+
for (const line of summarizeSessionEnd(outcomes)) {
|
|
66
|
+
process.stderr.write(line);
|
|
67
|
+
}
|
|
57
68
|
} catch (err) {
|
|
69
|
+
// Defensive backstop: runSessionEndTasks uses allSettled so it should never
|
|
70
|
+
// reject, but a synchronous throw (e.g. resolving userDir) must not block the
|
|
71
|
+
// user from closing their terminal.
|
|
58
72
|
process.stderr.write(
|
|
59
73
|
`cmk-compress-session: unexpected error: ${err?.message ?? err}\n`,
|
|
60
74
|
);
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// unconditionally — a throwing SessionStart hook would interrupt
|
|
15
15
|
// session start, worse than an empty additionalContext.
|
|
16
16
|
|
|
17
|
-
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
18
18
|
import { dirname, join } from 'node:path';
|
|
19
19
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
20
|
|
|
@@ -29,6 +29,16 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
29
29
|
const __dirname = dirname(__filename);
|
|
30
30
|
const modulePath = join(__dirname, '..', 'src', 'inject-context.mjs');
|
|
31
31
|
|
|
32
|
+
// Resolve the sibling lazy-compress bin (ships in this same bin/ dir) so
|
|
33
|
+
// inject-context can spawn `node <path>` directly instead of the shell:true
|
|
34
|
+
// `.cmd` shim — the Windows console-popup fix (Task 81). Env override first;
|
|
35
|
+
// null in a corrupt install → graceful shell:true fallback in spawnLazyCompress.
|
|
36
|
+
const compressLazyPath =
|
|
37
|
+
process.env.CMK_COMPRESS_LAZY_PATH ??
|
|
38
|
+
(existsSync(join(__dirname, 'cmk-compress-lazy.mjs'))
|
|
39
|
+
? join(__dirname, 'cmk-compress-lazy.mjs')
|
|
40
|
+
: null);
|
|
41
|
+
|
|
32
42
|
let injectContext;
|
|
33
43
|
try {
|
|
34
44
|
({ injectContext } = await import(pathToFileURL(modulePath).href));
|
|
@@ -50,7 +60,7 @@ try {
|
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
try {
|
|
53
|
-
const r = injectContext({ cwd: process.cwd() });
|
|
63
|
+
const r = injectContext({ cwd: process.cwd(), compressLazyPath });
|
|
54
64
|
process.stdout.write(JSON.stringify(r.hookOutput));
|
|
55
65
|
process.exit(0);
|
|
56
66
|
} catch (err) {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// cooldown marker are the load-bearing observability surfaces.
|
|
13
13
|
|
|
14
14
|
import { dirname, join } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
15
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
17
|
|
|
17
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -41,11 +42,22 @@ const envRoot = process.env.CMK_PROJECT_DIR && process.env.CMK_PROJECT_DIR.lengt
|
|
|
41
42
|
: null;
|
|
42
43
|
const projectRoot = argvRoot ?? envRoot ?? process.cwd();
|
|
43
44
|
|
|
45
|
+
// User tier (cross-project) lives at ~/.claude-memory-kit. Passing it
|
|
46
|
+
// activates the Design-B auto-persona hook (Task 45): the weekly cycle
|
|
47
|
+
// synthesizes cross-project doctrine from the project's fact archive and
|
|
48
|
+
// auto-promotes it into the user tier. Without userDir the curate runs
|
|
49
|
+
// project-only (backward-compatible).
|
|
50
|
+
const userDir = join(homedir(), '.claude-memory-kit');
|
|
51
|
+
|
|
44
52
|
try {
|
|
45
53
|
const backend = new HaikuViaAnthropicApi();
|
|
46
|
-
const r = await weeklyCurate({ projectRoot, backend });
|
|
54
|
+
const r = await weeklyCurate({ projectRoot, userDir, backend });
|
|
55
|
+
const p = r.persona;
|
|
56
|
+
const personaNote = p
|
|
57
|
+
? ` | persona: ${p.action}${p.promoted?.length ? ` (+${p.promoted.length})` : ''}${p.superseded?.length ? ` (~${p.superseded.length})` : ''}`
|
|
58
|
+
: '';
|
|
47
59
|
process.stderr.write(
|
|
48
|
-
`cmk-weekly-curate: ${r.action}${r.reason ? ` (${r.reason})` : ''}${r.archivedDays ? ` (archived: ${r.archivedDays}d, current: ${r.currentDays}d, in: ${r.bytesIn}b, out: ${r.bytesOut}b)` : ''} ms: ${r.duration_ms ?? 0}\n`,
|
|
60
|
+
`cmk-weekly-curate: ${r.action}${r.reason ? ` (${r.reason})` : ''}${r.archivedDays ? ` (archived: ${r.archivedDays}d, current: ${r.currentDays}d, in: ${r.bytesIn}b, out: ${r.bytesOut}b)` : ''}${personaNote} ms: ${r.duration_ms ?? 0}\n`,
|
|
49
61
|
);
|
|
50
62
|
} catch (err) {
|
|
51
63
|
process.stderr.write(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lh8ppl/claude-memory-kit",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.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": {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"test": "vitest run",
|
|
28
|
+
"prepack": "node ../../scripts/prepublish-copy-template.mjs",
|
|
28
29
|
"prepublishOnly": "node ../../scripts/prepublish-copy-template.mjs"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
33
34
|
"better-sqlite3": "^12.10.0",
|
|
34
35
|
"chokidar": "^5.0.0",
|
|
35
|
-
"commander": "^
|
|
36
|
+
"commander": "^15.0.0",
|
|
36
37
|
"js-yaml": "^4.1.0",
|
|
37
38
|
"zod": "^4.4.3"
|
|
38
39
|
},
|
package/src/audit-log.mjs
CHANGED
|
@@ -35,6 +35,8 @@ export const REASON_CODES = Object.freeze({
|
|
|
35
35
|
USER_REQUESTED: 'user-requested', // forget: user-initiated tombstone
|
|
36
36
|
CURATED_MERGE: 'curated-merge', // mergeFacts: explicit merge of A + B → C
|
|
37
37
|
SCRATCHPAD_APPEND: 'scratchpad-append', // scratchpad: appendScratchpadBullet (Task 12)
|
|
38
|
+
SCRATCHPAD_GRADUATED: 'scratchpad-graduated', // graduation: high-trust bullet moved out to a fact file under cap pressure (Task 91.1)
|
|
39
|
+
SCRATCHPAD_EVICTED: 'scratchpad-evicted', // consolidate: stale low/medium bullet dropped under cap pressure, archived to memory/archive/evicted-bullets.md (Task 91.2)
|
|
38
40
|
TRUST_CHANGE: 'trust-change', // trust: overrideTrust (Task 15)
|
|
39
41
|
CONFLICT_QUEUED: 'conflict-queued', // conflict-queue: new write contradicts existing higher-trust fact, routed to queues/conflicts.md (Task 25, design §6.8)
|
|
40
42
|
CONFLICT_RESOLVED: 'conflict-resolved', // conflict-queue: user resolved a pending conflict via cmk queue conflicts (keep-old / keep-new / merge-both)
|
|
@@ -46,6 +48,10 @@ export const REASON_CODES = Object.freeze({
|
|
|
46
48
|
REPAIR_HOOKS_NOOP: 'repair-hooks-noop', // cmk repair --hooks: settings.json already canonical, no-op (Task 39)
|
|
47
49
|
INSTALL_HOOKS_WIRED: 'install-hooks-wired', // cmk install: settings.json wired with npm-route hooks (Task 49). NOTE: no NOOP counterpart — install audits only on change, to keep re-runs byte-idempotent (the audit.log is append-only).
|
|
48
50
|
REPAIR_LOCK_REMOVED: 'repair-lock-removed', // cmk repair --locks: stale lock unlinked (Task 39)
|
|
51
|
+
PERSONA_PROMOTED: 'persona-promoted', // auto-persona: cross-project doctrine auto-promoted into a user-tier scratchpad at trust:medium (Task 45, design §16.16)
|
|
52
|
+
PERSONA_SUPERSEDED: 'persona-superseded', // auto-persona: a promoted persona fact auto-superseded a contradicting existing one (Task 45.6, reuses Task 25 conflict detection)
|
|
53
|
+
PERSONA_SECTION_CREATED: 'persona-section-created', // auto-persona: a new `## ` section was created on a user-tier scratchpad to land a candidate (Task 64 / F2)
|
|
54
|
+
PERSONA_IMPORTED: 'persona-imported', // persona-portability: a user-tier persona bundle was imported onto this machine (Task 72)
|
|
49
55
|
});
|
|
50
56
|
|
|
51
57
|
export function nowIso() {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Auto-drain the review + conflict queues (v0.2 Phase 2, decision-log D-6).
|
|
2
|
+
//
|
|
3
|
+
// The "i dont want to do anything, i want it to be automatic" posture: the
|
|
4
|
+
// daily-distill / weekly-curate maintenance passes resolve the queues with
|
|
5
|
+
// AUTOMATIC resolvers, instead of waiting for the user to run
|
|
6
|
+
// `cmk queue review` / `cmk queue conflicts`. Those manual verbs still work
|
|
7
|
+
// for anyone who wants explicit control — they are just no longer REQUIRED.
|
|
8
|
+
//
|
|
9
|
+
// Resolvers (optimistic):
|
|
10
|
+
// - Review queue — medium-trust auto-extractions awaiting blessing →
|
|
11
|
+
// **auto-promote**. Optimistic: trust the capture. Mistakes self-correct:
|
|
12
|
+
// a later contradicting fact auto-supersedes (the conflict path), and the
|
|
13
|
+
// 14-day medium-trust staleness drop + consolidation clean up noise.
|
|
14
|
+
// - Conflict queue — a new LOWER-trust write that contradicted an existing
|
|
15
|
+
// HIGHER-trust fact (that's the only thing that lands here; equal-or-higher
|
|
16
|
+
// trust auto-supersedes upstream and never reaches the queue) →
|
|
17
|
+
// **keep-old**. Protect the established/hand-curated higher-trust fact; the
|
|
18
|
+
// lower-trust contradiction is discarded (logged via the resolver's audit).
|
|
19
|
+
// Optimism never lets noise override an established fact.
|
|
20
|
+
//
|
|
21
|
+
// Per design §6.2 (review queue) + §6.8 (conflict queue) + §8.7 (cron passes).
|
|
22
|
+
|
|
23
|
+
import { resolveReviewQueue } from './review-queue.mjs';
|
|
24
|
+
import { resolveConflictQueue } from './conflict-queue.mjs';
|
|
25
|
+
import { mergeFacts } from './merge-facts.mjs';
|
|
26
|
+
|
|
27
|
+
// Stateless optimistic resolvers (no per-entry judgement — that's the point).
|
|
28
|
+
const AUTO_PROMOTE = async () => 'promote';
|
|
29
|
+
const KEEP_OLD = async () => 'keep-old';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Drain a tier's review + conflict queues with the optimistic auto-resolvers.
|
|
33
|
+
* Safe to call when the queues are absent/empty (the resolvers return
|
|
34
|
+
* zero-count results, not errors).
|
|
35
|
+
*
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {'P'|'U'|'L'} [opts.tier='P']
|
|
38
|
+
* @param {string} [opts.projectRoot]
|
|
39
|
+
* @param {string} [opts.userDir]
|
|
40
|
+
* @param {string} [opts.scratchpad] review-queue promotion target (default MEMORY.md)
|
|
41
|
+
* @param {string} [opts.section] review-queue promotion section (default Active Threads)
|
|
42
|
+
* @returns {Promise<{review: object, conflict: object}>}
|
|
43
|
+
*/
|
|
44
|
+
export async function autoDrainQueues({ tier = 'P', projectRoot, userDir, scratchpad, section } = {}) {
|
|
45
|
+
const reviewOpts = { tier, projectRoot, userDir, prompter: AUTO_PROMOTE };
|
|
46
|
+
if (scratchpad) reviewOpts.scratchpad = scratchpad;
|
|
47
|
+
if (section) reviewOpts.section = section;
|
|
48
|
+
const review = await resolveReviewQueue(reviewOpts);
|
|
49
|
+
|
|
50
|
+
const conflict = await resolveConflictQueue({
|
|
51
|
+
tier,
|
|
52
|
+
projectRoot,
|
|
53
|
+
userDir,
|
|
54
|
+
prompter: KEEP_OLD,
|
|
55
|
+
mergeFn: mergeFacts, // never invoked under KEEP_OLD; wired for correctness
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return { review, conflict };
|
|
59
|
+
}
|
package/src/auto-extract.mjs
CHANGED
|
@@ -55,6 +55,11 @@ import { pidIsAlive } from './lock-discipline.mjs';
|
|
|
55
55
|
import { nowIso } from './audit-log.mjs';
|
|
56
56
|
import { ERROR_CATEGORIES } from './result-shapes.mjs';
|
|
57
57
|
import { touchCooldownMarker } from './cooldown.mjs';
|
|
58
|
+
// Task 61 — inline cross-project promotion. Reuse auto-persona's classifier
|
|
59
|
+
// directive, parser, and promote-to-user-tier path so the SAME per-turn Haiku
|
|
60
|
+
// call that extracts project facts ALSO promotes cross-project doctrine to the
|
|
61
|
+
// user tier immediately (vs waiting for the weekly auto-persona janitor).
|
|
62
|
+
import { parsePersonaCandidates, promoteCandidatesToUserTier, PERSONA_CONFIDENCE_RULE } from './auto-persona.mjs';
|
|
58
63
|
|
|
59
64
|
const LOCK_FILENAME = 'auto-extract.lock';
|
|
60
65
|
const NOW_MD_RELATIVE = ['context', 'sessions', 'now.md'];
|
|
@@ -100,6 +105,12 @@ const TRUST_RANK = Object.freeze({
|
|
|
100
105
|
discarded: 0,
|
|
101
106
|
});
|
|
102
107
|
|
|
108
|
+
// Task 92 (G6): max chars of a discarded LOW candidate's text to record in the
|
|
109
|
+
// extract.log trace. Enough to identify what was dropped without bloating the
|
|
110
|
+
// log; the full turn still lives in transcripts/{date}.md if deeper recovery is
|
|
111
|
+
// needed.
|
|
112
|
+
const LOW_DISCARD_EXCERPT_MAX = 200;
|
|
113
|
+
|
|
103
114
|
// --- Lock file primitives -------------------------------------------
|
|
104
115
|
|
|
105
116
|
function acquireLock(lockPath) {
|
|
@@ -238,7 +249,7 @@ function parseTurnFile(rawTurn) {
|
|
|
238
249
|
// Written from scratch per design §6.4 — no text copied from claude-
|
|
239
250
|
// remember's prompts. Output format encodes origin so the routing
|
|
240
251
|
// layer can apply the assistant-demotion rule (§6.4 amendment, 2026-05-26).
|
|
241
|
-
function buildExtractionInstructions() {
|
|
252
|
+
export function buildExtractionInstructions() {
|
|
242
253
|
return [
|
|
243
254
|
'You are a memory-extraction agent for claude-memory-kit.',
|
|
244
255
|
'You read a captured turn pair (the user prompt + the assistant response) and identify durable facts worth saving.',
|
|
@@ -249,8 +260,8 @@ function buildExtractionInstructions() {
|
|
|
249
260
|
'Save when EITHER turn reveals any of the six writing triggers:',
|
|
250
261
|
' 1. User corrections — "don\'t do that again", "use this instead".',
|
|
251
262
|
' 2. Discovered preferences — patterns across multiple turns.',
|
|
252
|
-
' 3.
|
|
253
|
-
' 4. Project conventions — discovered
|
|
263
|
+
' 3. Setup / configuration facts — concrete setup or configuration values the next session would otherwise have to re-derive to recover (the answer to "what\'s our setup / how is this configured"). **Capture these even when they surface from the WORK itself, not only from a stated preference** — a value recalled from memory is what saves the next session from re-deriving it.',
|
|
264
|
+
' 4. Project conventions — patterns discovered while working in the project (in its files, structure, or materials).',
|
|
254
265
|
' 5. Completed complex workflows — 5+ tool calls; the approach is worth recording.',
|
|
255
266
|
' 6. Tool quirks and workarounds — non-obvious findings.',
|
|
256
267
|
'',
|
|
@@ -272,6 +283,15 @@ function buildExtractionInstructions() {
|
|
|
272
283
|
' - If a previous-entry context is included below, do NOT re-emit facts already in it.',
|
|
273
284
|
'',
|
|
274
285
|
'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
|
+
'',
|
|
287
|
+
'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
|
+
' PERSONA CANDIDATE | target=<HABITS.md|LESSONS.md|USER.md> | section=<Section> | confidence=<high|medium|low> | <one-line restatement>',
|
|
289
|
+
' - HABITS.md → sections: Iteration Cadence | Destructive Operations | Communication Style',
|
|
290
|
+
' - LESSONS.md → sections: Tooling Lessons | Process Lessons | Anti-patterns',
|
|
291
|
+
' - USER.md → sections: About | Preferences | Working Style',
|
|
292
|
+
' PREFER an existing section above — route to the closest fit. Only if NONE genuinely fits may you name a new short Title-Case section (2-4 words, letters/spaces only, e.g. "Architecture Preferences"). Never invent a new section when an existing one fits.',
|
|
293
|
+
PERSONA_CONFIDENCE_RULE,
|
|
294
|
+
' Emit no PERSONA CANDIDATE line if nothing is cross-project.',
|
|
275
295
|
].join('\n');
|
|
276
296
|
}
|
|
277
297
|
|
|
@@ -460,6 +480,8 @@ export async function runAutoExtract({
|
|
|
460
480
|
haikuBackend,
|
|
461
481
|
now,
|
|
462
482
|
sessionId,
|
|
483
|
+
userDir, // Task 61: when present, cross-project candidates promote to the user tier inline
|
|
484
|
+
settings,
|
|
463
485
|
} = {}) {
|
|
464
486
|
const ts = now ?? nowIso();
|
|
465
487
|
const t0 = Date.now();
|
|
@@ -586,7 +608,14 @@ export async function runAutoExtract({
|
|
|
586
608
|
instructions,
|
|
587
609
|
maxOutputBytes: 2000,
|
|
588
610
|
preserveCitationIds: false,
|
|
589
|
-
|
|
611
|
+
// 90s, not 25s: the real `claude --print` extraction (full turn +
|
|
612
|
+
// instructions) consistently exceeded a 25s ceiling on a live machine
|
|
613
|
+
// and was KILLED mid-call (extract.log: success:false, haiku_timeout,
|
|
614
|
+
// duration ≈ 25000ms = hitting the cap, not finishing) → automatic
|
|
615
|
+
// capture + persona promotion (F2) silently never ran. This call is
|
|
616
|
+
// DETACHED (fire-and-forget, never blocks the session), so a generous
|
|
617
|
+
// ceiling is free. Live-test finding (2026-06-01, lior-test-4 baseline).
|
|
618
|
+
timeoutMs: 90_000,
|
|
590
619
|
});
|
|
591
620
|
// Touch the cooldown marker IMMEDIATELY after the Haiku call
|
|
592
621
|
// resolves — this is the "we spent the budget" signal that
|
|
@@ -639,9 +668,61 @@ export async function runAutoExtract({
|
|
|
639
668
|
candidates = applyRetainOverride(candidates, retainSegments);
|
|
640
669
|
candidates = dedupByCanonicalId(candidates);
|
|
641
670
|
|
|
642
|
-
|
|
671
|
+
// Task 61 — inline cross-project promotion. The SAME Haiku output may
|
|
672
|
+
// carry PERSONA CANDIDATE lines (cross-project doctrine); promote them to
|
|
673
|
+
// the user tier THIS run (vs the weekly auto-persona janitor). No second
|
|
674
|
+
// LLM call — same outputText. Runs BEFORE the project-empty check so a
|
|
675
|
+
// turn that is ONLY cross-project doctrine still promotes.
|
|
676
|
+
let persona = null;
|
|
677
|
+
if (userDir) {
|
|
678
|
+
// Inline persona promotion is SECONDARY to project extraction — a bug in
|
|
679
|
+
// the cross-project path must never take down the primary job (project
|
|
680
|
+
// facts + extract.log). Isolate it: on throw, record the error on the
|
|
681
|
+
// result and continue routing project candidates normally.
|
|
682
|
+
try {
|
|
683
|
+
const personaCandidates = parsePersonaCandidates(haikuResult.outputText);
|
|
684
|
+
if (personaCandidates.length > 0) {
|
|
685
|
+
// Task 78 — the wedge's AUTO half. Only confidence=high candidates
|
|
686
|
+
// clear the promote gate, and (post-Task-78 grading) confidence=high
|
|
687
|
+
// means the user EXPLICITLY STATED a standing rule THIS turn → it is
|
|
688
|
+
// user-attested, so promote at trust:high (durable; won't be clobbered
|
|
689
|
+
// by a later inferred-medium entry). Inferred (medium) candidates still
|
|
690
|
+
// queue to persona-review. The weekly janitor stays at the default
|
|
691
|
+
// medium (45.6) — it synthesizes from accumulated facts, not a fresh
|
|
692
|
+
// statement.
|
|
693
|
+
persona = promoteCandidatesToUserTier({
|
|
694
|
+
candidates: personaCandidates,
|
|
695
|
+
userDir,
|
|
696
|
+
now: ts,
|
|
697
|
+
settings,
|
|
698
|
+
trust: 'high',
|
|
699
|
+
source: 'user-explicit',
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
} catch (err) {
|
|
703
|
+
persona = { promoted: [], queued: [], superseded: [], conflicts: [], error: err?.message ?? String(err) };
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const personaLanded =
|
|
707
|
+
!!persona && ((persona.promoted?.length ?? 0) + (persona.superseded?.length ?? 0)) > 0;
|
|
708
|
+
// Door 4 (observability): when the inline persona pass ran, the
|
|
709
|
+
// extract.log entry carries the promotion counts so a later debugger
|
|
710
|
+
// can see cross-project doctrine landed in the user tier without
|
|
711
|
+
// re-running the turn. Empty object when no userDir / no persona pass.
|
|
712
|
+
const personaLogFields = persona
|
|
713
|
+
? {
|
|
714
|
+
persona_promoted: persona.promoted?.length ?? 0,
|
|
715
|
+
persona_superseded: persona.superseded?.length ?? 0,
|
|
716
|
+
persona_queued: persona.queued?.length ?? 0,
|
|
717
|
+
persona_conflicts: persona.conflicts?.length ?? 0,
|
|
718
|
+
...(persona.error ? { persona_error: persona.error } : {}),
|
|
719
|
+
}
|
|
720
|
+
: {};
|
|
721
|
+
|
|
722
|
+
if (candidates.length === 0 && !personaLanded) {
|
|
643
723
|
const entry = {
|
|
644
724
|
...baseEntry,
|
|
725
|
+
...personaLogFields,
|
|
645
726
|
success: true,
|
|
646
727
|
skipped_reason: 'nothing_durable',
|
|
647
728
|
duration_ms: Date.now() - t0,
|
|
@@ -654,6 +735,7 @@ export async function runAutoExtract({
|
|
|
654
735
|
duration_ms: entry.duration_ms,
|
|
655
736
|
logPath,
|
|
656
737
|
candidates: [],
|
|
738
|
+
persona,
|
|
657
739
|
};
|
|
658
740
|
}
|
|
659
741
|
|
|
@@ -681,6 +763,27 @@ export async function runAutoExtract({
|
|
|
681
763
|
writes.push({ ...candidate, written: 'review', result: r });
|
|
682
764
|
} else {
|
|
683
765
|
writes.push({ ...candidate, written: 'discarded' });
|
|
766
|
+
// Task 92 (G6): a LOW (or assistant-demoted-to-discarded) candidate is
|
|
767
|
+
// dropped from active memory, but leave a recoverable trace — the
|
|
768
|
+
// excerpt + reason — in extract.log, so a fact Haiku mis-graded LOW (or
|
|
769
|
+
// an assistant-origin fact demoted to LOW) is auditable, not silently
|
|
770
|
+
// vanished (the §6.5 "don't lose without trace" principle at the capture
|
|
771
|
+
// edge; MEDIUM already gets a review queue, LOW got nothing). Log-only
|
|
772
|
+
// by decision (92.1): NOT routed to the review queue — that would flood
|
|
773
|
+
// it with low-signal noise. One discrete NDJSON entry per drop (Door 4).
|
|
774
|
+
writeExtractLogEntry({
|
|
775
|
+
projectRoot,
|
|
776
|
+
ts,
|
|
777
|
+
entry: {
|
|
778
|
+
event: 'low_trust_discarded',
|
|
779
|
+
reason: 'low_trust_discarded',
|
|
780
|
+
trust: candidate.trust,
|
|
781
|
+
demoted_from: candidate.demotedFrom ?? null,
|
|
782
|
+
origin: candidate.origin ?? null,
|
|
783
|
+
excerpt: candidate.text.slice(0, LOW_DISCARD_EXCERPT_MAX),
|
|
784
|
+
excerpt_truncated: candidate.text.length > LOW_DISCARD_EXCERPT_MAX,
|
|
785
|
+
},
|
|
786
|
+
});
|
|
684
787
|
}
|
|
685
788
|
}
|
|
686
789
|
|
|
@@ -688,9 +791,14 @@ export async function runAutoExtract({
|
|
|
688
791
|
(w) => w.written === 'memory' || w.written === 'review' || w.written === 'conflict',
|
|
689
792
|
).length;
|
|
690
793
|
|
|
691
|
-
|
|
794
|
+
// Persona-only turn: no project candidate landed, but cross-project
|
|
795
|
+
// doctrine promoted to the user tier this run. That IS a durable
|
|
796
|
+
// extraction — fall through to the 'extracted' return (observation_count
|
|
797
|
+
// stays 0; `persona` carries the user-tier result + Door 4 log fields).
|
|
798
|
+
if (observation_count === 0 && !personaLanded) {
|
|
692
799
|
const entry = {
|
|
693
800
|
...baseEntry,
|
|
801
|
+
...personaLogFields,
|
|
694
802
|
success: true,
|
|
695
803
|
skipped_reason: 'nothing_durable',
|
|
696
804
|
duration_ms: Date.now() - t0,
|
|
@@ -703,11 +811,13 @@ export async function runAutoExtract({
|
|
|
703
811
|
duration_ms: entry.duration_ms,
|
|
704
812
|
logPath,
|
|
705
813
|
candidates: writes,
|
|
814
|
+
persona,
|
|
706
815
|
};
|
|
707
816
|
}
|
|
708
817
|
|
|
709
818
|
const entry = {
|
|
710
819
|
...baseEntry,
|
|
820
|
+
...personaLogFields,
|
|
711
821
|
success: true,
|
|
712
822
|
observation_count,
|
|
713
823
|
duration_ms: Date.now() - t0,
|
|
@@ -719,6 +829,7 @@ export async function runAutoExtract({
|
|
|
719
829
|
duration_ms: entry.duration_ms,
|
|
720
830
|
logPath,
|
|
721
831
|
candidates: writes,
|
|
832
|
+
persona,
|
|
722
833
|
};
|
|
723
834
|
} finally {
|
|
724
835
|
// Cleanup order: turn-file FIRST (frees disk), lock LAST (releases
|