@lh8ppl/claude-memory-kit 0.3.1 → 0.3.3
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 +13 -4
- package/package.json +2 -2
- package/src/auto-extract.mjs +16 -5
- package/src/decisions-journal.mjs +223 -0
- package/src/digest.mjs +89 -0
- package/src/forget.mjs +6 -0
- package/src/import-claude-md.mjs +7 -2
- package/src/index-rebuild.mjs +91 -3
- package/src/inject-context.mjs +16 -6
- package/src/lazy-compress.mjs +81 -0
- package/src/mcp-server.mjs +9 -1
- package/src/read-core.mjs +65 -3
- package/src/remember-core.mjs +15 -15
- package/src/sanitize.mjs +30 -0
- package/src/search.mjs +224 -6
- package/src/session-end-tasks.mjs +40 -3
- package/src/subcommands.mjs +71 -5
- package/template/.claude/skills/memory-search/SKILL.md +20 -0
- package/template/CLAUDE.md.template +1 -0
package/README.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/LH8PPL/claude-memory-kit/main/docs/public/assets/wordmark-dark.svg">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/LH8PPL/claude-memory-kit/main/docs/public/assets/wordmark.svg" alt="claude-memory-kit" width="340">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
1
8
|
# @lh8ppl/claude-memory-kit
|
|
2
9
|
|
|
3
10
|
**`cmk`** — the CLI for [claude-memory-kit](https://github.com/LH8PPL/claude-memory-kit), a per-project, in-repo memory system for [Claude Code](https://docs.claude.com/en/docs/claude-code). It fixes Claude's per-session amnesia so you don't have to re-tell the backstory every time you start a new session.
|
|
@@ -40,11 +47,13 @@ cmk doctor # verify, then restart Claude Code
|
|
|
40
47
|
Inside Claude Code:
|
|
41
48
|
|
|
42
49
|
```text
|
|
43
|
-
/plugin marketplace add LH8PPL/claude-memory-kit
|
|
44
|
-
/plugin install claude-memory-kit
|
|
50
|
+
/plugin marketplace add LH8PPL/claude-memory-kit # add this repo as a plugin source (once per machine)
|
|
51
|
+
/plugin install claude-memory-kit # install the global machinery — hooks + skills (once per machine)
|
|
52
|
+
cd ~/my-project # the project you want memory in — bootstrap scaffolds into the CURRENT dir
|
|
53
|
+
/claude-memory-kit:bootstrap # scaffold this project's context/ memory tree (once per project)
|
|
45
54
|
```
|
|
46
55
|
|
|
47
|
-
|
|
56
|
+
The first two commands are **global** (per machine); `bootstrap` is **per project** — run it again (after a `cd`) in each project. The plugin bundles the hooks + the `bootstrap`, `memory-write`, and `memory-search` skills, so it's complete without the npm CLI (add the CLI later only if you want `cmk search` / `cmk doctor` / cron).
|
|
48
57
|
|
|
49
58
|
## CLI
|
|
50
59
|
|
|
@@ -55,7 +64,7 @@ Most-used commands (full list via `cmk --help`):
|
|
|
55
64
|
| `cmk install` | Scaffold `context/` + the `memory-write`/`memory-search` skills + `.gitignore` + CLAUDE.md block + wire hooks (`--no-hooks` for scaffold-only) |
|
|
56
65
|
| `cmk doctor` | Run HC-1..HC-8 health checks, surface repair commands |
|
|
57
66
|
| `cmk repair --hooks` / `--locks` / `--index` / `--all` | Idempotent self-repair |
|
|
58
|
-
| `cmk search "<query>" [--mode keyword\|semantic\|hybrid] [--scope facts\|transcripts]` | Search memory — by meaning with the embedder (hybrid default after `--with-semantic`); `--scope transcripts` = the raw session record |
|
|
67
|
+
| `cmk search "<query>" [--mode keyword\|semantic\|hybrid] [--scope facts\|transcripts\|decisions]` | Search memory — by meaning with the embedder (hybrid default after `--with-semantic`); `--scope transcripts` = the raw session record; `--scope decisions` = the decision journal (history / "what did we reject") |
|
|
59
68
|
| `cmk get <id…>` / `cmk timeline <id>` / `cmk cite <id>` / `cmk recent-activity` | Read the index back — full fact bodies + provenance, sequential context around an observation, a canonical citation link, recent changes (the CLI side of the `mk_*` MCP read tools) |
|
|
60
69
|
| `cmk roll --scope now\|today\|recent` | Manually trigger a compression pipeline |
|
|
61
70
|
| `cmk register-crons [--dry-run] [--unregister]` | Register daily + weekly jobs with cron / launchd / Task Scheduler |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lh8ppl/claude-memory-kit",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"better-sqlite3": "^12.10.0",
|
|
35
35
|
"chokidar": "^5.0.0",
|
|
36
36
|
"commander": "^15.0.0",
|
|
37
|
-
"js-yaml": "^4.
|
|
37
|
+
"js-yaml": "^4.2.0",
|
|
38
38
|
"sqlite-vec": "^0.1.9",
|
|
39
39
|
"zod": "^4.4.3"
|
|
40
40
|
},
|
package/src/auto-extract.mjs
CHANGED
|
@@ -53,6 +53,7 @@ import { hashContent } from './content-hash.mjs';
|
|
|
53
53
|
import { memoryWrite } from './memory-write.mjs';
|
|
54
54
|
import { writeFact } from './write-fact.mjs';
|
|
55
55
|
import { buildRichFactBody, slugifyFact } from './rich-fact.mjs';
|
|
56
|
+
import { sanitizeForTitle } from './sanitize.mjs';
|
|
56
57
|
import { HaikuTimeoutError } from './compressor.mjs';
|
|
57
58
|
import { pidIsAlive } from './lock-discipline.mjs';
|
|
58
59
|
import { nowIso } from './audit-log.mjs';
|
|
@@ -638,9 +639,16 @@ function routeMedium({ candidate, projectRoot, ts }) {
|
|
|
638
639
|
// Direct-to-fact-store (NOT the review queue the terse medium-trust path uses):
|
|
639
640
|
// the point of Task 103 is AUTOMATIC native-parity capture — native writes its
|
|
640
641
|
// fact files with no approval step, so parity requires the same. The fact store
|
|
641
|
-
// is searchable-but-not-full-trust-injected, writeFact
|
|
642
|
-
//
|
|
643
|
-
// later explicit `cmk remember` (trust:high) supersedes. See design §6.4.
|
|
642
|
+
// is searchable-but-not-full-trust-injected, writeFact screens the body +
|
|
643
|
+
// frontmatter (Poison_Guard + home-path sanitize + schema + INDEX/reindex), and
|
|
644
|
+
// a later explicit `cmk remember` (trust:high) supersedes. See design §6.4.
|
|
645
|
+
//
|
|
646
|
+
// CAVEAT (F-V0.3.3-2): writeFact does NOT sanitize the SLUG/filename — the slug
|
|
647
|
+
// is `slugifyFact(title)` derived HERE, before writeFact runs. So the title MUST
|
|
648
|
+
// be routed through sanitizeForTitle first, or a home path in Haiku's candidate
|
|
649
|
+
// title (auto-extract runs every turn, no user action) leaks the username into a
|
|
650
|
+
// COMMITTED filename. This was the same bug as cmk remember — the old comment
|
|
651
|
+
// here wrongly assumed "writeFact already sanitizes" the whole write.
|
|
644
652
|
//
|
|
645
653
|
// trust:medium / write_source:auto-extract marks it as a Haiku synthesis
|
|
646
654
|
// (proposal-grade), below the explicit-high tier. The body is built by the SAME
|
|
@@ -652,11 +660,14 @@ function routeRichFact({ candidate, projectRoot, ts }) {
|
|
|
652
660
|
why: candidate.why,
|
|
653
661
|
how: candidate.how,
|
|
654
662
|
});
|
|
663
|
+
// Sanitize the title BEFORE deriving the slug (F-V0.3.3-2) — writeFact won't
|
|
664
|
+
// catch a home path in the slug/filename. One helper, same as cmk remember.
|
|
665
|
+
const title = sanitizeForTitle(candidate.title);
|
|
655
666
|
return writeFact({
|
|
656
667
|
tier: 'P',
|
|
657
668
|
type: candidate.type,
|
|
658
|
-
slug: slugifyFact(
|
|
659
|
-
title
|
|
669
|
+
slug: slugifyFact(title),
|
|
670
|
+
title,
|
|
660
671
|
body,
|
|
661
672
|
writeSource: 'auto-extract',
|
|
662
673
|
trust: 'medium',
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// The append-only decision journal — context/DECISIONS.md (Task 147, D-161).
|
|
2
|
+
//
|
|
3
|
+
// A chronological, human-readable page of every decision + its why. The VIEW
|
|
4
|
+
// the kit was missing: decisions are captured as facts but scattered across N
|
|
5
|
+
// per-fact files with no chronological decision page (cmk search is pull, not
|
|
6
|
+
// browse; MEMORY.md is bounded + rolls). This is the squad `decisions.md` /
|
|
7
|
+
// our own DECISION-LOG.md equivalent, made automatic.
|
|
8
|
+
//
|
|
9
|
+
// LIFECYCLE — APPEND-ONLY, never regenerated, never parked (D-161):
|
|
10
|
+
// - This is NOT a derived view like INDEX.md. Regenerating from live facts
|
|
11
|
+
// would silently ERASE superseded/forgotten decisions — rewriting history
|
|
12
|
+
// to look like the current state was always obvious (the exact failure the
|
|
13
|
+
// decision-trail-preservation rule exists to prevent).
|
|
14
|
+
// - A decision journal is unbounded by design: old decisions are the MOST
|
|
15
|
+
// valuable part (they explain why the codebase is shaped as it is), so the
|
|
16
|
+
// MEMORY.md rolling-window must NOT apply.
|
|
17
|
+
// - Mechanics: new decision → appended; tombstoned → its entry MARKED
|
|
18
|
+
// retracted IN PLACE (never removed); every pre-existing entry survives
|
|
19
|
+
// every update.
|
|
20
|
+
//
|
|
21
|
+
// The update is triggered like a derived view (runs where reindex runs, so the
|
|
22
|
+
// journal stays current) but its WRITE LOGIC is append-only — the file is the
|
|
23
|
+
// accumulator, the facts are only the trigger. Each entry carries a stable
|
|
24
|
+
// machine marker `<!-- decision:P-XXXXXXXX -->` so the updater knows which ids
|
|
25
|
+
// are already journaled + which entries to annotate, without parsing prose
|
|
26
|
+
// (and so a human can freely add their own prose between entries — preserved).
|
|
27
|
+
//
|
|
28
|
+
// v0.3.2 scope: explicit signals only (capture appends; forget marks retracted;
|
|
29
|
+
// explicit supersession annotates). AUTOMATIC semantic contradiction-detection
|
|
30
|
+
// is deferred to F-D / Task 95.
|
|
31
|
+
|
|
32
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { parse as parseFrontmatter } from './frontmatter.mjs';
|
|
35
|
+
import { ID_PATTERN } from './tier-paths.mjs';
|
|
36
|
+
|
|
37
|
+
export const DECISIONS_HEADER =
|
|
38
|
+
'# Decisions\n\n' +
|
|
39
|
+
'> Append-only decision journal — every decision the kit captured, in order, with its why.\n' +
|
|
40
|
+
'> Maintained by claude-memory-kit (`cmk digest`). Superseded/retracted entries stay (the trail is the point).';
|
|
41
|
+
|
|
42
|
+
// Only this fact type is a "decision" in the kit taxonomy (the project/state
|
|
43
|
+
// category — what project-memory's decisions.md and our DECISION-LOG track).
|
|
44
|
+
const DECISION_TYPE = 'project';
|
|
45
|
+
|
|
46
|
+
const markerFor = (id) => `<!-- decision:${id} -->`;
|
|
47
|
+
const RETRACT_TAG = '_(retracted';
|
|
48
|
+
|
|
49
|
+
/** The yyyy-mm-dd slice of an ISO timestamp, or the raw value if unparseable. */
|
|
50
|
+
function dateOnly(iso) {
|
|
51
|
+
if (typeof iso !== 'string') return 'unknown-date';
|
|
52
|
+
const m = iso.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
53
|
+
return m ? m[1] : iso;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Render ONE journal entry for a decision fact. The machine marker lets the
|
|
58
|
+
* updater dedup + annotate; the human-readable lines are Title / date / Why / id.
|
|
59
|
+
*
|
|
60
|
+
* @param {{id:string,title:string,createdAt?:string,why?:string|null}} f
|
|
61
|
+
* @returns {string} the entry block (no trailing newline)
|
|
62
|
+
*/
|
|
63
|
+
export function buildDecisionEntry(f) {
|
|
64
|
+
const date = dateOnly(f.createdAt);
|
|
65
|
+
const lines = [
|
|
66
|
+
markerFor(f.id),
|
|
67
|
+
`### ${f.title}`,
|
|
68
|
+
`**When:** ${date} · **Fact:** \`${f.id}\``,
|
|
69
|
+
];
|
|
70
|
+
if (f.why && String(f.why).trim()) {
|
|
71
|
+
lines.push(`**Why:** ${String(f.why).trim()}`);
|
|
72
|
+
}
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The kit's id matcher, DERIVED from the canonical ID_PATTERN (tier-paths.mjs)
|
|
77
|
+
// so the base32 alphabet lives in exactly ONE place and can't drift. The
|
|
78
|
+
// original bug: this module hardcoded `[A-Z2-9]` (uppercase only), but the real
|
|
79
|
+
// alphabet includes a lowercase `a` — so any id containing `a` never matched
|
|
80
|
+
// "already journaled" → re-appended on EVERY digest run (the cut-gate find).
|
|
81
|
+
// Strip the `^…$` anchors to embed the pattern inside larger regexes.
|
|
82
|
+
const ID_CHARS = ID_PATTERN.source.replace(/^\^/, '').replace(/\$$/, '');
|
|
83
|
+
|
|
84
|
+
/** ids already present in the journal body (by their machine marker). */
|
|
85
|
+
function journaledIds(content) {
|
|
86
|
+
const ids = new Set();
|
|
87
|
+
const re = new RegExp(`<!-- decision:(${ID_CHARS}) -->`, 'g');
|
|
88
|
+
let m;
|
|
89
|
+
while ((m = re.exec(content)) !== null) ids.add(m[1]);
|
|
90
|
+
return ids;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Append-only journal update (D-161). Pure: content in → content out.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} a
|
|
97
|
+
* @param {string} a.existingContent current DECISIONS.md (‘’ if absent)
|
|
98
|
+
* @param {Array} a.facts live decision-class facts ({id,type,title,createdAt,why})
|
|
99
|
+
* @param {Set<string>} a.tombstonedIds ids whose fact has been forgotten
|
|
100
|
+
* @param {string} a.now ISO timestamp for retraction stamps
|
|
101
|
+
* @returns {string} the new DECISIONS.md content
|
|
102
|
+
*/
|
|
103
|
+
export function updateDecisionsJournal({ existingContent = '', facts = [], tombstonedIds = new Set(), now }) {
|
|
104
|
+
let content = existingContent.trim() === '' ? DECISIONS_HEADER + '\n' : existingContent;
|
|
105
|
+
const already = journaledIds(content);
|
|
106
|
+
|
|
107
|
+
// 1) Append entries for decision-class facts not yet journaled.
|
|
108
|
+
const newEntries = [];
|
|
109
|
+
for (const f of facts) {
|
|
110
|
+
if (f.type !== DECISION_TYPE) continue; // only decisions
|
|
111
|
+
if (already.has(f.id)) continue; // already journaled — never duplicate
|
|
112
|
+
newEntries.push(buildDecisionEntry(f));
|
|
113
|
+
already.add(f.id);
|
|
114
|
+
}
|
|
115
|
+
if (newEntries.length > 0) {
|
|
116
|
+
if (!content.endsWith('\n')) content += '\n';
|
|
117
|
+
content += '\n' + newEntries.join('\n\n') + '\n';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 2) Mark retracted (in place) any journaled entry whose fact is now
|
|
121
|
+
// tombstoned and not already marked. Never removes the entry.
|
|
122
|
+
const stamp = dateOnly(now);
|
|
123
|
+
for (const id of tombstonedIds) {
|
|
124
|
+
const marker = markerFor(id);
|
|
125
|
+
const idx = content.indexOf(marker);
|
|
126
|
+
if (idx === -1) continue; // not journaled — nothing to retract
|
|
127
|
+
// Bound the search to THIS entry's span — up to the next decision marker
|
|
128
|
+
// (or end-of-file). Prevents a malformed/hand-edited entry with no heading
|
|
129
|
+
// from attaching the retraction note to the NEXT entry's heading.
|
|
130
|
+
const nextMarker = content.indexOf('<!-- decision:', idx + marker.length);
|
|
131
|
+
const spanEnd = nextMarker === -1 ? content.length : nextMarker;
|
|
132
|
+
// Find this entry's heading line (the `### …` after the marker, within span).
|
|
133
|
+
const headingStart = content.indexOf('### ', idx);
|
|
134
|
+
if (headingStart === -1 || headingStart >= spanEnd) continue;
|
|
135
|
+
const headingEnd = content.indexOf('\n', headingStart);
|
|
136
|
+
if (headingEnd === -1) continue;
|
|
137
|
+
// Already retracted? (the note sits right after the heading)
|
|
138
|
+
const afterHeading = content.slice(headingEnd + 1, headingEnd + 1 + RETRACT_TAG.length);
|
|
139
|
+
if (afterHeading === RETRACT_TAG) continue;
|
|
140
|
+
const note = `${RETRACT_TAG} ${stamp})_`;
|
|
141
|
+
content = content.slice(0, headingEnd + 1) + note + '\n' + content.slice(headingEnd + 1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!content.endsWith('\n')) content += '\n';
|
|
145
|
+
return content;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- File-IO orchestration (the impure shell over the pure core) ----------
|
|
149
|
+
|
|
150
|
+
// Leading indent is [ \t]* (NOT \s*) so it can't match the newline the
|
|
151
|
+
// (?:^|\n) anchor already consumed — that overlap is the backtracking
|
|
152
|
+
// ambiguity SonarCloud flags as ReDoS. Disjoint character classes = linear.
|
|
153
|
+
const RICH_WHY_RE = /(?:^|\n)[ \t]*\*\*Why:\*\*[ \t]*([^\n]+)/;
|
|
154
|
+
|
|
155
|
+
/** Read decision-class facts (type:project) from the project tier. */
|
|
156
|
+
function readProjectDecisionFacts(projectRoot) {
|
|
157
|
+
const dir = join(projectRoot, 'context', 'memory');
|
|
158
|
+
const out = [];
|
|
159
|
+
if (!existsSync(dir)) return out;
|
|
160
|
+
for (const name of readdirSync(dir)) {
|
|
161
|
+
if (!name.endsWith('.md') || name === 'INDEX.md') continue;
|
|
162
|
+
try {
|
|
163
|
+
const { frontmatter, body } = parseFrontmatter(readFileSync(join(dir, name), 'utf8'));
|
|
164
|
+
if (!frontmatter?.id || frontmatter.type !== DECISION_TYPE) continue;
|
|
165
|
+
if (frontmatter.deleted_at) continue;
|
|
166
|
+
const whyMatch = String(body ?? '').match(RICH_WHY_RE);
|
|
167
|
+
out.push({
|
|
168
|
+
id: frontmatter.id,
|
|
169
|
+
type: frontmatter.type,
|
|
170
|
+
title: frontmatter.title ?? frontmatter.id,
|
|
171
|
+
createdAt: frontmatter.created_at ?? null,
|
|
172
|
+
why: whyMatch ? whyMatch[1].trim() : null,
|
|
173
|
+
});
|
|
174
|
+
} catch {
|
|
175
|
+
// unparseable file — reindex/HC-4 own that class; the journal skips it
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Stable chronological order (oldest first) so appends read like a timeline.
|
|
179
|
+
out.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** ids of forgotten facts (tombstone archive). */
|
|
184
|
+
function readTombstonedIds(projectRoot) {
|
|
185
|
+
const ids = new Set();
|
|
186
|
+
const dir = join(projectRoot, 'context', 'memory', 'archive', 'tombstones');
|
|
187
|
+
if (!existsSync(dir)) return ids;
|
|
188
|
+
for (const name of readdirSync(dir)) {
|
|
189
|
+
const m = name.match(new RegExp(`^(${ID_CHARS})\\.md$`));
|
|
190
|
+
if (m) ids.add(m[1]);
|
|
191
|
+
}
|
|
192
|
+
return ids;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Read → append-only update → write context/DECISIONS.md. Idempotent: a run
|
|
197
|
+
* with nothing new is a no-op write (same bytes). Best-effort: never throws
|
|
198
|
+
* into the caller (a journal failure must not break a capture/read path).
|
|
199
|
+
*
|
|
200
|
+
* @returns {{written:boolean, path:string, appended:number}|{written:false,error:string}}
|
|
201
|
+
*/
|
|
202
|
+
export function syncDecisionsJournal({ projectRoot, now } = {}) {
|
|
203
|
+
try {
|
|
204
|
+
const path = join(projectRoot, 'context', 'DECISIONS.md');
|
|
205
|
+
const existingContent = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
206
|
+
const facts = readProjectDecisionFacts(projectRoot);
|
|
207
|
+
const tombstonedIds = readTombstonedIds(projectRoot);
|
|
208
|
+
const before = existingContent;
|
|
209
|
+
const next = updateDecisionsJournal({
|
|
210
|
+
existingContent,
|
|
211
|
+
facts,
|
|
212
|
+
tombstonedIds,
|
|
213
|
+
now: now ?? new Date().toISOString(),
|
|
214
|
+
});
|
|
215
|
+
if (next !== before) {
|
|
216
|
+
writeFileSync(path, next, 'utf8');
|
|
217
|
+
return { written: true, path, appended: next.length - before.length };
|
|
218
|
+
}
|
|
219
|
+
return { written: false, path, appended: 0 };
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return { written: false, error: err?.message ?? String(err) };
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/digest.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// `cmk digest` — a regenerated, readable render of everything the kit currently
|
|
2
|
+
// knows (Task 147, D-132). Facts by type + the persona + active threads, as one
|
|
3
|
+
// markdown page. The README-demo artifact.
|
|
4
|
+
//
|
|
5
|
+
// REGENERATED (not append-only): unlike DECISIONS.md (the permanent journal),
|
|
6
|
+
// the digest is a CURRENT-KNOWLEDGE snapshot — it should reflect only what
|
|
7
|
+
// exists now, so it is rebuilt on every invocation (the INDEX.md lifecycle,
|
|
8
|
+
// correct here). The two surfaces differ on purpose: digest = "what do we know
|
|
9
|
+
// now", DECISIONS.md = "what did we decide over time" (D-161).
|
|
10
|
+
//
|
|
11
|
+
// Read-only by contract: pure reads over the fact archive + scratchpads. The
|
|
12
|
+
// `--decisions` flag also triggers the DECISIONS.md journal sync (the one
|
|
13
|
+
// mutation, delegated to the append-only writer).
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { parse as parseFrontmatter } from './frontmatter.mjs';
|
|
18
|
+
|
|
19
|
+
const TYPE_ORDER = ['project', 'feedback', 'reference', 'user'];
|
|
20
|
+
const TYPE_LABEL = {
|
|
21
|
+
project: 'Decisions & project state',
|
|
22
|
+
feedback: 'Working-style & preferences',
|
|
23
|
+
reference: 'References',
|
|
24
|
+
user: 'About the user',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function readFacts(projectRoot) {
|
|
28
|
+
const dir = join(projectRoot, 'context', 'memory');
|
|
29
|
+
const facts = [];
|
|
30
|
+
if (!existsSync(dir)) return facts;
|
|
31
|
+
for (const name of readdirSync(dir)) {
|
|
32
|
+
if (!name.endsWith('.md') || name === 'INDEX.md') continue;
|
|
33
|
+
try {
|
|
34
|
+
const { frontmatter } = parseFrontmatter(readFileSync(join(dir, name), 'utf8'));
|
|
35
|
+
if (!frontmatter?.id || frontmatter.deleted_at) continue;
|
|
36
|
+
facts.push({
|
|
37
|
+
id: frontmatter.id,
|
|
38
|
+
type: frontmatter.type ?? 'unknown',
|
|
39
|
+
title: frontmatter.title ?? frontmatter.id,
|
|
40
|
+
trust: frontmatter.trust ?? 'unknown',
|
|
41
|
+
createdAt: frontmatter.created_at ?? null,
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
// unparseable — reindex/HC-4 own that class
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return facts;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the digest markdown from facts (pure — exported for testing).
|
|
52
|
+
* @param {Array} facts
|
|
53
|
+
* @param {{now?:string}} [opts]
|
|
54
|
+
*/
|
|
55
|
+
export function buildDigest(facts, { now } = {}) {
|
|
56
|
+
const stamp = (now ?? new Date().toISOString()).slice(0, 10);
|
|
57
|
+
const lines = [`# Memory digest — ${stamp}`, ''];
|
|
58
|
+
if (facts.length === 0) {
|
|
59
|
+
lines.push('_Memory is empty — capture starts as you work._', '');
|
|
60
|
+
return lines.join('\n');
|
|
61
|
+
}
|
|
62
|
+
lines.push(`${facts.length} fact(s) in project memory.`, '');
|
|
63
|
+
|
|
64
|
+
const byType = new Map();
|
|
65
|
+
for (const f of facts) {
|
|
66
|
+
if (!byType.has(f.type)) byType.set(f.type, []);
|
|
67
|
+
byType.get(f.type).push(f);
|
|
68
|
+
}
|
|
69
|
+
const orderedTypes = [
|
|
70
|
+
...TYPE_ORDER.filter((t) => byType.has(t)),
|
|
71
|
+
...[...byType.keys()].filter((t) => !TYPE_ORDER.includes(t)),
|
|
72
|
+
];
|
|
73
|
+
for (const type of orderedTypes) {
|
|
74
|
+
const group = byType.get(type).slice().sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
75
|
+
lines.push(`## ${TYPE_LABEL[type] ?? type} (${group.length})`, '');
|
|
76
|
+
for (const f of group) {
|
|
77
|
+
const date = String(f.createdAt ?? '').slice(0, 10) || '—';
|
|
78
|
+
lines.push(`- **${f.title}** · \`${f.id}\` · ${f.trust} · ${date}`);
|
|
79
|
+
}
|
|
80
|
+
lines.push('');
|
|
81
|
+
}
|
|
82
|
+
return lines.join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Read facts + render the digest for a project (read-only). */
|
|
86
|
+
export function digest({ projectRoot, now } = {}) {
|
|
87
|
+
const facts = readFacts(projectRoot);
|
|
88
|
+
return buildDigest(facts, { now });
|
|
89
|
+
}
|
package/src/forget.mjs
CHANGED
|
@@ -172,6 +172,12 @@ function scrubAllScratchpads(tierRoot, id) {
|
|
|
172
172
|
if (!entry.isFile()) continue;
|
|
173
173
|
if (!entry.name.endsWith('.md')) continue;
|
|
174
174
|
if (entry.name === 'INDEX.md') continue;
|
|
175
|
+
// DECISIONS.md is the APPEND-ONLY decision journal (Task 147 / D-161), NOT
|
|
176
|
+
// a scratchpad — forget must NOT strip its id-bearing lines (the marker +
|
|
177
|
+
// **Fact:** line). The journal sync marks the entry RETRACTED in place
|
|
178
|
+
// instead (preserving the trail). Scrubbing it here would delete the
|
|
179
|
+
// entry's marker and break the retract-in-place path (composition bug).
|
|
180
|
+
if (entry.name === 'DECISIONS.md') continue;
|
|
175
181
|
const p = join(tierRoot, entry.name);
|
|
176
182
|
const r = scrubScratchpadFile(p, id);
|
|
177
183
|
if (r.changed) edits.push({ path: p, removed: r.removed });
|
package/src/import-claude-md.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
|
32
32
|
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
33
33
|
import { writeFact } from './write-fact.mjs';
|
|
34
34
|
import { slugifyFact } from './rich-fact.mjs';
|
|
35
|
-
import { sanitizeHomePaths } from './sanitize.mjs';
|
|
35
|
+
import { sanitizeHomePaths, sanitizeForTitle } from './sanitize.mjs';
|
|
36
36
|
import { parse as parseFrontmatter } from './frontmatter.mjs';
|
|
37
37
|
|
|
38
38
|
const DEFAULT_FILE = 'CLAUDE.md';
|
|
@@ -280,7 +280,12 @@ export async function importClaudeMd({
|
|
|
280
280
|
// absolute --file argument (the D-51 name-privacy class).
|
|
281
281
|
const sourceFileField = sanitizeHomePaths(fileRel);
|
|
282
282
|
for (const p of proposals) {
|
|
283
|
-
|
|
283
|
+
// Sanitize BEFORE deriving the title/slug (F-V0.3.3-2) — p.text is the RAW
|
|
284
|
+
// rule text (the `sanitized` above feeds only the dedup key/id), and the
|
|
285
|
+
// slug becomes the committed filename, which writeFact won't sanitize. A
|
|
286
|
+
// CLAUDE.md rule mentioning C:\Users\you\ would otherwise leak the username
|
|
287
|
+
// into the imported fact's filename. Same one helper as the other slug sites.
|
|
288
|
+
const title = sanitizeForTitle(p.text).split('\n')[0].slice(0, 80);
|
|
284
289
|
let slug = slugifyFact(title);
|
|
285
290
|
if (usedSlugs.has(`${p.type}/${slug}`)) slug = `${slug}-l${p.line}`;
|
|
286
291
|
usedSlugs.add(`${p.type}/${slug}`);
|
package/src/index-rebuild.mjs
CHANGED
|
@@ -295,6 +295,40 @@ function parseSource(source, { projectRoot, userDir }) {
|
|
|
295
295
|
|
|
296
296
|
// --- DB write helpers -------------------------------------------------
|
|
297
297
|
|
|
298
|
+
// Bug 1 (2026-06-16, fact P-UCG4RKNL): the kit dual-writes a fact to BOTH the
|
|
299
|
+
// MEMORY.md scratchpad bullet AND its granular archive file, both carrying the
|
|
300
|
+
// SAME content-addressed id. `observations.id` is a global PRIMARY KEY, so a
|
|
301
|
+
// plain INSERT of the second source's row collided (`UNIQUE constraint failed:
|
|
302
|
+
// observations.id`) and aborted the whole reindex. The fix is id-keyed upsert
|
|
303
|
+
// with deterministic ARCHIVE-BEATS-SCRATCHPAD precedence — validated against
|
|
304
|
+
// three markdown-first analogs that all key replacement on the id, never the
|
|
305
|
+
// file (TencentDB `ON CONFLICT(record_id) DO UPDATE`; basic-memory
|
|
306
|
+
// resolve-permalink precedence + partial unique index; memweave content-hash
|
|
307
|
+
// dedup). See docs/research/2026-06-16-index-uniqueness-id-vs-file-scoped-delete.md.
|
|
308
|
+
//
|
|
309
|
+
// Two precedence-keyed paths, order-INDEPENDENT (the source walk order must not
|
|
310
|
+
// change the surviving row):
|
|
311
|
+
// - fact (granular archive = the canonical Why/How home) → explicit
|
|
312
|
+
// DELETE-by-id then INSERT: always wins, overwriting any scratchpad row for
|
|
313
|
+
// the id.
|
|
314
|
+
// - scratchpad (the hot working-copy bullet) → ON CONFLICT(id) DO NOTHING:
|
|
315
|
+
// inserts only when no row exists yet; never overwrites a fact row.
|
|
316
|
+
// Whichever is walked first, the fact row is the one that survives.
|
|
317
|
+
//
|
|
318
|
+
// FTS5 CORRECTNESS (the self-review catch): the fact path uses an explicit
|
|
319
|
+
// DELETE-by-id, NOT `INSERT OR REPLACE`. `observations_fts` is an
|
|
320
|
+
// external-content FTS5 table whose only safe delete path is the
|
|
321
|
+
// `obs_after_delete` trigger firing the 'delete' SENTINEL with the OLD row's
|
|
322
|
+
// column values (index-db.mjs §4.4.3 comment). `INSERT OR REPLACE` reuses the
|
|
323
|
+
// conflicting row's rowid, so its internal delete+insert leaves the OLD
|
|
324
|
+
// scratchpad body orphaned in the FTS index (it keeps MATCH-ing with no backing
|
|
325
|
+
// row — silent stale-hit corruption). An explicit `DELETE FROM observations
|
|
326
|
+
// WHERE id = ?` fires obs_after_delete cleanly (sentinel removes the old terms),
|
|
327
|
+
// then the plain INSERT fires obs_after_insert. This is the same delete-then-
|
|
328
|
+
// insert pattern every other writer in the kit uses against this table.
|
|
329
|
+
|
|
330
|
+
const DELETE_OBSERVATION_BY_ID_SQL = `DELETE FROM observations WHERE id = ?`;
|
|
331
|
+
|
|
298
332
|
const INSERT_OBSERVATION_SQL = `
|
|
299
333
|
INSERT INTO observations
|
|
300
334
|
(id, tier, source_file, source_line, source_sha1, heading_path, body,
|
|
@@ -304,6 +338,16 @@ VALUES
|
|
|
304
338
|
@write_source, @trust, @created_at, @superseded_by, @deleted_at)
|
|
305
339
|
`;
|
|
306
340
|
|
|
341
|
+
const INSERT_SCRATCHPAD_OBSERVATION_SQL = `
|
|
342
|
+
INSERT INTO observations
|
|
343
|
+
(id, tier, source_file, source_line, source_sha1, heading_path, body,
|
|
344
|
+
write_source, trust, created_at, superseded_by, deleted_at)
|
|
345
|
+
VALUES
|
|
346
|
+
(@id, @tier, @source_file, @source_line, @source_sha1, @heading_path, @body,
|
|
347
|
+
@write_source, @trust, @created_at, @superseded_by, @deleted_at)
|
|
348
|
+
ON CONFLICT(id) DO NOTHING
|
|
349
|
+
`;
|
|
350
|
+
|
|
307
351
|
const UPSERT_FILE_SQL = `
|
|
308
352
|
INSERT INTO files (path, mtime, sha1, indexed_at)
|
|
309
353
|
VALUES (@path, @mtime, @sha1, @indexed_at)
|
|
@@ -322,10 +366,48 @@ const DELETE_OBSERVATIONS_FOR_PATH_SQL = `DELETE FROM observations WHERE source_
|
|
|
322
366
|
*/
|
|
323
367
|
function replaceObservationsForFile(db, { source, observations, mtime, sha1, projectRoot, userDir, now }) {
|
|
324
368
|
const source_file = relativeSource(source.path, { projectRoot, userDir });
|
|
369
|
+
// File-scoped delete clears THIS file's own rows so a re-index of a changed
|
|
370
|
+
// file is idempotent. It only matches rows whose source_file is this path, so
|
|
371
|
+
// a fact's row (source_file = context/memory/*.md) is untouched when the
|
|
372
|
+
// scratchpad (context/MEMORY.md) is re-indexed, and vice versa — the
|
|
373
|
+
// cross-file id collision is handled by the precedence-keyed insert below,
|
|
374
|
+
// NOT by this delete (Bug 1).
|
|
325
375
|
db.prepare(DELETE_OBSERVATIONS_FOR_PATH_SQL).run(source_file);
|
|
326
|
-
|
|
327
|
-
for
|
|
328
|
-
|
|
376
|
+
// Archive-beats-scratchpad precedence (Bug 1): a fact row wins the id by
|
|
377
|
+
// explicitly deleting any existing row for that id first (firing the FTS
|
|
378
|
+
// 'delete' sentinel cleanly) then inserting; a scratchpad row yields via
|
|
379
|
+
// ON CONFLICT(id) DO NOTHING. Within a FULL pass (reindexFull, or a
|
|
380
|
+
// reindexBoot that re-walks both sources) this is order-independent — the
|
|
381
|
+
// fact row always wins (listObservationSources walks scratchpad-before-facts
|
|
382
|
+
// per tier, but either order lands the same surviving row).
|
|
383
|
+
//
|
|
384
|
+
// INCREMENTAL caveat (skill-review I1): on the mtime-skip boot path / the
|
|
385
|
+
// single-file watcher path, only the CHANGED source is re-processed. If a
|
|
386
|
+
// fact file is removed while its scratchpad twin (same id) is untouched, the
|
|
387
|
+
// orphan-prune drops the fact row and the skipped scratchpad's DO-NOTHING
|
|
388
|
+
// insert never re-fires — so the id momentarily vanishes from search until
|
|
389
|
+
// the scratchpad is next edited (which re-inserts it). `cmk forget` does NOT
|
|
390
|
+
// hit this: it tombstones the fact AND scrubs the scratchpad bullet in the
|
|
391
|
+
// same op (forget.mjs scrubAllScratchpads), so the only window is a manual
|
|
392
|
+
// hand-`rm` of a context/memory/*.md leaving the bullet behind — a rare,
|
|
393
|
+
// self-healing transition, documented + tested rather than resurrected.
|
|
394
|
+
//
|
|
395
|
+
// The DELETE-by-id is UNQUALIFIED (no tier/source_file filter) by design and
|
|
396
|
+
// safe: ids are content-addressed WITH the tier as a prefix (`P-`/`L-`/`U-`),
|
|
397
|
+
// so a P-tier and U-tier fact can never share an id — no cross-tier delete is
|
|
398
|
+
// possible. (Defended by the P/U-same-content tier test below.)
|
|
399
|
+
if (source.kind === 'fact') {
|
|
400
|
+
const deleteById = db.prepare(DELETE_OBSERVATION_BY_ID_SQL);
|
|
401
|
+
const insert = db.prepare(INSERT_OBSERVATION_SQL);
|
|
402
|
+
for (const obs of observations) {
|
|
403
|
+
deleteById.run(obs.id);
|
|
404
|
+
insert.run(obs);
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
const insert = db.prepare(INSERT_SCRATCHPAD_OBSERVATION_SQL);
|
|
408
|
+
for (const obs of observations) {
|
|
409
|
+
insert.run(obs);
|
|
410
|
+
}
|
|
329
411
|
}
|
|
330
412
|
db.prepare(UPSERT_FILE_SQL).run({
|
|
331
413
|
path: source_file,
|
|
@@ -370,6 +452,9 @@ export function reindexBoot({ projectRoot, userDir, db, now }) {
|
|
|
370
452
|
userDir,
|
|
371
453
|
now: ts,
|
|
372
454
|
});
|
|
455
|
+
// observationsAffected counts insert-ATTEMPTS, not net rows: a fact that
|
|
456
|
+
// displaces a same-id scratchpad row (Bug 1 precedence) is net-zero but
|
|
457
|
+
// counts as one here. It's a "work done" metric, not a row-count invariant.
|
|
373
458
|
return result.observations.length;
|
|
374
459
|
});
|
|
375
460
|
|
|
@@ -537,6 +622,9 @@ export function reindexFull({ projectRoot, userDir, db, now }) {
|
|
|
537
622
|
userDir,
|
|
538
623
|
now: ts,
|
|
539
624
|
});
|
|
625
|
+
// observationsAffected counts insert-ATTEMPTS, not net rows: a fact that
|
|
626
|
+
// displaces a same-id scratchpad row (Bug 1 precedence) is net-zero but
|
|
627
|
+
// counts as one here. It's a "work done" metric, not a row-count invariant.
|
|
540
628
|
return result.observations.length;
|
|
541
629
|
});
|
|
542
630
|
|
package/src/inject-context.mjs
CHANGED
|
@@ -35,7 +35,7 @@ import { join } from 'node:path';
|
|
|
35
35
|
import { homedir } from 'node:os';
|
|
36
36
|
import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN } from './tier-paths.mjs';
|
|
37
37
|
import { nowIso } from './audit-log.mjs';
|
|
38
|
-
import { detectStaleness } from './lazy-compress.mjs';
|
|
38
|
+
import { detectStaleness, isJournalStale } from './lazy-compress.mjs';
|
|
39
39
|
import { isProvenanceCommentLine, parseBulletProvenance } from './provenance.mjs';
|
|
40
40
|
import { listConflictQueue } from './conflict-queue.mjs';
|
|
41
41
|
import { listReviewQueue } from './review-queue.mjs';
|
|
@@ -787,18 +787,28 @@ export function injectContext({
|
|
|
787
787
|
let lazyTrigger = null;
|
|
788
788
|
try {
|
|
789
789
|
const verdict = detectStaleness({ projectRoot, now: ts });
|
|
790
|
-
|
|
791
|
-
|
|
790
|
+
// Task 159 (D-169): journal-staleness is an INDEPENDENT spawn trigger — the
|
|
791
|
+
// detached lazy worker syncs DECISIONS.md unconditionally, so a session that's
|
|
792
|
+
// compress-fresh (or cron-active) but has new un-journaled decisions must
|
|
793
|
+
// still spawn, else the journal never renders without a clean SessionEnd
|
|
794
|
+
// (the Task-105/D-75 no-clean-exit class). Cron handles compress but NOT the
|
|
795
|
+
// journal, so cron-active + a stale journal SHOULD spawn (compress skips
|
|
796
|
+
// inside, the journal syncs). It is NOT a competing detectStaleness verdict
|
|
797
|
+
// (one verdict → one compress dispatch; folding journal in would suppress
|
|
798
|
+
// compress work — the separately-correct-jointly-broken class).
|
|
799
|
+
const journalStale = isJournalStale(projectRoot);
|
|
800
|
+
lazyTrigger = { verdict: verdict.action, reason: verdict.reason, journalStale };
|
|
801
|
+
const compressStale =
|
|
792
802
|
verdict.action === 'stale-now' ||
|
|
793
803
|
verdict.action === 'stale-daily' ||
|
|
794
|
-
verdict.action === 'stale-weekly'
|
|
795
|
-
) {
|
|
804
|
+
verdict.action === 'stale-weekly';
|
|
805
|
+
if (compressStale || journalStale) {
|
|
796
806
|
const spawner = typeof testSpawnLazy === 'function' ? testSpawnLazy : spawnLazyCompress;
|
|
797
807
|
const spawnResult = spawner(projectRoot, compressLazyPath);
|
|
798
808
|
lazyTrigger = { ...lazyTrigger, ...spawnResult };
|
|
799
809
|
}
|
|
800
810
|
} catch (err) {
|
|
801
|
-
// detectStaleness should be defensive; if
|
|
811
|
+
// detectStaleness / isJournalStale should be defensive; if they throw, log + continue.
|
|
802
812
|
lazyTrigger = { verdict: 'error', error: err?.message ?? String(err) };
|
|
803
813
|
}
|
|
804
814
|
|