claude-mem-lite 2.3.1 → 2.3.3

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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.1.6",
13
+ "version": "2.3.2",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.2.0",
3
+ "version": "2.3.3",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/.mcp.json CHANGED
@@ -1,8 +1,3 @@
1
1
  {
2
- "mcpServers": {
3
- "mem": {
4
- "command": "node",
5
- "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/launch.mjs"]
6
- }
7
- }
8
- }
2
+ "mcpServers": {}
3
+ }
package/commands/mem.md CHANGED
@@ -1,4 +1,5 @@
1
1
  ---
2
+ name: mem
2
3
  description: Search and manage project memory (observations, sessions, prompts)
3
4
  ---
4
5
 
@@ -13,6 +14,9 @@ Search and browse your project memory efficiently.
13
14
  - `/mem save <text>` — Save a manual memory/note
14
15
  - `/mem stats` — Show memory statistics
15
16
  - `/mem timeline <query>` — Browse timeline around a matching observation
17
+ - `/mem cleanup` — Scan and interactively purge stale data
18
+ - `/mem cleanup [N]d` — Purge stale data older than N days (e.g. `cleanup 60d`)
19
+ - `/mem cleanup keep [N]d` — Purge stale data but retain last N days (e.g. `cleanup keep 14d`)
16
20
 
17
21
  ## Efficient Search Workflow (3 steps, saves 10x tokens)
18
22
 
@@ -29,6 +33,9 @@ When the user invokes `/mem`, parse their intent:
29
33
  - `/mem save <text>` → call `mem_save` with the text as content
30
34
  - `/mem stats` → call `mem_stats`
31
35
  - `/mem timeline <query>` → call `mem_timeline` with the query
36
+ - `/mem cleanup` → run `mem_maintain(action="scan")`, report pending purge count and stale items to user, ask for confirmation, then run `mem_maintain(action="execute", operations=["purge_stale"])` if confirmed
37
+ - `/mem cleanup Nd` (e.g. `60d`) → same as above but use `retain_days=N` to only purge items older than N days
38
+ - `/mem cleanup keep Nd` (e.g. `keep 14d`) → same as above with `retain_days=N`
32
39
  - `/mem <query>` (no subcommand) → treat as search, call `mem_search`
33
40
 
34
41
  Always use the compact index from mem_search first, then mem_get for details only when needed. This minimizes token usage.
@@ -18,10 +18,11 @@ When the user invokes `/mem:update`, perform the following maintenance cycle:
18
18
  ### Phase 1: Memory Maintenance
19
19
 
20
20
  1. Call `mem_maintain(action="scan")` to analyze maintenance candidates
21
- 2. Report scan results to the user (duplicates, stale items, broken items, boostable items)
21
+ 2. Report scan results to the user (duplicates, stale items, broken items, boostable items, **pending purge** items)
22
22
  3. Call `mem_maintain(action="execute", operations=["cleanup","decay","boost"])` to apply safe automatic changes
23
23
  4. If duplicates were found in scan, review them and call `mem_maintain(action="execute", operations=["dedup"], merge_ids=[[keepId, removeId1, ...], ...])` — keep the more important/recent observation in each pair
24
24
  5. Run `mem_compress(preview=false)` for old low-value observations
25
+ 6. **If pending purge items > 0**: Report the count to the user and ask for confirmation. If confirmed, call `mem_maintain(action="execute", operations=["purge_stale"])`. User may optionally specify `retain_days` (default 30) to control how many days of data to keep. Do NOT purge without explicit user confirmation.
25
26
 
26
27
  ### Phase 2: Registry Maintenance
27
28
 
@@ -2,7 +2,7 @@
2
2
  // Formats resource recommendations for Claude Code's additionalContext
3
3
 
4
4
  import { existsSync, readFileSync } from 'fs';
5
- import { join } from 'path';
5
+ import { join, resolve } from 'path';
6
6
  import { homedir } from 'os';
7
7
  import { truncate } from './utils.mjs';
8
8
  import { DB_DIR } from './schema.mjs';
