@lh8ppl/claude-memory-kit 0.1.1 → 0.2.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/README.md +8 -5
- package/bin/cmk-auto-extract.mjs +13 -0
- package/bin/cmk-capture-prompt.mjs +0 -0
- package/bin/cmk-capture-turn.mjs +0 -0
- package/bin/cmk-compress-session.mjs +31 -17
- package/bin/cmk-inject-context.mjs +12 -2
- package/bin/cmk-observe-edit.mjs +0 -0
- 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 +37 -22
- package/src/conflict-queue.mjs +8 -1
- package/src/daily-distill.mjs +19 -11
- package/src/doctor.mjs +79 -26
- package/src/forget.mjs +14 -0
- package/src/graduate-session.mjs +65 -0
- package/src/graduation.mjs +179 -0
- package/src/index-rebuild.mjs +26 -4
- package/src/inject-context.mjs +352 -65
- package/src/install.mjs +52 -7
- package/src/lessons-promote.mjs +137 -0
- package/src/mcp-server.mjs +17 -0
- package/src/memory-write.mjs +20 -7
- 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/sanitize.mjs +39 -0
- package/src/scratchpad.mjs +247 -19
- package/src/session-end-tasks.mjs +127 -0
- package/src/settings-hooks.mjs +33 -3
- package/src/spawn-bin.mjs +83 -0
- package/src/subcommands.mjs +472 -26
- package/src/weekly-curate.mjs +53 -6
- package/src/write-fact.mjs +60 -3
- package/template/.claude/skills/memory-write/SKILL.md +47 -88
- package/template/.gitignore.fragment +6 -0
- package/template/CLAUDE.md.template +17 -7
- 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
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
// Auto-persona generation (Task 45, T-014) — v0.2 Phase 2.
|
|
2
|
+
//
|
|
3
|
+
// The friend-handoff gate. The 2026-05-30 self-test (finding #2)
|
|
4
|
+
// reproduced design §16.16's predicted failure: cross-project doctrine
|
|
5
|
+
// ("how I work everywhere" — venv-3.13, layered-backend) was captured
|
|
6
|
+
// but filed PROJECT-tier; the USER tier stayed empty, collapsing the
|
|
7
|
+
// 3-tier value prop to project+local. Lior won't hand-curate the user
|
|
8
|
+
// tier ("too much of a hassle"), so the user tier must fill itself.
|
|
9
|
+
//
|
|
10
|
+
// Posture (tasks.md 45.6 — supersedes 45.2/45.3's manual gate):
|
|
11
|
+
// OPTIMISTIC AUTO-PROMOTE. Lior 2026-05-30: "i dont want to do
|
|
12
|
+
// anything, i want it to be automatic." A synthesized doctrine that
|
|
13
|
+
// applies beyond the current project is auto-promoted to the user tier
|
|
14
|
+
// at trust:medium — no manual `cmk persona accept` step. A confidence
|
|
15
|
+
// gate (not a manual gate) routes only LOW-confidence candidates to the
|
|
16
|
+
// review queue, which the daily/weekly passes auto-drain.
|
|
17
|
+
//
|
|
18
|
+
// Design B (chosen): piggyback Task 34's weekly-curate consolidator —
|
|
19
|
+
// it already runs the CompressorBackend (Haiku) with no extra API call.
|
|
20
|
+
// The backend classifies each captured fact as cross-project doctrine
|
|
21
|
+
// (or not), names a user-tier target + section, and a confidence.
|
|
22
|
+
//
|
|
23
|
+
// Public boundary:
|
|
24
|
+
// autoPersona({projectRoot, userDir, backend, now, cooldownMs?,
|
|
25
|
+
// autoPromote?, settings?})
|
|
26
|
+
// → {action: 'promoted' | 'skipped' | 'error',
|
|
27
|
+
// promoted: [{id, target, section, text, trust}],
|
|
28
|
+
// queued: [{id, text, target, section, reason}],
|
|
29
|
+
// superseded: [{oldId, newId, target}],
|
|
30
|
+
// conflicts: [{id, text, target, section}],
|
|
31
|
+
// duration_ms, errorCategory?, errors?}
|
|
32
|
+
//
|
|
33
|
+
// Trust: auto-promoted entries land at trust:medium (system-derived,
|
|
34
|
+
// not user-attested); a `cmk persona accept` (45.2, still available)
|
|
35
|
+
// promotes to high. Write source: 'compressor' (Haiku-backend synthesis).
|
|
36
|
+
//
|
|
37
|
+
// Composes on: tier-paths, scratchpad (appendScratchpadBullet — the
|
|
38
|
+
// promotion primitive), audit-log, result-shapes, cooldown, compressor.
|
|
39
|
+
// Per design §16.16 + §6.2 (conflict) + §6.8 (auto-drain) + §8.3 + tasks.md 45.
|
|
40
|
+
|
|
41
|
+
import { readFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
42
|
+
import { join, dirname } from 'node:path';
|
|
43
|
+
import { generateId } from '@lh8ppl/cmk-canonicalize';
|
|
44
|
+
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
45
|
+
import { resolveTierRoot, resolveScratchpadPath } from './tier-paths.mjs';
|
|
46
|
+
import { ensureSectionExists } from './scratchpad.mjs';
|
|
47
|
+
import { listObservationSources } from './index-rebuild.mjs';
|
|
48
|
+
import { parse } from './frontmatter.mjs';
|
|
49
|
+
import { memoryWrite } from './memory-write.mjs';
|
|
50
|
+
import { detectConflicts } from './conflict-queue.mjs';
|
|
51
|
+
import { appendAuditEntry, REASON_CODES } from './audit-log.mjs';
|
|
52
|
+
import { DEFAULT_COOLDOWN_MS, isCooldownActive, touchCooldownMarker } from './cooldown.mjs';
|
|
53
|
+
|
|
54
|
+
// User-tier scratchpads auto-persona is allowed to promote into. A
|
|
55
|
+
// classifier-named target outside this set is dropped defensively (the
|
|
56
|
+
// backend is Haiku — never trust its routing blindly; §NFR-9 spirit).
|
|
57
|
+
const VALID_TARGETS = new Set(['USER.md', 'HABITS.md', 'LESSONS.md']);
|
|
58
|
+
|
|
59
|
+
// F2 (Task 64): a section name we're willing to CREATE on the user tier. Must
|
|
60
|
+
// read like a heading — starts with a letter, then letters/digits/spaces and a
|
|
61
|
+
// few mild separators (& / -), bounded length. Rejects path traversal, markdown
|
|
62
|
+
// metachars (`#`), punctuation noise, and overlong strings so Haiku can't inject
|
|
63
|
+
// a junk or unsafe heading. (c.section is already `.trim()`-ed by the parser.)
|
|
64
|
+
const SAFE_SECTION_NAME = /^[A-Za-z][A-Za-z0-9 &/-]{1,48}$/;
|
|
65
|
+
|
|
66
|
+
// One classifier candidate per line. The consolidator's Haiku Step-3
|
|
67
|
+
// (Design B) emits, for each captured fact that is cross-project
|
|
68
|
+
// doctrine, a line of this exact shape. Project-specific facts are NOT
|
|
69
|
+
// surfaced. Free text is the trailing group (may contain '=' / '|').
|
|
70
|
+
export const PERSONA_CANDIDATE_RE =
|
|
71
|
+
/^PERSONA CANDIDATE \| target=(.+?) \| section=(.+?) \| confidence=(\w+) \| (.+)$/;
|
|
72
|
+
|
|
73
|
+
// Assemble the PROJECT-tier captured facts (granular fact files +
|
|
74
|
+
// MEMORY.md scratchpad bullets) into one corpus the backend classifies.
|
|
75
|
+
// userDir is passed through to listObservationSources purely to keep the
|
|
76
|
+
// U-tier resolution sandbox-scoped (never walk the real home dir —
|
|
77
|
+
// design §16.36); we then filter to tier P, the synthesis SOURCE.
|
|
78
|
+
function assembleProjectCorpus({ projectRoot, userDir }) {
|
|
79
|
+
const sources = listObservationSources({ projectRoot, userDir });
|
|
80
|
+
const parts = [];
|
|
81
|
+
for (const s of sources) {
|
|
82
|
+
if (s.tier !== 'P') continue;
|
|
83
|
+
let content;
|
|
84
|
+
try {
|
|
85
|
+
content = readFileSync(s.path, 'utf8');
|
|
86
|
+
} catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (s.kind === 'fact') {
|
|
90
|
+
const { frontmatter, body } = parse(content);
|
|
91
|
+
const title = frontmatter?.title ?? frontmatter?.id ?? '';
|
|
92
|
+
parts.push(`### ${title}\n${(body ?? '').trim()}`);
|
|
93
|
+
} else {
|
|
94
|
+
parts.push((content ?? '').trim());
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return parts.filter(Boolean).join('\n\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Default size of the recent-transcript window handed to the SessionEnd persona
|
|
101
|
+
// classifier (Task 86c / D-44). Bounded — like hermes' "conversation snapshot"
|
|
102
|
+
// and claude-mem's last-message — so the focused Haiku call stays cheap and the
|
|
103
|
+
// MOST RECENT turns dominate. The classifier maxOutputBytes is 4096; input larger.
|
|
104
|
+
//
|
|
105
|
+
// RECALL vs PRECISION tradeoff (86c skill-review I1): a standing rule stated EARLY
|
|
106
|
+
// in a long session ("from now on …" at turn 2, then 40 more turns) can scroll out
|
|
107
|
+
// of the window — and the usual backstops don't recover it (the inline per-turn
|
|
108
|
+
// path is what drops under load, the reason this dedicated pass exists; the fact
|
|
109
|
+
// corpus already lost the cross-project signal per D-44). So the window must be
|
|
110
|
+
// generous enough to hold a typical full session, not just the last few turns.
|
|
111
|
+
// 40k chars ≈ a long session's worth of turns ≈ ~10k tokens — trivial cost for a
|
|
112
|
+
// once-per-session call, and the classifier prompt's "IGNORE anything specific to
|
|
113
|
+
// this ONE project" instruction guards precision at the larger size (live test:
|
|
114
|
+
// clean 2/2, no false promotes). The exact bound is a lior-test-9 tuning item.
|
|
115
|
+
// KNOWN LIMITATION (documented, not yet fixed): only the most-recent date-named
|
|
116
|
+
// file is read, so a session spanning midnight loses the pre-midnight turns. Rare;
|
|
117
|
+
// a multi-file read is the follow-up if it bites.
|
|
118
|
+
export const TRANSCRIPT_WINDOW_BYTES = 40_000;
|
|
119
|
+
|
|
120
|
+
// Assemble the recent-conversation window for the persona classifier (Task 86c).
|
|
121
|
+
// Reads the most-recent date-named transcript (`context/transcripts/{date}.md`,
|
|
122
|
+
// written per-turn by capture-turn) and returns its tail, snapped FORWARD to a
|
|
123
|
+
// `## ` turn boundary so the window never starts mid-line. Returns '' when no
|
|
124
|
+
// transcript exists (a no-turn session — nothing to classify).
|
|
125
|
+
//
|
|
126
|
+
// WHY the transcript, not the fact corpus (D-44, primary-source-verified):
|
|
127
|
+
// auto-extract distills a user's universal rule into project-scoped fact text
|
|
128
|
+
// ("Use uv … pip is not used in THIS PROJECT"), stripping the cross-project
|
|
129
|
+
// signal the classifier needs. The verbatim signal ("from now on …", "in every
|
|
130
|
+
// project") survives only in the transcript. hermes' background_review reviews
|
|
131
|
+
// "the conversation above"; claude-mem's summarize reads transcriptPath — both
|
|
132
|
+
// classify the raw conversation, never distilled memory.
|
|
133
|
+
//
|
|
134
|
+
// Injection posture (86c skill-review NOTE): the transcript is privacy-sanitized
|
|
135
|
+
// at write time (capture-turn → sanitizePrivacyTags) but NOT prompt-structure
|
|
136
|
+
// sanitized, so a user could type a literal "PERSONA CANDIDATE | …" line that the
|
|
137
|
+
// classifier echoes. The blast radius is bounded: it's the user's OWN single-user
|
|
138
|
+
// conversation, and the promote path (promoteCandidatesToUserTier → memoryWrite)
|
|
139
|
+
// is gated by VALID_TARGETS + the section-name guard + the confidence gate. A
|
|
140
|
+
// self-authored persona entry is the feature, not a third-party threat.
|
|
141
|
+
export function assembleTranscriptWindow({ projectRoot, maxBytes = TRANSCRIPT_WINDOW_BYTES }) {
|
|
142
|
+
const dir = join(projectRoot, 'context', 'transcripts');
|
|
143
|
+
if (!existsSync(dir)) return '';
|
|
144
|
+
let files;
|
|
145
|
+
try {
|
|
146
|
+
files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
|
|
147
|
+
} catch {
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
if (files.length === 0) return '';
|
|
151
|
+
// Date-named (YYYY-MM-DD) → lexical sort = chronological; take the latest.
|
|
152
|
+
const latest = files[files.length - 1];
|
|
153
|
+
let text;
|
|
154
|
+
try {
|
|
155
|
+
text = readFileSync(join(dir, latest), 'utf8');
|
|
156
|
+
} catch {
|
|
157
|
+
return '';
|
|
158
|
+
}
|
|
159
|
+
if (!text.trim()) return '';
|
|
160
|
+
if (text.length <= maxBytes) return text;
|
|
161
|
+
let tail = text.slice(text.length - maxBytes);
|
|
162
|
+
// Snap forward to the first whole turn so we don't begin mid-sentence.
|
|
163
|
+
const boundary = tail.indexOf('\n## ');
|
|
164
|
+
if (boundary !== -1) tail = tail.slice(boundary + 1);
|
|
165
|
+
return tail;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Shared persona grading rule (Task 78 — the wedge's AUTO half). The
|
|
169
|
+
// `confidence` axis encodes EXPLICIT-vs-INFERRED, which drives BOTH the promote
|
|
170
|
+
// gate (only `high` promotes; medium/low queue) AND the write trust on the
|
|
171
|
+
// inline path (an explicitly-stated rule is user-attested → trust:high). The
|
|
172
|
+
// live-test gap (D-30): the user STATED a universal rule but the classifier
|
|
173
|
+
// under-graded it to medium → it queued instead of landing in the user tier, so
|
|
174
|
+
// the cross-project persona never filled on its own. This rule makes the
|
|
175
|
+
// stated-vs-observed distinction explicit. Kept in ONE place so the inline
|
|
176
|
+
// (auto-extract) and weekly (classifier) prompts can never drift apart.
|
|
177
|
+
export const PERSONA_CONFIDENCE_RULE = [
|
|
178
|
+
'GRADE confidence by whether the user STATED it as a standing rule vs you INFERRED it from behavior:',
|
|
179
|
+
' - confidence=high → the user EXPLICITLY STATED a standing, cross-project rule: an imperative with universal scope — "always …", "never …", "in every project", "from now on", "going forward, in all my projects", "as a rule I …". Use high ONLY for a rule the user actually stated.',
|
|
180
|
+
' - confidence=medium → you are INFERRING the doctrine from how they worked this session (they did it, but did NOT declare it as a standing rule).',
|
|
181
|
+
' - When unsure whether it was stated-as-a-rule or merely-observed, use medium.',
|
|
182
|
+
].join('\n');
|
|
183
|
+
|
|
184
|
+
// `source` selects the INPUT framing (Task 86c / D-44):
|
|
185
|
+
// - 'transcript' (SessionEnd path): the input is the raw recent conversation,
|
|
186
|
+
// where standing-rule statements ("from now on …", "in every project") are
|
|
187
|
+
// verbatim — the reliable cross-project signal. This is the mature-product
|
|
188
|
+
// shape (hermes reviews "the conversation above"; claude-mem reads the
|
|
189
|
+
// transcript).
|
|
190
|
+
// - 'facts' (default; weekly-curate + manual `cmk persona generate`): the input
|
|
191
|
+
// is the distilled project fact corpus — appropriate for a whole-project
|
|
192
|
+
// sweep, but lossy for cross-project signal (D-44), so NOT used at SessionEnd.
|
|
193
|
+
// Only the framing lines differ; the routing + confidence rule are shared so the
|
|
194
|
+
// two paths can never drift apart.
|
|
195
|
+
export function buildClassifierInstructions(source = 'facts') {
|
|
196
|
+
const isTranscript = source === 'transcript';
|
|
197
|
+
const opener = isTranscript
|
|
198
|
+
? 'You are a persona archivist for claude-memory-kit. The input below is the RECENT CONVERSATION (user and assistant turns) from ONE project session.'
|
|
199
|
+
: 'You are a persona archivist for claude-memory-kit. The input below is a set of facts captured while the user worked on ONE project.';
|
|
200
|
+
const jobLine = isTranscript
|
|
201
|
+
? 'Your job: identify ONLY the things the user REVEALED or STATED that express CROSS-PROJECT doctrine — how this user works EVERYWHERE (tooling habits, how they structure their work, communication style, process rules). IGNORE anything specific to this ONE project (a particular value, name, or detail that would not carry to their other projects; one-off task state).'
|
|
202
|
+
: 'Your job: identify ONLY the facts that express CROSS-PROJECT doctrine — how this user works EVERYWHERE (tooling habits, how they structure their work, communication style, process rules). IGNORE anything specific to this ONE project (a particular value, name, or detail that would not carry to their other projects; one-off task state).';
|
|
203
|
+
const beginMarker = isTranscript
|
|
204
|
+
? '=== BEGIN RECENT CONVERSATION ==='
|
|
205
|
+
: '=== BEGIN CAPTURED PROJECT FACTS ===';
|
|
206
|
+
return [
|
|
207
|
+
opener,
|
|
208
|
+
'',
|
|
209
|
+
jobLine,
|
|
210
|
+
'',
|
|
211
|
+
'For EACH cross-project fact, emit exactly one line, nothing else, in this EXACT format:',
|
|
212
|
+
'PERSONA CANDIDATE | target=<FILE> | section=<SECTION> | confidence=<high|medium|low> | <one-line restatement>',
|
|
213
|
+
'',
|
|
214
|
+
'Routing:',
|
|
215
|
+
' - target=HABITS.md → working-style habits. sections: Iteration Cadence | Destructive Operations | Communication Style',
|
|
216
|
+
' - target=LESSONS.md → cross-project lessons. sections: Tooling Lessons | Process Lessons | Anti-patterns',
|
|
217
|
+
' - target=USER.md → identity/preferences. sections: About | Preferences | Working Style',
|
|
218
|
+
' 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). Never invent a new section when an existing one fits.',
|
|
219
|
+
'',
|
|
220
|
+
PERSONA_CONFIDENCE_RULE,
|
|
221
|
+
'',
|
|
222
|
+
'Output ONLY PERSONA CANDIDATE lines. No preamble, no commentary. If nothing is cross-project, output nothing.',
|
|
223
|
+
'',
|
|
224
|
+
beginMarker,
|
|
225
|
+
].join('\n');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function parsePersonaCandidates(outputText) {
|
|
229
|
+
const candidates = [];
|
|
230
|
+
for (const raw of (outputText ?? '').split('\n')) {
|
|
231
|
+
const line = raw.trim();
|
|
232
|
+
const m = PERSONA_CANDIDATE_RE.exec(line);
|
|
233
|
+
if (!m) continue;
|
|
234
|
+
const [, target, section, confidence, text] = m;
|
|
235
|
+
candidates.push({
|
|
236
|
+
target: target.trim(),
|
|
237
|
+
section: section.trim(),
|
|
238
|
+
confidence: confidence.trim().toLowerCase(),
|
|
239
|
+
text: text.trim(),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return candidates;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Run auto-persona synthesis: classify project-tier captured facts,
|
|
247
|
+
* auto-promote cross-project doctrine into the user tier (trust:medium).
|
|
248
|
+
*
|
|
249
|
+
* @returns {Promise<object>} action: 'promoted' | 'skipped' | 'error'
|
|
250
|
+
*/
|
|
251
|
+
export async function autoPersona(opts = {}) {
|
|
252
|
+
const t0 = Date.now();
|
|
253
|
+
const { projectRoot, userDir, backend, now, settings, cooldownMs = DEFAULT_COOLDOWN_MS, source = 'facts' } = opts;
|
|
254
|
+
|
|
255
|
+
if (!projectRoot) {
|
|
256
|
+
return errorResult({
|
|
257
|
+
category: ERROR_CATEGORIES.MISSING_PROJECT_ROOT,
|
|
258
|
+
errors: ['projectRoot is required'],
|
|
259
|
+
duration_ms: Date.now() - t0,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (!userDir) {
|
|
263
|
+
return errorResult({
|
|
264
|
+
category: ERROR_CATEGORIES.SCHEMA,
|
|
265
|
+
errors: ['userDir is required (auto-persona promotes to the user tier)'],
|
|
266
|
+
duration_ms: Date.now() - t0,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (!backend || typeof backend.compress !== 'function') {
|
|
270
|
+
return errorResult({
|
|
271
|
+
category: ERROR_CATEGORIES.MISSING_BACKEND,
|
|
272
|
+
errors: ['backend (CompressorBackend) is required'],
|
|
273
|
+
duration_ms: Date.now() - t0,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const ts = now ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
278
|
+
|
|
279
|
+
// Shared 120s Haiku cooldown (same marker daily-distill / weekly-curate /
|
|
280
|
+
// auto-extract touch). cooldownMs:0 override lets a caller run auto-persona
|
|
281
|
+
// inside an already-gated cycle (e.g. weekly-curate, per §8.7.2 — both Haiku
|
|
282
|
+
// calls belong to one cycle, not two independent invocations).
|
|
283
|
+
if (cooldownMs > 0 && isCooldownActive({ projectRoot, now: ts, cooldownMs })) {
|
|
284
|
+
return { action: 'skipped', reason: 'cooldown', promoted: [], queued: [], duration_ms: Date.now() - t0 };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Task 86c (D-44): the SessionEnd path classifies the RAW TRANSCRIPT (where a
|
|
288
|
+
// user's standing rule survives verbatim); the default 'facts' path classifies
|
|
289
|
+
// the distilled project corpus (whole-project sweep — weekly/manual).
|
|
290
|
+
const corpus = source === 'transcript'
|
|
291
|
+
? assembleTranscriptWindow({ projectRoot })
|
|
292
|
+
: assembleProjectCorpus({ projectRoot, userDir });
|
|
293
|
+
if (!corpus) {
|
|
294
|
+
const reason = source === 'transcript' ? 'no-transcript' : 'no-facts';
|
|
295
|
+
return { action: 'skipped', reason, promoted: [], queued: [], duration_ms: Date.now() - t0 };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let result;
|
|
299
|
+
try {
|
|
300
|
+
result = await backend.compress({
|
|
301
|
+
input: corpus,
|
|
302
|
+
instructions: buildClassifierInstructions(source),
|
|
303
|
+
preserveCitationIds: false,
|
|
304
|
+
maxOutputBytes: 4096,
|
|
305
|
+
timeoutMs: 50_000,
|
|
306
|
+
});
|
|
307
|
+
// Spent a Haiku call — refresh the shared cooldown marker so the next
|
|
308
|
+
// gated caller backs off. (touch even on cooldownMs:0 cycles: the call
|
|
309
|
+
// happened, so the marker should reflect it for any LATER gated caller.)
|
|
310
|
+
touchCooldownMarker({ projectRoot, now: ts });
|
|
311
|
+
} catch (err) {
|
|
312
|
+
touchCooldownMarker({ projectRoot, now: ts });
|
|
313
|
+
return errorResult({
|
|
314
|
+
category: ERROR_CATEGORIES.COMPRESS_FAILED,
|
|
315
|
+
errors: [err?.message ?? String(err)],
|
|
316
|
+
duration_ms: Date.now() - t0,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const candidates = parsePersonaCandidates(result?.outputText);
|
|
321
|
+
const { promoted, queued, superseded, conflicts, reviewQueuePath } = promoteCandidatesToUserTier({
|
|
322
|
+
candidates,
|
|
323
|
+
userDir,
|
|
324
|
+
now: ts,
|
|
325
|
+
settings,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const duration_ms = Date.now() - t0;
|
|
329
|
+
// A supersede IS a promotion outcome (the user tier changed).
|
|
330
|
+
if (promoted.length === 0 && superseded.length === 0) {
|
|
331
|
+
return { action: 'skipped', reason: 'no-promotions', promoted, queued, superseded, conflicts, reviewQueuePath, duration_ms };
|
|
332
|
+
}
|
|
333
|
+
return { action: 'promoted', promoted, queued, superseded, conflicts, reviewQueuePath, duration_ms };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Promote classified PERSONA CANDIDATE rows into the user tier. Shared by
|
|
338
|
+
* autoPersona (the weekly janitor) and auto-extract (Task 61 — inline at
|
|
339
|
+
* capture time). High-confidence → memoryWrite to the user-tier scratchpad
|
|
340
|
+
* (auto-supersede on conflict); low/medium → surfaced in `queued`.
|
|
341
|
+
*
|
|
342
|
+
* Trust posture (the `trust`/`source` params):
|
|
343
|
+
* - DEFAULT (no params) → trust:'medium', source:'persona-synthesis'. The
|
|
344
|
+
* SYSTEM-DERIVED posture (45.6) used by the weekly janitor and any
|
|
345
|
+
* inferred promotion. **45.4 invariant (original, pre-2026-06-02):** a
|
|
346
|
+
* medium write never overwrites a hand-curated trust:high rule — a
|
|
347
|
+
* same-topic collision against a high entry routes to the review queue
|
|
348
|
+
* (medium < high → queue), so hand-curated highs are protected from
|
|
349
|
+
* inferred noise. This still holds for every medium/inferred write.
|
|
350
|
+
* - trust:'high' (explicit path — Task 76 `cmk lessons promote` + Task 78
|
|
351
|
+
* inline grading of an EXPLICITLY-STATED rule). **45.4 REFINEMENT
|
|
352
|
+
* (2026-06-02, D-32 — Lior chose "latest explicit wins"):** an explicit,
|
|
353
|
+
* user-attested rule at trust:high MAY supersede an equal-trust same-topic
|
|
354
|
+
* entry (high >= high → supersede). The newest explicit statement wins,
|
|
355
|
+
* even over a hand-curated high. The original protection is unchanged for
|
|
356
|
+
* non-explicit (medium) writes; only an explicit high can replace a high.
|
|
357
|
+
*
|
|
358
|
+
* @returns {{promoted:Array, queued:Array, superseded:Array, conflicts:Array}}
|
|
359
|
+
*/
|
|
360
|
+
// Persist low/medium-confidence (and otherwise-not-promoted) candidates to a
|
|
361
|
+
// durable review-queue FILE at <userDir>/queues/persona-review.md, so they are
|
|
362
|
+
// not lost when only returned in the response (Lior 2026-05-31: "response
|
|
363
|
+
// object can get lost — i dont like it"). Dedup by canonical id against what's
|
|
364
|
+
// already in the file so repeated synthesis passes don't pile up duplicates.
|
|
365
|
+
// Returns the queue path (or null when there's nothing to write).
|
|
366
|
+
export function appendPersonaReviewQueue({ userDir, entries, now }) {
|
|
367
|
+
if (!entries || entries.length === 0) return null;
|
|
368
|
+
const ts = now ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
369
|
+
const userTierRoot = resolveTierRoot({ tier: 'U', userDir });
|
|
370
|
+
const queuePath = join(userTierRoot, 'queues', 'persona-review.md');
|
|
371
|
+
mkdirSync(dirname(queuePath), { recursive: true });
|
|
372
|
+
|
|
373
|
+
let existing = '';
|
|
374
|
+
try {
|
|
375
|
+
existing = readFileSync(queuePath, 'utf8');
|
|
376
|
+
} catch {
|
|
377
|
+
// file not created yet — fine.
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const blocks = [];
|
|
381
|
+
for (const e of entries) {
|
|
382
|
+
const id = generateId('U', e.text);
|
|
383
|
+
if (existing.includes(`(${id})`)) continue; // already queued in a prior pass
|
|
384
|
+
blocks.push(
|
|
385
|
+
`- (${id}) [${e.target} § ${e.section}] ${e.text}\n` +
|
|
386
|
+
` <!-- target: ${e.target}, section: ${e.section}, confidence: ${e.confidence ?? 'unknown'}, reason: ${e.reason ?? 'pending-review'}, source: persona-synthesis, at: ${ts} -->`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (blocks.length === 0) return queuePath;
|
|
390
|
+
|
|
391
|
+
const header = `## ${ts} — persona-synthesis (pending review)`;
|
|
392
|
+
appendFileSync(queuePath, `${header}\n${blocks.join('\n')}\n\n`, 'utf8');
|
|
393
|
+
return queuePath;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function promoteCandidatesToUserTier({ candidates, userDir, now, settings, trust = 'medium', source = 'persona-synthesis' }) {
|
|
397
|
+
// `trust`/`source` default to the AUTO-persona posture (medium, system-derived
|
|
398
|
+
// — 45.6). The EXPLICIT path (`cmk lessons promote`) passes trust:'high' +
|
|
399
|
+
// source:'user-explicit' so a user-attested promotion is durable (the
|
|
400
|
+
// maintenance passes never age out / auto-supersede a trust:high entry — the
|
|
401
|
+
// 45.4 invariant) instead of decaying like an inferred preference.
|
|
402
|
+
const ts = now ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
403
|
+
const userTierRoot = resolveTierRoot({ tier: 'U', userDir });
|
|
404
|
+
const promoted = [];
|
|
405
|
+
const queued = [];
|
|
406
|
+
const superseded = [];
|
|
407
|
+
const conflicts = [];
|
|
408
|
+
for (const c of candidates) {
|
|
409
|
+
if (!VALID_TARGETS.has(c.target)) continue; // defensive: drop bad routing
|
|
410
|
+
if (c.confidence !== 'high') {
|
|
411
|
+
// Confidence gate (not a manual gate): low/medium route to the review
|
|
412
|
+
// queue. They are returned in `queued` AND written to the durable
|
|
413
|
+
// queue FILE below (appendPersonaReviewQueue) so they survive past the
|
|
414
|
+
// response — the daily/weekly auto-drain (or a manual review) acts on them.
|
|
415
|
+
queued.push({ target: c.target, section: c.section, text: c.text, confidence: c.confidence, reason: `confidence-${c.confidence}` });
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// D-13: promote THROUGH memoryWrite so the write inherits home-path
|
|
420
|
+
// sanitization (privacy — finding #1 class), Poison_Guard, dedup,
|
|
421
|
+
// cap/consolidation, and audit — none of which a raw scratchpad
|
|
422
|
+
// append would carry. We pre-detect conflicts ourselves only to pick
|
|
423
|
+
// the verb: memoryWrite's v0.1.0 'supersede' path merely appends, so
|
|
424
|
+
// for the auto-supersede contract (45.6) we issue an explicit
|
|
425
|
+
// `replace`; the `queue` case (new<existing, e.g. vs a trust:high
|
|
426
|
+
// hand-curated rule — the 45.4 invariant) is left to memoryWrite's
|
|
427
|
+
// own conflict-queue routing via a plain `add`.
|
|
428
|
+
const scratchpadPath = resolveScratchpadPath({ tier: 'U', scratchpad: c.target, userDir });
|
|
429
|
+
|
|
430
|
+
// F2 (Task 64): the user tier grows sections organically. If Haiku routes a
|
|
431
|
+
// candidate to a sane-but-not-yet-existing section (the live test: HABITS.md
|
|
432
|
+
// § "Architecture Preferences"), CREATE the heading instead of letting
|
|
433
|
+
// memoryWrite schema-fail to the review queue → empty HABITS.md. Guard the
|
|
434
|
+
// name first so a malformed/unsafe section can't inject a junk heading.
|
|
435
|
+
// NB: the guard also gates an *existing* but unsafe-named section (e.g. a
|
|
436
|
+
// hand-edited weird heading) → such a candidate queues rather than promotes;
|
|
437
|
+
// acceptable, since every section the kit itself creates is guard-valid.
|
|
438
|
+
if (!SAFE_SECTION_NAME.test(c.section)) {
|
|
439
|
+
queued.push({ target: c.target, section: c.section, text: c.text, confidence: c.confidence, reason: 'not-promoted-bad-section-name' });
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
// Heading-creation is intentionally cap-exempt: it's ~30 bytes and the
|
|
443
|
+
// memoryWrite below enforces the scratchpad byte cap on the bullet (§7.1).
|
|
444
|
+
const ensured = ensureSectionExists(scratchpadPath, c.section);
|
|
445
|
+
if (ensured.error) {
|
|
446
|
+
queued.push({ target: c.target, section: c.section, text: c.text, confidence: c.confidence, reason: `not-promoted-${ensured.error}` });
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (ensured.created) {
|
|
450
|
+
// Door 4: a new section is a structural change to a committed/shared
|
|
451
|
+
// scratchpad — record it so "why did HABITS.md grow this section?" is
|
|
452
|
+
// answerable from the audit log, not just inferred from the bullet below.
|
|
453
|
+
appendAuditEntry(userTierRoot, {
|
|
454
|
+
ts,
|
|
455
|
+
action: 'persona-section-created',
|
|
456
|
+
tier: 'U',
|
|
457
|
+
// A deterministic id for the structural event (there's no bullet id yet
|
|
458
|
+
// — the bullet is written below); audit-log requires a non-null id.
|
|
459
|
+
id: generateId('U', `section:${c.target}:${c.section}`),
|
|
460
|
+
reasonCode: REASON_CODES.PERSONA_SECTION_CREATED,
|
|
461
|
+
reasonText: `${c.target} § ${c.section}`,
|
|
462
|
+
paths: { after: scratchpadPath },
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const conflict = detectConflicts({
|
|
467
|
+
newText: c.text,
|
|
468
|
+
newTrust: trust,
|
|
469
|
+
scratchpadPath,
|
|
470
|
+
sectionTitle: c.section,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const common = {
|
|
474
|
+
tier: 'U',
|
|
475
|
+
scratchpad: c.target,
|
|
476
|
+
section: c.section,
|
|
477
|
+
text: c.text,
|
|
478
|
+
trust, // 'medium' (auto, 45.6) | 'high' (explicit `cmk lessons promote`)
|
|
479
|
+
source, // 'persona-synthesis' (auto) | 'user-explicit' (explicit promote)
|
|
480
|
+
userDir,
|
|
481
|
+
now: ts,
|
|
482
|
+
settings,
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
if (conflict.conflict === true && conflict.action === 'supersede') {
|
|
486
|
+
// New medium-trust persona fact contradicts an existing same-or-
|
|
487
|
+
// lower-trust one → replace it (no duplicate; closes finding #3
|
|
488
|
+
// Gap B). doReplace needs the old bullet's exact text.
|
|
489
|
+
// doReplace returns {action:'replaced', oldId, newId, path}.
|
|
490
|
+
const res = memoryWrite({ action: 'replace', oldText: conflict.existingText, ...common });
|
|
491
|
+
if (res.action !== 'replaced') {
|
|
492
|
+
queued.push({ target: c.target, section: c.section, text: c.text, confidence: c.confidence, reason: `not-superseded-${res.errorCategory ?? res.action}` });
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
appendAuditEntry(userTierRoot, {
|
|
496
|
+
ts,
|
|
497
|
+
action: 'persona-supersede',
|
|
498
|
+
tier: 'U',
|
|
499
|
+
id: res.newId,
|
|
500
|
+
reasonCode: REASON_CODES.PERSONA_SUPERSEDED,
|
|
501
|
+
// Carry `source` so the audit trail distinguishes an explicit
|
|
502
|
+
// `cmk lessons promote` (user-explicit) from an auto-synthesis promote.
|
|
503
|
+
reasonText: `${c.target} § ${c.section} (superseded ${res.oldId}; ${source})`,
|
|
504
|
+
paths: { after: res.path },
|
|
505
|
+
});
|
|
506
|
+
superseded.push({ oldId: res.oldId, newId: res.newId, target: c.target, section: c.section });
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const res = memoryWrite({ action: 'add', ...common });
|
|
511
|
+
|
|
512
|
+
if (res.action === 'queued') {
|
|
513
|
+
// memoryWrite routed to queues/conflicts.md (new<existing trust —
|
|
514
|
+
// never overwrite a hand-curated trust:high rule, the 45.4 invariant).
|
|
515
|
+
conflicts.push({ id: res.id, target: c.target, section: c.section, text: c.text, conflictsWith: res.conflictsWith });
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (res.action !== 'appended') {
|
|
519
|
+
// Bad section / cap / dedup-skip — surface, don't silently lose.
|
|
520
|
+
queued.push({ target: c.target, section: c.section, text: c.text, confidence: c.confidence, reason: `not-promoted-${res.errorCategory ?? res.action}` });
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
appendAuditEntry(userTierRoot, {
|
|
525
|
+
ts,
|
|
526
|
+
action: 'persona-promote',
|
|
527
|
+
tier: 'U',
|
|
528
|
+
id: res.id,
|
|
529
|
+
reasonCode: REASON_CODES.PERSONA_PROMOTED,
|
|
530
|
+
// Carry `source` so the audit trail distinguishes an explicit
|
|
531
|
+
// `cmk lessons promote` (user-explicit) from an auto-synthesis promote.
|
|
532
|
+
reasonText: `${c.target} § ${c.section} (${source})`,
|
|
533
|
+
paths: { after: res.path },
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
promoted.push({ id: res.id, target: c.target, section: c.section, text: c.text, trust });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Persist the queued (low/medium-confidence + not-promoted) candidates to
|
|
540
|
+
// the durable review-queue file so they survive past this response.
|
|
541
|
+
const reviewQueuePath = appendPersonaReviewQueue({ userDir, entries: queued, now: ts });
|
|
542
|
+
|
|
543
|
+
return { promoted, queued, superseded, conflicts, reviewQueuePath };
|
|
544
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// bullet-lookup.mjs — "is this id a scratchpad BULLET (not a graduated fact)?"
|
|
2
|
+
//
|
|
3
|
+
// Why this exists (the cut-gate F-3/F-7 finding, 2026-06-06): `cmk search`
|
|
4
|
+
// surfaces ids for BOTH graduated facts (context/memory/<type>_<slug>.md) AND
|
|
5
|
+
// scratchpad bullets (a `- (ID) …` line in MEMORY.md / SOUL.md / the user-tier
|
|
6
|
+
// persona). But `cmk lessons promote` and `cmk forget` operate on FACTS only —
|
|
7
|
+
// so pasting a bullet id from `cmk search` into either returns a flat, unhelpful
|
|
8
|
+
// "no matching fact for ID", even though the id is right there in a scratchpad.
|
|
9
|
+
//
|
|
10
|
+
// findBulletScratchpad turns that dead end into an actionable error: the caller,
|
|
11
|
+
// on a fact-not-found, asks "but is it a bullet?" and if so explains what the id
|
|
12
|
+
// actually is and where the fact ids live. Pure read-only lookup; no mutation.
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import {
|
|
16
|
+
VALID_TIERS,
|
|
17
|
+
SCRATCHPADS_BY_TIER,
|
|
18
|
+
resolveScratchpadPath,
|
|
19
|
+
} from './tier-paths.mjs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Find the scratchpad that holds `id` as a bullet, if any.
|
|
23
|
+
*
|
|
24
|
+
* Scans only the id's OWN tier (the tier prefix is authoritative — a P- id can
|
|
25
|
+
* only be a project-tier bullet), across that tier's scratchpad files, for a
|
|
26
|
+
* line beginning `- (ID)` (the canonical bullet shape, matching the writer in
|
|
27
|
+
* scratchpad.mjs / provenance.mjs).
|
|
28
|
+
*
|
|
29
|
+
* @param {string} id citation id, e.g. "P-XXXXXXXX"
|
|
30
|
+
* @param {object} [opts]
|
|
31
|
+
* @param {string} [opts.projectRoot]
|
|
32
|
+
* @param {string} [opts.userDir]
|
|
33
|
+
* @returns {string|null} the scratchpad filename (e.g. "MEMORY.md") or null
|
|
34
|
+
*/
|
|
35
|
+
export function findBulletScratchpad(id, { projectRoot, userDir } = {}) {
|
|
36
|
+
if (typeof id !== 'string' || id.length < 2) return null;
|
|
37
|
+
const tier = id[0];
|
|
38
|
+
if (!VALID_TIERS.has(tier)) return null;
|
|
39
|
+
const scratchpads = SCRATCHPADS_BY_TIER[tier];
|
|
40
|
+
if (!scratchpads) return null;
|
|
41
|
+
|
|
42
|
+
const needle = `- (${id})`;
|
|
43
|
+
for (const scratchpad of scratchpads) {
|
|
44
|
+
const path = resolveScratchpadPath({ tier, scratchpad, projectRoot, userDir });
|
|
45
|
+
if (!path || !existsSync(path)) continue;
|
|
46
|
+
let text;
|
|
47
|
+
try {
|
|
48
|
+
text = readFileSync(path, 'utf8');
|
|
49
|
+
} catch {
|
|
50
|
+
continue; // unreadable scratchpad — skip, not a hard error
|
|
51
|
+
}
|
|
52
|
+
// Bullet lines start at column 0 with "- (ID)"; a line-anchored prefix scan
|
|
53
|
+
// avoids matching the id where it appears inside a provenance comment or body.
|
|
54
|
+
if (text.split('\n').some((line) => line.startsWith(needle))) {
|
|
55
|
+
return scratchpad;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|