@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
package/src/install.mjs
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// install.mjs — implementation of `cmk install`.
|
|
2
|
+
//
|
|
3
|
+
// Public contract (tests assert this; internals can change freely):
|
|
4
|
+
//
|
|
5
|
+
// install({
|
|
6
|
+
// projectRoot, // <repo> root for project + local tiers
|
|
7
|
+
// userTier, // resolved user-tier path (defaults via resolveUserTier())
|
|
8
|
+
// force, // currently unused; reserved for Task 4 CLAUDE.md downgrade override
|
|
9
|
+
// dryRun, // currently unused; print-only mode reserved for Task 32
|
|
10
|
+
// }) → {
|
|
11
|
+
// projectRoot, // resolved
|
|
12
|
+
// userTier, // resolved
|
|
13
|
+
// created: string[], // absolute paths newly written
|
|
14
|
+
// skipped: string[], // absolute paths that already existed (untouched)
|
|
15
|
+
// gitignore: { action: 'created' | 'replaced' | 'unchanged', path: string },
|
|
16
|
+
// errors: { path: string, error: string }[],
|
|
17
|
+
// }
|
|
18
|
+
//
|
|
19
|
+
// Design notes:
|
|
20
|
+
// - Deep module: the boundary above is the only public surface. Internal
|
|
21
|
+
// helpers walk the kit's template/ tree, strip .template suffixes,
|
|
22
|
+
// and copy files. Tests verify the contract, not the internals.
|
|
23
|
+
// - Never overwrites existing files in the target. If MEMORY.md (or any
|
|
24
|
+
// other tier seed) already has user edits, we skip it and log to
|
|
25
|
+
// `skipped`. This is what makes re-installs safe.
|
|
26
|
+
// - The .gitignore block is delimited so re-runs refresh in place
|
|
27
|
+
// without duplicating lines and without touching unrelated entries.
|
|
28
|
+
// - In dev (running from the cloned repo), the kit's template/ lives
|
|
29
|
+
// at repo root. When packaged for npm publish (Task 36), template/
|
|
30
|
+
// ships inside @lh8ppl/claude-memory-kit — `resolveTemplateDir()`
|
|
31
|
+
// handles both.
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
existsSync,
|
|
35
|
+
mkdirSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
readdirSync,
|
|
38
|
+
statSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
copyFileSync,
|
|
41
|
+
} from 'node:fs';
|
|
42
|
+
import { homedir } from 'node:os';
|
|
43
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
import { injectClaudeMdBlock } from './claude-md.mjs';
|
|
46
|
+
|
|
47
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
48
|
+
const CLI_SRC_DIR = dirname(__filename);
|
|
49
|
+
// Walk up: packages/cli/src → packages/cli → packages → repo root
|
|
50
|
+
const REPO_ROOT_DEV = resolve(CLI_SRC_DIR, '..', '..', '..');
|
|
51
|
+
const CLI_PKG_DIR = resolve(CLI_SRC_DIR, '..');
|
|
52
|
+
|
|
53
|
+
const GITIGNORE_START = '# claude-memory-kit:gitignore:start v0.1.0';
|
|
54
|
+
const GITIGNORE_END = '# claude-memory-kit:gitignore:end';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read the kit version from the cli package's package.json.
|
|
58
|
+
* Used as the default version for the CLAUDE.md marker.
|
|
59
|
+
*/
|
|
60
|
+
export function getKitVersion() {
|
|
61
|
+
const pkg = JSON.parse(readFileSync(join(CLI_PKG_DIR, 'package.json'), 'utf8'));
|
|
62
|
+
return pkg.version;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Locate the kit's template/ directory.
|
|
67
|
+
*
|
|
68
|
+
* Two scenarios:
|
|
69
|
+
* 1. Dev (running from cloned repo): template/ at repo root.
|
|
70
|
+
* 2. Published (Task 36): template/ shipped inside the cli package.
|
|
71
|
+
*
|
|
72
|
+
* For v0.1.0 dev, scenario 1 is the only working path. Scenario 2 is
|
|
73
|
+
* handled with a fallback so a future npm-published install still works
|
|
74
|
+
* after Task 36 wires the publish step.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveTemplateDir() {
|
|
77
|
+
const devPath = join(REPO_ROOT_DEV, 'template');
|
|
78
|
+
if (existsSync(devPath) && statSync(devPath).isDirectory()) return devPath;
|
|
79
|
+
|
|
80
|
+
// Published-package fallback: template/ alongside the cli package's src.
|
|
81
|
+
const packagedPath = resolve(CLI_SRC_DIR, '..', 'template');
|
|
82
|
+
if (existsSync(packagedPath) && statSync(packagedPath).isDirectory()) return packagedPath;
|
|
83
|
+
|
|
84
|
+
throw new Error(
|
|
85
|
+
`cmk install: could not locate template/ (checked ${devPath} and ${packagedPath}). ` +
|
|
86
|
+
`If you are running from a checkout, ensure template/ exists at the repo root.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the user-tier path.
|
|
92
|
+
*
|
|
93
|
+
* Precedence:
|
|
94
|
+
* 1. $MEMORY_KIT_USER_DIR if set (any non-empty value).
|
|
95
|
+
* 2. ~/.claude-memory-kit/ (default).
|
|
96
|
+
*
|
|
97
|
+
* Per design §1.1: "User-tier path override: the user tier path defaults
|
|
98
|
+
* to ~/.claude-memory-kit/ but can be overridden via the MEMORY_KIT_USER_DIR
|
|
99
|
+
* environment variable."
|
|
100
|
+
*/
|
|
101
|
+
export function resolveUserTier() {
|
|
102
|
+
const env = process.env.MEMORY_KIT_USER_DIR;
|
|
103
|
+
if (env && env.trim().length > 0) return env;
|
|
104
|
+
return join(homedir(), '.claude-memory-kit');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ------------------------------------------------------------------ */
|
|
108
|
+
/* Internal helpers (not exported; tests don't depend on these names) */
|
|
109
|
+
/* ------------------------------------------------------------------ */
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Walk a directory recursively, returning a list of file entries.
|
|
113
|
+
* Each entry: { absSrc, relPath, isGitkeep }
|
|
114
|
+
*/
|
|
115
|
+
function walkFiles(rootDir) {
|
|
116
|
+
const out = [];
|
|
117
|
+
function recurse(current) {
|
|
118
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
119
|
+
const full = join(current, entry.name);
|
|
120
|
+
if (entry.isDirectory()) {
|
|
121
|
+
recurse(full);
|
|
122
|
+
} else if (entry.isFile()) {
|
|
123
|
+
out.push({
|
|
124
|
+
absSrc: full,
|
|
125
|
+
relPath: relative(rootDir, full).replace(/\\/g, '/'),
|
|
126
|
+
isGitkeep: entry.name === '.gitkeep',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
recurse(rootDir);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Compute the target file name from a kit-template source name.
|
|
137
|
+
* "SOUL.md.template" → "SOUL.md"
|
|
138
|
+
* "INDEX.md.template" → "INDEX.md"
|
|
139
|
+
* "machine-paths.md.template" → "machine-paths.md"
|
|
140
|
+
* (.gitkeep files are filtered out before this is called)
|
|
141
|
+
*/
|
|
142
|
+
function targetName(srcName) {
|
|
143
|
+
if (srcName.endsWith('.template')) return srcName.slice(0, -'.template'.length);
|
|
144
|
+
return srcName;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Install one tier: copy every non-.gitkeep file from srcDir into destDir,
|
|
149
|
+
* stripping the .template suffix. Skips existing files.
|
|
150
|
+
*
|
|
151
|
+
* Side effects:
|
|
152
|
+
* - Creates destDir + any needed subdirs
|
|
153
|
+
* - Writes new files
|
|
154
|
+
* - Mutates the supplied `created` / `skipped` / `errors` arrays
|
|
155
|
+
*/
|
|
156
|
+
function installTier(srcDir, destDir, { created, skipped, errors }) {
|
|
157
|
+
if (!existsSync(srcDir)) {
|
|
158
|
+
errors.push({ path: srcDir, error: 'template tier missing from kit' });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Ensure root destDir exists (covers fresh installs).
|
|
163
|
+
mkdirSync(destDir, { recursive: true });
|
|
164
|
+
|
|
165
|
+
for (const file of walkFiles(srcDir)) {
|
|
166
|
+
if (file.isGitkeep) {
|
|
167
|
+
// .gitkeep marks an empty kit dir; mirror just the directory in target.
|
|
168
|
+
const targetDir = join(destDir, dirname(file.relPath));
|
|
169
|
+
mkdirSync(targetDir, { recursive: true });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const targetRel = join(dirname(file.relPath), targetName(file.relPath.split('/').pop()));
|
|
174
|
+
const targetAbs = join(destDir, targetRel);
|
|
175
|
+
|
|
176
|
+
if (existsSync(targetAbs)) {
|
|
177
|
+
skipped.push(targetAbs);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
mkdirSync(dirname(targetAbs), { recursive: true });
|
|
183
|
+
copyFileSync(file.absSrc, targetAbs);
|
|
184
|
+
created.push(targetAbs);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
errors.push({ path: targetAbs, error: err && err.message ? err.message : String(err) });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Build the canonical .gitignore managed block from template/.gitignore.fragment.
|
|
193
|
+
* Adds start/end markers around the fragment so we can refresh in place.
|
|
194
|
+
*/
|
|
195
|
+
function buildGitignoreBlock(templateDir) {
|
|
196
|
+
const fragmentPath = join(templateDir, '.gitignore.fragment');
|
|
197
|
+
const fragment = existsSync(fragmentPath)
|
|
198
|
+
? readFileSync(fragmentPath, 'utf8').trim()
|
|
199
|
+
: 'context.local/\ncontext/.index/\ncontext/.locks/';
|
|
200
|
+
return `${GITIGNORE_START}\n${fragment}\n${GITIGNORE_END}\n`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Inject (or refresh) the managed .gitignore block in `<projectRoot>/.gitignore`.
|
|
205
|
+
*
|
|
206
|
+
* Algorithm:
|
|
207
|
+
* - No .gitignore: create one containing only the managed block.
|
|
208
|
+
* - Has .gitignore, no markers: append the managed block at EOF.
|
|
209
|
+
* - Has .gitignore, markers present: replace the marker-delimited block
|
|
210
|
+
* in place (refresh). Everything outside the markers is byte-preserved.
|
|
211
|
+
*
|
|
212
|
+
* Returns: { action: 'created' | 'replaced' | 'unchanged', path: string }
|
|
213
|
+
*/
|
|
214
|
+
function injectGitignore(projectRoot, block) {
|
|
215
|
+
const giPath = join(projectRoot, '.gitignore');
|
|
216
|
+
const startRe = /# claude-memory-kit:gitignore:start[^\n]*\n/;
|
|
217
|
+
const endRe = /# claude-memory-kit:gitignore:end\n?/;
|
|
218
|
+
|
|
219
|
+
if (!existsSync(giPath)) {
|
|
220
|
+
writeFileSync(giPath, block, 'utf8');
|
|
221
|
+
return { action: 'created', path: giPath };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const existing = readFileSync(giPath, 'utf8');
|
|
225
|
+
const startMatch = existing.match(startRe);
|
|
226
|
+
const endMatch = existing.match(endRe);
|
|
227
|
+
|
|
228
|
+
if (!startMatch || !endMatch || startMatch.index > endMatch.index) {
|
|
229
|
+
// No managed block (or markers malformed) — append.
|
|
230
|
+
const sep = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
231
|
+
writeFileSync(giPath, existing + sep + block, 'utf8');
|
|
232
|
+
return { action: 'created', path: giPath };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Markers present — replace the slice between (and including) them.
|
|
236
|
+
const before = existing.slice(0, startMatch.index);
|
|
237
|
+
const after = existing.slice(endMatch.index + endMatch[0].length);
|
|
238
|
+
const next = before + block + after;
|
|
239
|
+
|
|
240
|
+
if (next === existing) {
|
|
241
|
+
return { action: 'unchanged', path: giPath };
|
|
242
|
+
}
|
|
243
|
+
writeFileSync(giPath, next, 'utf8');
|
|
244
|
+
return { action: 'replaced', path: giPath };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* ------------------------------------------------------------------ */
|
|
248
|
+
/* Public entry point */
|
|
249
|
+
/* ------------------------------------------------------------------ */
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Install the kit scaffold into a project + user tier.
|
|
253
|
+
* Idempotent. Never overwrites existing target files. Refreshes the
|
|
254
|
+
* managed .gitignore block in place when re-run.
|
|
255
|
+
*/
|
|
256
|
+
export async function install(options = {}) {
|
|
257
|
+
const projectRoot = options.projectRoot
|
|
258
|
+
? resolve(options.projectRoot)
|
|
259
|
+
: resolve(process.cwd());
|
|
260
|
+
const userTier = options.userTier ? resolve(options.userTier) : resolveUserTier();
|
|
261
|
+
const force = !!options.force;
|
|
262
|
+
const version = options.version || getKitVersion();
|
|
263
|
+
|
|
264
|
+
const templateDir = resolveTemplateDir();
|
|
265
|
+
|
|
266
|
+
const created = [];
|
|
267
|
+
const skipped = [];
|
|
268
|
+
const errors = [];
|
|
269
|
+
|
|
270
|
+
installTier(join(templateDir, 'project'), join(projectRoot, 'context'), { created, skipped, errors });
|
|
271
|
+
installTier(join(templateDir, 'local'), join(projectRoot, 'context.local'), { created, skipped, errors });
|
|
272
|
+
installTier(join(templateDir, 'user'), userTier, { created, skipped, errors });
|
|
273
|
+
|
|
274
|
+
const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir));
|
|
275
|
+
|
|
276
|
+
// CLAUDE.md loader block — Task 4. Read the block content from the kit's
|
|
277
|
+
// template/ and inject (or refresh) it inside marker delimiters. Never
|
|
278
|
+
// touches content outside the markers.
|
|
279
|
+
const claudeMdTemplatePath = join(templateDir, 'CLAUDE.md.template');
|
|
280
|
+
let claudeMd = { action: 'skipped', path: join(projectRoot, 'CLAUDE.md') };
|
|
281
|
+
if (existsSync(claudeMdTemplatePath)) {
|
|
282
|
+
const content = readFileSync(claudeMdTemplatePath, 'utf8');
|
|
283
|
+
try {
|
|
284
|
+
claudeMd = injectClaudeMdBlock({ projectRoot, content, version, force });
|
|
285
|
+
} catch (err) {
|
|
286
|
+
errors.push({
|
|
287
|
+
path: join(projectRoot, 'CLAUDE.md'),
|
|
288
|
+
error: err && err.message ? err.message : String(err),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
errors.push({
|
|
293
|
+
path: claudeMdTemplatePath,
|
|
294
|
+
error: 'CLAUDE.md.template missing from kit template/',
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { projectRoot, userTier, created, skipped, gitignore, claudeMd, errors };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* `cmk init-user-tier` — user-tier-only install. Task 14.
|
|
303
|
+
*
|
|
304
|
+
* Scaffolds the user-tier seeds (USER.md, HABITS.md, LESSONS.md, fragments/)
|
|
305
|
+
* at the resolved user-tier path. Does NOT touch project tier, local tier,
|
|
306
|
+
* .gitignore, or CLAUDE.md. Useful when:
|
|
307
|
+
* - A user wants to set up user-tier independently of any project install
|
|
308
|
+
* - A user wants to refresh user-tier seeds without re-running `cmk install`
|
|
309
|
+
* (which would also re-evaluate project tier + CLAUDE.md block)
|
|
310
|
+
*
|
|
311
|
+
* Path precedence (same as install()): explicit option > $MEMORY_KIT_USER_DIR
|
|
312
|
+
* > ~/.claude-memory-kit/. Re-runs are idempotent — existing files are
|
|
313
|
+
* skipped, not overwritten.
|
|
314
|
+
*
|
|
315
|
+
* Returns {userTier, created, skipped, errors}.
|
|
316
|
+
*/
|
|
317
|
+
export function initUserTier(options = {}) {
|
|
318
|
+
const userTier = options.userTier
|
|
319
|
+
? resolve(options.userTier)
|
|
320
|
+
: resolveUserTier();
|
|
321
|
+
const templateDir = resolveTemplateDir();
|
|
322
|
+
const created = [];
|
|
323
|
+
const skipped = [];
|
|
324
|
+
const errors = [];
|
|
325
|
+
installTier(join(templateDir, 'user'), userTier, { created, skipped, errors });
|
|
326
|
+
return { userTier, created, skipped, errors };
|
|
327
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Lazy compression fallback (Task 35, T-030).
|
|
2
|
+
//
|
|
3
|
+
// For environments where cron / launchd / Task Scheduler isn't available
|
|
4
|
+
// (corporate Windows without Task Scheduler access, restricted CI runners,
|
|
5
|
+
// ephemeral dev containers), the kit falls back to lazy-on-read compression
|
|
6
|
+
// triggered by the SessionStart hook (inject-context.mjs).
|
|
7
|
+
//
|
|
8
|
+
// Two public boundaries:
|
|
9
|
+
//
|
|
10
|
+
// detectStaleness({projectRoot, now, dailyTtlMs?, weeklyTtlMs?})
|
|
11
|
+
// → cheap (<5ms) inline check at SessionStart. Returns the
|
|
12
|
+
// work-needed verdict; inject-context.mjs uses it to decide
|
|
13
|
+
// whether to spawn `cmk compress --lazy`.
|
|
14
|
+
//
|
|
15
|
+
// async runLazyCompress({projectRoot, backend, now, cooldownMs?, dailyTtlMs?, weeklyTtlMs?})
|
|
16
|
+
// → the actual work. Composes on dailyDistill (Task 33) or
|
|
17
|
+
// weeklyCurate (Task 34) depending on staleness verdict.
|
|
18
|
+
//
|
|
19
|
+
// Cron-detection sentinel:
|
|
20
|
+
// <projectRoot>/context/.locks/cron-registered — marker file
|
|
21
|
+
// written by registerCron, removed by unregisterCron. When
|
|
22
|
+
// present, detectStaleness returns 'cron-active' so cmk compress
|
|
23
|
+
// --lazy becomes a no-op.
|
|
24
|
+
//
|
|
25
|
+
// Per design §8.2.1 + §8.2.2 + tasks.md 35.
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
appendFileSync,
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
statSync,
|
|
33
|
+
writeFileSync,
|
|
34
|
+
unlinkSync,
|
|
35
|
+
} from 'node:fs';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import { nowIso } from './audit-log.mjs';
|
|
38
|
+
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
39
|
+
import {
|
|
40
|
+
DEFAULT_COOLDOWN_MS,
|
|
41
|
+
isCooldownActive,
|
|
42
|
+
} from './cooldown.mjs';
|
|
43
|
+
import { dailyDistill } from './daily-distill.mjs';
|
|
44
|
+
import { weeklyCurate } from './weekly-curate.mjs';
|
|
45
|
+
|
|
46
|
+
const DEFAULT_DAILY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
47
|
+
const DEFAULT_WEEKLY_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
48
|
+
const SESSIONS_REL = ['context', 'sessions'];
|
|
49
|
+
const LOCKS_REL = ['context', '.locks'];
|
|
50
|
+
const RECENT_MD_REL = ['context', 'sessions', 'recent.md'];
|
|
51
|
+
const CRON_SENTINEL_REL = ['context', '.locks', 'cron-registered'];
|
|
52
|
+
const LAZY_LOG_REL = ['context', '.locks', 'lazy-compress.log'];
|
|
53
|
+
|
|
54
|
+
const TODAY_RE = /^today-(\d{4}-\d{2}-\d{2})\.md$/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Path helper for the cron-registered sentinel marker file. Public so
|
|
58
|
+
* register-crons.mjs can write/remove it without re-deriving the path.
|
|
59
|
+
*/
|
|
60
|
+
export function cronSentinelPath(projectRoot) {
|
|
61
|
+
return join(projectRoot, ...CRON_SENTINEL_REL);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Write the cron-registered sentinel marker. Called by registerCron
|
|
66
|
+
* after a successful host-scheduler registration.
|
|
67
|
+
*/
|
|
68
|
+
export function markCronRegistered({ projectRoot }) {
|
|
69
|
+
if (!projectRoot) return;
|
|
70
|
+
const locksDir = join(projectRoot, ...LOCKS_REL);
|
|
71
|
+
mkdirSync(locksDir, { recursive: true });
|
|
72
|
+
writeFileSync(cronSentinelPath(projectRoot), nowIso() + '\n', 'utf8');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Remove the cron-registered sentinel marker. Called by unregisterCron.
|
|
77
|
+
* Best-effort — if the marker is missing, that's already the desired state.
|
|
78
|
+
*/
|
|
79
|
+
export function unmarkCronRegistered({ projectRoot }) {
|
|
80
|
+
if (!projectRoot) return;
|
|
81
|
+
const path = cronSentinelPath(projectRoot);
|
|
82
|
+
if (existsSync(path)) {
|
|
83
|
+
try {
|
|
84
|
+
unlinkSync(path);
|
|
85
|
+
} catch {
|
|
86
|
+
// best-effort
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function listTodayFiles(projectRoot) {
|
|
92
|
+
const sessionsDir = join(projectRoot, ...SESSIONS_REL);
|
|
93
|
+
if (!existsSync(sessionsDir)) return [];
|
|
94
|
+
const matches = [];
|
|
95
|
+
for (const name of readdirSync(sessionsDir)) {
|
|
96
|
+
const m = TODAY_RE.exec(name);
|
|
97
|
+
if (!m) continue;
|
|
98
|
+
matches.push({ name, date: m[1], path: join(sessionsDir, name) });
|
|
99
|
+
}
|
|
100
|
+
return matches;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function recentMdMtimeMs(projectRoot) {
|
|
104
|
+
const p = join(projectRoot, ...RECENT_MD_REL);
|
|
105
|
+
if (!existsSync(p)) return null;
|
|
106
|
+
try {
|
|
107
|
+
return statSync(p).mtimeMs;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Cheap inline staleness check. Runs in <5ms — one stat + a few existsSync.
|
|
115
|
+
*
|
|
116
|
+
* Verdict semantics:
|
|
117
|
+
* - 'cron-active' : sentinel exists; cron will handle staleness. No-op.
|
|
118
|
+
* - 'no-context-dir': context/sessions/ doesn't exist. No-op (kit not installed).
|
|
119
|
+
* - 'stale-weekly' : ANY today-*.md older than 7d exists. Weekly curate needed.
|
|
120
|
+
* - 'stale-daily' : no OLD today files, but recent.md is missing OR older than dailyTtlMs.
|
|
121
|
+
* - 'fresh' : recent.md exists + younger than dailyTtlMs AND no OLD today files.
|
|
122
|
+
*
|
|
123
|
+
* weekly takes precedence over daily — weekly-curate also rebuilds recent.md
|
|
124
|
+
* (per §8.7.2), so doing weekly when both are stale handles both.
|
|
125
|
+
*/
|
|
126
|
+
export function detectStaleness({
|
|
127
|
+
projectRoot,
|
|
128
|
+
now,
|
|
129
|
+
dailyTtlMs = DEFAULT_DAILY_TTL_MS,
|
|
130
|
+
weeklyTtlMs = DEFAULT_WEEKLY_TTL_MS,
|
|
131
|
+
} = {}) {
|
|
132
|
+
if (!projectRoot) {
|
|
133
|
+
return { action: 'no-context-dir', reason: 'missing-project-root' };
|
|
134
|
+
}
|
|
135
|
+
// Cron sentinel short-circuits everything.
|
|
136
|
+
if (existsSync(cronSentinelPath(projectRoot))) {
|
|
137
|
+
return { action: 'cron-active', reason: 'cron-sentinel-present' };
|
|
138
|
+
}
|
|
139
|
+
const sessionsDir = join(projectRoot, ...SESSIONS_REL);
|
|
140
|
+
if (!existsSync(sessionsDir)) {
|
|
141
|
+
return { action: 'no-context-dir', reason: 'sessions-dir-missing' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ts = now ?? nowIso();
|
|
145
|
+
const nowMs = new Date(ts).getTime();
|
|
146
|
+
const files = listTodayFiles(projectRoot);
|
|
147
|
+
|
|
148
|
+
// Weekly check: any today-*.md older than weeklyTtlMs by its date stamp
|
|
149
|
+
// (NOT mtime — the file's date is the canonical age signal; mtime can
|
|
150
|
+
// drift if someone touched the file).
|
|
151
|
+
const weeklyCutoffMs = nowMs - weeklyTtlMs;
|
|
152
|
+
const hasOldToday = files.some((f) => {
|
|
153
|
+
const fileMs = new Date(f.date + 'T00:00:00Z').getTime();
|
|
154
|
+
return Number.isFinite(fileMs) && fileMs < weeklyCutoffMs;
|
|
155
|
+
});
|
|
156
|
+
if (hasOldToday) {
|
|
157
|
+
return { action: 'stale-weekly', reason: 'today-file-older-than-7d' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Task 36 I1 fix: if there are NO today-*.md files at all, the
|
|
161
|
+
// pipeline has nothing to compress — return fresh regardless of
|
|
162
|
+
// recent.md mtime. Previously this check only fired when recent.md
|
|
163
|
+
// was MISSING; for the stale-but-no-input case (e.g., right after
|
|
164
|
+
// weeklyCurate archived every today file), the daily-stale branch
|
|
165
|
+
// would fire and the SessionStart hook would spawn lazy-compress
|
|
166
|
+
// forever (no new today file means no work; dailyDistill would
|
|
167
|
+
// return skipped:no-input but not touch recent.md, so the next
|
|
168
|
+
// SessionStart sees the same stale verdict).
|
|
169
|
+
if (files.length === 0) {
|
|
170
|
+
return { action: 'fresh', reason: 'no-input' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Daily check: recent.md missing OR older than dailyTtlMs.
|
|
174
|
+
const mtimeMs = recentMdMtimeMs(projectRoot);
|
|
175
|
+
if (mtimeMs === null) {
|
|
176
|
+
return { action: 'stale-daily', reason: 'recent-md-missing' };
|
|
177
|
+
}
|
|
178
|
+
if (nowMs - mtimeMs > dailyTtlMs) {
|
|
179
|
+
return { action: 'stale-daily', reason: 'recent-md-older-than-ttl' };
|
|
180
|
+
}
|
|
181
|
+
return { action: 'fresh', reason: 'within-ttl' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function writeLazyLogEntry({ projectRoot, entry }) {
|
|
185
|
+
const path = join(projectRoot, ...LAZY_LOG_REL);
|
|
186
|
+
mkdirSync(join(projectRoot, ...LOCKS_REL), { recursive: true });
|
|
187
|
+
appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
|
|
188
|
+
return path;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Run the lazy-compress cycle. Dispatches to dailyDistill or weeklyCurate
|
|
193
|
+
* based on detectStaleness verdict.
|
|
194
|
+
*
|
|
195
|
+
* @returns {Promise<object>}
|
|
196
|
+
*/
|
|
197
|
+
export async function runLazyCompress({
|
|
198
|
+
projectRoot,
|
|
199
|
+
backend,
|
|
200
|
+
now,
|
|
201
|
+
cooldownMs = DEFAULT_COOLDOWN_MS,
|
|
202
|
+
dailyTtlMs = DEFAULT_DAILY_TTL_MS,
|
|
203
|
+
weeklyTtlMs = DEFAULT_WEEKLY_TTL_MS,
|
|
204
|
+
} = {}) {
|
|
205
|
+
const ts = now ?? nowIso();
|
|
206
|
+
const t0 = Date.now();
|
|
207
|
+
|
|
208
|
+
if (!projectRoot) {
|
|
209
|
+
return errorResult({
|
|
210
|
+
category: ERROR_CATEGORIES.MISSING_PROJECT_ROOT,
|
|
211
|
+
errors: ['projectRoot is required'],
|
|
212
|
+
duration_ms: Date.now() - t0,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (!backend || typeof backend.compress !== 'function') {
|
|
216
|
+
return errorResult({
|
|
217
|
+
category: ERROR_CATEGORIES.MISSING_BACKEND,
|
|
218
|
+
errors: ['backend (CompressorBackend) is required'],
|
|
219
|
+
duration_ms: Date.now() - t0,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Cooldown gate up front — composes with shared 120s marker.
|
|
224
|
+
if (isCooldownActive({ projectRoot, now: ts, cooldownMs })) {
|
|
225
|
+
const duration_ms = Date.now() - t0;
|
|
226
|
+
writeLazyLogEntry({
|
|
227
|
+
projectRoot,
|
|
228
|
+
entry: {
|
|
229
|
+
ts,
|
|
230
|
+
scope: 'lazy-compress',
|
|
231
|
+
action: 'skipped',
|
|
232
|
+
reason: 'cooldown',
|
|
233
|
+
// M1 fix: include verdict + delegated_to with null sentinels so
|
|
234
|
+
// every NDJSON entry shares the same schema (downstream `cmk
|
|
235
|
+
// doctor` HC-6 parsing can rely on key presence). The Haiku
|
|
236
|
+
// call was gated, so verdict was never computed and delegation
|
|
237
|
+
// never happened.
|
|
238
|
+
verdict: null,
|
|
239
|
+
delegated_to: null,
|
|
240
|
+
duration_ms,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
return { action: 'skipped', reason: 'cooldown', duration_ms };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const verdict = detectStaleness({
|
|
247
|
+
projectRoot,
|
|
248
|
+
now: ts,
|
|
249
|
+
dailyTtlMs,
|
|
250
|
+
weeklyTtlMs,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (verdict.action === 'cron-active') {
|
|
254
|
+
const duration_ms = Date.now() - t0;
|
|
255
|
+
writeLazyLogEntry({
|
|
256
|
+
projectRoot,
|
|
257
|
+
entry: {
|
|
258
|
+
ts,
|
|
259
|
+
scope: 'lazy-compress',
|
|
260
|
+
action: 'skipped',
|
|
261
|
+
reason: 'cron-active',
|
|
262
|
+
duration_ms,
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
return { action: 'skipped', reason: 'cron-active', duration_ms };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (verdict.action === 'no-context-dir' || verdict.action === 'fresh') {
|
|
269
|
+
const duration_ms = Date.now() - t0;
|
|
270
|
+
writeLazyLogEntry({
|
|
271
|
+
projectRoot,
|
|
272
|
+
entry: {
|
|
273
|
+
ts,
|
|
274
|
+
scope: 'lazy-compress',
|
|
275
|
+
action: 'skipped',
|
|
276
|
+
reason: verdict.reason,
|
|
277
|
+
verdict: verdict.action,
|
|
278
|
+
duration_ms,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
return { action: 'skipped', reason: verdict.reason, duration_ms };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// verdict.action is 'stale-daily' or 'stale-weekly'.
|
|
285
|
+
// Delegate to the appropriate cycle, passing cooldownMs=0 because we
|
|
286
|
+
// already gated above; the inner call shouldn't gate a second time on
|
|
287
|
+
// the same marker (which they would not touch yet).
|
|
288
|
+
let result;
|
|
289
|
+
if (verdict.action === 'stale-weekly') {
|
|
290
|
+
result = await weeklyCurate({
|
|
291
|
+
projectRoot,
|
|
292
|
+
backend,
|
|
293
|
+
now: ts,
|
|
294
|
+
cooldownMs: 0,
|
|
295
|
+
});
|
|
296
|
+
} else {
|
|
297
|
+
result = await dailyDistill({
|
|
298
|
+
projectRoot,
|
|
299
|
+
backend,
|
|
300
|
+
now: ts,
|
|
301
|
+
cooldownMs: 0,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const duration_ms = Date.now() - t0;
|
|
306
|
+
writeLazyLogEntry({
|
|
307
|
+
projectRoot,
|
|
308
|
+
entry: {
|
|
309
|
+
ts,
|
|
310
|
+
scope: 'lazy-compress',
|
|
311
|
+
action: result?.action ?? 'unknown',
|
|
312
|
+
verdict: verdict.action,
|
|
313
|
+
delegated_to: verdict.action === 'stale-weekly' ? 'weekly-curate' : 'daily-distill',
|
|
314
|
+
duration_ms,
|
|
315
|
+
success: result?.action !== 'error',
|
|
316
|
+
...(result?.errorCategory ? { error_category: result.errorCategory } : {}),
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
...result,
|
|
321
|
+
verdict: verdict.action,
|
|
322
|
+
delegatedTo:
|
|
323
|
+
verdict.action === 'stale-weekly' ? 'weekly-curate' : 'daily-distill',
|
|
324
|
+
duration_ms,
|
|
325
|
+
};
|
|
326
|
+
}
|