@@ -18,13 +18,14 @@ function truncateContent(str, max) {
18
18
 
19
19
  // Allowed base directories for resource file reads (defense-in-depth)
20
20
  const ALLOWED_BASES = [
21
- join(homedir(), '.claude'),
22
- join(DB_DIR, 'managed'),
21
+ resolve(join(homedir(), '.claude')),
22
+ resolve(join(DB_DIR, 'managed')),
23
23
  ];
24
24
 
25
25
  function isAllowedPath(filePath) {
26
26
  if (!filePath) return false;
27
- return ALLOWED_BASES.some(base => filePath === base || filePath.startsWith(base + '/'));
27
+ const resolved = resolve(filePath);
28
+ return ALLOWED_BASES.some(base => resolved === base || resolved.startsWith(base + '/'));
28
29
  }
29
30
 
30
31
  // ─── Template Detection ──────────────────────────────────────────────────────
package/dispatch.mjs CHANGED
@@ -32,22 +32,28 @@ export const BM25_MIN_THRESHOLD = 1.5;
32
32
  // ─── Haiku Circuit Breaker ──────────────────────────────────────────────────
33
33
  // Prevents cascading latency when Haiku API is down or slow.
34
34
  // After BREAKER_THRESHOLD consecutive failures, disable for BREAKER_RESET_MS.
35
+ // KNOWN LIMITATION: File-based state has a TOCTOU race under concurrent hook
36
+ // processes. Worst case: breaker trips on failure N+1 instead of N. This is
37
+ // acceptable — the breaker is a latency guard, not a correctness mechanism.
35
38
 
36
39
  const BREAKER_THRESHOLD = 3;
37
40
  const BREAKER_RESET_MS = 5 * 60 * 1000; // 5 minutes
38
- const BREAKER_FILE = join(RUNTIME_DIR, 'haiku-breaker.json');
41
+ let breakerFile = join(RUNTIME_DIR, 'haiku-breaker.json');
39
42
 
40
43
  function _readBreakerState() {
41
44
  try {
42
- if (!existsSync(BREAKER_FILE)) return { failures: 0, openUntil: 0 };
43
- return JSON.parse(readFileSync(BREAKER_FILE, 'utf8'));
45
+ if (!existsSync(breakerFile)) return { failures: 0, openUntil: 0 };
46
+ return JSON.parse(readFileSync(breakerFile, 'utf8'));
44
47
  } catch { return { failures: 0, openUntil: 0 }; }
45
48
  }
46
49
 
47
50
  function _writeBreakerState(state) {
48
- try { writeFileSync(BREAKER_FILE, JSON.stringify(state)); } catch {}
51
+ try { writeFileSync(breakerFile, JSON.stringify(state)); } catch {}
49
52
  }
50
53
 
54
+ /** Override breaker file path (for testing isolation). */
55
+ export function _setBreakerFile(path) { breakerFile = path; }
56
+
51
57
  function isHaikuCircuitOpen() {
52
58
  const state = _readBreakerState();
53
59
  if (state.openUntil > 0 && Date.now() < state.openUntil) return true;
@@ -636,15 +642,27 @@ JSON: {"query":"search keywords for finding the right skill or agent","type":"sk
636
642
 
637
643
  // ─── Cooldown & Dedup (DB-persisted, survives process restarts) ─────────────
638
644
 
645
+ /**
646
+ * Check if session has hit the recommendation cap.
647
+ * Separated from per-resource check so callers in filter loops can hoist this.
648
+ * @param {Database} db Registry database
649
+ * @param {string} sessionId Session identifier
650
+ * @returns {boolean} true if session cap is reached
651
+ */
652
+ export function isSessionCapped(db, sessionId) {
653
+ if (!sessionId) return false;
654
+ const sessionCount = db.prepare(
655
+ 'SELECT COUNT(*) as cnt FROM invocations WHERE session_id = ? AND recommended = 1'
656
+ ).get(sessionId);
657
+ return sessionCount.cnt >= SESSION_RECOMMEND_CAP;
658
+ }
659
+
639
660
  export function isRecentlyRecommended(db, resourceId, sessionId) {
640
- // Check 1 & 2: Session-scoped checks (cap + dedup) only when sessionId is available
661
+ // Check 1: Session cap (loop-invariant callers should prefer isSessionCapped for filter loops)
641
662
  if (sessionId) {
642
- const sessionCount = db.prepare(
643
- 'SELECT COUNT(*) as cnt FROM invocations WHERE session_id = ? AND recommended = 1'
644
- ).get(sessionId);
645
- if (sessionCount.cnt >= SESSION_RECOMMEND_CAP) return true;
663
+ if (isSessionCapped(db, sessionId)) return true;
646
664
 
647
- // Already recommended in this session (session dedup)
665
+ // Check 2: Already recommended in this session (session dedup)
648
666
  const sessionHit = db.prepare(
649
667
  'SELECT 1 FROM invocations WHERE resource_id = ? AND session_id = ? AND recommended = 1 LIMIT 1'
650
668
  ).get(resourceId, sessionId);
@@ -857,7 +875,8 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId, { hasHan
857
875
 
858
876
  if (results.length === 0) return null;
859
877
 
860
- // Filter by DB-persisted cooldown + session dedup
878
+ // Filter by DB-persisted cooldown + session dedup (hoisted cap check avoids N queries)
879
+ if (sessionId && isSessionCapped(db, sessionId)) return null;
861
880
  const viable = sessionId
862
881
  ? results.filter(r => !isRecentlyRecommended(db, r.id, sessionId))
863
882
  : results;
@@ -895,12 +914,13 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
895
914
  if (!userPrompt || !db) return null;
896
915
 
897
916
  try {
898
- // 1. Explicit request → highest priority, bypass all restrictions
917
+ // 1. Explicit request → highest priority, bypass cooldown but apply adoption decay
899
918
  const explicit = detectExplicitRequest(userPrompt);
900
919
  if (explicit.isExplicit) {
901
920
  const textQuery = buildQueryFromText(explicit.searchTerm);
902
921
  if (textQuery) {
903
- const explicitResults = retrieveResources(db, textQuery, { limit: 3, projectDomains: detectProjectDomains() });
922
+ let explicitResults = retrieveResources(db, textQuery, { limit: 3, projectDomains: detectProjectDomains() });
923
+ explicitResults = applyAdoptionDecay(explicitResults, db);
904
924
  if (explicitResults.length > 0) {
905
925
  const best = explicitResults[0];
906
926
  if (!sessionId || !isRecentlyRecommended(db, best.id, sessionId)) {
@@ -960,7 +980,8 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
960
980
  // Low confidence → skip (no Haiku in user_prompt path — stay fast)
961
981
  if (needsHaikuDispatch(results)) return null;
962
982
 
963
- // Filter by cooldown + session dedup (prevents double-recommend with SessionStart)
983
+ // Filter by cooldown + session dedup (hoisted cap check avoids N queries)
984
+ if (sessionId && isSessionCapped(db, sessionId)) return null;
964
985
  const viable = sessionId
965
986
  ? results.filter(r => !isRecentlyRecommended(db, r.id, sessionId))
966
987
  : results;
@@ -1028,8 +1049,9 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
1028
1049
  // Low-confidence results: skip recommendation rather than suggest unreliable match
1029
1050
  if (needsHaikuDispatch(results)) return null;
1030
1051
 
1031
- // Apply DB-persisted cooldown and session dedup (filter all, not just top)
1052
+ // Apply DB-persisted cooldown and session dedup (hoisted cap check avoids N queries)
1032
1053
  const sid = sessionCtx.sessionId || null;
1054
+ if (sid && isSessionCapped(db, sid)) return null;
1033
1055
  const viable = sid
1034
1056
  ? results.filter(r => !isRecentlyRecommended(db, r.id, sid))
1035
1057
  : results;
package/hook-shared.mjs CHANGED
@@ -6,7 +6,7 @@ import { randomUUID } from 'crypto';
6
6
  import { join } from 'path';
7
7
  import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, renameSync, unlinkSync } from 'fs';
8
8
  import { inferProject, debugCatch } from './utils.mjs';
9
- import { ensureDb, DB_DIR } from './schema.mjs';
9
+ import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
10
10
  import { ensureRegistryDb } from './registry.mjs';
11
11
  import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared } from './haiku-client.mjs';
12
12
 
@@ -23,14 +23,14 @@ export const STALE_SESSION_MS = 24 * 60 * 60 * 1000; // 24h
23
23
  export const STALE_LOCK_MS = 30000; // 30s
24
24
  export const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min (title dedup)
25
25
  export const RELATED_OBS_WINDOW_MS = 7 * 86400000; // 7 days
26
- export const FALLBACK_OBS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
26
+ export const FALLBACK_OBS_WINDOW_MS = RELATED_OBS_WINDOW_MS; // same window
27
27
  export const RESOURCE_RESCAN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
28
28
 
29
29
  // Handoff system constants
30
30
  export const HANDOFF_EXPIRY_CLEAR = 3600000; // 1 hour
31
31
  export const HANDOFF_EXPIRY_EXIT = 7 * 24 * 60 * 60 * 1000; // 7 days
32
32
  export const HANDOFF_MATCH_THRESHOLD = 3; // min weighted score
33
- export const CONTINUE_KEYWORDS = /继续|接着|上次|之前的|前面的|刚才|\bcontinue\b|\bresume\b|\bwhere[\s\-]+we[\s\-]+left\b|\bpick[\s\-]+up\b|\bcarry[\s\-]+on\b/i;
33
+ export const CONTINUE_KEYWORDS = /继续|接着|上次|之前的|前面的|刚才|\bcontinue\b|\bresume\b|\bwhere[\s-]+we[\s-]+left\b|\bpick[\s-]+up\b|\bcarry[\s-]+on\b/i;
34
34
 
35
35
  // Ensure runtime directory exists
36
36
  try { if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true }); } catch {}
@@ -70,8 +70,6 @@ export function openDb() {
70
70
  }
71
71
 
72
72
  // ─── Registry Database (dispatch system) ─────────────────────────────────────
73
-
74
- const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
75
73
  let _registryDb = null;
76
74
 
77
75
  export function getRegistryDb() {
@@ -101,6 +99,7 @@ export function callLLM(prompt, timeoutMs = 15000) {
101
99
  } catch (e) {
102
100
  const out = _extractResponseFromError(e);
103
101
  if (out) return out;
102
+ debugCatch(e, 'callLLM');
104
103
  return null;
105
104
  }
106
105
  }
@@ -201,7 +200,12 @@ export function peekToolEvents() {
201
200
  export function _extractResponseFromError(error) {
202
201
  const out = error.stdout?.toString?.()?.trim() || error.output?.[1]?.toString?.()?.trim() || '';
203
202
  if (out && out.startsWith('{') && out.endsWith('}')) {
204
- try { JSON.parse(out); return out; } catch { return null; }
203
+ try {
204
+ const parsed = JSON.parse(out);
205
+ // Reject structurally incomplete responses (e.g. truncated mid-output)
206
+ if (typeof parsed !== 'object' || parsed === null || Object.keys(parsed).length === 0) return null;
207
+ return out;
208
+ } catch { return null; }
205
209
  }
206
210
  return null;
207
211
  }
package/hook.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  truncate, typeIcon, inferProject, detectBashSignificance,
12
12
  extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
13
13
  makeEntryDesc, scrubSecrets, EDIT_TOOLS, debugCatch, debugLog, fmtTime,
14
+ COMPRESSED_AUTO,
14
15
  } from './utils.mjs';
15
16
  import {
16
17
  readEpisodeRaw, episodeFile,
@@ -256,7 +257,7 @@ async function handlePostToolUse() {
256
257
  appendToolEvent({
257
258
  tool_name,
258
259
  tool_input: toolInput,
259
- tool_response: (tool_name === 'Bash' && bashSig?.isError) ? resp.slice(0, 500) : '',
260
+ tool_response: (tool_name === 'Bash' && bashSig?.isError) ? scrubSecrets(resp.slice(0, 500)) : '',
260
261
  });
261
262
  }
262
263
  } finally {
@@ -457,7 +458,7 @@ async function handleSessionStart() {
457
458
  // Auto-compress: mark old low-importance observations as compressed (30+ days, importance=1)
458
459
  // Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
459
460
  const compressed = db.prepare(`
460
- UPDATE observations SET compressed_into = -1
461
+ UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
461
462
  WHERE COALESCE(compressed_into, 0) = 0
462
463
  AND importance = 1
463
464
  AND created_at_epoch < ?
@@ -883,7 +884,7 @@ async function handleResourceScan() {
883
884
  }
884
885
 
885
886
  // Upsert changed resources with fallback metadata (no Haiku)
886
- let firstErr = true;
887
+ let upsertErrors = 0;
887
888
  for (const res of toIndex) {
888
889
  try {
889
890
  upsertResource(rdb, {
@@ -898,7 +899,7 @@ async function handleResourceScan() {
898
899
  trigger_patterns: `when user needs ${res.name.replace(/-/g, ' ').replace(/\//g, ' ')}`,
899
900
  capability_summary: `${res.type}: ${res.name.replace(/-/g, ' ')}`,
900
901
  });
901
- } catch (e) { if (firstErr) { debugCatch(e, 'handleResourceScan-upsert'); firstErr = false; } }
902
+ } catch (e) { upsertErrors++; if (upsertErrors <= 3) debugCatch(e, `handleResourceScan-upsert[${upsertErrors}]`); }
902
903
  }
903
904
 
904
905
  // Disable resources no longer on filesystem
@@ -921,7 +922,7 @@ function readStdin() {
921
922
  const MAX_STDIN = 256 * 1024; // 256KB — large tool responses are truncated
922
923
  return new Promise((resolve, reject) => {
923
924
  let data = '';
924
- const timeout = setTimeout(() => { process.stdin.destroy(); reject(new Error('timeout')); }, 3000);
925
+ const timeout = setTimeout(() => { debugLog('WARN', 'readStdin', 'stdin timeout after 3s — event dropped'); process.stdin.destroy(); reject(new Error('timeout')); }, 3000);
925
926
  process.stdin.setEncoding('utf8');
926
927
  process.stdin.on('data', chunk => {
927
928
  data += chunk;
package/hooks/hooks.json CHANGED
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "type": "command",
15
15
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/hook.mjs\" session-start",
16
- "timeout": 10
16
+ "timeout": 15
17
17
  }
18
18
  ]
19
19
  }