@lh8ppl/claude-memory-kit 0.1.0
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/bin/cmk-compress-lazy.mjs +59 -0
- package/bin/cmk-daily-distill.mjs +67 -0
- package/bin/cmk-weekly-curate.mjs +56 -0
- package/bin/cmk.mjs +12 -0
- package/package.json +50 -0
- package/src/audit-log.mjs +103 -0
- package/src/auto-extract.mjs +742 -0
- package/src/capture-prompt.mjs +61 -0
- package/src/capture-turn.mjs +273 -0
- package/src/claude-md.mjs +212 -0
- package/src/compress-session.mjs +349 -0
- package/src/compressor.mjs +376 -0
- package/src/conflict-queue.mjs +796 -0
- package/src/cooldown.mjs +61 -0
- package/src/daily-distill.mjs +252 -0
- package/src/doctor.mjs +528 -0
- package/src/forget.mjs +335 -0
- package/src/frontmatter.mjs +73 -0
- package/src/import-anthropic-memory.mjs +266 -0
- package/src/index-db.mjs +154 -0
- package/src/index-rebuild.mjs +597 -0
- package/src/index.mjs +90 -0
- package/src/inject-context.mjs +484 -0
- package/src/install.mjs +327 -0
- package/src/lazy-compress.mjs +326 -0
- package/src/lock-discipline.mjs +166 -0
- package/src/mcp-server.mjs +498 -0
- package/src/memory-write.mjs +565 -0
- package/src/merge-facts.mjs +213 -0
- package/src/observe-edit.mjs +87 -0
- package/src/platform-commands.mjs +138 -0
- package/src/poison-guard.mjs +245 -0
- package/src/privacy.mjs +21 -0
- package/src/provenance.mjs +217 -0
- package/src/register-crons.mjs +354 -0
- package/src/reindex.mjs +134 -0
- package/src/repair.mjs +316 -0
- package/src/result-shapes.mjs +155 -0
- package/src/review-queue.mjs +345 -0
- package/src/roll.mjs +115 -0
- package/src/scratchpad.mjs +335 -0
- package/src/search.mjs +311 -0
- package/src/subcommands.mjs +1252 -0
- package/src/tier-paths.mjs +74 -0
- package/src/transcripts.mjs +234 -0
- package/src/trust.mjs +226 -0
- package/src/weekly-curate.mjs +454 -0
- package/src/write-fact.mjs +205 -0
- package/template/.claude/hooks/pre-tool-memory.js +78 -0
- package/template/.claude/hooks/transcript-capture.js +69 -0
- package/template/.claude/settings.json +27 -0
- package/template/.claude/skills/memory-write/SKILL.md +117 -0
- package/template/.gitignore.fragment +12 -0
- package/template/CLAUDE.md.template +49 -0
- package/template/docs/journey/journey-log.md.template +292 -0
- package/template/local/machine-paths.md.template +37 -0
- package/template/local/overrides.md.template +36 -0
- package/template/project/.index/.gitkeep +0 -0
- package/template/project/MEMORY.md.template +47 -0
- package/template/project/SOUL.md.template +35 -0
- package/template/project/memory/INDEX.md.template +47 -0
- package/template/project/memory/archive/superseded/.gitkeep +0 -0
- package/template/project/memory/archive/tombstones/.gitkeep +0 -0
- package/template/project/queues/.gitkeep +0 -0
- package/template/project/sessions/.gitkeep +0 -0
- package/template/project/transcripts/.gitkeep +0 -0
- package/template/support/cron-jobs/daily-memory-distill.md +15 -0
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
- package/template/support/milvus-deploy/README.md +57 -0
- package/template/support/milvus-deploy/docker-compose.yml +66 -0
- package/template/support/scripts/auto-extract-memory.sh +102 -0
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
- package/template/support/scripts/refresh-distill-timestamp.py +35 -0
- package/template/support/scripts/register-crons.py +242 -0
- package/template/support/scripts/run-daily-distill.sh +67 -0
- package/template/support/scripts/run-weekly-curate.sh +58 -0
- package/template/user/HABITS.md.template +18 -0
- package/template/user/LESSONS.md.template +18 -0
- package/template/user/USER.md.template +18 -0
- package/template/user/fragments/INDEX.md.template +23 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
// memory-write — the public boundary for durable memory writes (Task
|
|
2
|
+
// 24, T-021). Invoked by two callers, same code path (design §6.3):
|
|
3
|
+
//
|
|
4
|
+
// 1. The auto-extract subagent (programmatic) — passes
|
|
5
|
+
// {action: 'add', source: 'auto-extract', ...} after Haiku
|
|
6
|
+
// identifies a high-trust durable fact. Closes the Poison_Guard
|
|
7
|
+
// bypass gap Task 23 left documented.
|
|
8
|
+
//
|
|
9
|
+
// 2. The user-explicit Skill — plugin/skills/memory-write/SKILL.md.
|
|
10
|
+
// Claude Code's harness invokes the skill body when the user's
|
|
11
|
+
// prompt matches the description+when_to_use signals (e.g.
|
|
12
|
+
// "remember this"). The skill body delegates here.
|
|
13
|
+
//
|
|
14
|
+
// Three actions per design §6.3:
|
|
15
|
+
// - add → Poison_Guard → consolidate-if-over-cap → append bullet
|
|
16
|
+
// - replace → Poison_Guard → substring match → strip old, append new
|
|
17
|
+
// - remove → substring match → ALWAYS-confirm gate → tombstone
|
|
18
|
+
//
|
|
19
|
+
// Poison_Guard runs BEFORE any disk write. A rejection produces an
|
|
20
|
+
// NDJSON entry in .locks/poison-guard.log with the redacted excerpt
|
|
21
|
+
// (cleartext never leaves checkPoisonGuard()), and the caller gets
|
|
22
|
+
// action: 'error', errorCategory: 'poison_guard', pattern_id.
|
|
23
|
+
//
|
|
24
|
+
// Tombstone discipline (design §6.5): `remove` NEVER silently
|
|
25
|
+
// deletes. The matched bullet + its provenance comment are copied
|
|
26
|
+
// into a tombstone file at <tier-root>/archive/tombstones/<id>.md
|
|
27
|
+
// with `deleted_at` / `deleted_reason` / `deleted_by` frontmatter,
|
|
28
|
+
// then stripped from the scratchpad. `mk_get(<id>)` (Layer 6) can
|
|
29
|
+
// still resolve via the tombstone for audit purposes.
|
|
30
|
+
//
|
|
31
|
+
// Uses shared modules per CLAUDE.md "Shared modules" rule:
|
|
32
|
+
// tier-paths.mjs — tier/scratchpad path resolution
|
|
33
|
+
// audit-log.mjs — nowIso(), appendAuditEntry()
|
|
34
|
+
// result-shapes.mjs — ERROR_CATEGORIES, errorResult()
|
|
35
|
+
// scratchpad.mjs — appendScratchpadBullet() (add path)
|
|
36
|
+
// provenance.mjs — parseBulletProvenance() (replace/remove bullet match)
|
|
37
|
+
// poison-guard.mjs — checkPoisonGuard(), logPoisonGuardRejection()
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
existsSync,
|
|
41
|
+
readFileSync,
|
|
42
|
+
writeFileSync,
|
|
43
|
+
mkdirSync,
|
|
44
|
+
} from 'node:fs';
|
|
45
|
+
import { join, dirname } from 'node:path';
|
|
46
|
+
import { createHash } from 'node:crypto';
|
|
47
|
+
import { generateId } from '@lh8ppl/cmk-canonicalize';
|
|
48
|
+
import {
|
|
49
|
+
resolveTierRoot,
|
|
50
|
+
resolveScratchpadPath,
|
|
51
|
+
VALID_TIERS,
|
|
52
|
+
ID_PATTERN,
|
|
53
|
+
} from './tier-paths.mjs';
|
|
54
|
+
import { nowIso, appendAuditEntry, REASON_CODES } from './audit-log.mjs';
|
|
55
|
+
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
56
|
+
import { appendScratchpadBullet } from './scratchpad.mjs';
|
|
57
|
+
import { parseBulletProvenance } from './provenance.mjs';
|
|
58
|
+
import { checkPoisonGuard, logPoisonGuardRejection } from './poison-guard.mjs';
|
|
59
|
+
import { detectConflicts, writeConflictEntry } from './conflict-queue.mjs';
|
|
60
|
+
|
|
61
|
+
const VALID_ACTIONS = new Set(['add', 'replace', 'remove']);
|
|
62
|
+
|
|
63
|
+
// --- Validation ----------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function validateCommon(opts) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
if (!VALID_ACTIONS.has(opts.action)) {
|
|
68
|
+
errors.push(
|
|
69
|
+
`action: required, one of ${[...VALID_ACTIONS].join(' / ')} (got ${JSON.stringify(opts.action)})`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!opts.tier || !VALID_TIERS.has(opts.tier)) {
|
|
73
|
+
errors.push(`tier: required, one of 'U', 'P', 'L'`);
|
|
74
|
+
}
|
|
75
|
+
if (!opts.scratchpad || typeof opts.scratchpad !== 'string') {
|
|
76
|
+
errors.push('scratchpad: required, non-empty string');
|
|
77
|
+
}
|
|
78
|
+
if (!opts.section || typeof opts.section !== 'string') {
|
|
79
|
+
errors.push('section: required, non-empty string');
|
|
80
|
+
}
|
|
81
|
+
if (!opts.source || typeof opts.source !== 'string') {
|
|
82
|
+
errors.push('source: required, non-empty string');
|
|
83
|
+
}
|
|
84
|
+
return errors;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateText(opts, errors) {
|
|
88
|
+
if (opts.text == null || typeof opts.text !== 'string' || !opts.text.trim()) {
|
|
89
|
+
errors.push('text: required, non-empty string');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- Poison_Guard integration --------------------------------------
|
|
94
|
+
|
|
95
|
+
function runPoisonGuard({ text, projectRoot, source, sessionId, now }) {
|
|
96
|
+
const result = checkPoisonGuard(text);
|
|
97
|
+
if (!result.rejected) return null;
|
|
98
|
+
// Schema-class rejections (non-string input) don't need a log entry
|
|
99
|
+
// — there's no input to redact, and the caller will surface schema
|
|
100
|
+
// through the normal error path.
|
|
101
|
+
if (result.pattern_id === 'schema') {
|
|
102
|
+
return errorResult({
|
|
103
|
+
category: ERROR_CATEGORIES.SCHEMA,
|
|
104
|
+
errors: ['text: must be a non-empty string'],
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const sourceFile = sessionId
|
|
108
|
+
? `${source}-${sessionId}`
|
|
109
|
+
: source;
|
|
110
|
+
logPoisonGuardRejection({
|
|
111
|
+
projectRoot,
|
|
112
|
+
ts: now ?? nowIso(),
|
|
113
|
+
pattern_id: result.pattern_id,
|
|
114
|
+
source_file: sourceFile,
|
|
115
|
+
source_line: 1,
|
|
116
|
+
redacted_excerpt: result.redacted_excerpt,
|
|
117
|
+
});
|
|
118
|
+
// Route through errorResult() (not a hand-rolled object) so the result
|
|
119
|
+
// shape carries the `errors: [...]` array that downstream callers
|
|
120
|
+
// (review-queue.resolveReviewQueue → subcommands.runQueueReview's
|
|
121
|
+
// `err.errors.join('; ')`) expect. Without this, `cmk queue review`
|
|
122
|
+
// crashes with `TypeError: Cannot read properties of undefined`
|
|
123
|
+
// when the user promotes a candidate that contains a Poison_Guard
|
|
124
|
+
// pattern. The `pattern_id` + `redacted_excerpt` stay reachable on
|
|
125
|
+
// the returned object for analytics use.
|
|
126
|
+
return errorResult({
|
|
127
|
+
category: ERROR_CATEGORIES.POISON_GUARD,
|
|
128
|
+
errors: [
|
|
129
|
+
`Poison_Guard rejected write: pattern_id=${result.pattern_id}`,
|
|
130
|
+
],
|
|
131
|
+
pattern_id: result.pattern_id,
|
|
132
|
+
redacted_excerpt: result.redacted_excerpt,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- Bullet search helpers (for replace + remove) -------------------
|
|
137
|
+
|
|
138
|
+
// Walk the scratchpad lines and find the first bullet (in the
|
|
139
|
+
// caller's section) whose text contains the substring. Returns
|
|
140
|
+
// {bulletIdx, commentIdx, id, bulletText, commentLine} or null.
|
|
141
|
+
// The bullet shape per provenance.mjs writeBullet():
|
|
142
|
+
// - (P-XXXXXXXX) <text>
|
|
143
|
+
// <!-- source:..., source_line:..., sha1:..., write:..., trust:..., at:... -->
|
|
144
|
+
//
|
|
145
|
+
// ID class is derived from the canonical ID_PATTERN exported by
|
|
146
|
+
// tier-paths.mjs (the kit's base32 alphabet: 2345679ABCDEFGHJKLMNPQRSTUVWXYZa,
|
|
147
|
+
// note the lowercase `a`). An earlier draft hard-coded [A-Z0-9]{8}
|
|
148
|
+
// which silently failed on the ~22% of IDs that canonicalize emits
|
|
149
|
+
// with a lowercase `a` — surfaced by the holistic code-review pass
|
|
150
|
+
// for Task 24.
|
|
151
|
+
const ID_TIER_PREFIX = '(U|P|L)';
|
|
152
|
+
const ID_BODY_CLASS = '[2345679ABCDEFGHJKLMNPQRSTUVWXYZa]{8}';
|
|
153
|
+
const BULLET_LINE_RE = new RegExp(`^- \\(${ID_TIER_PREFIX}-(${ID_BODY_CLASS})\\)\\s+(.*)$`);
|
|
154
|
+
|
|
155
|
+
// Sanity check: the regex we just built must accept anything
|
|
156
|
+
// ID_PATTERN does. Caught at module load — if a future edit to
|
|
157
|
+
// tier-paths.mjs adds a character we forgot to mirror here, this
|
|
158
|
+
// fails fast instead of silently producing not-found errors.
|
|
159
|
+
{
|
|
160
|
+
const sample = 'P-a2RH5GMN';
|
|
161
|
+
if (!ID_PATTERN.test(sample)) {
|
|
162
|
+
throw new Error(`memory-write: ID_PATTERN regression — sample ${sample} rejected`);
|
|
163
|
+
}
|
|
164
|
+
if (!BULLET_LINE_RE.test(`- (${sample}) example`)) {
|
|
165
|
+
throw new Error(`memory-write: BULLET_LINE_RE does not cover ID_PATTERN alphabet`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Find the bullet by section scope: walk only the section identified
|
|
170
|
+
// by `sectionTitle`. Without this, a substring that appears in two
|
|
171
|
+
// sections would match the first occurrence file-wide (wrong section).
|
|
172
|
+
function findSectionRange(lines, sectionTitle) {
|
|
173
|
+
const startIdx = lines.findIndex((l) => l.trim() === `## ${sectionTitle}`);
|
|
174
|
+
if (startIdx === -1) return null;
|
|
175
|
+
let endIdx = lines.findIndex((l, i) => i > startIdx && /^##\s/.test(l));
|
|
176
|
+
if (endIdx === -1) endIdx = lines.length;
|
|
177
|
+
return { startIdx, endIdx };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function findMatchingBullet({ lines, substring, sectionTitle }) {
|
|
181
|
+
if (typeof substring !== 'string' || substring === '') return null;
|
|
182
|
+
const range = sectionTitle
|
|
183
|
+
? findSectionRange(lines, sectionTitle)
|
|
184
|
+
: { startIdx: 0, endIdx: lines.length };
|
|
185
|
+
if (!range) return null;
|
|
186
|
+
for (let i = range.startIdx; i < range.endIdx - 1; i++) {
|
|
187
|
+
const m = lines[i].match(BULLET_LINE_RE);
|
|
188
|
+
if (!m) continue;
|
|
189
|
+
const [, tier, idShort, bulletText] = m;
|
|
190
|
+
if (!bulletText.includes(substring)) continue;
|
|
191
|
+
const commentLine = lines[i + 1];
|
|
192
|
+
if (!commentLine || !/^\s*<!--.*-->\s*$/.test(commentLine)) continue;
|
|
193
|
+
return {
|
|
194
|
+
bulletIdx: i,
|
|
195
|
+
commentIdx: i + 1,
|
|
196
|
+
id: `${tier}-${idShort}`,
|
|
197
|
+
bulletText,
|
|
198
|
+
commentLine,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- Tombstone writer (design §6.5) --------------------------------
|
|
205
|
+
|
|
206
|
+
function writeTombstone({
|
|
207
|
+
tierRoot,
|
|
208
|
+
id,
|
|
209
|
+
bulletText,
|
|
210
|
+
commentLine,
|
|
211
|
+
deletedAt,
|
|
212
|
+
deletedReason,
|
|
213
|
+
deletedBy,
|
|
214
|
+
}) {
|
|
215
|
+
const tombstoneDir = join(tierRoot, 'archive', 'tombstones');
|
|
216
|
+
mkdirSync(tombstoneDir, { recursive: true });
|
|
217
|
+
const tombstonePath = join(tombstoneDir, `${id}.md`);
|
|
218
|
+
const provenance = parseBulletProvenance(commentLine) ?? {};
|
|
219
|
+
const body = [
|
|
220
|
+
'---',
|
|
221
|
+
`id: ${id}`,
|
|
222
|
+
`deleted_at: ${deletedAt}`,
|
|
223
|
+
`deleted_reason: ${JSON.stringify(deletedReason)}`,
|
|
224
|
+
`deleted_by: ${deletedBy}`,
|
|
225
|
+
provenance.source ? `original_source: ${JSON.stringify(provenance.source)}` : null,
|
|
226
|
+
provenance.at ? `original_at: ${provenance.at}` : null,
|
|
227
|
+
provenance.trust ? `original_trust: ${provenance.trust}` : null,
|
|
228
|
+
'---',
|
|
229
|
+
'',
|
|
230
|
+
bulletText,
|
|
231
|
+
'',
|
|
232
|
+
].filter((l) => l !== null).join('\n');
|
|
233
|
+
writeFileSync(tombstonePath, body, 'utf8');
|
|
234
|
+
return tombstonePath;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Action: add ---------------------------------------------------
|
|
238
|
+
//
|
|
239
|
+
// Split into two functions on purpose: `doAdd` is the public path
|
|
240
|
+
// (validate → Poison_Guard → write); `appendBulletGuarded` is the
|
|
241
|
+
// inner write-only step that assumes the caller has ALREADY gated
|
|
242
|
+
// through Poison_Guard. `doReplace` calls the inner directly so the
|
|
243
|
+
// guard doesn't fire twice on the same text. This isn't just
|
|
244
|
+
// performance — the guard call has a side effect (NDJSON log write),
|
|
245
|
+
// and double-rejecting a still-fine text would produce log noise the
|
|
246
|
+
// moment Poison_Guard ever becomes non-deterministic (e.g. settings-
|
|
247
|
+
// driven extension patterns from design §6.7's tunability hook).
|
|
248
|
+
|
|
249
|
+
function doAdd(opts) {
|
|
250
|
+
const errors = [];
|
|
251
|
+
validateText(opts, errors);
|
|
252
|
+
if (errors.length > 0) {
|
|
253
|
+
return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
|
|
254
|
+
}
|
|
255
|
+
const poisonResult = runPoisonGuard({
|
|
256
|
+
text: opts.text,
|
|
257
|
+
projectRoot: opts.projectRoot,
|
|
258
|
+
source: opts.source,
|
|
259
|
+
sessionId: opts.sessionId,
|
|
260
|
+
now: opts.now,
|
|
261
|
+
});
|
|
262
|
+
if (poisonResult) return poisonResult;
|
|
263
|
+
|
|
264
|
+
// Conflict-queue check (Task 25, design §6.8). Runs BEFORE the append:
|
|
265
|
+
// - new.trust < existing.trust → route to queues/conflicts.md
|
|
266
|
+
// - new.trust >= existing.trust ('supersede' action) → for v0.1.0
|
|
267
|
+
// this continues to normal append; auto-marking the existing
|
|
268
|
+
// bullet's provenance with `superseded_by:` is deferred to v0.1.x
|
|
269
|
+
// (callers can use the explicit `replace` action when they want
|
|
270
|
+
// that semantics today).
|
|
271
|
+
const newTrust = opts.trust ?? 'high';
|
|
272
|
+
const scratchpadPath = resolveScratchpadPath({
|
|
273
|
+
tier: opts.tier,
|
|
274
|
+
scratchpad: opts.scratchpad,
|
|
275
|
+
projectRoot: opts.projectRoot,
|
|
276
|
+
userDir: opts.userDir,
|
|
277
|
+
});
|
|
278
|
+
const conflict = detectConflicts({
|
|
279
|
+
newText: opts.text,
|
|
280
|
+
newTrust,
|
|
281
|
+
scratchpadPath,
|
|
282
|
+
sectionTitle: opts.section,
|
|
283
|
+
});
|
|
284
|
+
// Defensive guard against a future detectConflicts schema-error
|
|
285
|
+
// path. Today the upstream validator catches bad opts before this
|
|
286
|
+
// call, so action:'error' from detectConflicts is unreachable.
|
|
287
|
+
// The guard is brittleness insurance: a future change to
|
|
288
|
+
// detectConflicts that adds a new schema check (e.g., for malformed
|
|
289
|
+
// existing scratchpad bullets) would otherwise fall through to
|
|
290
|
+
// appendBulletGuarded and silently drop the error.
|
|
291
|
+
if (conflict.action === 'error') {
|
|
292
|
+
return conflict;
|
|
293
|
+
}
|
|
294
|
+
if (conflict.conflict === true && conflict.action === 'queue') {
|
|
295
|
+
// Compute the proposed ID using the same canonical-id derivation
|
|
296
|
+
// appendScratchpadBullet would have used, then route to the queue.
|
|
297
|
+
// (Task 25b fix: generateId is positional `(tier, text)`, not
|
|
298
|
+
// named-args — Task 25 originally called it as an object.)
|
|
299
|
+
const proposedId = generateId(opts.tier, opts.text);
|
|
300
|
+
const ts = opts.now ?? nowIso();
|
|
301
|
+
return writeConflictEntry({
|
|
302
|
+
tier: opts.tier,
|
|
303
|
+
projectRoot: opts.projectRoot,
|
|
304
|
+
userDir: opts.userDir,
|
|
305
|
+
newId: proposedId,
|
|
306
|
+
newText: opts.text,
|
|
307
|
+
newTrust,
|
|
308
|
+
existingId: conflict.existingId,
|
|
309
|
+
existingText: conflict.existingText,
|
|
310
|
+
existingTrust: conflict.existingTrust,
|
|
311
|
+
similarity: conflict.similarity,
|
|
312
|
+
similarityBackend: conflict.similarityBackend,
|
|
313
|
+
detectedAt: ts,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return appendBulletGuarded(opts);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function appendBulletGuarded(opts) {
|
|
320
|
+
// Caller MUST have run Poison_Guard already. This is the inner
|
|
321
|
+
// write step — delegates to the existing scratchpad writer which
|
|
322
|
+
// handles dedup + cap + consolidation + audit + ID derivation.
|
|
323
|
+
const sha1 = createHash('sha1').update(opts.text, 'utf8').digest('hex');
|
|
324
|
+
const ts = opts.now ?? nowIso();
|
|
325
|
+
return appendScratchpadBullet({
|
|
326
|
+
tier: opts.tier,
|
|
327
|
+
scratchpad: opts.scratchpad,
|
|
328
|
+
section: opts.section,
|
|
329
|
+
text: opts.text,
|
|
330
|
+
projectRoot: opts.projectRoot,
|
|
331
|
+
userDir: opts.userDir,
|
|
332
|
+
now: ts,
|
|
333
|
+
settings: opts.settings,
|
|
334
|
+
provenance: {
|
|
335
|
+
source: opts.sessionId
|
|
336
|
+
? `${opts.source}-${opts.sessionId}`
|
|
337
|
+
: opts.source,
|
|
338
|
+
source_line: 1,
|
|
339
|
+
sha1,
|
|
340
|
+
write: opts.source === 'auto-extract' ? 'auto-extract' : 'user-explicit',
|
|
341
|
+
trust: opts.trust ?? 'high',
|
|
342
|
+
at: ts,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// --- Action: replace -----------------------------------------------
|
|
348
|
+
|
|
349
|
+
function doReplace(opts) {
|
|
350
|
+
const errors = [];
|
|
351
|
+
validateText(opts, errors);
|
|
352
|
+
if (!opts.oldText || typeof opts.oldText !== 'string') {
|
|
353
|
+
errors.push('oldText: required for action=replace, non-empty string');
|
|
354
|
+
}
|
|
355
|
+
if (errors.length > 0) {
|
|
356
|
+
return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
|
|
357
|
+
}
|
|
358
|
+
// Poison_Guard against the NEW text — never accept a write whose
|
|
359
|
+
// replacement contains a secret.
|
|
360
|
+
const poisonResult = runPoisonGuard({
|
|
361
|
+
text: opts.text,
|
|
362
|
+
projectRoot: opts.projectRoot,
|
|
363
|
+
source: opts.source,
|
|
364
|
+
sessionId: opts.sessionId,
|
|
365
|
+
now: opts.now,
|
|
366
|
+
});
|
|
367
|
+
if (poisonResult) return poisonResult;
|
|
368
|
+
|
|
369
|
+
const path = resolveScratchpadPath({
|
|
370
|
+
tier: opts.tier,
|
|
371
|
+
scratchpad: opts.scratchpad,
|
|
372
|
+
projectRoot: opts.projectRoot,
|
|
373
|
+
userDir: opts.userDir,
|
|
374
|
+
});
|
|
375
|
+
if (!existsSync(path)) {
|
|
376
|
+
return errorResult({
|
|
377
|
+
category: ERROR_CATEGORIES.NOT_FOUND,
|
|
378
|
+
errors: [`scratchpad does not exist: ${path}`],
|
|
379
|
+
path,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const original = readFileSync(path, 'utf8');
|
|
383
|
+
const lines = original.split('\n');
|
|
384
|
+
const match = findMatchingBullet({
|
|
385
|
+
lines,
|
|
386
|
+
substring: opts.oldText,
|
|
387
|
+
sectionTitle: opts.section,
|
|
388
|
+
});
|
|
389
|
+
if (!match) {
|
|
390
|
+
// Canonical "operation failed because target wasn't found" shape:
|
|
391
|
+
// action='error' + errorCategory='not-found'. The bare
|
|
392
|
+
// notFoundResult() shape (action='not-found') is for read-side
|
|
393
|
+
// boundaries where "I couldn't find it" is the success path of
|
|
394
|
+
// a lookup. For a write that needed an existing target,
|
|
395
|
+
// matching the user's expectation that a failed replace is an
|
|
396
|
+
// error.
|
|
397
|
+
return errorResult({
|
|
398
|
+
category: ERROR_CATEGORIES.NOT_FOUND,
|
|
399
|
+
errors: [
|
|
400
|
+
`replace: no bullet in ${opts.scratchpad} § ${opts.section} contains substring "${opts.oldText}"`,
|
|
401
|
+
],
|
|
402
|
+
path,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
// Strip the matched bullet + provenance comment.
|
|
406
|
+
const stripped = [
|
|
407
|
+
...lines.slice(0, match.bulletIdx),
|
|
408
|
+
...lines.slice(match.commentIdx + 1),
|
|
409
|
+
].join('\n');
|
|
410
|
+
writeFileSync(path, stripped, 'utf8');
|
|
411
|
+
|
|
412
|
+
// Append the new bullet via the GUARDED inner path. We already ran
|
|
413
|
+
// Poison_Guard at the top of doReplace — calling doAdd() here
|
|
414
|
+
// would re-run it on the same text, with side effects (NDJSON log
|
|
415
|
+
// writes) that double-count once Poison_Guard becomes settings-
|
|
416
|
+
// driven per design §6.7. The inner appendBulletGuarded() skips
|
|
417
|
+
// the guard since the caller already gated.
|
|
418
|
+
const addResult = appendBulletGuarded({
|
|
419
|
+
...opts,
|
|
420
|
+
action: 'add',
|
|
421
|
+
text: opts.text,
|
|
422
|
+
});
|
|
423
|
+
if (addResult.action !== 'appended') {
|
|
424
|
+
// Append failed AFTER we stripped the old (e.g. cap_exceeded
|
|
425
|
+
// after consolidation). Roll back: restore the original file so
|
|
426
|
+
// the user isn't left with a partial state.
|
|
427
|
+
writeFileSync(path, original, 'utf8');
|
|
428
|
+
return addResult;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Audit the replace as a curated-merge-style event so analytics
|
|
432
|
+
// can distinguish from raw appends.
|
|
433
|
+
const tierRoot = resolveTierRoot({
|
|
434
|
+
tier: opts.tier,
|
|
435
|
+
projectRoot: opts.projectRoot,
|
|
436
|
+
userDir: opts.userDir,
|
|
437
|
+
});
|
|
438
|
+
appendAuditEntry(tierRoot, {
|
|
439
|
+
ts: opts.now ?? nowIso(),
|
|
440
|
+
action: 'replaced',
|
|
441
|
+
tier: opts.tier,
|
|
442
|
+
id: addResult.id,
|
|
443
|
+
reasonCode: REASON_CODES.CURATED_MERGE,
|
|
444
|
+
reasonText: `replace via memory-write: old=${match.id}`,
|
|
445
|
+
paths: { before: path, after: path },
|
|
446
|
+
extra: { oldId: match.id, newId: addResult.id, scratchpad: opts.scratchpad },
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
action: 'replaced',
|
|
451
|
+
oldId: match.id,
|
|
452
|
+
newId: addResult.id,
|
|
453
|
+
path: addResult.path,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// --- Action: remove ------------------------------------------------
|
|
458
|
+
|
|
459
|
+
function doRemove(opts) {
|
|
460
|
+
const errors = [];
|
|
461
|
+
validateText(opts, errors);
|
|
462
|
+
if (opts.confirmRemove !== true) {
|
|
463
|
+
errors.push(
|
|
464
|
+
'confirmRemove: must be explicitly true for action=remove (safety gate — tombstoning is irreversible from user perspective)',
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
if (errors.length > 0) {
|
|
468
|
+
return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const path = resolveScratchpadPath({
|
|
472
|
+
tier: opts.tier,
|
|
473
|
+
scratchpad: opts.scratchpad,
|
|
474
|
+
projectRoot: opts.projectRoot,
|
|
475
|
+
userDir: opts.userDir,
|
|
476
|
+
});
|
|
477
|
+
if (!existsSync(path)) {
|
|
478
|
+
return errorResult({
|
|
479
|
+
category: ERROR_CATEGORIES.NOT_FOUND,
|
|
480
|
+
errors: [`scratchpad does not exist: ${path}`],
|
|
481
|
+
path,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
const original = readFileSync(path, 'utf8');
|
|
485
|
+
const lines = original.split('\n');
|
|
486
|
+
const match = findMatchingBullet({
|
|
487
|
+
lines,
|
|
488
|
+
substring: opts.text,
|
|
489
|
+
sectionTitle: opts.section,
|
|
490
|
+
});
|
|
491
|
+
if (!match) {
|
|
492
|
+
return errorResult({
|
|
493
|
+
category: ERROR_CATEGORIES.NOT_FOUND,
|
|
494
|
+
errors: [
|
|
495
|
+
`remove: no bullet in ${opts.scratchpad} § ${opts.section} contains substring "${opts.text}"`,
|
|
496
|
+
],
|
|
497
|
+
path,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const tierRoot = resolveTierRoot({
|
|
502
|
+
tier: opts.tier,
|
|
503
|
+
projectRoot: opts.projectRoot,
|
|
504
|
+
userDir: opts.userDir,
|
|
505
|
+
});
|
|
506
|
+
const ts = opts.now ?? nowIso();
|
|
507
|
+
const tombstonePath = writeTombstone({
|
|
508
|
+
tierRoot,
|
|
509
|
+
id: match.id,
|
|
510
|
+
bulletText: match.bulletText,
|
|
511
|
+
commentLine: match.commentLine,
|
|
512
|
+
deletedAt: ts,
|
|
513
|
+
deletedReason: `user said: forget about "${opts.text}"`,
|
|
514
|
+
deletedBy: opts.source === 'auto-extract' ? 'auto-extract' : 'user-explicit',
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// Strip from scratchpad.
|
|
518
|
+
const stripped = [
|
|
519
|
+
...lines.slice(0, match.bulletIdx),
|
|
520
|
+
...lines.slice(match.commentIdx + 1),
|
|
521
|
+
].join('\n');
|
|
522
|
+
writeFileSync(path, stripped, 'utf8');
|
|
523
|
+
|
|
524
|
+
appendAuditEntry(tierRoot, {
|
|
525
|
+
ts,
|
|
526
|
+
action: 'tombstoned',
|
|
527
|
+
tier: opts.tier,
|
|
528
|
+
id: match.id,
|
|
529
|
+
reasonCode: REASON_CODES.USER_REQUESTED,
|
|
530
|
+
reasonText: `tombstone via memory-write remove: substring="${opts.text}"`,
|
|
531
|
+
paths: { before: path, archive: tombstonePath },
|
|
532
|
+
extra: { scratchpad: opts.scratchpad },
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
action: 'tombstoned',
|
|
537
|
+
id: match.id,
|
|
538
|
+
path,
|
|
539
|
+
tombstonePath,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// --- Public boundary -----------------------------------------------
|
|
544
|
+
|
|
545
|
+
export function memoryWrite(opts = {}) {
|
|
546
|
+
const errors = validateCommon(opts);
|
|
547
|
+
if (errors.length > 0) {
|
|
548
|
+
return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
|
|
549
|
+
}
|
|
550
|
+
switch (opts.action) {
|
|
551
|
+
case 'add':
|
|
552
|
+
return doAdd(opts);
|
|
553
|
+
case 'replace':
|
|
554
|
+
return doReplace(opts);
|
|
555
|
+
case 'remove':
|
|
556
|
+
return doRemove(opts);
|
|
557
|
+
default:
|
|
558
|
+
// Unreachable — validateCommon catches unknown actions — but
|
|
559
|
+
// be explicit so the switch is exhaustive.
|
|
560
|
+
return errorResult({
|
|
561
|
+
category: ERROR_CATEGORIES.SCHEMA,
|
|
562
|
+
errors: [`unknown action: ${JSON.stringify(opts.action)}`],
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|