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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +2 -7
- package/commands/mem.md +7 -0
- package/commands/update.md +2 -1
- package/dispatch-inject.mjs +5 -4
- package/dispatch.mjs +37 -15
- package/hook-shared.mjs +10 -6
- package/hook.mjs +6 -5
- package/hooks/hooks.json +1 -1
- package/install.mjs +440 -11
- package/package.json +1 -1
- package/registry/preinstalled.json +0 -13
- package/registry-retriever.mjs +0 -3
- package/registry.mjs +1 -1
- package/schema.mjs +1 -0
- package/scripts/setup.sh +20 -1
- package/server.mjs +153 -159
- package/tool-schemas.mjs +4 -2
- package/utils.mjs +10 -2
package/.mcp.json
CHANGED
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.
|
package/commands/update.md
CHANGED
|
@@ -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
|
|
package/dispatch-inject.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
41
|
+
let breakerFile = join(RUNTIME_DIR, 'haiku-breaker.json');
|
|
39
42
|
|
|
40
43
|
function _readBreakerState() {
|
|
41
44
|
try {
|
|
42
|
-
if (!existsSync(
|
|
43
|
-
return JSON.parse(readFileSync(
|
|
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(
|
|
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
|
|
661
|
+
// Check 1: Session cap (loop-invariant — callers should prefer isSessionCapped for filter loops)
|
|
641
662
|
if (sessionId) {
|
|
642
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 =
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
|
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 (
|
|
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;
|