claude-mem-lite 3.35.2 → 3.37.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/bash-utils.mjs +7 -1
- package/deep-search.mjs +13 -6
- package/format-utils.mjs +6 -2
- package/haiku-client.mjs +16 -10
- package/hook-context.mjs +12 -1
- package/hook-handoff.mjs +9 -3
- package/hook-llm.mjs +116 -23
- package/hook-memory.mjs +8 -2
- package/hook-optimize.mjs +45 -8
- package/hook-shared.mjs +22 -9
- package/hook-update.mjs +5 -2
- package/hook.mjs +31 -16
- package/install.mjs +33 -11
- package/lib/atomic-write.mjs +19 -8
- package/lib/citation-tracker.mjs +5 -2
- package/lib/deferred-work.mjs +3 -1
- package/lib/doctor-benchmark.mjs +6 -1
- package/lib/maintain-core.mjs +12 -0
- package/lib/task-imperative.mjs +4 -2
- package/mem-cli.mjs +57 -28
- package/nlp.mjs +36 -4
- package/package.json +1 -2
- package/registry-recommend.mjs +55 -14
- package/registry.mjs +19 -7
- package/schema.mjs +55 -4
- package/scripts/post-tool-use.sh +10 -1
- package/search-engine.mjs +5 -1
- package/search-scoring.mjs +10 -0
- package/secret-scrub.mjs +17 -4
- package/source-files.mjs +1 -1
- package/tool-schemas.mjs +2 -2
- package/utils.mjs +5 -1
- package/registry-indexer.mjs +0 -175
package/hook.mjs
CHANGED
|
@@ -118,12 +118,19 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
|
118
118
|
try {
|
|
119
119
|
const ep = readEpisodeRaw();
|
|
120
120
|
if (ep && ep.entries && ep.entries.length > 0) {
|
|
121
|
-
// Persist a rule-based observation synchronously
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
121
|
+
// Persist a rule-based observation synchronously — the ONLY thing that
|
|
122
|
+
// salvages the in-flight episode on abnormal termination (audit #6). A
|
|
123
|
+
// detached llm-episode child can't be spawned from a dying process, so no
|
|
124
|
+
// ep-flush-* file is written here: it would have NO consumer AND would make
|
|
125
|
+
// every later handleLLMSummary poll the full CLAUDE_MEM_FLUSH_TIMEOUT (~15s)
|
|
126
|
+
// waiting for a file that only the 24h orphan-sweep ever removes.
|
|
127
|
+
// Split by CC session first (v3.35.2 parity): the normal flush and the Stop
|
|
128
|
+
// lock-contended fallback both planEpisodeFlush, but this crash path flushed the
|
|
129
|
+
// WHOLE buffer as one observation, co-attributing two interleaved same-project
|
|
130
|
+
// sessions into one garbled row. planEpisodeFlush returns [ep] by reference when
|
|
131
|
+
// there is ≤1 CC session (the common case → identical to before), else one sub
|
|
132
|
+
// per session. Pure/sync → safe inside the signal handler.
|
|
133
|
+
for (const sub of planEpisodeFlush(ep)) saveEpisodeImmediate(sub);
|
|
127
134
|
try { unlinkSync(join(RUNTIME_DIR, `ep-${inferProject()}.json`)); } catch {}
|
|
128
135
|
}
|
|
129
136
|
} catch {}
|
|
@@ -468,16 +475,24 @@ async function handleStop() {
|
|
|
468
475
|
if (episode && episode.entries && episode.entries.length > 0 && episodeHasSignificantContent(episode)) {
|
|
469
476
|
if (!episode.sessionId) episode.sessionId = sessionId;
|
|
470
477
|
if (!episode.project) episode.project = project;
|
|
471
|
-
//
|
|
472
|
-
// Without this,
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
478
|
+
// Split by CC session before saving — parity with flushEpisode's non-contended path
|
|
479
|
+
// (v3.35.2). Without this, the lock-contended fallback re-merged exactly the interleaved
|
|
480
|
+
// concurrent-session buffers v3.35.2 split apart, co-attributing two sessions' work into
|
|
481
|
+
// one garbled observation. planEpisodeFlush returns [episode] by reference for the common
|
|
482
|
+
// single-session case, so this is a no-op there. Immediate-save each group BEFORE its
|
|
483
|
+
// flush-file write (same ordering as flushEpisodeGroup) so a worker crash can't lose it.
|
|
484
|
+
for (const sub of planEpisodeFlush(episode)) {
|
|
485
|
+
if (!sub.sessionId) sub.sessionId = sessionId;
|
|
486
|
+
if (!sub.project) sub.project = project;
|
|
487
|
+
try {
|
|
488
|
+
const obs = buildImmediateObservation(sub);
|
|
489
|
+
const id = saveObservation(obs, sub.project, sub.sessionId);
|
|
490
|
+
if (id) sub.savedId = id;
|
|
491
|
+
} catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
|
|
492
|
+
const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
|
|
493
|
+
writeFileSync(flushFile, JSON.stringify(sub));
|
|
494
|
+
spawnBackground('llm-episode', flushFile);
|
|
495
|
+
}
|
|
481
496
|
}
|
|
482
497
|
} finally {
|
|
483
498
|
try { unlinkSync(claimFile); } catch {}
|
package/install.mjs
CHANGED
|
@@ -1979,30 +1979,52 @@ async function manualUpdate() {
|
|
|
1979
1979
|
// latest code even when local install.mjs / hook-update.mjs are themselves
|
|
1980
1980
|
// buggy on disk.
|
|
1981
1981
|
async function repair() {
|
|
1982
|
-
console.log('\nclaude-mem-lite repair — re-syncing from latest GitHub release\n');
|
|
1982
|
+
console.log('\nclaude-mem-lite repair — re-syncing from the latest SIGNED GitHub release\n');
|
|
1983
1983
|
const stagingDir = mkdtempSync(join(tmpdir(), 'claude-mem-lite-repair-'));
|
|
1984
1984
|
try {
|
|
1985
|
-
|
|
1985
|
+
// Resolve the latest RELEASE (tag) and cryptographically VERIFY it before running any
|
|
1986
|
+
// downloaded code — parity with the auto-update path (hook-update.downloadAndInstall).
|
|
1987
|
+
// The old code fetched `/tarball` (default-branch main HEAD, unreleased WIP) and ran its
|
|
1988
|
+
// install.mjs UNVERIFIED, and this path is auto-triggered by hook-launcher on any
|
|
1989
|
+
// ERR_MODULE_NOT_FOUND — so a drifted install silently self-healed onto main, and a
|
|
1990
|
+
// repo/TLS-MITM compromise achieved RCE, bypassing the Ed25519 signed-release control that
|
|
1991
|
+
// the manual `update` path enforces. Lazy import so a missing/broken hook-update dependency
|
|
1992
|
+
// degrades to the manual fallback (fail-closed) rather than to unverified auto-install.
|
|
1993
|
+
let fetchLatestRelease, verifyReleaseAuthenticity;
|
|
1994
|
+
try {
|
|
1995
|
+
({ fetchLatestRelease, verifyReleaseAuthenticity } = await import('./hook-update.mjs'));
|
|
1996
|
+
} catch (e) {
|
|
1997
|
+
throw new Error(`cannot load the verified-update path (${e.message}) — refusing to auto-install unverified code`, { cause: e });
|
|
1998
|
+
}
|
|
1999
|
+
const rel = await fetchLatestRelease();
|
|
2000
|
+
if (!rel || !rel.tarballUrl) throw new Error('could not resolve the latest release (network / rate-limit)');
|
|
2001
|
+
// URL allow-list mirrors hook-update.downloadAndInstall — only github.com tarball URLs.
|
|
2002
|
+
if (!/^https:\/\/(?:api\.)?github\.com\/[a-zA-Z0-9./_-]+$/.test(rel.tarballUrl)) {
|
|
2003
|
+
throw new Error(`refusing suspicious tarball URL: ${rel.tarballUrl}`);
|
|
2004
|
+
}
|
|
1986
2005
|
const tarballPath = join(stagingDir, 'release.tgz');
|
|
1987
|
-
log(
|
|
1988
|
-
execFileSync('curl', ['-sL', '-f', '-H', 'Accept: application/vnd.github+json', tarballUrl, '-o', tarballPath],
|
|
2006
|
+
log(`Downloading release v${rel.version}...`);
|
|
2007
|
+
execFileSync('curl', ['-sL', '-f', '-H', 'Accept: application/vnd.github+json', rel.tarballUrl, '-o', tarballPath],
|
|
1989
2008
|
{ timeout: 60000, stdio: ['ignore', 'pipe', 'inherit'] });
|
|
1990
2009
|
log('Extracting...');
|
|
1991
2010
|
execFileSync('tar', ['xzf', tarballPath, '-C', stagingDir, '--strip-components=1'],
|
|
1992
2011
|
{ timeout: 30000, stdio: ['ignore', 'pipe', 'inherit'] });
|
|
2012
|
+
// Verify the Ed25519 signature BEFORE running the downloaded install.mjs. Fail-closed:
|
|
2013
|
+
// any tampering / missing-signature / fetch-failure aborts to the manual fallback.
|
|
2014
|
+
log('Verifying release signature...');
|
|
2015
|
+
const authentic = await verifyReleaseAuthenticity(stagingDir, rel.assets);
|
|
2016
|
+
if (!authentic.ok) throw new Error(`release signature check failed (${authentic.action})`);
|
|
1993
2017
|
const tarballInstaller = join(stagingDir, 'install.mjs');
|
|
1994
|
-
if (!existsSync(tarballInstaller))
|
|
1995
|
-
|
|
1996
|
-
process.exit(1);
|
|
1997
|
-
}
|
|
1998
|
-
log('Re-running install from freshly-downloaded sources...');
|
|
2018
|
+
if (!existsSync(tarballInstaller)) throw new Error('verified tarball missing install.mjs');
|
|
2019
|
+
log('Re-running install from the verified release sources...');
|
|
1999
2020
|
execFileSync(process.execPath, [tarballInstaller, 'install'],
|
|
2000
2021
|
{ stdio: 'inherit', timeout: 300000 });
|
|
2001
|
-
ok(
|
|
2022
|
+
ok(`Repair complete — resynced from verified release v${rel.version}`);
|
|
2002
2023
|
} catch (e) {
|
|
2003
2024
|
fail(`Repair failed: ${e.message}`);
|
|
2004
2025
|
console.log('');
|
|
2005
|
-
console.log('
|
|
2026
|
+
console.log(' Automatic repair fails closed rather than run unverified code.');
|
|
2027
|
+
console.log(' Manual fallback — run this in any shell (you are choosing to trust it):');
|
|
2006
2028
|
console.log('');
|
|
2007
2029
|
console.log(' T=$(mktemp -d) && curl -sL https://api.github.com/repos/sdsrss/claude-mem-lite/tarball | tar xz -C "$T" --strip-components=1 && node "$T/install.mjs" install');
|
|
2008
2030
|
console.log('');
|
package/lib/atomic-write.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// This writes to a pid-unique temp then renames (atomic on POSIX), and can drop
|
|
9
9
|
// a one-time ".bak" so a logic bug in the caller's merge is recoverable.
|
|
10
10
|
|
|
11
|
-
import { writeFileSync, renameSync, existsSync, copyFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { writeFileSync, renameSync, existsSync, copyFileSync, mkdirSync, lstatSync, realpathSync } from 'node:fs';
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -22,17 +22,28 @@ import { dirname } from 'node:path';
|
|
|
22
22
|
* preserves the last-known-good rather than being overwritten each run.
|
|
23
23
|
*/
|
|
24
24
|
export function atomicWriteFileSync(filePath, data, { backup = false } = {}) {
|
|
25
|
-
|
|
25
|
+
// Write THROUGH a symlink to its real target. renameSync onto a symlink NAME replaces the
|
|
26
|
+
// link with a regular file, silently orphaning a dotfiles-managed (chezmoi/stow/yadm)
|
|
27
|
+
// config: ~/.claude/settings.json (and ~/.claude.json) get severed from the dotfiles
|
|
28
|
+
// repo, so future dotfiles edits stop applying and .bak captures the wrong content.
|
|
29
|
+
// Resolve first, then put the temp in the TARGET's dir so the rename stays same-device
|
|
30
|
+
// (atomic, no EXDEV). A broken/absent symlink falls through to a direct write.
|
|
31
|
+
let target = filePath;
|
|
32
|
+
try {
|
|
33
|
+
if (lstatSync(filePath).isSymbolicLink()) target = realpathSync(filePath);
|
|
34
|
+
} catch { /* not a symlink, or missing — write filePath directly */ }
|
|
35
|
+
|
|
36
|
+
const dir = dirname(target);
|
|
26
37
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
27
38
|
|
|
28
|
-
if (backup && existsSync(
|
|
29
|
-
try { copyFileSync(
|
|
39
|
+
if (backup && existsSync(target) && !existsSync(target + '.bak')) {
|
|
40
|
+
try { copyFileSync(target, target + '.bak'); } catch { /* best-effort backup */ }
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
// pid-unique temp: a fixed ".tmp" name lets two concurrent installs clobber
|
|
33
|
-
// each other's temp mid-write. Same-dir temp keeps the rename atomic (no
|
|
34
|
-
// cross-device move).
|
|
35
|
-
const tmp = `${
|
|
44
|
+
// each other's temp mid-write. Same-dir-as-target temp keeps the rename atomic (no
|
|
45
|
+
// cross-device move) even when the target lives in a dotfiles repo on another mount.
|
|
46
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
36
47
|
writeFileSync(tmp, data);
|
|
37
|
-
renameSync(tmp,
|
|
48
|
+
renameSync(tmp, target);
|
|
38
49
|
}
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -525,7 +525,7 @@ export function computeCitationAdoption(db, project) {
|
|
|
525
525
|
* decide cited vs uncited and mutate importance/streak/cited_count per spec.
|
|
526
526
|
*
|
|
527
527
|
* - cited: importance += 1 (cap 3), cited_count += 1, streak = 0.
|
|
528
|
-
* - uncited: streak += 1; if it reaches 3, importance -= 1 (floor
|
|
528
|
+
* - uncited: streak += 1; if it reaches 3, importance -= 1 (floor 1, IMPORTANCE_FLOOR), streak = 0.
|
|
529
529
|
* - per-(session, obs) idempotent via last_decided_session_id; re-running for
|
|
530
530
|
* the same session is a no-op (Stop hook may fire more than once).
|
|
531
531
|
* - cross-project IDs are silently ignored by the WHERE clause.
|
|
@@ -568,7 +568,10 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
568
568
|
const suppressDemotion = adoption.seen >= ADOPTION_MIN_SEEN && adoption.rate < adoptionThreshold;
|
|
569
569
|
|
|
570
570
|
const selectStmt = db.prepare(
|
|
571
|
-
|
|
571
|
+
// superseded_at IS NULL: mirror computeCitationAdoption + the 4 injection SELECTs so a
|
|
572
|
+
// row superseded mid-session (injected live, then auto-dedup supersedes it before this
|
|
573
|
+
// decay resolves) is not decayed/streaked/mutated — defense-in-depth parity.
|
|
574
|
+
'SELECT id, importance, uncited_streak, last_decided_session_id FROM observations WHERE id = ? AND project = ? AND superseded_at IS NULL'
|
|
572
575
|
);
|
|
573
576
|
// decay_seen_count (v34) bumps on every resolution branch — gives
|
|
574
577
|
// citation-stats a denominator that's same-source as cited_count, so the
|
package/lib/deferred-work.mjs
CHANGED
|
@@ -119,7 +119,9 @@ export function resolveDeferredIds(db, project, tokens) {
|
|
|
119
119
|
throw new Error(`D#${id} belongs to project "${row.project}", not "${project}"`);
|
|
120
120
|
}
|
|
121
121
|
if (row.status !== 'open') {
|
|
122
|
-
|
|
122
|
+
// Verb-neutral: resolveDeferredIds is shared by close (save --closes-deferred)
|
|
123
|
+
// AND drop (mem_defer_drop), so "cannot close" mis-described the drop path.
|
|
124
|
+
throw new Error(`D#${id} status is "${row.status}" — only 'open' items can be closed or dropped`);
|
|
123
125
|
}
|
|
124
126
|
} else {
|
|
125
127
|
throw new Error(`invalid token type ${typeof t} — expected D#N or integer ordinal`);
|
package/lib/doctor-benchmark.mjs
CHANGED
|
@@ -110,7 +110,12 @@ export function runBenchmark(db, { prompts = [], project = 'mem', skipHookLatenc
|
|
|
110
110
|
* Returns true iff the simulator would produce a non-empty injection.
|
|
111
111
|
*/
|
|
112
112
|
const runInjection = (promptText) => {
|
|
113
|
-
|
|
113
|
+
// Mirror the real hook's min-length gate (hook-memory.mjs searchRelevantMemories):
|
|
114
|
+
// 5-char floor for non-CJK, 2 for CJK. The old flat 15 was 3x stricter and dropped
|
|
115
|
+
// every 5-14-char prompt (and all 2-14-char CJK) the live hook processes, so the
|
|
116
|
+
// simulated injection_rate systematically under-counted "how often memory fires".
|
|
117
|
+
const cjk = /[一-鿿㐀-䶿]/.test(promptText || '');
|
|
118
|
+
if (!promptText || promptText.length < (cjk ? 2 : 5)) return false;
|
|
114
119
|
const q = sanitizeFtsQuery(promptText);
|
|
115
120
|
if (!q) return false;
|
|
116
121
|
try {
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -145,6 +145,12 @@ export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP })
|
|
|
145
145
|
SELECT id FROM observations
|
|
146
146
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
147
147
|
AND (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
|
|
148
|
+
-- A lesson-bearing row is NOT "broken" — it still carries the distilled value,
|
|
149
|
+
-- so empty title+narrative isn't grounds to hard-delete it (a degenerate
|
|
150
|
+
-- cluster-merge can write merged_title='' onto a row that kept a synthesized
|
|
151
|
+
-- lesson). Parity with the "lessons never auto-GC" guards in
|
|
152
|
+
-- decayAndMarkIdle / selectCompressionCandidates / findSmartCompressCandidates.
|
|
153
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
148
154
|
${projectFilter} LIMIT ${opCap}
|
|
149
155
|
`).all(...baseParams).map(r => r.id);
|
|
150
156
|
if (!doomed.length) return 0;
|
|
@@ -329,6 +335,12 @@ export function purgeStalePreview(db, { projectFilter, baseParams }, retainCutof
|
|
|
329
335
|
|
|
330
336
|
/** Delete pending-purge observations older than the retain cutoff. Returns rows deleted. */
|
|
331
337
|
export function purgeStale(db, { projectFilter, baseParams, opCap = OP_CAP }, retainCutoff) {
|
|
338
|
+
// No lesson guard HERE by design: this hard-DELETE only touches rows already
|
|
339
|
+
// marked COMPRESSED_PENDING_PURGE, and every writer of that sentinel is itself
|
|
340
|
+
// lesson-guarded (decayAndMarkIdle above + search-scoring.runIdleCleanup), so a
|
|
341
|
+
// lesson row can never reach here. INVARIANT: any NEW code that sets
|
|
342
|
+
// compressed_into = COMPRESSED_PENDING_PURGE MUST carry the "lessons never auto-GC"
|
|
343
|
+
// guard, or it re-opens the path that hard-deletes lessons through this DELETE.
|
|
332
344
|
const doomed = db.prepare(`
|
|
333
345
|
SELECT id FROM observations
|
|
334
346
|
WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ?
|
package/lib/task-imperative.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// measurement that gates Phase 2): ONE tested source of truth so the measured
|
|
4
4
|
// framing and the shipped framing cannot drift. Hot-path-shared → regex/string
|
|
5
5
|
// only, NO heavy imports (lesson #8447), mirroring lib/lesson-idents.mjs.
|
|
6
|
+
// format-utils.mjs is a zero-import pure-string module, so it respects that constraint.
|
|
7
|
+
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
6
8
|
//
|
|
7
9
|
// Delivers a high-value lesson at the task-prompt position as an imperative,
|
|
8
10
|
// task-bound constraint: attribution kept (honest + #NN cite-traceable), the
|
|
@@ -11,7 +13,7 @@
|
|
|
11
13
|
// Spec: docs/superpowers/specs/2026-06-29-task-imperative-memory-injection-design.md
|
|
12
14
|
|
|
13
15
|
export function formatTaskImperative(lesson, id) {
|
|
14
|
-
const body = String(lesson || '').trim().replace(/\.$/, '');
|
|
16
|
+
const body = neutralizeContextDelimiters(String(lesson || '').trim().replace(/\.$/, ''));
|
|
15
17
|
if (!body) return '';
|
|
16
18
|
const tag = (id === undefined || id === null || id === '') ? '' : ` (#${id})`;
|
|
17
19
|
return `Memory — a past lesson applies to THIS task. You must: ${body}.${tag}`;
|
|
@@ -27,7 +29,7 @@ export function formatTaskImperative(lesson, id) {
|
|
|
27
29
|
// / "reference, not an instruction" / appended-below-the-task / no adversarial
|
|
28
30
|
// tokens) are the measured difference between adopt and refuse — do not drift them.
|
|
29
31
|
export function formatSubagentContext(lesson, id) {
|
|
30
|
-
const body = String(lesson || '').trim().replace(/\.$/, '');
|
|
32
|
+
const body = neutralizeContextDelimiters(String(lesson || '').trim().replace(/\.$/, ''));
|
|
31
33
|
if (!body) return '';
|
|
32
34
|
const tag = (id === undefined || id === null || id === '') ? '' : `#${id} — `;
|
|
33
35
|
return [
|
package/mem-cli.mjs
CHANGED
|
@@ -605,21 +605,17 @@ function cmdTimeline(db, args) {
|
|
|
605
605
|
// (nlp.mjs string ops on a boolean). No sensible default for a search anchor — reject
|
|
606
606
|
// cleanly (#8470). (`--project` bare is absorbed by resolveProject's non-string guard.)
|
|
607
607
|
if (rejectBareStringFlags(flags, ['query'])) return;
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
return n;
|
|
620
|
-
};
|
|
621
|
-
const before = parseWindow('before', flags.before);
|
|
622
|
-
const after = parseWindow('after', flags.after);
|
|
608
|
+
// Route --before/--after through the shared bounded parser, range [0,50] (mirrors MCP
|
|
609
|
+
// mem_timeline's before/after .min(0).max(50)). NOTE this is parseIntFlag's reject-to-default
|
|
610
|
+
// convention, NOT a clamp: an out-of-range value (e.g. `--before 100`) warns to stderr and
|
|
611
|
+
// falls back to the default 5 — same as recent/search/recall, so the behavior is consistent
|
|
612
|
+
// across the CLI (the user sees the valid range and can retry). The point is the UPPER bound:
|
|
613
|
+
// the old hand-rolled validator had none, so `--before 999999999` flowed straight into
|
|
614
|
+
// `LIMIT before+after+1` / the window fetch as a raw SQL LIMIT (whole-table dump) — the
|
|
615
|
+
// #8802 uncapped-LIMIT footgun. min:0 keeps a 0 window legal; parseIntFlag preserves the
|
|
616
|
+
// warn-then-default garbage handling (float truncation + "2abc"/"1e2" rejection).
|
|
617
|
+
const before = parseIntFlag(flags.before, { name: '--before', defaultValue: 5, min: 0, max: 50 });
|
|
618
|
+
const after = parseIntFlag(flags.after, { name: '--after', defaultValue: 5, min: 0, max: 50 });
|
|
623
619
|
const project = flags.project ? resolveProject(db, flags.project) : null;
|
|
624
620
|
const jsonOutput = flags.json === true || flags.json === 'true';
|
|
625
621
|
|
|
@@ -1621,7 +1617,17 @@ function cmdExport(db, args) {
|
|
|
1621
1617
|
process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
|
|
1622
1618
|
}
|
|
1623
1619
|
|
|
1624
|
-
|
|
1620
|
+
// Backup default: with no --limit, export the COMPLETE matching set. `export` is
|
|
1621
|
+
// the documented backup half of backup/restore (README; cmdRestore header), yet its
|
|
1622
|
+
// old default capped at 200 (hard max 1000) and the "capped" warning went only to
|
|
1623
|
+
// stderr — so a bare `export > backup.json` on a >200-row store silently wrote a
|
|
1624
|
+
// truncated backup that lost rows on restore, and `--limit 5000` was REJECTED back
|
|
1625
|
+
// to 200 (can't back up >1000 at all). Now: omit --limit → LIMIT -1 (SQLite = no
|
|
1626
|
+
// limit); pass --limit N → honor any positive N (a backup may exceed 1000).
|
|
1627
|
+
const limitGiven = flags.limit !== undefined && flags.limit !== null && flags.limit !== '';
|
|
1628
|
+
const limit = limitGiven
|
|
1629
|
+
? parseIntFlag(flags.limit, { name: '--limit', defaultValue: 200 })
|
|
1630
|
+
: -1;
|
|
1625
1631
|
const format = flags.format || 'json';
|
|
1626
1632
|
if (!['json', 'jsonl'].includes(format)) {
|
|
1627
1633
|
fail(`[mem] Invalid format "${format}". Use: json or jsonl`);
|
|
@@ -1662,8 +1668,8 @@ function cmdExport(db, args) {
|
|
|
1662
1668
|
out(JSON.stringify(rows, null, 2));
|
|
1663
1669
|
}
|
|
1664
1670
|
|
|
1665
|
-
if (rows.length >= limit) {
|
|
1666
|
-
process.stderr.write(`[mem] Note: Results capped at ${limit}.
|
|
1671
|
+
if (limitGiven && rows.length >= limit) {
|
|
1672
|
+
process.stderr.write(`[mem] Note: Results capped at ${limit}. Raise --limit or narrow --from/--to to export more.\n`);
|
|
1667
1673
|
}
|
|
1668
1674
|
}
|
|
1669
1675
|
|
|
@@ -1785,8 +1791,13 @@ function cmdCompress(db, args) {
|
|
|
1785
1791
|
// isNumericToken (not bare parseInt) so "1e5"→1 and "30x"→30 are rejected rather than
|
|
1786
1792
|
// silently mis-parsed into a far-too-broad cutoff — parity with recent/search/maintain.
|
|
1787
1793
|
const parsed = Number(flags['age-days']);
|
|
1788
|
-
|
|
1789
|
-
|
|
1794
|
+
// [30,365] floor/ceil = parity with mem_compress (tool-schemas memCompressSchema
|
|
1795
|
+
// .min(30).max(365)). The CLI previously accepted any positive int, so `--age-days 1`
|
|
1796
|
+
// compressed day-old rows while the MCP description claimed the CLI "rejects <30 anyway"
|
|
1797
|
+
// — a false parity claim (the candidate gate is the real data guard, but the age floor
|
|
1798
|
+
// should match the tool the description promises equivalence with).
|
|
1799
|
+
if (!isNumericToken(flags['age-days']) || !Number.isInteger(parsed) || parsed < 30 || parsed > 365) {
|
|
1800
|
+
fail(`[mem] Invalid --age-days "${flags['age-days']}". Must be an integer between 30 and 365 (parity with mem_compress).`);
|
|
1790
1801
|
return;
|
|
1791
1802
|
}
|
|
1792
1803
|
ageDays = parsed;
|
|
@@ -2575,15 +2586,17 @@ Commands:
|
|
|
2575
2586
|
--narrative T New narrative
|
|
2576
2587
|
--concepts T Space-separated concept tags
|
|
2577
2588
|
|
|
2578
|
-
export Export observations as JSON/JSONL
|
|
2579
|
-
restore <file> Restore observations from an export file (JSON/JSONL); --dry-run to preview
|
|
2589
|
+
export Export observations as JSON/JSONL (complete backup by default)
|
|
2580
2590
|
--project P Filter by project
|
|
2581
2591
|
--type T Filter by type
|
|
2582
2592
|
--format F json (default) or jsonl
|
|
2583
2593
|
--from DATE Start date
|
|
2584
2594
|
--to DATE End date
|
|
2585
2595
|
--include-compressed Include compressed observations
|
|
2586
|
-
--limit N
|
|
2596
|
+
--limit N Cap output at N rows (default: export ALL matching rows)
|
|
2597
|
+
restore <file> Restore observations from an export file (JSON/JSONL)
|
|
2598
|
+
--project P Override the restored project for every row
|
|
2599
|
+
--dry-run Preview what would be restored without writing
|
|
2587
2600
|
|
|
2588
2601
|
compress Compress old low-value observations
|
|
2589
2602
|
--execute Execute compression (preview by default)
|
|
@@ -2876,6 +2889,18 @@ async function cmdEnrich(argv) {
|
|
|
2876
2889
|
}
|
|
2877
2890
|
|
|
2878
2891
|
async function cmdOptimize(db, args) {
|
|
2892
|
+
// cmdOptimize parses flags positionally (args.indexOf('--task') + args[idx+1])
|
|
2893
|
+
// instead of the shared parseArgs, so the GNU `--flag=value` form silently
|
|
2894
|
+
// vanished: indexOf found no bare `--flag`, dropping the value with zero signal.
|
|
2895
|
+
// On the mutating --run path this was a real footgun — `optimize --run
|
|
2896
|
+
// --task=smart-compress --project=p --max=5` ran ALL tasks across ALL projects at
|
|
2897
|
+
// the default budget (tasks/project undefined → run-everything). Normalize
|
|
2898
|
+
// `--flag=value` into `--flag value` up front so both forms parse identically;
|
|
2899
|
+
// the `--execute=` special-case below already anticipated this for one flag.
|
|
2900
|
+
args = args.flatMap(a => {
|
|
2901
|
+
const m = /^(--[a-z][a-z-]*)=([\s\S]*)$/.exec(a);
|
|
2902
|
+
return m ? [m[1], m[2]] : [a];
|
|
2903
|
+
});
|
|
2879
2904
|
const run = args.includes('--run');
|
|
2880
2905
|
const runAll = args.includes('--run-all');
|
|
2881
2906
|
// Sibling-command flag footgun: optimize executes with --run (compress uses
|
|
@@ -3045,11 +3070,15 @@ export async function run(argv) {
|
|
|
3045
3070
|
const JSON_SUPPORTED_CMDS = new Set([
|
|
3046
3071
|
'search', 'context', 'recent', 'recall', 'timeline', 'stats', 'browse', 'export', 'citation-stats',
|
|
3047
3072
|
]);
|
|
3048
|
-
//
|
|
3049
|
-
// "
|
|
3050
|
-
//
|
|
3051
|
-
|
|
3052
|
-
|
|
3073
|
+
// Suppress the note on subpaths that DO emit JSON, so it never FALSELY tells a script
|
|
3074
|
+
// "outputs text" while writing valid JSON to stdout — a false note is worse than a
|
|
3075
|
+
// missing one (it makes the consumer skip a parse that would have succeeded). doctor
|
|
3076
|
+
// emits JSON for --benchmark / --metrics / --session-audit; activity's show/save emit
|
|
3077
|
+
// JSON. Without those sub-flags, doctor is text and the note stays useful.
|
|
3078
|
+
const jsonCapableSubpath =
|
|
3079
|
+
(cmd === 'doctor' && (cmdArgs.includes('--benchmark') || cmdArgs.includes('--metrics') || cmdArgs.includes('--session-audit'))) ||
|
|
3080
|
+
cmd === 'activity';
|
|
3081
|
+
if (cmdArgs.includes('--json') && !JSON_SUPPORTED_CMDS.has(cmd) && !jsonCapableSubpath) {
|
|
3053
3082
|
process.stderr.write(`[mem] Note: --json is supported only on: ${[...JSON_SUPPORTED_CMDS].join(', ')}. "${cmd}" outputs text.\n`);
|
|
3054
3083
|
}
|
|
3055
3084
|
|
package/nlp.mjs
CHANGED
|
@@ -151,10 +151,14 @@ export function extractCjkLikePatterns(query) {
|
|
|
151
151
|
/**
|
|
152
152
|
* Post-FTS precision filter for CJK queries.
|
|
153
153
|
*
|
|
154
|
-
* Background: FTS5 unicode61 tokenizer
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
154
|
+
* Background: this build's FTS5 unicode61 tokenizer indexes an entire CJK run
|
|
155
|
+
* as ONE token (it does NOT split each CJK character). CJK text is made
|
|
156
|
+
* searchable by the write path, which stores the content plus its space-
|
|
157
|
+
* separated overlapping bigrams; a query is likewise reduced to bigrams. An
|
|
158
|
+
* application-layer bigram query therefore matches via those stored bigrams,
|
|
159
|
+
* and after the AND→OR fallback (relaxFtsQueryToOr) any document sharing even a
|
|
160
|
+
* single query bigram becomes a hit — extremely permissive in Chinese prose,
|
|
161
|
+
* where common bigrams recur across unrelated topics.
|
|
158
162
|
*
|
|
159
163
|
* Precision check: given the raw query and a candidate result's full text,
|
|
160
164
|
* require that at least `threshold` fraction of the query's CJK bigrams
|
|
@@ -276,6 +280,15 @@ export function sanitizeFtsQuery(query) {
|
|
|
276
280
|
t && !/^-+$/.test(t) && !FTS5_KEYWORDS.has(t.toUpperCase()) && !/^NEAR(\/\d*)?$/i.test(t)
|
|
277
281
|
// Skip single ASCII-letter tokens — too noisy for FTS5 (CJK single chars handled separately below)
|
|
278
282
|
&& !(t.length === 1 && /^[a-zA-Z]$/.test(t))
|
|
283
|
+
// Drop tokens with NO index-able character — emoji 💥, symbols ★☆✦, pure
|
|
284
|
+
// punctuation. unicode61 strips those at index time, so ftsToken would phrase-quote
|
|
285
|
+
// such a token ("💥") into a REQUIRED AND term that can never match → strict FTS
|
|
286
|
+
// returns 0 (and a lone-emoji query has no OR recovery). Gate on any Unicode LETTER
|
|
287
|
+
// or NUMBER (\p{L}/\p{N}), NOT an ASCII+Han allowlist: unicode61 indexes every
|
|
288
|
+
// script's letters (Cyrillic / Greek / kana / Hangul / Thai / accented Latin …), so
|
|
289
|
+
// an allowlist silently killed search for all non-Latin/non-Han scripts (round-5
|
|
290
|
+
// review catch). Letters are kept; only true symbols/emoji/punctuation are dropped.
|
|
291
|
+
&& /[\p{L}\p{N}]/u.test(t)
|
|
279
292
|
);
|
|
280
293
|
// Filter stop words (but keep all if filtering would empty the query)
|
|
281
294
|
const filtered = tokens.filter(t => !FTS_STOP_WORDS.has(t.toLowerCase()));
|
|
@@ -303,6 +316,25 @@ export function sanitizeFtsQuery(query) {
|
|
|
303
316
|
}
|
|
304
317
|
continue;
|
|
305
318
|
}
|
|
319
|
+
// No dictionary word matched. For a PURE-CJK run, pushing the whole
|
|
320
|
+
// unsegmented token creates a required AND term that matches neither the
|
|
321
|
+
// stored full-run token nor its overlapping bigrams: the write path stores
|
|
322
|
+
// content + space-separated bigrams, so a run like "同义词扩展" is indexed
|
|
323
|
+
// only as the longer whole-run token AND as 同义/义词/词扩/扩展 — never as
|
|
324
|
+
// "同义词扩展" itself. The strict AND is thus unsatisfiable (strict FTS = 0);
|
|
325
|
+
// only relaxFtsQueryToOr in the callers salvaged recall. Emit the non-noise
|
|
326
|
+
// bigrams the index actually holds instead. Mixed-script tokens (latin+CJK,
|
|
327
|
+
// e.g. "xyzAbc不存在") stay whole — the latin portion is a literal anchor and
|
|
328
|
+
// bigramming the CJK suffix over-recalls (mirrors the bigram guard below).
|
|
329
|
+
if (!/[A-Za-z0-9]/.test(t)) {
|
|
330
|
+
const fallbackBigrams = cjkBigrams(t)
|
|
331
|
+
.split(' ')
|
|
332
|
+
.filter(bg => bg && !isCjkNoiseBigram(bg));
|
|
333
|
+
if (fallbackBigrams.length > 0) {
|
|
334
|
+
expandedTokens.push(...fallbackBigrams);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
306
338
|
}
|
|
307
339
|
expandedTokens.push(t);
|
|
308
340
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.37.0",
|
|
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",
|
|
@@ -102,7 +102,6 @@
|
|
|
102
102
|
"registry.mjs",
|
|
103
103
|
"registry-retriever.mjs",
|
|
104
104
|
"registry-recommend.mjs",
|
|
105
|
-
"registry-indexer.mjs",
|
|
106
105
|
"registry-scanner.mjs",
|
|
107
106
|
"registry-github.mjs",
|
|
108
107
|
"registry-importer.mjs",
|