claude-mem-lite 3.65.0 → 3.66.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/format-utils.mjs +2 -1
- package/haiku-client.mjs +39 -6
- package/hook-context.mjs +5 -4
- package/hook-llm.mjs +7 -5
- package/hook-memory.mjs +4 -3
- package/hook-optimize.mjs +11 -10
- package/hook-precompact.mjs +12 -12
- package/hook-shared.mjs +122 -5
- package/hook.mjs +40 -14
- package/lib/citation-tracker.mjs +59 -5
- package/lib/db-backup.mjs +2 -1
- package/lib/deferred-work.mjs +1 -1
- package/lib/err-sampler.mjs +1 -1
- package/lib/hook-telemetry.mjs +1 -1
- package/lib/keyctx-marker.mjs +74 -0
- package/lib/maintain-core.mjs +2 -1
- package/lib/metrics.mjs +1 -1
- package/lib/save-enrich.mjs +3 -2
- package/lib/search-core.mjs +2 -1
- package/lib/stats-core.mjs +4 -3
- package/lib/stats-quality.mjs +2 -1
- package/lib/time-constants.mjs +21 -0
- package/mem-cli.mjs +6 -5
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -1
- package/registry-enricher.mjs +2 -2
- package/registry-recommend.mjs +3 -2
- package/scoring-sql.mjs +8 -7
- package/scripts/pre-tool-recall.js +2 -1
- package/scripts/user-prompt-search.js +2 -1
- package/search-scoring.mjs +2 -1
- package/server.mjs +10 -4
- package/source-files.mjs +6 -0
- package/tier.mjs +4 -3
package/hook.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS,
|
|
43
43
|
HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
|
|
44
44
|
sessionFile, getSessionId, createSessionId, openDb,
|
|
45
|
-
spawnBackground, sweepOrphanEpisodeFiles,
|
|
45
|
+
spawnBackground, sweepOrphanEpisodeFiles, sweepStaleProjectMarkers,
|
|
46
46
|
} from './hook-shared.mjs';
|
|
47
47
|
import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation, saveEpisodeImmediate } from './hook-llm.mjs';
|
|
48
48
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
@@ -54,6 +54,7 @@ import { snapshotDb } from './lib/db-backup.mjs';
|
|
|
54
54
|
import {
|
|
55
55
|
extractCitationsFromTranscript,
|
|
56
56
|
extractAllInjected,
|
|
57
|
+
extractInjectedFromKeyContext,
|
|
57
58
|
bumpCitationAccess,
|
|
58
59
|
computeCiteRecall,
|
|
59
60
|
applyCitationDecay,
|
|
@@ -69,6 +70,7 @@ import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs
|
|
|
69
70
|
import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
|
|
70
71
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
71
72
|
import { injectedIdsFileName, keyContextIdsFileName } from './lib/injected-ids.mjs';
|
|
73
|
+
import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
|
|
72
74
|
import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
|
|
73
75
|
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
74
76
|
import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
|
|
@@ -90,6 +92,7 @@ async function loadCacheGuard() {
|
|
|
90
92
|
import { SKIP_TOOLS, SKIP_PREFIXES } from './skip-tools.mjs';
|
|
91
93
|
import { getVocabulary } from './tfidf.mjs';
|
|
92
94
|
|
|
95
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
93
96
|
// Prevent recursive hooks from background claude -p calls
|
|
94
97
|
// Background workers (llm-episode, llm-summary) are exempt — they're ours
|
|
95
98
|
const event = process.argv[2];
|
|
@@ -745,7 +748,19 @@ async function handleStop() {
|
|
|
745
748
|
// edit promotes them even without a literal #NN in text.
|
|
746
749
|
const citeBackIds = extractCiteBackSignals(transcriptPath);
|
|
747
750
|
for (const id of citeBackIds) injected.add(id);
|
|
748
|
-
|
|
751
|
+
// D#124, promotion-only (v3.66.1): the SessionStart Key Context block
|
|
752
|
+
// leaves no hook attachment, so its ids come from the per-session
|
|
753
|
+
// marker. They are added to the decay set ONLY where they were
|
|
754
|
+
// actually cited (below), never as bare denominator: the block
|
|
755
|
+
// re-renders the same fixed top-10 unconditionally, so an uncited
|
|
756
|
+
// render says nothing about relevance — and since keyObs gates on
|
|
757
|
+
// `importance >= 2`, one demotion evicts the common importance-2 row
|
|
758
|
+
// from Key Context for good. v3.66.0 fed them in as denominator and
|
|
759
|
+
// that made the block eat its own contents.
|
|
760
|
+
const keyCtxIds = extractInjectedFromKeyContext({
|
|
761
|
+
runtimeDir: RUNTIME_DIR, project, sessionId: ccSessionId,
|
|
762
|
+
});
|
|
763
|
+
if (injected.size > 0 || keyCtxIds.size > 0) {
|
|
749
764
|
// Text-floor gate: skip decay on tool-only Stops. Without this,
|
|
750
765
|
// a turn that ends on tool_use locks every injected obs as
|
|
751
766
|
// uncited (last_decided_session_id set), so a later turn that
|
|
@@ -758,6 +773,10 @@ async function handleStop() {
|
|
|
758
773
|
} else {
|
|
759
774
|
const citedMain = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
|
|
760
775
|
for (const id of citeBackIds) citedMain.add(id);
|
|
776
|
+
// The promotion-only half: a Key Context row the agent actually
|
|
777
|
+
// cited joins the decay set (and takes the promote branch); one
|
|
778
|
+
// it ignored is never entered, so it cannot streak or demote.
|
|
779
|
+
for (const id of keyCtxIds) if (citedMain.has(id)) injected.add(id);
|
|
761
780
|
// D#60: the idempotency key must be the CC session UUID, NOT the
|
|
762
781
|
// project-scoped memory sessionId — concurrent same-project CC
|
|
763
782
|
// sessions share the latter, so the second session's decay pass
|
|
@@ -890,7 +909,7 @@ function gcStalePreRecallCooldowns() {
|
|
|
890
909
|
function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now }) {
|
|
891
910
|
// ── DB mutations in a transaction (crash-safe consistency) ──
|
|
892
911
|
const staleSessionCutoff = Date.now() - STALE_SESSION_MS;
|
|
893
|
-
const autoCompressAge = Date.now() - 30 *
|
|
912
|
+
const autoCompressAge = Date.now() - 30 * DAY_MS; // 30 days (accelerated from 90)
|
|
894
913
|
|
|
895
914
|
db.transaction(() => {
|
|
896
915
|
// Ensure session exists in DB (INSERT OR IGNORE avoids race condition)
|
|
@@ -945,7 +964,7 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
|
|
|
945
964
|
// imp=1 on these already; this just shrinks the GC latency so the
|
|
946
965
|
// projected 32.5% corpus reduction materializes within a week on live
|
|
947
966
|
// DBs instead of bleeding into the 30-day tier.
|
|
948
|
-
const noiseCompressAge = Date.now() - 7 *
|
|
967
|
+
const noiseCompressAge = Date.now() - 7 * DAY_MS;
|
|
949
968
|
const noiseCompressed = db.prepare(`
|
|
950
969
|
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
951
970
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
@@ -975,7 +994,7 @@ function runSessionStartAutoMaintain(db) {
|
|
|
975
994
|
} catch {}
|
|
976
995
|
if (shouldMaintain) {
|
|
977
996
|
try {
|
|
978
|
-
const STALE_AGE = Date.now() - 30 *
|
|
997
|
+
const STALE_AGE = Date.now() - 30 * DAY_MS;
|
|
979
998
|
const OP_CAP = 500;
|
|
980
999
|
// Shared maintenance context (whole-DB, cap 500) — used by every maintain-core
|
|
981
1000
|
// op below AND the MED-2 snapshot guard. injection_count>0 protection lives in
|
|
@@ -995,7 +1014,7 @@ function runSessionStartAutoMaintain(db) {
|
|
|
995
1014
|
// children (compressed_into dangling at a deleted id). purgeStale recovers them
|
|
996
1015
|
// first and caps at opCap. Schema has no marked_at_epoch, so retention anchors on
|
|
997
1016
|
// created_at_epoch: 30d marking gate + 7d grace = 37d.
|
|
998
|
-
const purged = purgeStale(db, mctx, Date.now() - 37 *
|
|
1017
|
+
const purged = purgeStale(db, mctx, Date.now() - 37 * DAY_MS);
|
|
999
1018
|
if (purged > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged} stale observations`);
|
|
1000
1019
|
|
|
1001
1020
|
const cleaned = cleanupBroken(db, mctx);
|
|
@@ -1109,7 +1128,7 @@ function runSessionStartAutoMaintain(db) {
|
|
|
1109
1128
|
DELETE FROM session_handoffs
|
|
1110
1129
|
WHERE (type = 'clear' AND created_at_epoch < ?)
|
|
1111
1130
|
OR (type != 'clear' AND created_at_epoch < ?)
|
|
1112
|
-
`).run(Date.now() - HANDOFF_EXPIRY_CLEAR -
|
|
1131
|
+
`).run(Date.now() - HANDOFF_EXPIRY_CLEAR - DAY_MS, Date.now() - HANDOFF_EXPIRY_EXIT - DAY_MS);
|
|
1113
1132
|
if (gc.changes > 0) debugLog('DEBUG', 'auto-maintain', `gc'd ${gc.changes} expired session_handoffs`);
|
|
1114
1133
|
} catch (e) { debugCatch(e, 'auto-maintain-handoff-gc'); }
|
|
1115
1134
|
|
|
@@ -1349,6 +1368,10 @@ async function handleSessionStart() {
|
|
|
1349
1368
|
// GC stale per-session cooldown files. Cheap (<5ms typical) and idempotent;
|
|
1350
1369
|
// moved here from pre-tool-recall.js's hot path.
|
|
1351
1370
|
gcStalePreRecallCooldowns();
|
|
1371
|
+
// P2-15: the per-PROJECT half of the same problem — markers written once per
|
|
1372
|
+
// project and never revisited (session-/cite-recall-/skill cooldowns). Same
|
|
1373
|
+
// SessionStart cadence, 30d gate, named family list (hook-shared.mjs).
|
|
1374
|
+
try { sweepStaleProjectMarkers(RUNTIME_DIR); } catch { /* best-effort */ }
|
|
1352
1375
|
// Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
|
|
1353
1376
|
try { gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
|
|
1354
1377
|
// Same for the opt-in metrics sink (RUNTIME_DIR's parent is DB_DIR). Runs even when
|
|
@@ -1487,12 +1510,15 @@ async function handleSessionStart() {
|
|
|
1487
1510
|
// exclude-set blanked the same-project leg on adopted projects). Written
|
|
1488
1511
|
// unconditionally (even when empty) so a resumed session can't act on a
|
|
1489
1512
|
// previous session's stale marker semantics; 24h GC below.
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1513
|
+
// D#124: the same call also bumps injection_count on the rendered rows —
|
|
1514
|
+
// Key Context was a shown-but-uncounted surface, so its rows could never
|
|
1515
|
+
// reach applyCitationDecay's denominator. One recorder, both writers.
|
|
1516
|
+
recordKeyContextInjection(db, {
|
|
1517
|
+
runtimeDir: RUNTIME_DIR,
|
|
1518
|
+
project,
|
|
1519
|
+
sessionId: ccSessionId,
|
|
1520
|
+
ids: contextCollector.keyContextIds || [],
|
|
1521
|
+
});
|
|
1496
1522
|
|
|
1497
1523
|
// One-time migration: remove any stale <claude-mem-context> block left in
|
|
1498
1524
|
// CLAUDE.md by pre-v2.30 installs. Idempotent no-op afterwards.
|
|
@@ -1797,7 +1823,7 @@ function handleAutoCompress() {
|
|
|
1797
1823
|
if (!db) return;
|
|
1798
1824
|
|
|
1799
1825
|
try {
|
|
1800
|
-
const compressCutoff = Date.now() - 60 *
|
|
1826
|
+
const compressCutoff = Date.now() - 60 * DAY_MS; // 60 days
|
|
1801
1827
|
const compressCandidates = selectCompressionCandidates(db, { cutoff: compressCutoff, includeAutoMarked: true });
|
|
1802
1828
|
if (compressCandidates.length < 3) return;
|
|
1803
1829
|
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
14
14
|
import { join } from 'path';
|
|
15
15
|
import { debugCatch } from '../utils.mjs';
|
|
16
|
+
import { keyContextIdsFileName } from './injected-ids.mjs';
|
|
16
17
|
|
|
18
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
17
19
|
// `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
|
|
18
20
|
// Bounded to 1-7 digits to skip URL fragments, markdown anchors, etc.
|
|
19
21
|
const CITATION_RE = /#(\d{1,7})\b/g;
|
|
@@ -343,9 +345,61 @@ export function extractInjectedFromFyi(transcriptPath, opts = {}) {
|
|
|
343
345
|
}
|
|
344
346
|
|
|
345
347
|
/**
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
348
|
+
* Extract observation IDs rendered into the SessionStart / PreCompact
|
|
349
|
+
* `<claude-mem-context>` File Lessons + Key Context sections (D#124).
|
|
350
|
+
*
|
|
351
|
+
* The odd one out among the extractors: this surface leaves no hook attachment
|
|
352
|
+
* in the transcript — the block is written straight to stdout at SessionStart —
|
|
353
|
+
* so there is nothing to parse. It reads the per-session marker instead, the
|
|
354
|
+
* same file handleUserPrompt uses as its exclude-set, which by construction
|
|
355
|
+
* lists what was ACTUALLY rendered (empty on quiet/adopted projects where the
|
|
356
|
+
* sections never appear).
|
|
357
|
+
*
|
|
358
|
+
* Session-gated: a marker whose recorded session differs from the caller's is
|
|
359
|
+
* another window's render and must not be attributed to this session.
|
|
360
|
+
*
|
|
361
|
+
* PROMOTION-ONLY (v3.66.1). Deliberately NOT part of extractAllInjected: the
|
|
362
|
+
* other four faces are query-conditioned — a row appears there because it
|
|
363
|
+
* MATCHED something, so its absence from the cited set is evidence it was not
|
|
364
|
+
* useful. A Key Context render is unconditional and re-renders the same fixed
|
|
365
|
+
* top-10 every session, so an uncited render is evidence of nothing but elapsed
|
|
366
|
+
* time. Feeding these ids into the decay DENOMINATOR made the block consume
|
|
367
|
+
* itself: keyObs gates on `importance >= 2` (hook-context.mjs), and a demotion
|
|
368
|
+
* takes the common importance-2 row to 1, dropping it out of Key Context
|
|
369
|
+
* permanently after 3 uncited sessions — each departure promoting the next row
|
|
370
|
+
* into the same grinder. Callers must therefore intersect with the cited set
|
|
371
|
+
* (see handleStop) so a CITED Key Context row is credited while an uncited one
|
|
372
|
+
* is left alone.
|
|
373
|
+
*
|
|
374
|
+
* @param {object} [ctx]
|
|
375
|
+
* @param {string} [ctx.runtimeDir]
|
|
376
|
+
* @param {string} [ctx.project]
|
|
377
|
+
* @param {string|null} [ctx.sessionId]
|
|
378
|
+
* @returns {Set<number>}
|
|
379
|
+
*/
|
|
380
|
+
export function extractInjectedFromKeyContext({ runtimeDir, project, sessionId = null } = {}) {
|
|
381
|
+
const ids = new Set();
|
|
382
|
+
if (!runtimeDir || !project) return ids;
|
|
383
|
+
try {
|
|
384
|
+
const raw = readFileSync(join(runtimeDir, keyContextIdsFileName(project, sessionId)), 'utf8');
|
|
385
|
+
const parsed = JSON.parse(raw);
|
|
386
|
+
if (sessionId && parsed?.session && parsed.session !== sessionId) return ids;
|
|
387
|
+
for (const id of Array.isArray(parsed?.ids) ? parsed.ids : []) addObsId(ids, id);
|
|
388
|
+
} catch { /* no marker / torn write → nothing was rendered, fail open */ }
|
|
389
|
+
return ids;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Union of the QUERY-CONDITIONED injection surfaces for a transcript:
|
|
394
|
+
* pre-tool-recall + UserPromptSubmit `<memory-context>` + PostToolUse
|
|
395
|
+
* error-recall + the user-prompt-search FYI block. Single integration point the
|
|
396
|
+
* Stop handler calls for the decay DENOMINATOR.
|
|
397
|
+
*
|
|
398
|
+
* Key Context is intentionally absent (v3.66.1 — it was unioned here for one
|
|
399
|
+
* release): every face above appears because a row matched something, so an
|
|
400
|
+
* uncited appearance carries relevance information. An unconditional
|
|
401
|
+
* SessionStart render does not. Callers wanting the Key Context ids ask
|
|
402
|
+
* `extractInjectedFromKeyContext` directly and use them promotion-only.
|
|
349
403
|
*
|
|
350
404
|
* @param {string|null|undefined} transcriptPath
|
|
351
405
|
* @param {object} [opts]
|
|
@@ -808,8 +862,8 @@ export function computeCitationFunnelTrend(db, { days = 7, limit = 10, project =
|
|
|
808
862
|
if (!db) return empty;
|
|
809
863
|
try {
|
|
810
864
|
const now = Date.now();
|
|
811
|
-
const windowStart = now - days *
|
|
812
|
-
const priorStart = now - 2 * days *
|
|
865
|
+
const windowStart = now - days * DAY_MS;
|
|
866
|
+
const priorStart = now - 2 * days * DAY_MS;
|
|
813
867
|
const projClause = project ? 'AND project = ?' : '';
|
|
814
868
|
|
|
815
869
|
const sessions = db.prepare(`
|
package/lib/db-backup.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { readdirSync, unlinkSync, statSync } from 'fs';
|
|
|
11
11
|
import { dirname, basename, join } from 'path';
|
|
12
12
|
import { debugLog } from '../utils.mjs';
|
|
13
13
|
|
|
14
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
14
15
|
// M-9 (audit 2026-08-14): per-tag retention alone let one-shot tags live forever —
|
|
15
16
|
// each tag kept its newest 3, but a tag written once (pre-backfill-v3390 and four
|
|
16
17
|
// siblings) never got a second snapshot to age it out, so 9 orphaned .bak files held
|
|
@@ -35,7 +36,7 @@ export function backupBudgetBytes() {
|
|
|
35
36
|
// a per-tag exemption would make every one-shot tag's only snapshot immortal —
|
|
36
37
|
// exactly the 360MB orphan shape M-9 exists to evict. Mirrors the purge grace
|
|
37
38
|
// convention (7d) used by stale-observation retention.
|
|
38
|
-
export const BACKUP_EVICTION_GRACE_MS = 7 *
|
|
39
|
+
export const BACKUP_EVICTION_GRACE_MS = 7 * DAY_MS;
|
|
39
40
|
|
|
40
41
|
// Canonical snapshot shape `<base>.<tag>-<ISO stamp>-<pid>-<seq>.bak` (what
|
|
41
42
|
// snapshotDb writes). Budget eviction deletes ONLY names matching this — a
|
package/lib/deferred-work.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
1
2
|
// claude-mem-lite — deferred_work data layer
|
|
2
3
|
// Pure-data CRUD + ordinal resolver + transactional closure helper.
|
|
3
4
|
// Decoupled from observations table: different lifecycle, different scoring.
|
|
@@ -60,7 +61,6 @@ export function listOpenWithOrdinal(db, project, limit = 10) {
|
|
|
60
61
|
|
|
61
62
|
// ─── G11: list age + stale refresh hint (roadmap 2026-07-18) ─────────────────
|
|
62
63
|
|
|
63
|
-
const DAY_MS = 86_400_000;
|
|
64
64
|
// Internal const (was exported at v3.51.0 birth with zero external importers —
|
|
65
65
|
// knip-baseline discipline: un-export rather than grow the unused-exports list).
|
|
66
66
|
const DEFER_STALE_DAYS = 30;
|
package/lib/err-sampler.mjs
CHANGED
|
@@ -19,8 +19,8 @@ import { appendFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSyn
|
|
|
19
19
|
import { join } from 'path';
|
|
20
20
|
import { scrubSecrets } from '../secret-scrub.mjs';
|
|
21
21
|
|
|
22
|
-
const DAY_MS = 86400000;
|
|
23
22
|
|
|
23
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
24
24
|
function today() {
|
|
25
25
|
// UTC date string; sharding daily keeps files bounded even at high sample rates.
|
|
26
26
|
return new Date(Date.now()).toISOString().slice(0, 10);
|
package/lib/hook-telemetry.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { join } from 'path';
|
|
|
26
26
|
import { isNativeBindingError } from './binding-probe.mjs';
|
|
27
27
|
import { recordNativeBindingBreakage } from './native-binding-hint.mjs';
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
30
30
|
const RETENTION_MS = 14 * DAY_MS;
|
|
31
31
|
const HOOK_ERRORS_SUBDIR = 'hook-errors';
|
|
32
32
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// lib/keyctx-marker.mjs — the one place the SessionStart Key Context render is
|
|
2
|
+
// recorded. Two things happen together because they must describe the SAME set:
|
|
3
|
+
//
|
|
4
|
+
// ① the per-session marker file (D#123 review C-1): the obs ids ACTUALLY
|
|
5
|
+
// rendered into <claude-mem-context>, which handleUserPrompt reads as its
|
|
6
|
+
// exclude-set. Written even when empty, so a resumed session can never act
|
|
7
|
+
// on a previous session's stale semantics.
|
|
8
|
+
// ② injection_count / last_injected_at on those same rows (D#124): before
|
|
9
|
+
// this, Key Context was a shown-but-uncounted surface — up to 10 rows per
|
|
10
|
+
// session that could accrue no denominator and therefore never promote or
|
|
11
|
+
// demote through applyCitationDecay.
|
|
12
|
+
//
|
|
13
|
+
// handleSessionStart (hook.mjs) and handlePreCompact (hook-precompact.mjs) both
|
|
14
|
+
// render the block, so both call this. Keeping the pair in one function is the
|
|
15
|
+
// point: the CLI/MCP and SessionStart/PreCompact twin pairs in this repo have
|
|
16
|
+
// drifted often enough that "two callers, one body" is the standing rule.
|
|
17
|
+
//
|
|
18
|
+
// Never throws: a marker-write failure must not break context delivery, and it
|
|
19
|
+
// must not cost the metering either — the bump runs first.
|
|
20
|
+
|
|
21
|
+
import { writeFileSync } from 'fs';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
import { debugCatch } from '../utils.mjs';
|
|
24
|
+
import { keyContextIdsFileName } from './injected-ids.mjs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Record one Key Context render: bump the rendered rows, then persist the id
|
|
28
|
+
* list for the prompt-time exclude-set and the citation extractor.
|
|
29
|
+
*
|
|
30
|
+
* @param {import('better-sqlite3').Database} db
|
|
31
|
+
* @param {object} ctx
|
|
32
|
+
* @param {string} ctx.runtimeDir RUNTIME_DIR of the caller
|
|
33
|
+
* @param {string} ctx.project inferProject() value (filename-safe)
|
|
34
|
+
* @param {string|null} [ctx.sessionId] CC session id
|
|
35
|
+
* @param {number[]} [ctx.ids] obs ids ACTUALLY rendered (empty on quiet projects)
|
|
36
|
+
* @returns {{bumped: number, written: boolean}}
|
|
37
|
+
*/
|
|
38
|
+
export function recordKeyContextInjection(db, { runtimeDir, project, sessionId = null, ids = [] } = {}) {
|
|
39
|
+
const clean = [];
|
|
40
|
+
for (const raw of Array.isArray(ids) ? ids : []) {
|
|
41
|
+
const id = Number(raw);
|
|
42
|
+
if (Number.isInteger(id) && id > 0 && id < 1e7) clean.push(id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// NO injection_count bump. v3.66.0 added one here and it had to be reverted in
|
|
46
|
+
// v3.66.1: injection_count is not a neutral counter. scoring-sql.mjs states the
|
|
47
|
+
// invariant — "bumped ONLY on UserPromptSubmit / hook-memory auto-inject" —
|
|
48
|
+
// because noisePenaltyClause reads it as a NOISE signal: a row scores x0.5 once
|
|
49
|
+
// injection_count >= 4 (and > access_count * 3), x0.2 at >= 8. Nothing bumps
|
|
50
|
+
// access_count for a rendered row (bumpCitationAccess fires on CITED ids only),
|
|
51
|
+
// so the counter crosses those thresholds purely as a function of elapsed
|
|
52
|
+
// sessions, deprioritising the highest-importance rows — the exact rows Key
|
|
53
|
+
// Context renders — in mem_search, UPS ranking and injectionRelevanceSql.
|
|
54
|
+
//
|
|
55
|
+
// The UPS bump the reverted code claimed to "mirror verbatim" is
|
|
56
|
+
// query-conditioned: a row is counted only when it MATCHED a query, so the
|
|
57
|
+
// counter means "auto-injected and never useful". A Key Context render is
|
|
58
|
+
// unconditional and hits the same fixed row set every session, so it would have
|
|
59
|
+
// measured nothing but time. D#124's requirement is decay reachability, which
|
|
60
|
+
// the extractor face delivers on its own — decay reads decay_seen_count /
|
|
61
|
+
// uncited_streak / cited_count, never injection_count.
|
|
62
|
+
const bumped = 0;
|
|
63
|
+
|
|
64
|
+
let written = false;
|
|
65
|
+
try {
|
|
66
|
+
writeFileSync(
|
|
67
|
+
join(runtimeDir, keyContextIdsFileName(project, sessionId)),
|
|
68
|
+
JSON.stringify({ ids: clean, ts: Date.now(), session: sessionId || null }),
|
|
69
|
+
);
|
|
70
|
+
written = true;
|
|
71
|
+
} catch (e) { debugCatch(e, 'keyctx-marker-write'); }
|
|
72
|
+
|
|
73
|
+
return { bumped, written };
|
|
74
|
+
}
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -17,7 +17,8 @@ import { rebuildVocabulary, computeVector, _resetVocabCache, vectorsEnabled, vec
|
|
|
17
17
|
import { DEDUP_JACCARD_THRESHOLD, MINHASH_PRE_THRESHOLD as MINHASH_PRE_THRESHOLD_SRC, FUZZY_DEDUP_THRESHOLD, FUZZY_BODY_THRESHOLD, MINHASH_PREFILTER } from './dedup-constants.mjs';
|
|
18
18
|
import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
21
|
+
export const STALE_AGE_MS = 30 * DAY_MS;
|
|
21
22
|
export const OP_CAP = 1000;
|
|
22
23
|
export const SCAN_LIMIT = 500;
|
|
23
24
|
export const DUPLICATE_LIMIT = 50;
|
package/lib/metrics.mjs
CHANGED
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'fs';
|
|
20
20
|
import { join } from 'path';
|
|
21
21
|
|
|
22
|
-
const DAY_MS = 86400000;
|
|
23
22
|
|
|
23
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
24
24
|
function today() { return new Date(Date.now()).toISOString().slice(0, 10); }
|
|
25
25
|
|
|
26
26
|
function metricsEnabled() {
|
package/lib/save-enrich.mjs
CHANGED
|
@@ -84,7 +84,8 @@ export async function executeSaveEnrich(db, id, { callJson } = {}) {
|
|
|
84
84
|
const wantAliases = !(row.search_aliases && row.search_aliases.trim());
|
|
85
85
|
if (!wantLesson && !wantAliases) return { enriched: false, reason: 'complete' };
|
|
86
86
|
|
|
87
|
-
const
|
|
87
|
+
const { callModelJSON, BG_LLM_TIMEOUT_MS } = await import('../haiku-client.mjs');
|
|
88
|
+
const call = callJson || callModelJSON;
|
|
88
89
|
const prompt = `A coding memory was just saved. Enrich its retrievability. Return ONLY valid JSON, no markdown fences.
|
|
89
90
|
|
|
90
91
|
Title: ${truncate(row.title || '(untitled)', 200)}
|
|
@@ -96,7 +97,7 @@ search_aliases: 3-6 terms a user might search for the SAME memory that are NOT a
|
|
|
96
97
|
|
|
97
98
|
let parsed = null;
|
|
98
99
|
try {
|
|
99
|
-
parsed = await call(prompt, 'haiku', { timeout:
|
|
100
|
+
parsed = await call(prompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 400 });
|
|
100
101
|
} catch (e) { debugCatch(e, 'save-enrich-llm'); }
|
|
101
102
|
if (!parsed || typeof parsed !== 'object') return { enriched: false, reason: 'llm-null' };
|
|
102
103
|
|
package/lib/search-core.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { countSearchTotal, attachBodyTokens } from '../search-engine.mjs';
|
|
|
31
31
|
import { notLowSignalTitleClause } from '../scoring-sql.mjs';
|
|
32
32
|
import { liveObsFilterSql, recencyDecaySql } from './inject-search-core.mjs';
|
|
33
33
|
|
|
34
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
34
35
|
/** Sanitize a user query to FTS5 syntax; optionally force OR semantics. */
|
|
35
36
|
export function buildSearchFtsQuery(query, { or = false } = {}) {
|
|
36
37
|
let ftsQuery = sanitizeFtsQuery(query);
|
|
@@ -40,7 +41,7 @@ export function buildSearchFtsQuery(query, { or = false } = {}) {
|
|
|
40
41
|
|
|
41
42
|
// Relative-duration units for `--since`. Months/years intentionally omitted —
|
|
42
43
|
// they're calendar-ambiguous; absolute --from/--to cover longer windows.
|
|
43
|
-
const DURATION_UNIT_MS = { s: 1000, m: 60000, h: 3600000, d:
|
|
44
|
+
const DURATION_UNIT_MS = { s: 1000, m: 60000, h: 3600000, d: DAY_MS, w: 604800000 };
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Parse a relative duration like "7d", "24h", "90m", "2w", "30s" into ms.
|
package/lib/stats-core.mjs
CHANGED
|
@@ -12,13 +12,14 @@ import { TIER_CASE_SQL, tierSqlParams } from '../tier.mjs';
|
|
|
12
12
|
import { computeNoiseGauge } from './stats-quality.mjs';
|
|
13
13
|
import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
14
14
|
|
|
15
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
15
16
|
/**
|
|
16
17
|
* Compute the primary stats feed. Row shapes are returned exactly as the twin
|
|
17
18
|
* blocks produced them ({c}/{t}/{v} single-row objects, arrays for distributions)
|
|
18
19
|
* so both call sites render with minimal diff.
|
|
19
20
|
*/
|
|
20
21
|
export function computeStatsFeed(db, { project = null, days = 30, now = Date.now() } = {}) {
|
|
21
|
-
const cutoff = now - days *
|
|
22
|
+
const cutoff = now - days * DAY_MS;
|
|
22
23
|
const projectFilter = project ? 'AND project = ?' : '';
|
|
23
24
|
const baseParams = project ? [project] : [];
|
|
24
25
|
|
|
@@ -51,7 +52,7 @@ export function computeStatsFeed(db, { project = null, days = 30, now = Date.now
|
|
|
51
52
|
SELECT date(created_at) as day, COUNT(*) as c FROM observations
|
|
52
53
|
WHERE created_at_epoch >= ? ${projectFilter}
|
|
53
54
|
GROUP BY day ORDER BY day DESC LIMIT 7
|
|
54
|
-
`).all(now - 7 *
|
|
55
|
+
`).all(now - 7 * DAY_MS, ...baseParams);
|
|
55
56
|
|
|
56
57
|
// Data health
|
|
57
58
|
const tokenEst = db.prepare(`
|
|
@@ -60,7 +61,7 @@ export function computeStatsFeed(db, { project = null, days = 30, now = Date.now
|
|
|
60
61
|
`).get(...baseParams);
|
|
61
62
|
const avgImp = db.prepare(`SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
|
|
62
63
|
|
|
63
|
-
const thirtyDaysAgo = now - 30 *
|
|
64
|
+
const thirtyDaysAgo = now - 30 * DAY_MS;
|
|
64
65
|
// v3.23 noise-gauge de-blinding: `<= 1` makes the imp=0 dormant population visible
|
|
65
66
|
// (decay floor + LLM low-signal filter push ~half the live corpus to 0);
|
|
66
67
|
// injection_count=0 mirrors decay's NEVER-INJECTED guard so injected-but-decayed
|
package/lib/stats-quality.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { buildNotLowSignalSql } from './low-signal-patterns.mjs';
|
|
|
7
7
|
import { truncate } from '../format-utils.mjs';
|
|
8
8
|
import { COMPRESSED_PENDING_PURGE } from '../utils.mjs';
|
|
9
9
|
|
|
10
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
10
11
|
// v3.42 F7: noise/low-signal ratios must divide by the LIVE (non-compressed) observation
|
|
11
12
|
// count, NOT the all-rows total. Both numerators (lowVal, lowSignalTitle) already exclude
|
|
12
13
|
// compressed rows (`compressed_into = 0`); dividing by an all-inclusive denominator
|
|
@@ -25,7 +26,7 @@ export function computeNoiseGauge({ liveTotal, lowValCount, lowSignalCount }) {
|
|
|
25
26
|
export function computeQualityStats(db, { project, days }) {
|
|
26
27
|
const projectFilter = project ? 'AND project = ?' : '';
|
|
27
28
|
const baseParams = project ? [project] : [];
|
|
28
|
-
const cutoff = Date.now() - days *
|
|
29
|
+
const cutoff = Date.now() - days * DAY_MS;
|
|
29
30
|
|
|
30
31
|
// LOW_SIGNAL match = NOT notLowSignal. Pure title-only builder: this is a
|
|
31
32
|
// METRIC counting pattern-titled rows ("Low-signal titles" in stats output),
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// lib/time-constants.mjs — millisecond time units, defined once.
|
|
2
|
+
//
|
|
3
|
+
// Four modules each carried their own `const DAY_MS` (deferred-work, metrics,
|
|
4
|
+
// err-sampler, hook-telemetry) with ~60 more bare 86400000 literals scattered
|
|
5
|
+
// across the runtime. The value cannot drift — that is not the problem. The
|
|
6
|
+
// problem is that a reader auditing a window ("is this 30 days or 30 hours?")
|
|
7
|
+
// had to re-derive the magic number at every site, and a grep for the policy
|
|
8
|
+
// returned a number rather than a name.
|
|
9
|
+
//
|
|
10
|
+
// Zero imports on purpose: lib/metrics.mjs and lib/err-sampler.mjs are sinks
|
|
11
|
+
// that must never crash or slow their callers, so anything they import has to
|
|
12
|
+
// stay a leaf.
|
|
13
|
+
|
|
14
|
+
// Kept internal rather than exported: only DAY_MS has runtime consumers today,
|
|
15
|
+
// and an export born with zero importers is how the knip baseline drifted
|
|
16
|
+
// 46→47 during v3.50–3.53 (DEFER_STALE_DAYS). Promote one when a caller needs it.
|
|
17
|
+
const SECOND_MS = 1000;
|
|
18
|
+
const MINUTE_MS = 60 * SECOND_MS;
|
|
19
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
20
|
+
|
|
21
|
+
export const DAY_MS = 24 * HOUR_MS;
|
package/mem-cli.mjs
CHANGED
|
@@ -1197,7 +1197,7 @@ async function cmdStats(db, args) {
|
|
|
1197
1197
|
// recorded in the last 24h. Surfaces silent breakage (DB corruption,
|
|
1198
1198
|
// CC upstream field rename) that would otherwise stay invisible — the
|
|
1199
1199
|
// failure mode that left code-graph's matcher bug undetected for 10 sessions.
|
|
1200
|
-
const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now -
|
|
1200
|
+
const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - DAY_MS);
|
|
1201
1201
|
|
|
1202
1202
|
// M-9 (audit 2026-08-14): disk footprint — a "lite" store had accumulated 360MB of
|
|
1203
1203
|
// pre-maintain snapshots against a 59MB DB with nothing reporting it. Cheap probes
|
|
@@ -1636,7 +1636,7 @@ function cmdExport(db, args) {
|
|
|
1636
1636
|
if (flags.to) {
|
|
1637
1637
|
exportToEpoch = new Date(flags.to).getTime();
|
|
1638
1638
|
if (isNaN(exportToEpoch)) { fail(`[mem] Invalid --to date: "${flags.to}". Use YYYY-MM-DD or ISO 8601.`); return; }
|
|
1639
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(flags.to)) exportToEpoch +=
|
|
1639
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(flags.to)) exportToEpoch += DAY_MS - 1;
|
|
1640
1640
|
wheres.push('created_at_epoch <= ?'); params.push(exportToEpoch);
|
|
1641
1641
|
}
|
|
1642
1642
|
if (exportFromEpoch !== null && exportToEpoch !== null && exportFromEpoch > exportToEpoch) {
|
|
@@ -1883,7 +1883,7 @@ function cmdCompress(db, args) {
|
|
|
1883
1883
|
}
|
|
1884
1884
|
ageDays = parsed;
|
|
1885
1885
|
}
|
|
1886
|
-
const cutoff = Date.now() - ageDays *
|
|
1886
|
+
const cutoff = Date.now() - ageDays * DAY_MS;
|
|
1887
1887
|
const project = flags.project ? resolveProject(db, flags.project) : null;
|
|
1888
1888
|
|
|
1889
1889
|
const candidates = selectCompressionCandidates(db, { cutoff, project });
|
|
@@ -2021,7 +2021,7 @@ function cmdMaintain(db, args) {
|
|
|
2021
2021
|
}
|
|
2022
2022
|
retainDays = parsed;
|
|
2023
2023
|
}
|
|
2024
|
-
const retainCutoff = Date.now() - retainDays *
|
|
2024
|
+
const retainCutoff = Date.now() - retainDays * DAY_MS;
|
|
2025
2025
|
// purge_stale is the only DELETE here — require --confirm so a mis-typed run can't wipe rows.
|
|
2026
2026
|
const confirmed = flags.confirm === true || flags.confirm === 'true';
|
|
2027
2027
|
|
|
@@ -2592,7 +2592,7 @@ function cmdCitationStats(db, args) {
|
|
|
2592
2592
|
out(`Recently demoted (last ${days}d, importance ↓):`);
|
|
2593
2593
|
if (demoted.length === 0) out(' (none)');
|
|
2594
2594
|
for (const r of demoted) {
|
|
2595
|
-
const ago = Math.round((Date.now() - r.demoted_at) /
|
|
2595
|
+
const ago = Math.round((Date.now() - r.demoted_at) / DAY_MS);
|
|
2596
2596
|
out(` #${r.id} [${r.type}] ${(r.title || '').slice(0, 60)} imp=${r.importance} ${ago}d ago`);
|
|
2597
2597
|
}
|
|
2598
2598
|
}
|
|
@@ -3140,6 +3140,7 @@ import { cmdDoctor } from './cli/doctor.mjs';
|
|
|
3140
3140
|
// cmdActivity (T7 v2.31) extracted to cli/activity.mjs (v2.41 split).
|
|
3141
3141
|
import { cmdActivity } from './cli/activity.mjs';
|
|
3142
3142
|
|
|
3143
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
3143
3144
|
// ─── Main Entry Point ────────────────────────────────────────────────────────
|
|
3144
3145
|
|
|
3145
3146
|
export async function run(argv) {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.66.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.66.1",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.66.1",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -83,6 +83,8 @@
|
|
|
83
83
|
"lib/release-digest.mjs",
|
|
84
84
|
"lib/mem-override.mjs",
|
|
85
85
|
"lib/injected-ids.mjs",
|
|
86
|
+
"lib/time-constants.mjs",
|
|
87
|
+
"lib/keyctx-marker.mjs",
|
|
86
88
|
"lib/inject-search-core.mjs",
|
|
87
89
|
"lib/get-core.mjs",
|
|
88
90
|
"lib/browse-core.mjs",
|
package/registry-enricher.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Sends resource content to Haiku for semantic metadata generation
|
|
3
3
|
// Graceful degradation: failure preserves existing data
|
|
4
4
|
|
|
5
|
-
import { callHaikuJSON } from './haiku-client.mjs';
|
|
5
|
+
import { callHaikuJSON, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
6
6
|
import { truncate, debugCatch } from './utils.mjs';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -84,7 +84,7 @@ export async function enrichResource(db, name, type, content) {
|
|
|
84
84
|
|
|
85
85
|
try {
|
|
86
86
|
const prompt = buildEnrichPrompt(name, content, existing);
|
|
87
|
-
const result = await callHaikuJSON(prompt, { timeout:
|
|
87
|
+
const result = await callHaikuJSON(prompt, { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 500 });
|
|
88
88
|
|
|
89
89
|
if (!result || !result.capability_summary) {
|
|
90
90
|
db.prepare("UPDATE resources SET enrichment_status = 'failed' WHERE name = ? AND type = ?").run(name, type);
|
package/registry-recommend.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { join } from 'path';
|
|
|
8
8
|
import { resolveDataDir } from './lib/resolve-data-dir.mjs';
|
|
9
9
|
import { searchResources, cjkIntentTokens } from './registry-retriever.mjs';
|
|
10
10
|
|
|
11
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
11
12
|
const VALID_MODES = new Set(['shadow', 'live', 'off']);
|
|
12
13
|
|
|
13
14
|
/** Recommendation mode from env; default 'shadow', unknown → 'shadow'. */
|
|
@@ -137,7 +138,7 @@ export function gcOldShadowShards(retainDays = 90) {
|
|
|
137
138
|
try {
|
|
138
139
|
const dir = shadowDir();
|
|
139
140
|
if (!existsSync(dir)) return 0;
|
|
140
|
-
const cutoff = new Date(Date.now() - retainDays *
|
|
141
|
+
const cutoff = new Date(Date.now() - retainDays * DAY_MS).toISOString().slice(0, 10);
|
|
141
142
|
let removed = 0;
|
|
142
143
|
for (const name of readdirSync(dir)) {
|
|
143
144
|
const m = /^(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
|
|
@@ -155,7 +156,7 @@ export function logShadowAdoption(project, rec) { appendShadow({ ts: new Date().
|
|
|
155
156
|
/** Yield parsed shadow rows from the last `days` daily shards. */
|
|
156
157
|
export function* readShadowLog(days = 7) {
|
|
157
158
|
for (let i = 0; i < days; i++) {
|
|
158
|
-
const d = new Date(Date.now() - i *
|
|
159
|
+
const d = new Date(Date.now() - i * DAY_MS).toISOString().slice(0, 10);
|
|
159
160
|
let raw;
|
|
160
161
|
try { raw = readFileSync(join(shadowDir(), `${d}.jsonl`), 'utf8'); } catch { continue; }
|
|
161
162
|
for (const line of raw.split('\n')) { if (!line) continue; try { yield JSON.parse(line); } catch { /* skip malformed */ } }
|