@lh8ppl/claude-memory-kit 0.4.0 → 0.4.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 +135 -54
- package/bin/cmk-approve-permission.mjs +62 -0
- package/bin/cmk-daily-distill.mjs +14 -0
- package/bin/cmk-inject-context.mjs +12 -0
- package/bin/cmk-weekly-curate.mjs +12 -0
- package/package.json +3 -2
- package/src/approve-permission.mjs +92 -0
- package/src/auto-extract.mjs +17 -10
- package/src/auto-persona.mjs +7 -3
- package/src/compaction-state.mjs +204 -0
- package/src/doctor.mjs +42 -1
- package/src/inject-context.mjs +8 -15
- package/src/install.mjs +37 -4
- package/src/lazy-compress.mjs +43 -110
- package/src/register-crons.mjs +31 -0
- package/src/settings-hooks.mjs +58 -1
- package/src/tier-paths.mjs +47 -13
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Compaction-state deep module (Task 167, D-206/D-207).
|
|
2
|
+
//
|
|
3
|
+
// Owns the single question "does memory need compacting right now, and is the
|
|
4
|
+
// scheduled cron alive?" — replacing the old detectStaleness gating that
|
|
5
|
+
// short-circuited to 'cron-active' on the mere EXISTENCE of a cron-registered
|
|
6
|
+
// sentinel (so a registered-but-dead cron disabled the lazy roll and now.md grew
|
|
7
|
+
// unbounded — the v0.4.0 dogfood's 410 KB freeze).
|
|
8
|
+
//
|
|
9
|
+
// Two public methods (the deep interface — Q3):
|
|
10
|
+
//
|
|
11
|
+
// isCompactionNeeded({projectRoot, now, dailyTtlMs?, weeklyTtlMs?})
|
|
12
|
+
// → {verdict, cronStale, heartbeatAge}
|
|
13
|
+
// verdict: 'fresh' | 'stale-now' | 'stale-daily' | 'stale-weekly'
|
|
14
|
+
// | 'cron-active' | 'no-context-dir'
|
|
15
|
+
// cronStale: boolean — a cron IS registered but its heartbeat is stale
|
|
16
|
+
// heartbeatAge: number|null — ms since the last cron run, null if no cron
|
|
17
|
+
//
|
|
18
|
+
// recordCronHeartbeat({projectRoot, now}) — the ONLY writer; the cron bins
|
|
19
|
+
// (cmk-daily-distill / cmk-weekly-curate) call it on each fire so the gate
|
|
20
|
+
// keys off "a run HAPPENED recently" (age), not "a scheduler is registered"
|
|
21
|
+
// (existence). The anacron model — see docs/research/2026-06-25-cron-liveness.
|
|
22
|
+
//
|
|
23
|
+
// Marker-vs-derive = HYBRID (Q2; the GNU make "Empty Target Files" rule): the
|
|
24
|
+
// now/daily/weekly verdicts are DERIVED from the artifacts the work already
|
|
25
|
+
// rewrites (now.md content, recent.md mtime, today-*.md dates — no new marker,
|
|
26
|
+
// ADR-0002); only cron-liveness gets a stamp, because no artifact expresses "is
|
|
27
|
+
// the background scheduler alive".
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
existsSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
statSync,
|
|
35
|
+
utimesSync,
|
|
36
|
+
writeFileSync,
|
|
37
|
+
} from 'node:fs';
|
|
38
|
+
import { dirname, join } from 'node:path';
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_DAILY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
41
|
+
export const DEFAULT_WEEKLY_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
42
|
+
|
|
43
|
+
// The cron heartbeat is considered alive within ~2× the daily cron interval
|
|
44
|
+
// (the anacron model: a registered cron that hasn't run in 2 days is dead — the
|
|
45
|
+
// machine was asleep, the registration silently failed, etc.). 2× the 24h daily
|
|
46
|
+
// cadence gives one full grace period before the lazy roll takes over.
|
|
47
|
+
export const DEFAULT_HEARTBEAT_TTL_MS = 2 * DEFAULT_DAILY_TTL_MS; // 48 hours
|
|
48
|
+
|
|
49
|
+
const SESSIONS_REL = ['context', 'sessions'];
|
|
50
|
+
const NOW_MD_REL = ['context', 'sessions', 'now.md'];
|
|
51
|
+
const RECENT_MD_REL = ['context', 'sessions', 'recent.md'];
|
|
52
|
+
const HEARTBEAT_REL = ['context', '.locks', 'cron-heartbeat'];
|
|
53
|
+
|
|
54
|
+
const TODAY_RE = /^today-(\d{4}-\d{2}-\d{2})\.md$/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Path to the cron-heartbeat stamp. Public so register-crons.mjs + the cron bins
|
|
58
|
+
* can write it without re-deriving the path.
|
|
59
|
+
*/
|
|
60
|
+
export function cronHeartbeatPath(projectRoot) {
|
|
61
|
+
return join(projectRoot, ...HEARTBEAT_REL);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Record that the scheduled cron actually RAN (the anacron stamp). The ONLY
|
|
66
|
+
* writer of the heartbeat — called by the cron bins on each fire (success OR
|
|
67
|
+
* benign no-op; "ran and did nothing" still proves the cron is alive). Best-effort
|
|
68
|
+
* + atomic-enough (mkdir then touch); mtime is the load-bearing signal.
|
|
69
|
+
*/
|
|
70
|
+
export function recordCronHeartbeat({ projectRoot, now }) {
|
|
71
|
+
if (!projectRoot) return;
|
|
72
|
+
const marker = cronHeartbeatPath(projectRoot);
|
|
73
|
+
mkdirSync(dirname(marker), { recursive: true });
|
|
74
|
+
if (!existsSync(marker)) {
|
|
75
|
+
writeFileSync(marker, '', 'utf8');
|
|
76
|
+
}
|
|
77
|
+
const ts = new Date(now ?? Date.now());
|
|
78
|
+
try {
|
|
79
|
+
utimesSync(marker, ts, ts);
|
|
80
|
+
} catch {
|
|
81
|
+
// utimes can fail on exotic filesystems; existence + a write timestamp are
|
|
82
|
+
// the load-bearing signal, and writeFileSync already stamped it on create.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** ms since the cron last ran, or null if no cron heartbeat exists. */
|
|
87
|
+
function heartbeatAgeMs(projectRoot, nowMs) {
|
|
88
|
+
const marker = cronHeartbeatPath(projectRoot);
|
|
89
|
+
if (!existsSync(marker)) return null;
|
|
90
|
+
try {
|
|
91
|
+
return nowMs - statSync(marker).mtimeMs;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function nowMdHasContent(projectRoot) {
|
|
98
|
+
const p = join(projectRoot, ...NOW_MD_REL);
|
|
99
|
+
if (!existsSync(p)) return false;
|
|
100
|
+
try {
|
|
101
|
+
return readFileSync(p, 'utf8').trim() !== '';
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function recentMdMtimeMs(projectRoot) {
|
|
108
|
+
const p = join(projectRoot, ...RECENT_MD_REL);
|
|
109
|
+
if (!existsSync(p)) return null;
|
|
110
|
+
try {
|
|
111
|
+
return statSync(p).mtimeMs;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function listTodayFiles(projectRoot) {
|
|
118
|
+
const sessionsDir = join(projectRoot, ...SESSIONS_REL);
|
|
119
|
+
if (!existsSync(sessionsDir)) return [];
|
|
120
|
+
const matches = [];
|
|
121
|
+
for (const name of readdirSync(sessionsDir)) {
|
|
122
|
+
const m = TODAY_RE.exec(name);
|
|
123
|
+
if (!m) continue;
|
|
124
|
+
matches.push({ name, date: m[1], path: join(sessionsDir, name) });
|
|
125
|
+
}
|
|
126
|
+
return matches;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The deep read: does memory need compacting, and is the cron alive?
|
|
131
|
+
*
|
|
132
|
+
* Verdict precedence (Q2): no-context-dir > stale-now > cron-active(fresh) >
|
|
133
|
+
* stale-weekly > stale-daily > fresh. Note 'cron-active' NO LONGER short-circuits
|
|
134
|
+
* above the bloat check — a stale-now (un-rolled prior-session content) ALWAYS
|
|
135
|
+
* wins, because correctness > deferring to a possibly-dead cron ("we're in the
|
|
136
|
+
* memory business", Q4). Only a FRESH cron defers the derived daily/weekly work.
|
|
137
|
+
*/
|
|
138
|
+
export function isCompactionNeeded({
|
|
139
|
+
projectRoot,
|
|
140
|
+
now,
|
|
141
|
+
dailyTtlMs = DEFAULT_DAILY_TTL_MS,
|
|
142
|
+
weeklyTtlMs = DEFAULT_WEEKLY_TTL_MS,
|
|
143
|
+
heartbeatTtlMs = DEFAULT_HEARTBEAT_TTL_MS,
|
|
144
|
+
} = {}) {
|
|
145
|
+
const nowMs = new Date(now ?? Date.now()).getTime();
|
|
146
|
+
|
|
147
|
+
if (!projectRoot) {
|
|
148
|
+
return { verdict: 'no-context-dir', reason: 'missing-project-root', cronStale: false, heartbeatAge: null };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Cron liveness — by AGE, never existence (the 167.A fix). A registered cron
|
|
152
|
+
// whose heartbeat is older than the TTL is DEAD; treat it as no cron so the
|
|
153
|
+
// lazy roll takes over.
|
|
154
|
+
const hbAge = heartbeatAgeMs(projectRoot, nowMs);
|
|
155
|
+
const cronRegistered = hbAge !== null;
|
|
156
|
+
const cronAlive = cronRegistered && hbAge < heartbeatTtlMs;
|
|
157
|
+
const cronStale = cronRegistered && !cronAlive;
|
|
158
|
+
|
|
159
|
+
const base = { cronStale, heartbeatAge: hbAge };
|
|
160
|
+
|
|
161
|
+
const sessionsDir = join(projectRoot, ...SESSIONS_REL);
|
|
162
|
+
if (!existsSync(sessionsDir)) {
|
|
163
|
+
return { verdict: 'no-context-dir', reason: 'sessions-dir-missing', ...base };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// stale-now wins over cron-active (Q4): un-rolled now.md content must drain
|
|
167
|
+
// THIS session regardless of whether a (possibly dead, possibly alive) cron is
|
|
168
|
+
// registered — the correctness-over-deferral rule.
|
|
169
|
+
if (nowMdHasContent(projectRoot)) {
|
|
170
|
+
return { verdict: 'stale-now', reason: 'now-md-has-prior-session-content', ...base };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A LIVE cron owns the remaining (daily/weekly) levels — defer to it.
|
|
174
|
+
if (cronAlive) {
|
|
175
|
+
return { verdict: 'cron-active', reason: 'cron-heartbeat-fresh', ...base };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const files = listTodayFiles(projectRoot);
|
|
179
|
+
|
|
180
|
+
// Weekly: any today-*.md older than weeklyTtlMs by its date stamp.
|
|
181
|
+
const weeklyCutoffMs = nowMs - weeklyTtlMs;
|
|
182
|
+
const hasOldToday = files.some((f) => {
|
|
183
|
+
const fileMs = new Date(f.date + 'T00:00:00Z').getTime();
|
|
184
|
+
return Number.isFinite(fileMs) && fileMs < weeklyCutoffMs;
|
|
185
|
+
});
|
|
186
|
+
if (hasOldToday) {
|
|
187
|
+
return { verdict: 'stale-weekly', reason: 'today-file-older-than-7d', ...base };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// No today files at all → nothing to compress → fresh (Task 36 I1).
|
|
191
|
+
if (files.length === 0) {
|
|
192
|
+
return { verdict: 'fresh', reason: 'no-input', ...base };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Daily: recent.md missing OR older than dailyTtlMs.
|
|
196
|
+
const mtimeMs = recentMdMtimeMs(projectRoot);
|
|
197
|
+
if (mtimeMs === null) {
|
|
198
|
+
return { verdict: 'stale-daily', reason: 'recent-md-missing', ...base };
|
|
199
|
+
}
|
|
200
|
+
if (nowMs - mtimeMs > dailyTtlMs) {
|
|
201
|
+
return { verdict: 'stale-daily', reason: 'recent-md-older-than-ttl', ...base };
|
|
202
|
+
}
|
|
203
|
+
return { verdict: 'fresh', reason: 'within-ttl', ...base };
|
|
204
|
+
}
|
package/src/doctor.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import { basename, join } from 'node:path';
|
|
|
43
43
|
import { nowIso } from './audit-log.mjs';
|
|
44
44
|
import { detectStaleLocks } from './lock-discipline.mjs';
|
|
45
45
|
import { cronSentinelPath } from './lazy-compress.mjs';
|
|
46
|
+
import { isCompactionNeeded } from './compaction-state.mjs';
|
|
46
47
|
import { getNativeAutoMemoryState } from './native-memory.mjs';
|
|
47
48
|
import { checkKitBinding, checkEmbedderBinding } from './native-binding.mjs';
|
|
48
49
|
import { resolveDefaultSearchMode } from './semantic-backend.mjs';
|
|
@@ -640,6 +641,45 @@ function hc9VersionDrift({ projectRoot, kitVersion }) {
|
|
|
640
641
|
return checkVersionDrift({ claudeMdText, kitVersion });
|
|
641
642
|
}
|
|
642
643
|
|
|
644
|
+
// --- HC-10: Scheduled compaction liveness (Task 167 / D-207) ----------
|
|
645
|
+
// INFORMATIONAL — memory self-heals automatically every session (the lazy roll
|
|
646
|
+
// is the floor, 167.A/D), so a dead cron is a degraded OPTIMIZATION, not data
|
|
647
|
+
// loss. NEVER prescribes a manual heal (no recoveryCommand). A DEV/diagnostic
|
|
648
|
+
// aid (it surfaced THIS bug class) + a heads-up for a power user. SKIPs when no
|
|
649
|
+
// cron is registered (the default) — the lazy roll covers compaction there.
|
|
650
|
+
function hc10CompactionLiveness({ projectRoot, now }) {
|
|
651
|
+
const v = isCompactionNeeded({ projectRoot, now });
|
|
652
|
+
// No cron registered (heartbeatAge null) → nothing to check; the lazy roll owns
|
|
653
|
+
// compaction. SKIP (consistent with HC-5's optional-cron posture).
|
|
654
|
+
if (v.heartbeatAge === null) {
|
|
655
|
+
return {
|
|
656
|
+
id: 'HC-10',
|
|
657
|
+
name: 'Scheduled compaction is alive',
|
|
658
|
+
status: 'skip',
|
|
659
|
+
message: 'no scheduled cron registered (optional) — memory self-heals each session via the lazy roll.',
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
if (v.cronStale) {
|
|
663
|
+
const days = Math.floor(v.heartbeatAge / (24 * 60 * 60 * 1000));
|
|
664
|
+
return {
|
|
665
|
+
id: 'HC-10',
|
|
666
|
+
name: 'Scheduled compaction is alive',
|
|
667
|
+
status: 'fail',
|
|
668
|
+
// Informational framing — NO recoveryCommand. The kit self-heals; this is a
|
|
669
|
+
// heads-up that the OPTIONAL nightly schedule isn't firing (asleep at 23:00,
|
|
670
|
+
// a registration that didn't take catch-up). Re-running register-crons is a
|
|
671
|
+
// user choice, not a required repair.
|
|
672
|
+
message: `your scheduled compaction looks dead (last ran ~${days}d ago) — memory still self-heals automatically each session, so no action is needed; run \`cmk register-crons\` only if you want the nightly schedule back.`,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
id: 'HC-10',
|
|
677
|
+
name: 'Scheduled compaction is alive',
|
|
678
|
+
status: 'pass',
|
|
679
|
+
message: 'scheduled compaction heartbeat is fresh',
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
643
683
|
export async function runDoctor({
|
|
644
684
|
projectRoot,
|
|
645
685
|
userDir,
|
|
@@ -672,10 +712,11 @@ export async function runDoctor({
|
|
|
672
712
|
const c8 = await hc8NativeBindings({ projectRoot, kitBindingProbe, embedderBindingProbe });
|
|
673
713
|
// HC-9: kitVersion injectable for tests; defaults to the installed binary's version.
|
|
674
714
|
const c9 = hc9VersionDrift({ projectRoot, kitVersion: kitVersion ?? getKitVersion() });
|
|
715
|
+
const c10 = hc10CompactionLiveness({ projectRoot, now: ts });
|
|
675
716
|
|
|
676
717
|
return {
|
|
677
718
|
action: 'completed',
|
|
678
|
-
checks: [c1, c2, c3, c4, c5, c6, c7, c8, c9],
|
|
719
|
+
checks: [c1, c2, c3, c4, c5, c6, c7, c8, c9, c10],
|
|
679
720
|
duration_ms: Date.now() - t0,
|
|
680
721
|
};
|
|
681
722
|
}
|
package/src/inject-context.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { spawn } from 'node:child_process';
|
|
|
34
34
|
import { join, dirname } from 'node:path';
|
|
35
35
|
import { fileURLToPath } from 'node:url';
|
|
36
36
|
import { homedir } from 'node:os';
|
|
37
|
-
import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN } from './tier-paths.mjs';
|
|
37
|
+
import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN, discoverRootUpward } from './tier-paths.mjs';
|
|
38
38
|
import { nowIso } from './audit-log.mjs';
|
|
39
39
|
import { detectStaleness, isJournalStale } from './lazy-compress.mjs';
|
|
40
40
|
import { isProvenanceCommentLine, parseBulletProvenance } from './provenance.mjs';
|
|
@@ -171,21 +171,14 @@ function latestDaySession(sessionsDir) {
|
|
|
171
171
|
// kit's project-tier root convention is `<repo>/context/`; the walk-up
|
|
172
172
|
// matches `git rev-parse --show-toplevel`'s semantics for nested invocations
|
|
173
173
|
// (a hook may fire while Claude Code's cwd is in a sub-package).
|
|
174
|
+
// Task 168: discovery walks up to the nearest project root, recognizing EITHER
|
|
175
|
+
// tier directory — `context/` (committed) OR `context.local/` (gitignored local)
|
|
176
|
+
// — so a local-only project resolves correctly, and STOPPING at the home dir so a
|
|
177
|
+
// stray `~/context/` can't hijack discovery from an unrelated subdir. Shares the
|
|
178
|
+
// single `discoverRootUpward` implementation with resolveMcpProjectRoot (one
|
|
179
|
+
// home-boundary + canonicalize algorithm, no drift across the two walkers).
|
|
174
180
|
function discoverProjectRoot(cwd) {
|
|
175
|
-
|
|
176
|
-
// Defensive bound: walk no more than 64 ancestors.
|
|
177
|
-
for (let i = 0; i < 64; i++) {
|
|
178
|
-
if (existsSync(join(dir, 'context'))) return dir;
|
|
179
|
-
const parent = join(dir, '..');
|
|
180
|
-
const norm = statSync(parent).isDirectory() ? parent : null;
|
|
181
|
-
if (!norm || norm === dir) break;
|
|
182
|
-
// Stop at the filesystem root.
|
|
183
|
-
if (/^[A-Za-z]:\\?$|^\/$/.test(dir)) break;
|
|
184
|
-
dir = parent;
|
|
185
|
-
}
|
|
186
|
-
// Fall back to `cwd` — the per-tier readers will return empty for
|
|
187
|
-
// absent dirs, so this stays safe.
|
|
188
|
-
return cwd;
|
|
181
|
+
return discoverRootUpward(cwd, ['context', 'context.local']);
|
|
189
182
|
}
|
|
190
183
|
|
|
191
184
|
function tierDirExists(tier, tierRoot) {
|
package/src/install.mjs
CHANGED
|
@@ -494,7 +494,12 @@ export async function install(options = {}) {
|
|
|
494
494
|
// the pin-off; checked first below).
|
|
495
495
|
let semantic = { action: 'skipped' };
|
|
496
496
|
if (options.withSemantic) {
|
|
497
|
-
semantic = await enableSemantic({
|
|
497
|
+
semantic = await enableSemantic({
|
|
498
|
+
projectRoot,
|
|
499
|
+
spawnNpm: options.spawnNpm,
|
|
500
|
+
warm: options.warmEmbedder,
|
|
501
|
+
probeEmbedder: options.probeEmbedder,
|
|
502
|
+
});
|
|
498
503
|
if (semantic.action === 'error') errors.push({ path: 'semantic', error: semantic.error });
|
|
499
504
|
} else if (options.noSemantic) {
|
|
500
505
|
const r = mergeProjectSettings(projectRoot, { search: { default_mode: 'keyword' } });
|
|
@@ -571,15 +576,43 @@ export function buildDefaultNpmRunner({ spawnSyncImpl = spawnSync } = {}) {
|
|
|
571
576
|
};
|
|
572
577
|
}
|
|
573
578
|
|
|
574
|
-
async function enableSemantic({ projectRoot, spawnNpm, warm }) {
|
|
579
|
+
async function enableSemantic({ projectRoot, spawnNpm, warm, probeEmbedder }) {
|
|
575
580
|
// 1. Install the optional embedder globally (it resolves as a sibling of
|
|
576
581
|
// the globally-installed kit). Injectable for tests.
|
|
577
582
|
const runNpm = spawnNpm ?? buildDefaultNpmRunner();
|
|
578
583
|
const npm = runNpm();
|
|
579
|
-
|
|
584
|
+
|
|
585
|
+
// Task 170 (the v0.4.1 cut-gate find): gate on whether the embedder ACTUALLY
|
|
586
|
+
// IMPORTS, NOT on npm's exit code — verify the thing worked, not the command's
|
|
587
|
+
// exit (the D-199 class). Two failure modes the exit code gets wrong, BOTH
|
|
588
|
+
// sides:
|
|
589
|
+
// - npm exits NON-ZERO but the package installed fine: on Windows a benign
|
|
590
|
+
// cleanup-EBUSY (npm failing to unlink a leftover temp DLL still locked by
|
|
591
|
+
// a running process — sharp-win32-x64 / libvips) makes npm exit non-zero
|
|
592
|
+
// AFTER a successful install. Trusting the exit FALSELY reported "NOT
|
|
593
|
+
// enabled" while the embedder was present + importable (the live find).
|
|
594
|
+
// - npm exits ZERO but the import is BROKEN (partial/corrupt native module):
|
|
595
|
+
// trusting the exit would wrongly write a hybrid default with no working
|
|
596
|
+
// embedder — every search would degrade to the fallback warning (the
|
|
597
|
+
// half-state this function exists to avoid).
|
|
598
|
+
// So PROBE the import once, and enable hybrid IFF it imports — regardless of
|
|
599
|
+
// npm's exit. The install must be AUTOMATIC: the user does nothing and never
|
|
600
|
+
// needs a manual `cmk config set` to recover from a benign npm warning.
|
|
601
|
+
const probe = probeEmbedder ?? (async () => {
|
|
602
|
+
const { checkEmbedderBinding } = await import('./native-binding.mjs');
|
|
603
|
+
return checkEmbedderBinding();
|
|
604
|
+
});
|
|
605
|
+
let imported;
|
|
606
|
+
try {
|
|
607
|
+
imported = await probe();
|
|
608
|
+
} catch {
|
|
609
|
+
imported = { ok: false };
|
|
610
|
+
}
|
|
611
|
+
if (!imported?.ok) {
|
|
612
|
+
const detail = npm.status !== 0 ? (npm.error ?? `exit ${npm.status}`) : (imported?.reason ?? 'embedder import failed');
|
|
580
613
|
return {
|
|
581
614
|
action: 'error',
|
|
582
|
-
error: `
|
|
615
|
+
error: `semantic embedder not usable after install (${detail}) — semantic recall NOT enabled; keyword search is unaffected`,
|
|
583
616
|
};
|
|
584
617
|
}
|
|
585
618
|
// 2. Flip the project default to hybrid ONLY after the dependency landed
|
package/src/lazy-compress.mjs
CHANGED
|
@@ -29,9 +29,7 @@ import {
|
|
|
29
29
|
existsSync,
|
|
30
30
|
mkdirSync,
|
|
31
31
|
readdirSync,
|
|
32
|
-
readFileSync,
|
|
33
32
|
statSync,
|
|
34
|
-
writeFileSync,
|
|
35
33
|
unlinkSync,
|
|
36
34
|
} from 'node:fs';
|
|
37
35
|
import { join } from 'node:path';
|
|
@@ -46,44 +44,58 @@ import { weeklyCurate } from './weekly-curate.mjs';
|
|
|
46
44
|
import { compressSession } from './compress-session.mjs';
|
|
47
45
|
import { CEILING_FREE_TIMEOUT_MS, CEILING_FREE_BACKOFF_MS } from './compress-retry.mjs';
|
|
48
46
|
import { syncDecisionsJournal } from './decisions-journal.mjs';
|
|
47
|
+
import {
|
|
48
|
+
isCompactionNeeded,
|
|
49
|
+
recordCronHeartbeat,
|
|
50
|
+
cronHeartbeatPath,
|
|
51
|
+
} from './compaction-state.mjs';
|
|
49
52
|
|
|
50
53
|
const DEFAULT_DAILY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
51
54
|
const DEFAULT_WEEKLY_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
52
|
-
const SESSIONS_REL = ['context', 'sessions'];
|
|
53
55
|
const LOCKS_REL = ['context', '.locks'];
|
|
54
|
-
const NOW_MD_REL = ['context', 'sessions', 'now.md'];
|
|
55
|
-
const RECENT_MD_REL = ['context', 'sessions', 'recent.md'];
|
|
56
|
-
const CRON_SENTINEL_REL = ['context', '.locks', 'cron-registered'];
|
|
57
56
|
const LAZY_LOG_REL = ['context', '.locks', 'lazy-compress.log'];
|
|
58
57
|
|
|
59
|
-
|
|
58
|
+
// Task 167 (D-206/D-207): the cron-liveness gate moved from a presence-only
|
|
59
|
+
// `cron-registered` sentinel to an anacron-style `cron-heartbeat` (in
|
|
60
|
+
// compaction-state.mjs), gated on AGE not existence — a registered-but-dead cron
|
|
61
|
+
// no longer disables the lazy roll. These three exports are kept as thin
|
|
62
|
+
// back-compat shims so existing callers (register-crons / doctor / subcommands /
|
|
63
|
+
// tests) keep working; they now operate on the heartbeat.
|
|
64
|
+
//
|
|
65
|
+
// **Decision-trail (the retired sentinel):** the old `cron-registered` marker
|
|
66
|
+
// recorded "a cron was REGISTERED" (written once at register-crons time, never
|
|
67
|
+
// updated). detectStaleness short-circuited to 'cron-active' on its mere
|
|
68
|
+
// existence — so a cron that never actually fired (laptop asleep at 23:00)
|
|
69
|
+
// disabled the lazy fallback forever and now.md grew to 410 KB (the v0.4.0
|
|
70
|
+
// dogfood). The heartbeat records "a run HAPPENED" and is re-stamped each fire.
|
|
60
71
|
|
|
61
72
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
73
|
+
* Back-compat alias for the cron-liveness marker path. Now points at the
|
|
74
|
+
* `cron-heartbeat` stamp (was `cron-registered`). Retained for callers that still
|
|
75
|
+
* import it; prefer `cronHeartbeatPath` from compaction-state.mjs in new code.
|
|
64
76
|
*/
|
|
65
77
|
export function cronSentinelPath(projectRoot) {
|
|
66
|
-
return
|
|
78
|
+
return cronHeartbeatPath(projectRoot);
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
82
|
+
* Record that the cron is alive. Now writes the anacron-style heartbeat (was: a
|
|
83
|
+
* one-time registration sentinel). `register-crons` calls it on registration so a
|
|
84
|
+
* just-registered cron reads alive until its first real run re-stamps it; the
|
|
85
|
+
* cron BINS also call `recordCronHeartbeat` on each fire (the durable liveness
|
|
86
|
+
* signal). Both go to the same stamp.
|
|
72
87
|
*/
|
|
73
88
|
export function markCronRegistered({ projectRoot }) {
|
|
74
|
-
|
|
75
|
-
const locksDir = join(projectRoot, ...LOCKS_REL);
|
|
76
|
-
mkdirSync(locksDir, { recursive: true });
|
|
77
|
-
writeFileSync(cronSentinelPath(projectRoot), nowIso() + '\n', 'utf8');
|
|
89
|
+
recordCronHeartbeat({ projectRoot });
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
/**
|
|
81
|
-
* Remove the cron-
|
|
82
|
-
*
|
|
93
|
+
* Remove the cron-liveness heartbeat (on unregister). Best-effort — a missing
|
|
94
|
+
* stamp is already the desired "no live cron" state.
|
|
83
95
|
*/
|
|
84
96
|
export function unmarkCronRegistered({ projectRoot }) {
|
|
85
97
|
if (!projectRoot) return;
|
|
86
|
-
const path =
|
|
98
|
+
const path = cronHeartbeatPath(projectRoot);
|
|
87
99
|
if (existsSync(path)) {
|
|
88
100
|
try {
|
|
89
101
|
unlinkSync(path);
|
|
@@ -93,18 +105,6 @@ export function unmarkCronRegistered({ projectRoot }) {
|
|
|
93
105
|
}
|
|
94
106
|
}
|
|
95
107
|
|
|
96
|
-
function listTodayFiles(projectRoot) {
|
|
97
|
-
const sessionsDir = join(projectRoot, ...SESSIONS_REL);
|
|
98
|
-
if (!existsSync(sessionsDir)) return [];
|
|
99
|
-
const matches = [];
|
|
100
|
-
for (const name of readdirSync(sessionsDir)) {
|
|
101
|
-
const m = TODAY_RE.exec(name);
|
|
102
|
-
if (!m) continue;
|
|
103
|
-
matches.push({ name, date: m[1], path: join(sessionsDir, name) });
|
|
104
|
-
}
|
|
105
|
-
return matches;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
108
|
// Task 105 (D-75): does now.md carry prior-session content? The now→today
|
|
109
109
|
// roll (compressSession) fires only at SessionEnd, and Claude Code fires
|
|
110
110
|
// SessionEnd ONLY on a clean window-close — so a never-cleanly-closed session
|
|
@@ -114,25 +114,10 @@ function listTodayFiles(projectRoot) {
|
|
|
114
114
|
// capture-turn writes haven't fired yet), so non-empty ⇒ stale. Emptiness must
|
|
115
115
|
// match compressSession's own `buffer.trim() === ''` check so the spawn verdict
|
|
116
116
|
// and the actual roll agree (else we'd spawn for a roll that immediately skips).
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return readFileSync(p, 'utf8').trim() !== '';
|
|
122
|
-
} catch {
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function recentMdMtimeMs(projectRoot) {
|
|
128
|
-
const p = join(projectRoot, ...RECENT_MD_REL);
|
|
129
|
-
if (!existsSync(p)) return null;
|
|
130
|
-
try {
|
|
131
|
-
return statSync(p).mtimeMs;
|
|
132
|
-
} catch {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
117
|
+
// Task 167 (D-207): nowMdHasContent / recentMdMtimeMs / listTodayFiles + the
|
|
118
|
+
// now/daily/weekly derive logic moved to compaction-state.mjs (the deep module
|
|
119
|
+
// that owns the verdict). detectStaleness now delegates there; only isJournalStale
|
|
120
|
+
// (an INDEPENDENT signal, not part of the compaction verdict) stays here.
|
|
136
121
|
|
|
137
122
|
const MEMORY_REL = ['context', 'memory'];
|
|
138
123
|
const DECISIONS_MD_REL = ['context', 'DECISIONS.md'];
|
|
@@ -210,66 +195,14 @@ export function detectStaleness({
|
|
|
210
195
|
dailyTtlMs = DEFAULT_DAILY_TTL_MS,
|
|
211
196
|
weeklyTtlMs = DEFAULT_WEEKLY_TTL_MS,
|
|
212
197
|
} = {}) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (!existsSync(sessionsDir)) {
|
|
222
|
-
return { action: 'no-context-dir', reason: 'sessions-dir-missing' };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Task 105 (D-75): a non-empty now.md is the now→today roll the SessionEnd
|
|
226
|
-
// hook would have done. It takes PRECEDENCE over daily/weekly because it's
|
|
227
|
-
// the FIRST pipeline level (now → today → recent → archive) — roll it this
|
|
228
|
-
// SessionStart; the today→recent + weekly levels cascade on subsequent
|
|
229
|
-
// SessionStarts once now.md is drained. (cron-active above still wins — a
|
|
230
|
-
// registered cron owns the whole pipeline.)
|
|
231
|
-
if (nowMdHasContent(projectRoot)) {
|
|
232
|
-
return { action: 'stale-now', reason: 'now-md-has-prior-session-content' };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const ts = now ?? nowIso();
|
|
236
|
-
const nowMs = new Date(ts).getTime();
|
|
237
|
-
const files = listTodayFiles(projectRoot);
|
|
238
|
-
|
|
239
|
-
// Weekly check: any today-*.md older than weeklyTtlMs by its date stamp
|
|
240
|
-
// (NOT mtime — the file's date is the canonical age signal; mtime can
|
|
241
|
-
// drift if someone touched the file).
|
|
242
|
-
const weeklyCutoffMs = nowMs - weeklyTtlMs;
|
|
243
|
-
const hasOldToday = files.some((f) => {
|
|
244
|
-
const fileMs = new Date(f.date + 'T00:00:00Z').getTime();
|
|
245
|
-
return Number.isFinite(fileMs) && fileMs < weeklyCutoffMs;
|
|
246
|
-
});
|
|
247
|
-
if (hasOldToday) {
|
|
248
|
-
return { action: 'stale-weekly', reason: 'today-file-older-than-7d' };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Task 36 I1 fix: if there are NO today-*.md files at all, the
|
|
252
|
-
// pipeline has nothing to compress — return fresh regardless of
|
|
253
|
-
// recent.md mtime. Previously this check only fired when recent.md
|
|
254
|
-
// was MISSING; for the stale-but-no-input case (e.g., right after
|
|
255
|
-
// weeklyCurate archived every today file), the daily-stale branch
|
|
256
|
-
// would fire and the SessionStart hook would spawn lazy-compress
|
|
257
|
-
// forever (no new today file means no work; dailyDistill would
|
|
258
|
-
// return skipped:no-input but not touch recent.md, so the next
|
|
259
|
-
// SessionStart sees the same stale verdict).
|
|
260
|
-
if (files.length === 0) {
|
|
261
|
-
return { action: 'fresh', reason: 'no-input' };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Daily check: recent.md missing OR older than dailyTtlMs.
|
|
265
|
-
const mtimeMs = recentMdMtimeMs(projectRoot);
|
|
266
|
-
if (mtimeMs === null) {
|
|
267
|
-
return { action: 'stale-daily', reason: 'recent-md-missing' };
|
|
268
|
-
}
|
|
269
|
-
if (nowMs - mtimeMs > dailyTtlMs) {
|
|
270
|
-
return { action: 'stale-daily', reason: 'recent-md-older-than-ttl' };
|
|
271
|
-
}
|
|
272
|
-
return { action: 'fresh', reason: 'within-ttl' };
|
|
198
|
+
// Task 167 (D-207): delegate to the compaction-state deep module. This is a
|
|
199
|
+
// thin back-compat adapter — it maps the rich `{verdict, cronStale,
|
|
200
|
+
// heartbeatAge}` return onto the legacy `{action, reason}` shape callers
|
|
201
|
+
// (inject-context, runLazyCompress) already consume. The KEY behavior change
|
|
202
|
+
// vs the old body: `cron-active` is now gated on heartbeat FRESHNESS (age), not
|
|
203
|
+
// sentinel existence, so a dead cron falls through to the stale verdicts.
|
|
204
|
+
const r = isCompactionNeeded({ projectRoot, now, dailyTtlMs, weeklyTtlMs });
|
|
205
|
+
return { action: r.verdict, reason: r.reason, cronStale: r.cronStale, heartbeatAge: r.heartbeatAge };
|
|
273
206
|
}
|
|
274
207
|
|
|
275
208
|
function writeLazyLogEntry({ projectRoot, entry }) {
|
package/src/register-crons.mjs
CHANGED
|
@@ -303,6 +303,37 @@ export function registerCron(opts = {}) {
|
|
|
303
303
|
// schtasks path + the verbatim argv). Production never passes it.
|
|
304
304
|
const spawn = opts.spawn ?? spawnSync;
|
|
305
305
|
const r = spawn(schtasksExe, argv, { encoding: 'utf8', windowsHide: true, timeout: 10_000 });
|
|
306
|
+
|
|
307
|
+
// Task 167.E (D-207): set StartWhenAvailable so a missed nightly run (laptop
|
|
308
|
+
// asleep at 23:00) runs on wake instead of being silently dropped. schtasks
|
|
309
|
+
// /Create has NO CLI flag for this (verified — not in the help); it's settable
|
|
310
|
+
// only via XML or PowerShell. We use a follow-up PowerShell Set-ScheduledTask,
|
|
311
|
+
// BEST-EFFORT: a failure here never fails registration — the lazy roll
|
|
312
|
+
// (167.A/D) is the guarantee; this is a catch-up OPTIMIZATION. NB: this only
|
|
313
|
+
// covers a missed run while the machine is OFF/asleep; all OS catch-up
|
|
314
|
+
// mechanisms COALESCE multiple missed periods into one run (research note).
|
|
315
|
+
if (r.status === 0) {
|
|
316
|
+
const psExe = join(
|
|
317
|
+
process.env.SystemRoot || process.env.windir || 'C:\\Windows',
|
|
318
|
+
'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe',
|
|
319
|
+
);
|
|
320
|
+
const psScript =
|
|
321
|
+
`try { Set-ScheduledTask -TaskName '${entryName}' ` +
|
|
322
|
+
`-Settings (New-ScheduledTaskSettingsSet -StartWhenAvailable) ` +
|
|
323
|
+
`-ErrorAction Stop | Out-Null } catch { exit 1 }`;
|
|
324
|
+
try {
|
|
325
|
+
spawn(psExe, ['-NoProfile', '-NonInteractive', '-Command', psScript], {
|
|
326
|
+
encoding: 'utf8',
|
|
327
|
+
windowsHide: true,
|
|
328
|
+
timeout: 10_000,
|
|
329
|
+
});
|
|
330
|
+
// We intentionally ignore the PS exit status — registration already
|
|
331
|
+
// succeeded; catch-up is best-effort.
|
|
332
|
+
} catch {
|
|
333
|
+
// never let the catch-up call abort a successful registration
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
306
337
|
return {
|
|
307
338
|
action: r.status === 0 ? 'registered' : 'error',
|
|
308
339
|
platform,
|