@lh8ppl/claude-memory-kit 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -5
- package/bin/cmk-auto-extract.mjs +13 -0
- package/bin/cmk-compress-session.mjs +31 -17
- package/bin/cmk-inject-context.mjs +12 -2
- package/bin/cmk-weekly-curate.mjs +14 -2
- package/package.json +3 -2
- package/src/audit-log.mjs +6 -0
- package/src/auto-drain.mjs +59 -0
- package/src/auto-extract.mjs +117 -6
- package/src/auto-persona.mjs +544 -0
- package/src/bullet-lookup.mjs +59 -0
- package/src/capture-turn.mjs +54 -0
- package/src/compress-session.mjs +6 -8
- package/src/compressor.mjs +19 -4
- package/src/conflict-queue.mjs +8 -1
- package/src/daily-distill.mjs +19 -11
- package/src/doctor.mjs +74 -23
- package/src/forget.mjs +14 -0
- package/src/graduate-session.mjs +65 -0
- package/src/graduation.mjs +179 -0
- package/src/inject-context.mjs +206 -59
- package/src/install.mjs +52 -7
- package/src/lessons-promote.mjs +137 -0
- package/src/memory-write.mjs +2 -2
- package/src/native-memory.mjs +98 -0
- package/src/persona-portability.mjs +253 -0
- package/src/provenance.mjs +23 -5
- package/src/read-hook-stdin.mjs +47 -0
- package/src/register-crons.mjs +17 -8
- package/src/scratchpad.mjs +247 -19
- package/src/session-end-tasks.mjs +127 -0
- package/src/settings-hooks.mjs +33 -3
- package/src/subcommands.mjs +339 -16
- package/src/weekly-curate.mjs +53 -6
- package/src/write-fact.mjs +14 -0
- package/template/.claude/skills/memory-write/SKILL.md +47 -88
- package/template/.gitignore.fragment +6 -0
- package/template/CLAUDE.md.template +15 -9
- package/template/local/machine-paths.md.template +1 -12
- package/template/local/overrides.md.template +1 -11
- package/template/project/MEMORY.md.template +5 -26
- package/template/project/SOUL.md.template +1 -10
- package/template/user/fragments/INDEX.md.template +1 -1
- package/template/.claude/hooks/pre-tool-memory.js +0 -78
- package/template/.claude/hooks/transcript-capture.js +0 -69
- package/template/.claude/settings.json +0 -27
- package/template/support/scripts/auto-extract-memory.sh +0 -102
- package/template/support/scripts/refresh-distill-timestamp.py +0 -35
- package/template/support/scripts/register-crons.py +0 -242
- package/template/support/scripts/run-daily-distill.sh +0 -67
- package/template/support/scripts/run-weekly-curate.sh +0 -58
package/src/subcommands.mjs
CHANGED
|
@@ -23,12 +23,18 @@ import { memoryWrite } from './memory-write.mjs';
|
|
|
23
23
|
import { runMcpServer } from './mcp-server.mjs';
|
|
24
24
|
import { dailyDistill } from './daily-distill.mjs';
|
|
25
25
|
import { weeklyCurate } from './weekly-curate.mjs';
|
|
26
|
+
import { autoPersona } from './auto-persona.mjs';
|
|
27
|
+
import { exportPersona, importPersona } from './persona-portability.mjs';
|
|
28
|
+
import { setNativeAutoMemory, nativeMemoryInstallNote } from './native-memory.mjs';
|
|
29
|
+
import { writeFact } from './write-fact.mjs';
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
26
31
|
import { runLazyCompress } from './lazy-compress.mjs';
|
|
27
32
|
import { runDoctor } from './doctor.mjs';
|
|
28
33
|
import { importAnthropicMemory } from './import-anthropic-memory.mjs';
|
|
29
34
|
import { extractTranscript, discoverSessions } from './transcripts.mjs';
|
|
30
35
|
import { runRepair } from './repair.mjs';
|
|
31
36
|
import { runRoll, ROLL_SCOPES } from './roll.mjs';
|
|
37
|
+
import { lessonsPromote } from './lessons-promote.mjs';
|
|
32
38
|
import {
|
|
33
39
|
markCronRegistered,
|
|
34
40
|
unmarkCronRegistered,
|
|
@@ -42,6 +48,7 @@ import {
|
|
|
42
48
|
} from './register-crons.mjs';
|
|
43
49
|
import { fileURLToPath } from 'node:url';
|
|
44
50
|
import { dirname } from 'node:path';
|
|
51
|
+
import { readFileSync } from 'node:fs';
|
|
45
52
|
|
|
46
53
|
const __filename_subcommands = fileURLToPath(import.meta.url);
|
|
47
54
|
const __dirname_subcommands = dirname(__filename_subcommands);
|
|
@@ -99,6 +106,11 @@ async function runInstall(options /* , command */) {
|
|
|
99
106
|
' Restart Claude Code to activate. Complete install — no separate /plugin step needed.',
|
|
100
107
|
);
|
|
101
108
|
}
|
|
109
|
+
// Task 60 / ADR-0011 heads-up: the kit coexists with Claude Code's native
|
|
110
|
+
// Auto Memory by default; surface the one-command opt-out (null when already
|
|
111
|
+
// opted out, so we don't nag).
|
|
112
|
+
const nativeNote = nativeMemoryInstallNote(result.projectRoot);
|
|
113
|
+
if (nativeNote) console.log(nativeNote);
|
|
102
114
|
if (verbose) {
|
|
103
115
|
console.log(
|
|
104
116
|
` files: ${result.created.length} created, ${result.skipped.length} already present` +
|
|
@@ -190,6 +202,49 @@ function runTrust(id, level /* , options, command */) {
|
|
|
190
202
|
}
|
|
191
203
|
}
|
|
192
204
|
|
|
205
|
+
/**
|
|
206
|
+
* `cmk lessons promote <id> [--to <file>] [--section <title>]` — Task 76.
|
|
207
|
+
*
|
|
208
|
+
* The explicit half of the wedge: carry a project observation across ALL your
|
|
209
|
+
* projects. Routes through the safe promote path (home-path sanitization,
|
|
210
|
+
* Poison_Guard, dedup, audit trail) — never hand-edits the user-tier files.
|
|
211
|
+
*/
|
|
212
|
+
function runLessonsPromote(id, options = {}) {
|
|
213
|
+
const projectRoot = resolvePath(process.cwd());
|
|
214
|
+
const userDir =
|
|
215
|
+
process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
|
|
216
|
+
const result = lessonsPromote({
|
|
217
|
+
id,
|
|
218
|
+
projectRoot,
|
|
219
|
+
userDir,
|
|
220
|
+
to: options.to,
|
|
221
|
+
section: options.section,
|
|
222
|
+
});
|
|
223
|
+
if (result.action === 'promoted') {
|
|
224
|
+
console.log(`cmk lessons promote: ${result.id} → ${result.target} § ${result.section}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (result.action === 'queued') {
|
|
228
|
+
// Exit 3 (not 2): the fact is durably SAVED to the review queue — it didn't
|
|
229
|
+
// fail, it just needs a `cmk queue review` to land in the persona. Distinct
|
|
230
|
+
// from the genuine-error exits (2) so scripts can tell them apart.
|
|
231
|
+
console.error(
|
|
232
|
+
`cmk lessons promote: saved to the user-tier review queue (${result.reason}) — run \`cmk queue review\` to land it`,
|
|
233
|
+
);
|
|
234
|
+
process.exitCode = 3;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (result.action === 'not-found') {
|
|
238
|
+
console.error(`cmk lessons promote: ${result.errors[0]}`);
|
|
239
|
+
process.exitCode = 2;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (result.action === 'error') {
|
|
243
|
+
for (const e of result.errors) console.error(`cmk lessons promote: ${e}`);
|
|
244
|
+
process.exitCode = 2;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
193
248
|
/**
|
|
194
249
|
* `cmk search` — Task 30. Hybrid keyword + optional semantic.
|
|
195
250
|
*
|
|
@@ -299,11 +354,115 @@ function runSearch(queryParts, options) {
|
|
|
299
354
|
* routing (same deferral as mk_remember, design §16) — the always-on home-path
|
|
300
355
|
* abstraction is the privacy net regardless of tier.
|
|
301
356
|
*/
|
|
357
|
+
// Task 63 (F1): a slug derived from the title — lowercased, non-alphanumerics
|
|
358
|
+
// collapsed to '-', trimmed, capped. Always passes writeFact's SLUG_PATTERN.
|
|
359
|
+
function slugifyFact(s) {
|
|
360
|
+
// Collapse every run of non-alphanumerics to a single '-' (so dashes are
|
|
361
|
+
// never doubled), cap, then trim a leading/trailing dash without a regex
|
|
362
|
+
// quantifier (static analysis flags trailing `-+$` as ReDoS-prone; a single
|
|
363
|
+
// dash is all that can remain after the collapse, so string ops suffice).
|
|
364
|
+
let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 60);
|
|
365
|
+
if (base.startsWith('-')) base = base.slice(1);
|
|
366
|
+
if (base.endsWith('-')) base = base.slice(0, -1);
|
|
367
|
+
return base || 'fact';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Assemble the rich fact body in the v0.1.1 shape: headline + Why + How.
|
|
371
|
+
function buildRichFactBody({ text, why, how }) {
|
|
372
|
+
const parts = [String(text).trim()];
|
|
373
|
+
if (why && String(why).trim()) parts.push(`**Why:** ${String(why).trim()}`);
|
|
374
|
+
if (how && String(how).trim()) parts.push(`**How to apply:** ${String(how).trim()}`);
|
|
375
|
+
return parts.join('\n\n');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* `cmk remember --why … --how … --type … --title …` (Task 63 / F1) — RICH
|
|
380
|
+
* capture. Writes a real granular fact file (frontmatter + Why/How/links) via
|
|
381
|
+
* writeFact(), which ALREADY runs home-path sanitization + Poison_Guard + the
|
|
382
|
+
* correct schema. Restores v0.1.1 richness through v0.1.2's safe path. `deps`
|
|
383
|
+
* carries injection seams for testing.
|
|
384
|
+
*/
|
|
385
|
+
export function runRememberRich(text, options = {}, deps = {}) {
|
|
386
|
+
const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
|
|
387
|
+
const log = deps.log ?? console.log;
|
|
388
|
+
const logError = deps.logError ?? console.error;
|
|
389
|
+
const write = deps.writeFact ?? writeFact;
|
|
390
|
+
|
|
391
|
+
// M2: rich capture writes the project tier (P) in v0.1.x — same deferral as
|
|
392
|
+
// the terse path + mk_remember (U/L need per-tier scratchpad routing, design
|
|
393
|
+
// §16). Terse mode ERRORS on a non-P --tier; rich mode notes it and proceeds
|
|
394
|
+
// (a no-write surprise is worse than a captured-to-P note). Surface it so the
|
|
395
|
+
// divergence isn't silent.
|
|
396
|
+
if (options.tier && options.tier !== 'P') {
|
|
397
|
+
log(
|
|
398
|
+
`cmk remember: --tier '${options.tier}' is v0.1.x — rich capture writes the project tier (P) for now.`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const headline = String(text).trim();
|
|
403
|
+
const title = (options.title && String(options.title).trim()) || headline.split('\n')[0].slice(0, 80);
|
|
404
|
+
const body = buildRichFactBody({ text: headline, why: options.why, how: options.how });
|
|
405
|
+
const related = options.links
|
|
406
|
+
? String(options.links).split(',').map((s) => s.trim()).filter(Boolean)
|
|
407
|
+
: undefined;
|
|
408
|
+
|
|
409
|
+
const r = write({
|
|
410
|
+
tier: 'P',
|
|
411
|
+
type: options.type ?? 'feedback',
|
|
412
|
+
slug: slugifyFact(title),
|
|
413
|
+
title,
|
|
414
|
+
body,
|
|
415
|
+
writeSource: 'user-explicit',
|
|
416
|
+
trust: options.trust ?? 'high',
|
|
417
|
+
sourceFile: 'user-explicit',
|
|
418
|
+
sourceLine: 1,
|
|
419
|
+
// Content fingerprint for provenance/dedup — NOT a security context.
|
|
420
|
+
// Matches the kit's sha1-of-content convention (memory-write.mjs,
|
|
421
|
+
// index-rebuild.mjs); writeFact dedups by content-addressed id, this is
|
|
422
|
+
// just the source_sha1 provenance field. // NOSONAR
|
|
423
|
+
sourceSha1: createHash('sha1').update(body).digest('hex'), // NOSONAR
|
|
424
|
+
|
|
425
|
+
related,
|
|
426
|
+
projectRoot,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
if (r.action === 'error') {
|
|
430
|
+
// M1: a collision means a fact file with this title (→ slug) already exists
|
|
431
|
+
// but with different content (different id). Give an actionable hint rather
|
|
432
|
+
// than the raw "refusing overwrite" — the user almost certainly wants to
|
|
433
|
+
// edit the existing fact or pick a new --title.
|
|
434
|
+
if (r.errorCategory === 'collision') {
|
|
435
|
+
logError(
|
|
436
|
+
`cmk remember: a fact titled "${title}" already exists with different content. ` +
|
|
437
|
+
`Edit it directly, or capture under a new --title.`,
|
|
438
|
+
);
|
|
439
|
+
return r;
|
|
440
|
+
}
|
|
441
|
+
logError(`cmk remember: ${(r.errors ?? [r.errorCategory ?? 'error']).join('; ')}`);
|
|
442
|
+
return r;
|
|
443
|
+
}
|
|
444
|
+
if (r.action === 'skipped') {
|
|
445
|
+
log(`cmk remember: already captured (${r.skipReason})${r.id ? ` [${r.id}]` : ''}`);
|
|
446
|
+
return r;
|
|
447
|
+
}
|
|
448
|
+
log(`cmk remember: saved rich fact → ${r.path}${r.id ? ` [${r.id}]` : ''}`);
|
|
449
|
+
return r;
|
|
450
|
+
}
|
|
451
|
+
|
|
302
452
|
function runRemember(textParts, options) {
|
|
303
453
|
const projectRoot = resolvePath(process.cwd());
|
|
304
454
|
const userDir =
|
|
305
455
|
process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
|
|
306
456
|
const text = Array.isArray(textParts) ? textParts.join(' ') : textParts;
|
|
457
|
+
// Rich mode: any of --why/--how/--type/--title/--links → write a real fact
|
|
458
|
+
// file (the F1 fix) instead of a terse MEMORY.md bullet. M3: --trust and
|
|
459
|
+
// --section are intentionally NOT triggers — --trust is shared by both forms
|
|
460
|
+
// (rich reads it too), and --section is terse-only (a MEMORY.md heading, no
|
|
461
|
+
// meaning for a granular fact file). So `--trust high` alone stays terse.
|
|
462
|
+
if (options?.why || options?.how || options?.type || options?.title || options?.links) {
|
|
463
|
+
runRememberRich(text, options, { projectRoot });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
307
466
|
const tier = options?.tier ?? 'P';
|
|
308
467
|
if (tier !== 'P') {
|
|
309
468
|
console.error(
|
|
@@ -504,6 +663,125 @@ async function runWeeklyCurate(/* options */) {
|
|
|
504
663
|
}
|
|
505
664
|
}
|
|
506
665
|
|
|
666
|
+
/**
|
|
667
|
+
* `cmk persona generate` (Task 45 follow-up) — run cross-project persona
|
|
668
|
+
* synthesis ON DEMAND. Classifies this project's captured facts, auto-promotes
|
|
669
|
+
* the high-confidence cross-project doctrine into the user tier (trust:medium),
|
|
670
|
+
* and saves the low/medium-confidence candidates to
|
|
671
|
+
* <userDir>/queues/persona-review.md. Same pipeline weekly-curate runs
|
|
672
|
+
* automatically — this is the manual trigger (a deterministic hook for the
|
|
673
|
+
* fresh-session live test, and for users who want to fill the user tier now).
|
|
674
|
+
*/
|
|
675
|
+
// `opts` is the Commander options object in production (no relevant keys →
|
|
676
|
+
// every field falls back to its default). The injection seams (projectRoot,
|
|
677
|
+
// userDir, backend, log, logError) keep the wrapper unit-testable without a
|
|
678
|
+
// live `claude --print` spawn — see cli-auto-persona.test.js.
|
|
679
|
+
export async function runPersonaGenerate(opts = {}) {
|
|
680
|
+
const projectRoot = opts.projectRoot ?? resolvePath(process.cwd());
|
|
681
|
+
const userDir =
|
|
682
|
+
opts.userDir ?? process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
|
|
683
|
+
const log = opts.log ?? console.log;
|
|
684
|
+
const logError = opts.logError ?? console.error;
|
|
685
|
+
try {
|
|
686
|
+
const backend =
|
|
687
|
+
opts.backend ?? new (await import('./compressor.mjs')).HaikuViaAnthropicApi();
|
|
688
|
+
const r = await autoPersona({ projectRoot, userDir, backend });
|
|
689
|
+
if (r.action === 'error') {
|
|
690
|
+
logError(
|
|
691
|
+
`cmk persona generate: error (${r.errorCategory ?? 'unknown'})${(r.errors && r.errors.length) ? `: ${r.errors.join('; ')}` : ''}`,
|
|
692
|
+
);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const promoted = r.promoted?.length ?? 0;
|
|
696
|
+
const superseded = r.superseded?.length ?? 0;
|
|
697
|
+
const queued = r.queued?.length ?? 0;
|
|
698
|
+
const conflicts = r.conflicts?.length ?? 0;
|
|
699
|
+
log(
|
|
700
|
+
`cmk persona generate: ${r.action}${r.reason ? ` (${r.reason})` : ''} — promoted: ${promoted}, superseded: ${superseded}, queued: ${queued}, conflicts: ${conflicts}`,
|
|
701
|
+
);
|
|
702
|
+
if (queued > 0 && r.reviewQueuePath) {
|
|
703
|
+
log(` ${queued} lower-confidence candidate(s) saved for review → ${r.reviewQueuePath}`);
|
|
704
|
+
}
|
|
705
|
+
} catch (err) {
|
|
706
|
+
logError(`cmk persona generate: unexpected error: ${err?.message ?? err}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function resolveUserDir() {
|
|
711
|
+
return process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* `cmk persona export <file>` (Task 72) — bundle the user-tier persona into one
|
|
716
|
+
* portable file to carry to another of YOUR machines.
|
|
717
|
+
*/
|
|
718
|
+
export function runPersonaExport(file, options = {}) {
|
|
719
|
+
const outFile = file ?? options.out;
|
|
720
|
+
if (!outFile) {
|
|
721
|
+
console.error('cmk persona export: give an output file, e.g. `cmk persona export persona-bundle.json`');
|
|
722
|
+
process.exitCode = 2;
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const r = exportPersona({ userDir: resolveUserDir(), outFile });
|
|
726
|
+
if (r.action === 'error') {
|
|
727
|
+
console.error(`cmk persona export: ${r.errors?.join('; ') || r.errorCategory}`);
|
|
728
|
+
process.exitCode = 2;
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
console.log(`cmk persona export: ${r.fileCount} files → ${r.path} (${r.bytes} B)`);
|
|
732
|
+
console.log(' Carry it via your own private channel (USB / private git repo / Dropbox), then `cmk persona import` on the other machine.');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* `cmk persona import <file>` (Task 72) — apply a persona bundle to this
|
|
737
|
+
* machine's user tier. Overwrites; any replaced file is backed up first.
|
|
738
|
+
*/
|
|
739
|
+
export function runPersonaImport(file, options = {}) {
|
|
740
|
+
const inFile = file ?? options.from;
|
|
741
|
+
if (!inFile) {
|
|
742
|
+
console.error('cmk persona import: give a bundle file, e.g. `cmk persona import persona-bundle.json`');
|
|
743
|
+
process.exitCode = 2;
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const r = importPersona({ userDir: resolveUserDir(), inFile });
|
|
747
|
+
if (r.action === 'error') {
|
|
748
|
+
console.error(`cmk persona import: ${r.errors?.join('; ') || r.errorCategory}`);
|
|
749
|
+
process.exitCode = 2;
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const bkp = r.backedUp > 0 ? ` (backed up ${r.backedUp} existing file(s) → ${r.backupPath})` : '';
|
|
753
|
+
console.log(`cmk persona import: ${r.fileCount} files → ${resolveUserDir()}${bkp}${r.reindexed ? ' · search reindexed' : ''}`);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* `cmk disable-native-memory` / `cmk enable-native-memory` (Task 60, ADR-0011)
|
|
758
|
+
* — write `autoMemoryEnabled` into the project's committable
|
|
759
|
+
* `.claude/settings.json`. Default install does NOT touch it (coexist); this
|
|
760
|
+
* is the one-command opt-in/out. `opts` carries injection seams for testing.
|
|
761
|
+
*/
|
|
762
|
+
export function runSetNativeMemory(enabled, opts = {}) {
|
|
763
|
+
const projectRoot = opts.projectRoot ?? resolvePath(process.cwd());
|
|
764
|
+
const log = opts.log ?? console.log;
|
|
765
|
+
const logError = opts.logError ?? console.error;
|
|
766
|
+
const cmd = enabled ? 'enable-native-memory' : 'disable-native-memory';
|
|
767
|
+
const r = setNativeAutoMemory({ projectRoot, enabled });
|
|
768
|
+
if (r.action === 'error') {
|
|
769
|
+
logError(`cmk ${cmd}: ${(r.errors && r.errors.join('; ')) || 'could not update settings.json'}`);
|
|
770
|
+
return r;
|
|
771
|
+
}
|
|
772
|
+
const verb = enabled ? 'enabled' : 'disabled';
|
|
773
|
+
if (r.action === 'unchanged') {
|
|
774
|
+
log(`cmk ${cmd}: Anthropic native Auto Memory already ${verb} for this project (no change).`);
|
|
775
|
+
} else if (enabled) {
|
|
776
|
+
log('Anthropic native Auto Memory re-enabled for this project — native + kit memory will coexist again.');
|
|
777
|
+
log(` → ${r.settingsPath} (reverse with \`cmk disable-native-memory\`)`);
|
|
778
|
+
} else {
|
|
779
|
+
log('Anthropic native Auto Memory disabled for this project — the kit is now the sole memory layer (one lean snapshot at session start).');
|
|
780
|
+
log(` → ${r.settingsPath} (committable: travels with \`git clone\`; reverse with \`cmk enable-native-memory\`)`);
|
|
781
|
+
}
|
|
782
|
+
return r;
|
|
783
|
+
}
|
|
784
|
+
|
|
507
785
|
/**
|
|
508
786
|
* `cmk register-crons [--dry-run] [--unregister]` (Task 33) — register
|
|
509
787
|
* the daily-distill cron entry on the current platform.
|
|
@@ -1150,13 +1428,19 @@ export const subcommands = [
|
|
|
1150
1428
|
},
|
|
1151
1429
|
{
|
|
1152
1430
|
name: 'remember',
|
|
1153
|
-
description: '
|
|
1431
|
+
description: 'capture a durable fact (Poison_Guard + home-path abstraction). Terse → a MEMORY.md bullet; RICH (--why/--how/--type) → a granular fact file with rationale (the safe way to capture richly).',
|
|
1154
1432
|
milestone: 24,
|
|
1155
1433
|
argSpec: [{ flags: '<text...>', description: 'the fact to remember' }],
|
|
1156
1434
|
optionSpec: [
|
|
1157
1435
|
{ flags: '--tier <tier>', description: 'P (default; U/L are v0.1.x)' },
|
|
1158
1436
|
{ flags: '--trust <level>', description: 'high | medium | low (default: high)' },
|
|
1159
|
-
{ flags: '--section <name>', description: 'MEMORY.md section (default: Active Threads)' },
|
|
1437
|
+
{ flags: '--section <name>', description: 'MEMORY.md section for the terse form (default: Active Threads)' },
|
|
1438
|
+
// Rich mode (Task 63 / F1): any of these → write a granular fact file.
|
|
1439
|
+
{ flags: '--why <text>', description: 'rich: the rationale (becomes the **Why:** block)' },
|
|
1440
|
+
{ flags: '--how <text>', description: 'rich: how to apply it (becomes the **How to apply:** block)' },
|
|
1441
|
+
{ flags: '--type <type>', description: 'rich: feedback | project | reference | user (default: feedback)' },
|
|
1442
|
+
{ flags: '--title <text>', description: 'rich: a short title (also the fact-file slug)' },
|
|
1443
|
+
{ flags: '--links <a,b>', description: 'rich: related fact names for [[cross-links]]' },
|
|
1160
1444
|
],
|
|
1161
1445
|
action: runRemember,
|
|
1162
1446
|
},
|
|
@@ -1263,16 +1547,20 @@ export const subcommands = [
|
|
|
1263
1547
|
},
|
|
1264
1548
|
{
|
|
1265
1549
|
name: 'lessons',
|
|
1266
|
-
description: 'promote project-tier
|
|
1267
|
-
milestone:
|
|
1550
|
+
description: 'promote a project-tier observation to the user tier (carry it across all your projects)',
|
|
1551
|
+
milestone: 76,
|
|
1268
1552
|
children: [
|
|
1269
1553
|
{
|
|
1270
1554
|
name: 'promote',
|
|
1271
|
-
description: 'move a project observation to
|
|
1272
|
-
argSpec: [{ flags: '<id>', description: 'citation ID' }],
|
|
1555
|
+
description: 'move a project observation to the user tier through the safe promote path',
|
|
1556
|
+
argSpec: [{ flags: '<id>', description: 'citation ID (e.g. P-S79MJHFN)' }],
|
|
1557
|
+
optionSpec: [
|
|
1558
|
+
{ flags: '--to <file>', description: 'target user-tier file: LESSONS.md (default) | HABITS.md | USER.md' },
|
|
1559
|
+
{ flags: '--section <title>', description: 'landing section (default per-target)' },
|
|
1560
|
+
],
|
|
1561
|
+
action: (id, options) => runLessonsPromote(id, options),
|
|
1273
1562
|
},
|
|
1274
1563
|
],
|
|
1275
|
-
action: stub('lessons', 'v0.1.x'),
|
|
1276
1564
|
},
|
|
1277
1565
|
{
|
|
1278
1566
|
name: 'queue',
|
|
@@ -1335,6 +1623,44 @@ export const subcommands = [
|
|
|
1335
1623
|
milestone: 34,
|
|
1336
1624
|
action: runWeeklyCurate,
|
|
1337
1625
|
},
|
|
1626
|
+
{
|
|
1627
|
+
name: 'persona',
|
|
1628
|
+
description: 'cross-project persona — synthesize "how you work everywhere" into the user tier, and carry it across YOUR machines',
|
|
1629
|
+
milestone: 45,
|
|
1630
|
+
children: [
|
|
1631
|
+
{
|
|
1632
|
+
name: 'generate',
|
|
1633
|
+
description: 'synthesize cross-project doctrine from this project\'s captured facts now: auto-promote high-confidence to the user tier, save the rest to queues/persona-review.md',
|
|
1634
|
+
action: runPersonaGenerate,
|
|
1635
|
+
},
|
|
1636
|
+
{
|
|
1637
|
+
name: 'export',
|
|
1638
|
+
description: 'export your cross-project persona (the user tier) to one portable bundle file, to carry to another of YOUR machines (the persona stays private — never committed to a project)',
|
|
1639
|
+
milestone: 72,
|
|
1640
|
+
argSpec: [{ flags: '<file>', description: 'output bundle path, e.g. persona-bundle.json' }],
|
|
1641
|
+
action: (file, options) => runPersonaExport(file, options),
|
|
1642
|
+
},
|
|
1643
|
+
{
|
|
1644
|
+
name: 'import',
|
|
1645
|
+
description: 'import a persona bundle (from `cmk persona export`) into this machine\'s user tier; any file it replaces is backed up first',
|
|
1646
|
+
milestone: 72,
|
|
1647
|
+
argSpec: [{ flags: '<file>', description: 'bundle path produced by `cmk persona export`' }],
|
|
1648
|
+
action: (file, options) => runPersonaImport(file, options),
|
|
1649
|
+
},
|
|
1650
|
+
],
|
|
1651
|
+
},
|
|
1652
|
+
{
|
|
1653
|
+
name: 'disable-native-memory',
|
|
1654
|
+
description: 'opt out of Anthropic\'s native Auto Memory for THIS project (writes autoMemoryEnabled:false to the committable .claude/settings.json) so only the kit\'s memory runs — avoids the both-layers context bloat (ADR-0011)',
|
|
1655
|
+
milestone: 60,
|
|
1656
|
+
action: () => runSetNativeMemory(false),
|
|
1657
|
+
},
|
|
1658
|
+
{
|
|
1659
|
+
name: 'enable-native-memory',
|
|
1660
|
+
description: 're-enable Anthropic\'s native Auto Memory for this project (reverses cmk disable-native-memory)',
|
|
1661
|
+
milestone: 60,
|
|
1662
|
+
action: () => runSetNativeMemory(true),
|
|
1663
|
+
},
|
|
1338
1664
|
{
|
|
1339
1665
|
name: 'compress',
|
|
1340
1666
|
description: 'lazy-on-read compression fallback for no-cron environments. Use `--lazy` to delegate to daily-distill or weekly-curate based on staleness (typically invoked detached from the SessionStart hook).',
|
|
@@ -1366,15 +1692,12 @@ export const subcommands = [
|
|
|
1366
1692
|
description: 'print the cmk version (alias for --version)',
|
|
1367
1693
|
milestone: 'always',
|
|
1368
1694
|
action: function action() {
|
|
1369
|
-
//
|
|
1370
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
// For v0.1.0 stub-only milestone, defer the actual print to the
|
|
1376
|
-
// top-level --version handler.
|
|
1377
|
-
console.log(`cmk version: see \`cmk --version\``);
|
|
1695
|
+
// Print the same bare version string as `cmk --version`, resolved from
|
|
1696
|
+
// package.json (the single source). Was a v0.1.0 stub that punted to
|
|
1697
|
+
// `cmk --version`; the live test surfaced that as unhelpful friction.
|
|
1698
|
+
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
1699
|
+
const { version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'));
|
|
1700
|
+
console.log(version);
|
|
1378
1701
|
},
|
|
1379
1702
|
},
|
|
1380
1703
|
];
|
package/src/weekly-curate.mjs
CHANGED
|
@@ -45,6 +45,9 @@ import {
|
|
|
45
45
|
touchCooldownMarker,
|
|
46
46
|
} from './cooldown.mjs';
|
|
47
47
|
import { dailyDistill } from './daily-distill.mjs';
|
|
48
|
+
import { autoPersona } from './auto-persona.mjs';
|
|
49
|
+
import { initUserTier } from './install.mjs';
|
|
50
|
+
import { autoDrainQueues } from './auto-drain.mjs';
|
|
48
51
|
|
|
49
52
|
const DEFAULT_ARCHIVE_MAX_BYTES = 4096;
|
|
50
53
|
const DEFAULT_RECENT_MAX_BYTES = 4096;
|
|
@@ -69,11 +72,12 @@ function buildCurateInstructions(archiveMaxBytes) {
|
|
|
69
72
|
'Under each week heading, emit bullets that summarize the work across the days in that week. Each bullet is a single line ≤120 chars. Bullets within a week appear in chronological order.',
|
|
70
73
|
'',
|
|
71
74
|
'HARD RULES:',
|
|
72
|
-
' 1.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
' 4.
|
|
76
|
-
' 5.
|
|
75
|
+
' 1. Every bullet must be grounded in the daily summaries below. Do not infer or add any fact not explicitly present in them. When the summaries show a fact was later corrected, replaced, or reversed, keep ONLY the latest version of that fact — never list the superseded one alongside it (this resolves contradictions, NOT coexisting facts on different points). If unsure, omit it.',
|
|
76
|
+
' 2. Preserve every citation ID matching /#[ULP]-[A-Z0-9]{6,8}/ verbatim. Never invent new IDs.',
|
|
77
|
+
` 3. Total output ≤ ${archiveMaxBytes} bytes.`,
|
|
78
|
+
' 4. Deduplicate aggressively: if the same fact appears across multiple days, emit it ONCE. The deterministic dedup pass after your output will collapse exact-after-canonical duplicates; YOUR job is to catch the looser semantic duplicates (paraphrases, restatements).',
|
|
79
|
+
' 5. No prose between bullets — only the bulleted list per week section.',
|
|
80
|
+
' 6. Your output goes directly into archive.md. Do not address the user, do not refer to yourself.',
|
|
77
81
|
'',
|
|
78
82
|
'=== BEGIN OLD DAILY SUMMARIES TO ARCHIVE ===',
|
|
79
83
|
].join('\n');
|
|
@@ -242,6 +246,7 @@ function writeCurateLogEntry({ projectRoot, date, entry }) {
|
|
|
242
246
|
*/
|
|
243
247
|
export async function weeklyCurate({
|
|
244
248
|
projectRoot,
|
|
249
|
+
userDir,
|
|
245
250
|
backend,
|
|
246
251
|
now,
|
|
247
252
|
cooldownMs = DEFAULT_COOLDOWN_MS,
|
|
@@ -277,6 +282,12 @@ export async function weeklyCurate({
|
|
|
277
282
|
};
|
|
278
283
|
}
|
|
279
284
|
|
|
285
|
+
// Auto-drain the review + conflict queues (v0.2 Phase 2, D-6) — project
|
|
286
|
+
// tier always, user tier when a userDir is supplied (persona queues).
|
|
287
|
+
// Non-Haiku file IO; runs every weekly pass regardless of the cooldown.
|
|
288
|
+
const drained = { P: await autoDrainQueues({ tier: 'P', projectRoot }) };
|
|
289
|
+
if (userDir) drained.U = await autoDrainQueues({ tier: 'U', userDir });
|
|
290
|
+
|
|
280
291
|
if (isCooldownActive({ projectRoot, now: ts, cooldownMs })) {
|
|
281
292
|
const duration_ms = Date.now() - t0;
|
|
282
293
|
writeCurateLogEntry({
|
|
@@ -297,7 +308,38 @@ export async function weeklyCurate({
|
|
|
297
308
|
skipped_reason: 'cooldown',
|
|
298
309
|
},
|
|
299
310
|
});
|
|
300
|
-
return { action: 'skipped', reason: 'cooldown', duration_ms };
|
|
311
|
+
return { action: 'skipped', reason: 'cooldown', drained, duration_ms };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Design-B auto-persona hook (Task 45). The weekly cycle is the natural
|
|
315
|
+
// trigger: once past the shared cooldown gate, synthesize cross-project
|
|
316
|
+
// doctrine from the granular fact archive and auto-promote it into the
|
|
317
|
+
// user tier. cooldownMs:0 — this Haiku call belongs to the SAME weekly
|
|
318
|
+
// cycle as the curate compress below (per §8.7.2, like the inline
|
|
319
|
+
// dailyDistill call). Skipped silently if userDir wasn't supplied (so
|
|
320
|
+
// existing project-only callers/tests are unaffected). D-14/D-15.
|
|
321
|
+
let persona;
|
|
322
|
+
if (userDir) {
|
|
323
|
+
// Ensure the user tier exists before auto-persona tries to promote into
|
|
324
|
+
// it. Without this, a user who never ran `cmk init-user-tier` has no
|
|
325
|
+
// USER.md/HABITS.md/LESSONS.md, so every promotion would silently route
|
|
326
|
+
// to `queued[]` (NOT_FOUND) and the cross-project tier would stay empty
|
|
327
|
+
// — the exact friend-handoff failure auto-persona exists to fix.
|
|
328
|
+
// initUserTier is idempotent (skips existing files).
|
|
329
|
+
try {
|
|
330
|
+
initUserTier({ userTier: userDir });
|
|
331
|
+
} catch {
|
|
332
|
+
// Best-effort: if scaffolding fails (perms, etc.), autoPersona still
|
|
333
|
+
// degrades gracefully (promotions route to queued[]); don't abort the
|
|
334
|
+
// whole curate cycle over a user-tier scaffold hiccup.
|
|
335
|
+
}
|
|
336
|
+
persona = await autoPersona({
|
|
337
|
+
projectRoot,
|
|
338
|
+
userDir,
|
|
339
|
+
backend,
|
|
340
|
+
now: ts,
|
|
341
|
+
cooldownMs: 0,
|
|
342
|
+
});
|
|
301
343
|
}
|
|
302
344
|
|
|
303
345
|
const files = listAllTodayFiles(projectRoot);
|
|
@@ -329,6 +371,8 @@ export async function weeklyCurate({
|
|
|
329
371
|
action: 'skipped',
|
|
330
372
|
reason: 'no-old-files',
|
|
331
373
|
currentDays: current.length,
|
|
374
|
+
persona,
|
|
375
|
+
drained,
|
|
332
376
|
duration_ms,
|
|
333
377
|
};
|
|
334
378
|
}
|
|
@@ -415,6 +459,7 @@ export async function weeklyCurate({
|
|
|
415
459
|
now: ts,
|
|
416
460
|
cooldownMs: 0,
|
|
417
461
|
maxOutputBytes: recentMaxBytes,
|
|
462
|
+
skipDrain: true, // weeklyCurate already drained above; don't double-drain
|
|
418
463
|
});
|
|
419
464
|
if (recentResult?.outputPath) recentPath = recentResult.outputPath;
|
|
420
465
|
}
|
|
@@ -449,6 +494,8 @@ export async function weeklyCurate({
|
|
|
449
494
|
recentPath,
|
|
450
495
|
bytesIn: input_bytes,
|
|
451
496
|
bytesOut: output_bytes,
|
|
497
|
+
persona,
|
|
498
|
+
drained,
|
|
452
499
|
duration_ms,
|
|
453
500
|
};
|
|
454
501
|
}
|
package/src/write-fact.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { join } from 'node:path';
|
|
|
17
17
|
import { generateId } from '@lh8ppl/cmk-canonicalize';
|
|
18
18
|
import { VALID_TIERS, resolveTierRoot, resolveFactDir } from './tier-paths.mjs';
|
|
19
19
|
import { parse, format } from './frontmatter.mjs';
|
|
20
|
+
import { reindex } from './reindex.mjs';
|
|
20
21
|
import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
21
22
|
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
22
23
|
import { sanitizeHomePaths } from './sanitize.mjs';
|
|
@@ -244,5 +245,18 @@ export function writeFact(opts = {}) {
|
|
|
244
245
|
const frontmatter = buildFrontmatterObject(factOpts, { id, createdAt });
|
|
245
246
|
writeFileSync(path, format({ frontmatter, body: `\n${factOpts.body}\n` }), 'utf8');
|
|
246
247
|
|
|
248
|
+
// Keep INDEX.md consistent on every create — the index is a derived view of
|
|
249
|
+
// the fact files, so the writer owns keeping it current. Without this, a fresh
|
|
250
|
+
// `cmk remember` left INDEX.md stale until a manual `cmk reindex`, and
|
|
251
|
+
// `cmk doctor` HC-5 failed from the first capture (Task 85; lior-test-7
|
|
252
|
+
// 2026-06-03 — "users should get it working from the start"). Best-effort: the
|
|
253
|
+
// fact is already durably on disk, so an index-rebuild hiccup must not turn a
|
|
254
|
+
// successful capture into an error — the next reindex/search self-heals.
|
|
255
|
+
try {
|
|
256
|
+
reindex({ tier: opts.tier, projectRoot: opts.projectRoot, userDir: opts.userDir, warn: () => {} });
|
|
257
|
+
} catch {
|
|
258
|
+
// index rebuild is best-effort; capture already succeeded
|
|
259
|
+
}
|
|
260
|
+
|
|
247
261
|
return { action: 'created', id, path };
|
|
248
262
|
}
|