claude-mem-lite 2.89.0 → 2.90.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/cli/activity.mjs +9 -5
- package/cli.mjs +15 -10
- package/hook-handoff.mjs +44 -12
- package/hook-update.mjs +11 -4
- package/hook.mjs +18 -10
- package/install.mjs +46 -19
- package/lib/cli-flags.mjs +24 -2
- package/mem-cli.mjs +164 -18
- package/nlp.mjs +6 -0
- package/package.json +1 -1
- package/registry-importer.mjs +5 -2
- package/schema.mjs +21 -2
- package/scripts/pre-skill-bridge.js +9 -3
- package/scripts/user-prompt-search.js +11 -3
- package/server.mjs +17 -7
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "2.
|
|
13
|
+
"version": "2.90.1",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"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)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.90.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
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/cli/activity.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { inferProject } from '../utils.mjs';
|
|
12
12
|
import { resolveProject } from '../project-utils.mjs';
|
|
13
13
|
import { parseArgs, out, fail } from './common.mjs';
|
|
14
|
+
import { parseIntFlag } from '../lib/cli-flags.mjs';
|
|
14
15
|
|
|
15
16
|
function formatActivityResults(rows) {
|
|
16
17
|
if (!rows || rows.length === 0) return '(no events)';
|
|
@@ -77,17 +78,20 @@ export async function cmdActivity(db, args) {
|
|
|
77
78
|
fail(`[mem] activity search: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
80
|
-
const limit = flags.limit
|
|
81
|
+
const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 1000 });
|
|
81
82
|
const rows = searchEvents(db, q, { project, type, limit });
|
|
82
83
|
out(formatActivityResults(rows));
|
|
83
84
|
return;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
if (sub === 'recent') {
|
|
87
|
-
// Accept either `activity recent 5` or `activity recent --limit 5`.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
// Accept either `activity recent 5` or `activity recent --limit 5`. Both routed
|
|
89
|
+
// through parseIntFlag so garbage ("2abc"), negatives (SQLite LIMIT -1 = UNLIMITED
|
|
90
|
+
// full-table dump), and uncapped huge values warn + clamp to default/max, matching
|
|
91
|
+
// the search/recent/browse siblings.
|
|
92
|
+
const limit = positional.length > 0
|
|
93
|
+
? parseIntFlag(positional[0], { name: 'count', defaultValue: 20, max: 1000 })
|
|
94
|
+
: parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
|
|
91
95
|
const type = flags.type || null;
|
|
92
96
|
if (type !== null && !VALID_EVENT_TYPES.has(type)) {
|
|
93
97
|
fail(`[mem] activity recent: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
|
package/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'citation-stats', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'import-jsonl', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'defer', 'help']);
|
|
2
|
+
const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'citation-stats', 'delete', 'update', 'export', 'restore', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'import-jsonl', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'defer', 'help']);
|
|
3
3
|
const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'repair', 'release']);
|
|
4
4
|
|
|
5
5
|
const cmd = process.argv[2];
|
|
@@ -13,14 +13,16 @@ if (cmd === '--version' || cmd === '-v') {
|
|
|
13
13
|
} else if (cmd === '--help' || cmd === '-h') {
|
|
14
14
|
const { run } = await import('./mem-cli.mjs');
|
|
15
15
|
await run(['help']);
|
|
16
|
-
} else if (cmd === 'doctor' && process.argv.slice(3).some(a => a
|
|
17
|
-
// Per #8217
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// install health-check
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
16
|
+
} else if (cmd === 'doctor' && process.argv.slice(3).some(a => a === '--benchmark' || a === '--metrics' || a === '--session-audit')) {
|
|
17
|
+
// Per #8217: the DB-layer doctor modes (--benchmark / --metrics / --session-audit,
|
|
18
|
+
// each implemented in cli/doctor.mjs) route to mem-cli. Everything else — plain
|
|
19
|
+
// `doctor`, `doctor --` (POSIX end-of-options), and `doctor --json` — stays with
|
|
20
|
+
// install.mjs's health-check, which OWNS --json (install.mjs doctor() line ~1216).
|
|
21
|
+
// Pre-fix the router forwarded ANY flagged `doctor --X` to mem-cli, so the documented
|
|
22
|
+
// `doctor --json` (install health JSON, advertised in install.mjs usage) was shadowed
|
|
23
|
+
// and rejected by cli/doctor.mjs. Gating on the three DB-layer flags keeps --json
|
|
24
|
+
// (and any future install-doctor flag) on the install path. Adding a NEW DB-layer
|
|
25
|
+
// mode requires extending this list — a deliberate trade for a working --json.
|
|
24
26
|
const { run } = await import('./mem-cli.mjs');
|
|
25
27
|
await run(process.argv.slice(2));
|
|
26
28
|
} else if (CLI_COMMANDS.has(cmd)) {
|
|
@@ -30,7 +32,10 @@ if (cmd === '--version' || cmd === '-v') {
|
|
|
30
32
|
// No command: show CLI help if installed, install help if not
|
|
31
33
|
const { existsSync } = await import('fs');
|
|
32
34
|
const { join } = await import('path');
|
|
33
|
-
|
|
35
|
+
// D#29: honor CLAUDE_MEM_DIR so the install-vs-CLI help routing is correct on
|
|
36
|
+
// relocated installs (matches schema.mjs DB_DIR; HOME fallback when env unset).
|
|
37
|
+
const dataDir = process.env.CLAUDE_MEM_DIR || join(process.env.HOME || '', '.claude-mem-lite');
|
|
38
|
+
const dbPath = join(dataDir, 'claude-mem-lite.db');
|
|
34
39
|
if (existsSync(dbPath)) {
|
|
35
40
|
const { run } = await import('./mem-cli.mjs');
|
|
36
41
|
await run(['help']);
|
package/hook-handoff.mjs
CHANGED
|
@@ -32,12 +32,26 @@ import * as taskReaderModule from './lib/task-reader.mjs';
|
|
|
32
32
|
* @param {string|null} [scopeSessionId=null] CC UUID for session_handoffs.session_id column
|
|
33
33
|
*/
|
|
34
34
|
export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapshot, scopeSessionId = null) {
|
|
35
|
-
// 1. Working objective — from user prompts
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
`
|
|
35
|
+
// 1. Working objective — from user prompts.
|
|
36
|
+
// D#26: getSessionId() is project-scoped, so multiple CC sessions in one project
|
|
37
|
+
// share `content_session_id`. When a genuine CC scope is passed (scopeSessionId is
|
|
38
|
+
// the CC UUID, i.e. differs from the mem-internal sessionId), filter to THIS CC
|
|
39
|
+
// session's prompts so working_on doesn't merge concurrent/sequential sessions.
|
|
40
|
+
// `OR cc_session_id IS NULL` keeps legacy rows + non-CC/no-stdin invocations. When
|
|
41
|
+
// scopeSessionId is absent or == sessionId (legacy/test/no-stdin), fall back to the
|
|
42
|
+
// unfiltered query (identical to pre-D#26 behavior).
|
|
43
|
+
const ccScope = scopeSessionId && scopeSessionId !== sessionId ? scopeSessionId : null;
|
|
44
|
+
const prompts = ccScope
|
|
45
|
+
? db.prepare(`
|
|
46
|
+
SELECT prompt_text FROM user_prompts
|
|
47
|
+
WHERE content_session_id = ? AND (cc_session_id = ? OR cc_session_id IS NULL)
|
|
48
|
+
ORDER BY prompt_number ASC LIMIT 5
|
|
49
|
+
`).all(sessionId, ccScope)
|
|
50
|
+
: db.prepare(`
|
|
51
|
+
SELECT prompt_text FROM user_prompts
|
|
52
|
+
WHERE content_session_id = ?
|
|
53
|
+
ORDER BY prompt_number ASC LIMIT 5
|
|
54
|
+
`).all(sessionId);
|
|
41
55
|
if (prompts.length === 0) return; // Empty session — nothing to hand off
|
|
42
56
|
|
|
43
57
|
// Filter prompts whose only content is workflow/control language ("继续",
|
|
@@ -73,12 +87,30 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
73
87
|
}
|
|
74
88
|
}
|
|
75
89
|
|
|
90
|
+
// D#28 (completes D#26): observations carry the project-scoped memory_session_id, shared by
|
|
91
|
+
// parallel/sequential same-project CC sessions. Lower-bound the observation queries below to
|
|
92
|
+
// THIS CC session's start (earliest prompt epoch for ccScope) so Completed / Key Files / Key
|
|
93
|
+
// Decisions stop merging a prior session's work — the observation-side complement of
|
|
94
|
+
// working_on's cc-scoping. When ccScope is absent or its session has no prompts (MIN→null),
|
|
95
|
+
// ccWindowStart stays null and the queries run unscoped (pre-D#28 behavior). Residual: truly
|
|
96
|
+
// concurrent same-project sessions whose windows overlap can still co-attribute a few rows.
|
|
97
|
+
let ccWindowStart = null;
|
|
98
|
+
if (ccScope) {
|
|
99
|
+
const w = db.prepare(`
|
|
100
|
+
SELECT MIN(created_at_epoch) AS startEpoch FROM user_prompts
|
|
101
|
+
WHERE content_session_id = ? AND cc_session_id = ?
|
|
102
|
+
`).get(sessionId, ccScope);
|
|
103
|
+
if (typeof w?.startEpoch === 'number') ccWindowStart = w.startEpoch;
|
|
104
|
+
}
|
|
105
|
+
const obsWindowClause = ccWindowStart !== null ? 'AND created_at_epoch >= ?' : '';
|
|
106
|
+
const obsWindowParams = ccWindowStart !== null ? [ccWindowStart] : [];
|
|
107
|
+
|
|
76
108
|
// 2. Completed — from observations (include narrative for richer handoff)
|
|
77
109
|
const completed = db.prepare(`
|
|
78
110
|
SELECT title, type, narrative FROM observations
|
|
79
|
-
WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
|
|
111
|
+
WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0 ${obsWindowClause}
|
|
80
112
|
ORDER BY created_at_epoch DESC LIMIT 15
|
|
81
|
-
`).all(sessionId);
|
|
113
|
+
`).all(sessionId, ...obsWindowParams);
|
|
82
114
|
|
|
83
115
|
// 3. Recent activity — episode snapshot + full session edit history from narratives.
|
|
84
116
|
// Keep only entries that represent in-flight work (file edits) or outright failures
|
|
@@ -131,9 +163,9 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
131
163
|
if (episodeSnapshot?.files) episodeSnapshot.files.filter(isValidFile).forEach(f => fileSet.add(f));
|
|
132
164
|
const obsFiles = db.prepare(`
|
|
133
165
|
SELECT files_modified FROM observations
|
|
134
|
-
WHERE memory_session_id = ? AND files_modified IS NOT NULL
|
|
166
|
+
WHERE memory_session_id = ? AND files_modified IS NOT NULL ${obsWindowClause}
|
|
135
167
|
ORDER BY created_at_epoch DESC LIMIT 10
|
|
136
|
-
`).all(sessionId);
|
|
168
|
+
`).all(sessionId, ...obsWindowParams);
|
|
137
169
|
for (const row of obsFiles) {
|
|
138
170
|
try { JSON.parse(row.files_modified).filter(isValidFile).forEach(f => fileSet.add(f)); } catch {}
|
|
139
171
|
}
|
|
@@ -142,9 +174,9 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
142
174
|
const decisions = db.prepare(`
|
|
143
175
|
SELECT title FROM observations
|
|
144
176
|
WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
|
|
145
|
-
AND COALESCE(compressed_into, 0) = 0
|
|
177
|
+
AND COALESCE(compressed_into, 0) = 0 ${obsWindowClause}
|
|
146
178
|
ORDER BY created_at_epoch DESC LIMIT 10
|
|
147
|
-
`).all(sessionId).filter(d => d.title && !LOW_SIGNAL_TITLE.test(d.title)).slice(0, 5);
|
|
179
|
+
`).all(sessionId, ...obsWindowParams).filter(d => d.title && !LOW_SIGNAL_TITLE.test(d.title)).slice(0, 5);
|
|
148
180
|
|
|
149
181
|
// 6. Match keywords
|
|
150
182
|
const allText = [workingOn, ...completed.map(c => c.title).filter(Boolean), unfinished].join(' ');
|
package/hook-update.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { readFileSync, writeFileSync, copyFileSync, cpSync, readdirSync, existsS
|
|
|
7
7
|
import { join, dirname } from 'node:path';
|
|
8
8
|
import { pathToFileURL } from 'node:url';
|
|
9
9
|
import { tmpdir, homedir } from 'node:os';
|
|
10
|
-
import { DB_DIR } from './schema.mjs';
|
|
10
|
+
import { DB_DIR, CODE_DIR } from './schema.mjs';
|
|
11
11
|
import { debugCatch, debugLog } from './utils.mjs';
|
|
12
12
|
// Local manifest is fallback only — the active manifest is loaded from the
|
|
13
13
|
// extracted tarball's own source-files.mjs inside installExtractedRelease.
|
|
@@ -16,8 +16,15 @@ import { SOURCE_FILES as LOCAL_SOURCE_FILES, HOOK_SCRIPT_FILES as LOCAL_HOOK_SCR
|
|
|
16
16
|
|
|
17
17
|
// ── Configuration ──────────────────────────────────────────
|
|
18
18
|
const GITHUB_REPO = 'sdsrss/claude-mem-lite';
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// Plugin CODE location (server.mjs / package.json / install target) — always
|
|
20
|
+
// homedir-rooted, NEVER follows CLAUDE_MEM_DIR (see schema.mjs CODE_DIR). Used
|
|
21
|
+
// for dev-mode detection, current-version read, and the install target dir.
|
|
22
|
+
const INSTALL_DIR = CODE_DIR; // ~/.claude-mem-lite/ (code)
|
|
23
|
+
// DATA/state location — runtime/update-state.json lives with the data (env-aware
|
|
24
|
+
// DB_DIR), matching hook-shared RUNTIME_DIR and install.mjs doctor's read path.
|
|
25
|
+
// Equal to INSTALL_DIR unless CLAUDE_MEM_DIR relocates the data dir.
|
|
26
|
+
const STATE_DIR = DB_DIR;
|
|
27
|
+
const STATE_FILE = join(STATE_DIR, 'runtime', 'update-state.json');
|
|
21
28
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
22
29
|
const FETCH_TIMEOUT_MS = 3000; // 3s network timeout
|
|
23
30
|
const RATE_LIMIT_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h if rate-limited
|
|
@@ -558,7 +565,7 @@ function readState() {
|
|
|
558
565
|
|
|
559
566
|
function saveState(state) {
|
|
560
567
|
try {
|
|
561
|
-
const dir = join(
|
|
568
|
+
const dir = join(STATE_DIR, 'runtime');
|
|
562
569
|
mkdirSync(dir, { recursive: true });
|
|
563
570
|
const tmpFile = STATE_FILE + `.tmp-${process.pid}`;
|
|
564
571
|
writeFileSync(tmpFile, JSON.stringify(state, null, 2));
|
package/hook.mjs
CHANGED
|
@@ -1208,7 +1208,12 @@ async function handleUserPrompt() {
|
|
|
1208
1208
|
// every downstream consumer (user_prompts INSERT, FTS query, continuation
|
|
1209
1209
|
// detection, semantic-memory injection) sees the redacted text — single
|
|
1210
1210
|
// source of truth for the privacy primitive.
|
|
1211
|
-
|
|
1211
|
+
// Strip NUL / C0 control chars (keep \t \n \r) before any downstream use: an
|
|
1212
|
+
// embedded NUL terminates SQLite's C string, silently truncating the stored
|
|
1213
|
+
// prompt_text at the first NUL (and breaking FTS). Single source of truth, so the
|
|
1214
|
+
// user_prompts INSERT, FTS query, and continuation detection all see clean text.
|
|
1215
|
+
// eslint-disable-next-line no-control-regex -- intentional: NUL/C0 strip prevents SQLite C-string truncation
|
|
1216
|
+
const promptText = stripPrivate(rawPrompt).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
|
|
1212
1217
|
|
|
1213
1218
|
const sessionId = getSessionId();
|
|
1214
1219
|
const db = openDb();
|
|
@@ -1233,24 +1238,27 @@ async function handleUserPrompt() {
|
|
|
1233
1238
|
).get(sessionId);
|
|
1234
1239
|
const promptNumber = bumped?.prompt_counter || 1;
|
|
1235
1240
|
|
|
1241
|
+
// Claude Code's real session_id (CC UUID) from hook stdin. Persisted on the
|
|
1242
|
+
// prompt row (cc_session_id) so buildAndSaveHandoff can scope working_on to ONE
|
|
1243
|
+
// CC session — getSessionId() is project-scoped (no CC-UUID), so without this
|
|
1244
|
+
// concurrent/within-TTL same-project sessions merge each other's prompts (D#26).
|
|
1245
|
+
// Also scopes handoff-row injection below. Null (legacy) when stdin lacks session_id.
|
|
1246
|
+
const ccSessionId = typeof hookData.session_id === 'string' && hookData.session_id.length > 0
|
|
1247
|
+
? hookData.session_id
|
|
1248
|
+
: null;
|
|
1249
|
+
|
|
1236
1250
|
db.prepare(`
|
|
1237
|
-
INSERT INTO user_prompts (content_session_id, prompt_text, prompt_number, created_at, created_at_epoch)
|
|
1238
|
-
VALUES (?, ?, ?, ?, ?)
|
|
1251
|
+
INSERT INTO user_prompts (content_session_id, prompt_text, prompt_number, cc_session_id, created_at, created_at_epoch)
|
|
1252
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1239
1253
|
`).run(
|
|
1240
1254
|
sessionId,
|
|
1241
1255
|
scrubSecrets(promptText.slice(0, 10000)),
|
|
1242
1256
|
promptNumber,
|
|
1257
|
+
ccSessionId,
|
|
1243
1258
|
now.toISOString(), now.getTime()
|
|
1244
1259
|
);
|
|
1245
1260
|
|
|
1246
1261
|
// Cross-session handoff injection (first 3 prompts window, before semantic memory).
|
|
1247
|
-
// Use Claude Code's real session_id from hook stdin to scope handoffs to this CC
|
|
1248
|
-
// session — prevents cross-session bleed when running parallel sessions for the
|
|
1249
|
-
// same project (see docs/bug.txt). Falls back to null (legacy behavior) if the
|
|
1250
|
-
// hook input does not carry session_id.
|
|
1251
|
-
const ccSessionId = typeof hookData.session_id === 'string' && hookData.session_id.length > 0
|
|
1252
|
-
? hookData.session_id
|
|
1253
|
-
: null;
|
|
1254
1262
|
if (promptNumber <= 3) {
|
|
1255
1263
|
try {
|
|
1256
1264
|
if (detectContinuationIntent(db, promptText, project, ccSessionId)) {
|
package/install.mjs
CHANGED
|
@@ -10,8 +10,18 @@ import { createRequire } from 'node:module';
|
|
|
10
10
|
|
|
11
11
|
const PROJECT_DIR = resolve(import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)));
|
|
12
12
|
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
13
|
+
// Plugin CODE / install location — ALWAYS homedir-rooted. Claude Code's
|
|
14
|
+
// settings.json + MCP registration bake ABSOLUTE paths to server.mjs / hooks here,
|
|
15
|
+
// and env vars are per-shell (the MCP launcher won't reliably inherit
|
|
16
|
+
// CLAUDE_MEM_DIR), so code must NOT follow the relocation env var.
|
|
13
17
|
const DATA_DIR = join(homedir(), '.claude-mem-lite');
|
|
14
|
-
|
|
18
|
+
// User DATA location — DB, managed resources, registry DB, runtime/. Honors
|
|
19
|
+
// CLAUDE_MEM_DIR exactly like schema.mjs DB_DIR so the installer WRITES data where
|
|
20
|
+
// the runtime/data layer READS it (pre-fix: installer wrote homedir, runtime read
|
|
21
|
+
// the relocated dir → preinstalled skills silently vanished, doctor read the wrong
|
|
22
|
+
// DB). Equals DATA_DIR when CLAUDE_MEM_DIR is unset (the common case).
|
|
23
|
+
const MEM_DATA_DIR = process.env.CLAUDE_MEM_DIR || DATA_DIR;
|
|
24
|
+
const DB_PATH = join(MEM_DATA_DIR, 'claude-mem-lite.db');
|
|
15
25
|
const OLD_DATA_DIR = join(homedir(), '.claude-mem');
|
|
16
26
|
|
|
17
27
|
// Detect ephemeral context (npx) — files won't persist after exit
|
|
@@ -319,6 +329,8 @@ async function install() {
|
|
|
319
329
|
}
|
|
320
330
|
|
|
321
331
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
332
|
+
// Under relocation the DB/managed/runtime live here, not in the code dir — create it too.
|
|
333
|
+
if (!existsSync(MEM_DATA_DIR)) mkdirSync(MEM_DATA_DIR, { recursive: true });
|
|
322
334
|
|
|
323
335
|
if (IS_DEV) {
|
|
324
336
|
log('Dev mode — creating symlinks in ~/.claude-mem-lite/...');
|
|
@@ -675,7 +687,7 @@ async function install() {
|
|
|
675
687
|
// "no such column: memory_session_id". Rename to a timestamped backup
|
|
676
688
|
// so the new install creates a fresh v28 DB.
|
|
677
689
|
try {
|
|
678
|
-
const r = migrateLegacyClaudeMemData(OLD_DATA_DIR,
|
|
690
|
+
const r = migrateLegacyClaudeMemData(OLD_DATA_DIR, MEM_DATA_DIR);
|
|
679
691
|
if (r.action === 'backed-up') {
|
|
680
692
|
ok(`Legacy ~/.claude-mem/ DB backed up to ${r.backupPath}`);
|
|
681
693
|
log('New v28 DB will be created on first launch (legacy schema is incompatible).');
|
|
@@ -685,7 +697,7 @@ async function install() {
|
|
|
685
697
|
}
|
|
686
698
|
|
|
687
699
|
// 5b. Rename claude-mem.db → claude-mem-lite.db in same directory
|
|
688
|
-
const oldDbInDir = join(
|
|
700
|
+
const oldDbInDir = join(MEM_DATA_DIR, 'claude-mem.db');
|
|
689
701
|
if (existsSync(oldDbInDir) && !existsSync(DB_PATH)) {
|
|
690
702
|
renameSync(oldDbInDir, DB_PATH);
|
|
691
703
|
for (const ext of ['-wal', '-shm']) {
|
|
@@ -714,7 +726,7 @@ async function install() {
|
|
|
714
726
|
const resources = manifest.resources || [];
|
|
715
727
|
|
|
716
728
|
if (resources.length > 0) {
|
|
717
|
-
const managedDir = join(
|
|
729
|
+
const managedDir = join(MEM_DATA_DIR, 'managed');
|
|
718
730
|
|
|
719
731
|
// 6a. Git shallow clone unique repos
|
|
720
732
|
const repos = new Map();
|
|
@@ -805,7 +817,7 @@ async function install() {
|
|
|
805
817
|
|
|
806
818
|
// 6b. Init registry DB and record preinstalled entries
|
|
807
819
|
const { ensureRegistryDb } = await importFromInstall('registry.mjs');
|
|
808
|
-
const regDbPath = join(
|
|
820
|
+
const regDbPath = join(MEM_DATA_DIR, 'resource-registry.db');
|
|
809
821
|
const rdb = ensureRegistryDb(regDbPath);
|
|
810
822
|
|
|
811
823
|
const insertPre = rdb.prepare(`
|
|
@@ -853,7 +865,7 @@ async function install() {
|
|
|
853
865
|
// 6d. Scan and index resources (fallback-only, Haiku indexing deferred to first run)
|
|
854
866
|
log(' Scanning resources...');
|
|
855
867
|
const { scanAllResources, diffResources } = await importFromInstall('registry-scanner.mjs');
|
|
856
|
-
const scanned = scanAllResources({ dataDir:
|
|
868
|
+
const scanned = scanAllResources({ dataDir: MEM_DATA_DIR });
|
|
857
869
|
|
|
858
870
|
// Attach star counts and repo URLs
|
|
859
871
|
for (const s of scanned) {
|
|
@@ -1063,15 +1075,26 @@ async function uninstall() {
|
|
|
1063
1075
|
|
|
1064
1076
|
// 6. Purge data if requested
|
|
1065
1077
|
if (flags.has('--purge')) {
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1078
|
+
const homeDir = join(homedir(), '.claude-mem-lite');
|
|
1079
|
+
// Always remove the homedir code/install dir (guarded to the canonical path).
|
|
1080
|
+
if (existsSync(DATA_DIR) && DATA_DIR === homeDir) {
|
|
1068
1081
|
rmSync(DATA_DIR, { recursive: true, force: true });
|
|
1069
1082
|
ok('Data purged (~/.claude-mem-lite/)');
|
|
1070
1083
|
} else if (existsSync(DATA_DIR)) {
|
|
1071
1084
|
fail('DATA_DIR path mismatch, refusing to purge for safety: ' + DATA_DIR);
|
|
1072
1085
|
}
|
|
1086
|
+
// Also remove the relocated data dir — but ONLY if it's genuinely our data dir
|
|
1087
|
+
// (contains claude-mem-lite.db), so a mistyped CLAUDE_MEM_DIR is never rm'd.
|
|
1088
|
+
if (MEM_DATA_DIR !== homeDir) {
|
|
1089
|
+
if (existsSync(join(MEM_DATA_DIR, 'claude-mem-lite.db'))) {
|
|
1090
|
+
rmSync(MEM_DATA_DIR, { recursive: true, force: true });
|
|
1091
|
+
ok(`Relocated data purged (${MEM_DATA_DIR})`);
|
|
1092
|
+
} else if (existsSync(MEM_DATA_DIR)) {
|
|
1093
|
+
warn(`CLAUDE_MEM_DIR (${MEM_DATA_DIR}) has no claude-mem-lite.db — left untouched. Remove manually if intended.`);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1073
1096
|
} else {
|
|
1074
|
-
log('Data preserved
|
|
1097
|
+
log('Data preserved (use --purge to remove)');
|
|
1075
1098
|
}
|
|
1076
1099
|
|
|
1077
1100
|
console.log('\n Done!\n');
|
|
@@ -1383,7 +1406,7 @@ async function doctor() {
|
|
|
1383
1406
|
|
|
1384
1407
|
// Update state
|
|
1385
1408
|
try {
|
|
1386
|
-
const stateFile = join(
|
|
1409
|
+
const stateFile = join(MEM_DATA_DIR, 'runtime', 'update-state.json');
|
|
1387
1410
|
if (existsSync(stateFile)) {
|
|
1388
1411
|
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
|
|
1389
1412
|
const parts = [];
|
|
@@ -1439,11 +1462,14 @@ async function doctor() {
|
|
|
1439
1462
|
|
|
1440
1463
|
// Stale temp files
|
|
1441
1464
|
try {
|
|
1442
|
-
|
|
1465
|
+
// hook-update + the episode workers write runtime/ + staging under DB_DIR
|
|
1466
|
+
// (= MEM_DATA_DIR, env-aware), NOT the homedir code dir — scan there so doctor
|
|
1467
|
+
// sees the real residue under relocation.
|
|
1468
|
+
const runtimeDir = join(MEM_DATA_DIR, 'runtime');
|
|
1443
1469
|
let staleCount = 0;
|
|
1444
1470
|
const stalePatterns = ['.update-staging-', '.update-backup-'];
|
|
1445
|
-
if (existsSync(
|
|
1446
|
-
for (const f of readdirSync(
|
|
1471
|
+
if (existsSync(MEM_DATA_DIR)) {
|
|
1472
|
+
for (const f of readdirSync(MEM_DATA_DIR)) {
|
|
1447
1473
|
if (stalePatterns.some(p => f.startsWith(p))) staleCount++;
|
|
1448
1474
|
}
|
|
1449
1475
|
}
|
|
@@ -1712,10 +1738,11 @@ function cleanup() {
|
|
|
1712
1738
|
console.log(`\nclaude-mem-lite cleanup${dryRun ? ' (--dry-run)' : ''}\n`);
|
|
1713
1739
|
let removed = 0;
|
|
1714
1740
|
|
|
1715
|
-
// Clean .update-staging-* / .update-backup-*
|
|
1741
|
+
// Clean .update-staging-* / .update-backup-* — hook-update writes these under
|
|
1742
|
+
// DB_DIR (= MEM_DATA_DIR, env-aware), so scan the data dir, not the homedir code dir.
|
|
1716
1743
|
const stalePatterns = ['.update-staging-', '.update-backup-'];
|
|
1717
|
-
if (existsSync(
|
|
1718
|
-
for (const f of readdirSync(
|
|
1744
|
+
if (existsSync(MEM_DATA_DIR)) {
|
|
1745
|
+
for (const f of readdirSync(MEM_DATA_DIR)) {
|
|
1719
1746
|
if (stalePatterns.some(p => f.startsWith(p))) {
|
|
1720
1747
|
if (dryRun) {
|
|
1721
1748
|
ok(`Would remove: ${f}`);
|
|
@@ -1723,7 +1750,7 @@ function cleanup() {
|
|
|
1723
1750
|
continue;
|
|
1724
1751
|
}
|
|
1725
1752
|
try {
|
|
1726
|
-
rmSync(join(
|
|
1753
|
+
rmSync(join(MEM_DATA_DIR, f), { recursive: true, force: true });
|
|
1727
1754
|
ok(`Removed: ${f}`);
|
|
1728
1755
|
removed++;
|
|
1729
1756
|
} catch (e) {
|
|
@@ -1733,8 +1760,8 @@ function cleanup() {
|
|
|
1733
1760
|
}
|
|
1734
1761
|
}
|
|
1735
1762
|
|
|
1736
|
-
// Clean pending-* / ep-flush-* in runtime/
|
|
1737
|
-
const runtimeDir = join(
|
|
1763
|
+
// Clean pending-* / ep-flush-* in runtime/ (under the env-aware data dir)
|
|
1764
|
+
const runtimeDir = join(MEM_DATA_DIR, 'runtime');
|
|
1738
1765
|
if (existsSync(runtimeDir)) {
|
|
1739
1766
|
for (const f of readdirSync(runtimeDir)) {
|
|
1740
1767
|
if (f.startsWith('pending-') || f.startsWith('ep-flush-')) {
|
package/lib/cli-flags.mjs
CHANGED
|
@@ -19,6 +19,21 @@
|
|
|
19
19
|
|
|
20
20
|
const DEFAULT_STDERR_WRITE = msg => process.stderr.write(msg);
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* True if `raw` is a clean integer or float-literal token — no trailing garbage,
|
|
24
|
+
* hex, or scientific notation. Float literals ARE accepted (callers truncate via
|
|
25
|
+
* parseInt, the deliberate #8277 decision); this only rejects shapes bare parseInt
|
|
26
|
+
* would silently coerce ("2abc"→2, "0x10"→0, "1e2"→1). Single source of the
|
|
27
|
+
* strict-shape rule shared by parseIntFlag and the reject-style numeric flags
|
|
28
|
+
* (save/update --importance, defer --priority).
|
|
29
|
+
*
|
|
30
|
+
* @param {string|number} raw Flag value as captured by parseArgs.
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function isNumericToken(raw) {
|
|
34
|
+
return /^-?\d+(\.\d+)?$/.test(String(raw).trim());
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
/**
|
|
23
38
|
* Validate and parse a CLI numeric flag with optional bounds.
|
|
24
39
|
*
|
|
@@ -38,8 +53,15 @@ export function parseIntFlag(rawValue, opts) {
|
|
|
38
53
|
return defaultValue;
|
|
39
54
|
}
|
|
40
55
|
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
// Reject trailing-garbage / hex / scientific tokens that bare parseInt would
|
|
57
|
+
// silently coerce by stopping at the first non-digit ("2abc"→2, "0x10"→0,
|
|
58
|
+
// "1e2"→1) — those slip past the Number.isInteger gate and violate the
|
|
59
|
+
// warn+default contract above. Float literals ("3.7"→3) stay ACCEPTED via
|
|
60
|
+
// parseInt truncation: that's the deliberate #8277 decision pinned by the
|
|
61
|
+
// 'rejects floats' case in cli-flags.test.mjs, so the shape check admits them.
|
|
62
|
+
const str = String(rawValue).trim();
|
|
63
|
+
const parsed = parseInt(str, 10);
|
|
64
|
+
if (!isNumericToken(str) || !Number.isInteger(parsed) || parsed < min || parsed > max) {
|
|
43
65
|
const range = max === Number.MAX_SAFE_INTEGER ? `≥ ${min}` : `between ${min} and ${max}`;
|
|
44
66
|
warn(`[mem] Invalid ${name} "${rawValue}" (must be an integer ${range}); using default ${defaultValue}\n`);
|
|
45
67
|
return defaultValue;
|
package/mem-cli.mjs
CHANGED
|
@@ -23,10 +23,10 @@ import {
|
|
|
23
23
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
24
24
|
import { buildSessionContextLines } from './hook-context.mjs';
|
|
25
25
|
import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
|
|
26
|
-
import { parseIntFlag } from './lib/cli-flags.mjs';
|
|
26
|
+
import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
|
|
27
27
|
import { auditMemdir, memdirPath } from './memdir.mjs';
|
|
28
28
|
import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
|
|
29
|
-
import { basename, join } from 'path';
|
|
29
|
+
import { basename, join, sep } from 'path';
|
|
30
30
|
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
31
31
|
|
|
32
32
|
// v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
|
|
@@ -71,16 +71,16 @@ function cmdSearch(db, args) {
|
|
|
71
71
|
process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
|
|
72
72
|
}
|
|
73
73
|
const minImportance = flags.importance !== undefined ? parseInt(flags.importance, 10) : null;
|
|
74
|
-
|
|
74
|
+
// isNumericToken first: "2abc"→2 / "1e2"→1 would pass the range check and silently
|
|
75
|
+
// filter at a value the user never typed. Reject garbage like out-of-range does.
|
|
76
|
+
if (minImportance !== null && (!isNumericToken(flags.importance) || isNaN(minImportance) || minImportance < 1 || minImportance > 3)) {
|
|
75
77
|
fail(`[mem] Invalid --importance "${flags.importance}". Must be 1, 2, or 3.`);
|
|
76
78
|
return;
|
|
77
79
|
}
|
|
78
80
|
const branch = flags.branch || null;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
const offset = Number.isInteger(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
|
|
81
|
+
// parseIntFlag (min=0) rejects garbage ("2abc"→2, "1e2"→1) the old isInteger check let
|
|
82
|
+
// through, warns once, and falls back to 0 — same WARN-style contract, now garbage-proof.
|
|
83
|
+
const offset = parseIntFlag(flags.offset, { name: '--offset', defaultValue: 0, min: 0 });
|
|
84
84
|
const tier = flags.tier || null;
|
|
85
85
|
if (tier && !['working', 'active', 'archive'].includes(tier)) {
|
|
86
86
|
fail(`[mem] Invalid --tier "${tier}". Use: working, active, archive`);
|
|
@@ -126,8 +126,14 @@ function cmdSearch(db, args) {
|
|
|
126
126
|
// so the post-merge sort has room to pick the best from each (paired-path with
|
|
127
127
|
// server.mjs:377 — without this, obs gets systematically squeezed out by sessions).
|
|
128
128
|
const isCrossSourceMode = !effectiveSource;
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
// Over-fetch from offset 0 and apply --offset ONCE at the final slice (below) in
|
|
130
|
+
// ALL modes — mirrors server.mjs. Pushing OFFSET into the obs hybrid path was
|
|
131
|
+
// unreliable: its AND→OR fallback / vector / concept-cooccurrence stages re-add
|
|
132
|
+
// rows the SQL OFFSET already skipped, so engine-side paging dropped (or
|
|
133
|
+
// duplicated) rows on the --type/--tier/--importance/--branch path (a page that
|
|
134
|
+
// MCP returned came back empty).
|
|
135
|
+
const perSourceLimit = Math.max(limit * 3, offset + limit + 10);
|
|
136
|
+
const perSourceOffset = 0;
|
|
131
137
|
|
|
132
138
|
const results = [];
|
|
133
139
|
// Tracks whether AND returned 0 and OR recovered non-empty. Mirrors server.mjs
|
|
@@ -305,7 +311,10 @@ function cmdSearch(db, args) {
|
|
|
305
311
|
}
|
|
306
312
|
// else 'relevance' keeps BM25 score order (already sorted)
|
|
307
313
|
|
|
308
|
-
// Trim to limit with offset
|
|
314
|
+
// Trim to limit with offset. The engine always received perSourceOffset=0 and
|
|
315
|
+
// over-fetched (see above), so the merged+reranked `results` start at row 0 and
|
|
316
|
+
// the offset is applied exactly ONCE here — for every mode. `total` is the full
|
|
317
|
+
// match count (capped at perSourceLimit), enabling the "N of M" display.
|
|
309
318
|
const total = results.length;
|
|
310
319
|
const paged = results.slice(offset, offset + limit);
|
|
311
320
|
|
|
@@ -389,7 +398,9 @@ function cmdRecent(db, args) {
|
|
|
389
398
|
const { positional, flags } = parseArgs(args);
|
|
390
399
|
const rawArg = positional[0];
|
|
391
400
|
const rawLimit = parseInt(rawArg, 10);
|
|
392
|
-
|
|
401
|
+
// isNumericToken first: "2abc"→2 / "1e2"→1 are positive integers that the bare check
|
|
402
|
+
// accepted silently; the positional path must reject garbage like the --limit flag does.
|
|
403
|
+
const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0;
|
|
393
404
|
if (rawArg !== undefined && !isValid) {
|
|
394
405
|
process.stderr.write(`[mem] Invalid count "${rawArg}" (must be a positive integer); using default 10\n`);
|
|
395
406
|
}
|
|
@@ -692,7 +703,9 @@ function cmdTimeline(db, args) {
|
|
|
692
703
|
const parseWindow = (label, raw) => {
|
|
693
704
|
if (raw === undefined) return 5;
|
|
694
705
|
const n = parseInt(raw, 10);
|
|
695
|
-
|
|
706
|
+
// isNumericToken first: "2abc"→2 / "1e2"→1 are non-negative integers the bare check
|
|
707
|
+
// accepted silently; reject garbage tokens like the negative path already does.
|
|
708
|
+
if (!isNumericToken(raw) || !Number.isInteger(n) || n < 0) {
|
|
696
709
|
process.stderr.write(`[mem] Invalid --${label} "${raw}" (must be a non-negative integer); using default 5\n`);
|
|
697
710
|
return 5;
|
|
698
711
|
}
|
|
@@ -925,7 +938,9 @@ function cmdSave(db, args) {
|
|
|
925
938
|
|
|
926
939
|
// Explicit saves default to importance=2 (notable) — user chose to save
|
|
927
940
|
const rawImp = flags.importance !== undefined ? parseInt(flags.importance, 10) : 2;
|
|
928
|
-
|
|
941
|
+
// isNumericToken first: bare parseInt would coerce "2abc"→2 / "1e2"→1 and persist a
|
|
942
|
+
// wrong importance that silently skews ranking/decay. Float literals still truncate (#8277).
|
|
943
|
+
if (flags.importance !== undefined && (!isNumericToken(flags.importance) || isNaN(rawImp) || rawImp < 1 || rawImp > 3)) {
|
|
929
944
|
fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
|
|
930
945
|
return;
|
|
931
946
|
}
|
|
@@ -1041,6 +1056,12 @@ function cmdDeferAdd(db, args) {
|
|
|
1041
1056
|
return;
|
|
1042
1057
|
}
|
|
1043
1058
|
const priority = flags.priority !== undefined ? parseInt(flags.priority, 10) : 2;
|
|
1059
|
+
// isNumericToken first: bare parseInt would coerce "3xyz"→3 and silently escalate a
|
|
1060
|
+
// deferred item's urgency. Float literals still truncate (#8277).
|
|
1061
|
+
if (flags.priority !== undefined && !isNumericToken(flags.priority)) {
|
|
1062
|
+
fail(`[mem] Invalid --priority "${flags.priority}". Must be 1 (low), 2 (normal), or 3 (urgent).`);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1044
1065
|
if (![1, 2, 3].includes(priority)) {
|
|
1045
1066
|
fail(`[mem] Invalid --priority "${flags.priority}". Must be 1 (low), 2 (normal), or 3 (urgent).`);
|
|
1046
1067
|
return;
|
|
@@ -1255,6 +1276,7 @@ async function cmdStats(db, args) {
|
|
|
1255
1276
|
const lowVal = db.prepare(`
|
|
1256
1277
|
SELECT COUNT(*) as c FROM observations
|
|
1257
1278
|
WHERE COALESCE(importance,1) = 1 AND COALESCE(access_count,0) = 0
|
|
1279
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
1258
1280
|
AND created_at_epoch < ? ${projectFilter}
|
|
1259
1281
|
`).get(thirtyDaysAgo, ...baseParams);
|
|
1260
1282
|
const noiseRatio = obsTotal.c > 0 ? lowVal.c / obsTotal.c : 0;
|
|
@@ -1607,6 +1629,19 @@ function cmdUpdate(db, args) {
|
|
|
1607
1629
|
return;
|
|
1608
1630
|
}
|
|
1609
1631
|
|
|
1632
|
+
// A value-less `--flag` (last arg, or immediately followed by another --flag)
|
|
1633
|
+
// parses to boolean `true` (cli/common.mjs parseArgs). For string-valued fields
|
|
1634
|
+
// that boolean would slip past the string-only empty guards below and reach the
|
|
1635
|
+
// SQLite bind, surfacing a raw "TypeError: SQLite3 can only bind ..." stacktrace
|
|
1636
|
+
// — the same accidental shell-strip class the empty-title guard (#8470) catches.
|
|
1637
|
+
// Reject it cleanly for every string-valued update flag.
|
|
1638
|
+
for (const key of ['title', 'narrative', 'lesson', 'lesson-learned', 'concepts']) {
|
|
1639
|
+
if (flags[key] === true) {
|
|
1640
|
+
fail(`[mem] --${key} requires a value (received a bare flag with no value).`);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1610
1645
|
const updates = [];
|
|
1611
1646
|
const params = [];
|
|
1612
1647
|
if (flags.title !== undefined) {
|
|
@@ -1629,7 +1664,9 @@ function cmdUpdate(db, args) {
|
|
|
1629
1664
|
}
|
|
1630
1665
|
if (flags.importance) {
|
|
1631
1666
|
const imp = parseInt(flags.importance, 10);
|
|
1632
|
-
|
|
1667
|
+
// isNumericToken first: bare parseInt would coerce "2abc"→2 and UPDATE the row to a
|
|
1668
|
+
// wrong importance. Float literals still truncate (#8277).
|
|
1669
|
+
if (!isNumericToken(flags.importance) || isNaN(imp) || imp < 1 || imp > 3) {
|
|
1633
1670
|
fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
|
|
1634
1671
|
return;
|
|
1635
1672
|
}
|
|
@@ -1731,8 +1768,16 @@ function cmdExport(db, args) {
|
|
|
1731
1768
|
return;
|
|
1732
1769
|
}
|
|
1733
1770
|
|
|
1771
|
+
// Full round-trippable column set so `restore` rebuilds observations faithfully —
|
|
1772
|
+
// content + value-signals (access/cited/uncited/injection/decay) + branch + timing.
|
|
1773
|
+
// Additive vs the pre-v2.90 13-col shape; existing `export | jq '.[].title'` consumers
|
|
1774
|
+
// are unaffected. id + memory_session_id are informational (restore remaps id and
|
|
1775
|
+
// buckets under a restore session).
|
|
1734
1776
|
const rows = db.prepare(`
|
|
1735
|
-
SELECT id, project, type, title, subtitle, narrative, concepts, facts,
|
|
1777
|
+
SELECT id, memory_session_id, project, type, title, subtitle, narrative, concepts, facts,
|
|
1778
|
+
files_read, files_modified, lesson_learned, importance, branch,
|
|
1779
|
+
access_count, cited_count, uncited_streak, injection_count, decay_seen_count,
|
|
1780
|
+
last_accessed_at, created_at, created_at_epoch
|
|
1736
1781
|
FROM observations WHERE ${wheres.join(' AND ')}
|
|
1737
1782
|
ORDER BY created_at_epoch DESC LIMIT ?
|
|
1738
1783
|
`).all(...params, limit);
|
|
@@ -1759,6 +1804,83 @@ function cmdExport(db, args) {
|
|
|
1759
1804
|
}
|
|
1760
1805
|
}
|
|
1761
1806
|
|
|
1807
|
+
// ─── Restore ───────────────────────────────────────────────────────────────
|
|
1808
|
+
// Inverse of `export` — the backup/restore half README:690 promises. Reuses
|
|
1809
|
+
// lib/save-observation.mjs so FK / FTS / TF-IDF vector / minhash / files-junction
|
|
1810
|
+
// stay consistent with cmdSave, then a targeted UPDATE re-applies the value-signals
|
|
1811
|
+
// (access/cited/uncited/injection/decay), branch, and concepts/facts/files_read that
|
|
1812
|
+
// saveObservation derives or zeros — so a restored backup keeps its citation-decay
|
|
1813
|
+
// history and original timing (created_at via the `now` param). Source ids are
|
|
1814
|
+
// discarded (local AUTOINCREMENT; export omits related_ids); session provenance
|
|
1815
|
+
// collapses to saveObservation's manual-<project> bucket (documented MVP tradeoff).
|
|
1816
|
+
function cmdRestore(db, argv) {
|
|
1817
|
+
const { positional, flags } = parseArgs(argv);
|
|
1818
|
+
const file = positional[0];
|
|
1819
|
+
if (!file) { fail('[mem] Usage: claude-mem-lite restore <file> [--project P] [--dry-run]'); return; }
|
|
1820
|
+
let raw;
|
|
1821
|
+
try { raw = readFileSync(file, 'utf8'); }
|
|
1822
|
+
catch (e) { fail(`[mem] Cannot read "${file}": ${e.message}`); return; }
|
|
1823
|
+
const trimmed = raw.trim();
|
|
1824
|
+
if (!trimmed) { out('[mem] Empty file — nothing to restore.'); return; }
|
|
1825
|
+
let rows;
|
|
1826
|
+
try {
|
|
1827
|
+
rows = trimmed[0] === '['
|
|
1828
|
+
? JSON.parse(trimmed)
|
|
1829
|
+
: trimmed.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
|
|
1830
|
+
} catch (e) { fail(`[mem] "${file}" is not valid export JSON/JSONL: ${e.message}`); return; }
|
|
1831
|
+
if (!Array.isArray(rows) || rows.length === 0) { out('[mem] No observations in file.'); return; }
|
|
1832
|
+
|
|
1833
|
+
const projOverride = flags.project ? resolveProject(db, flags.project) : null;
|
|
1834
|
+
const dryRun = flags['dry-run'] === true || flags['dry-run'] === 'true';
|
|
1835
|
+
const num = (v) => Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : 0;
|
|
1836
|
+
|
|
1837
|
+
const dupCheck = db.prepare('SELECT id FROM observations WHERE project = ? AND title = ? AND created_at_epoch = ? LIMIT 1');
|
|
1838
|
+
const signalUpdate = db.prepare(`UPDATE observations SET
|
|
1839
|
+
subtitle = ?, concepts = ?, facts = ?, files_read = ?, branch = COALESCE(?, branch),
|
|
1840
|
+
access_count = ?, cited_count = ?, uncited_streak = ?, injection_count = ?,
|
|
1841
|
+
decay_seen_count = ?, last_accessed_at = ?
|
|
1842
|
+
WHERE id = ?`);
|
|
1843
|
+
|
|
1844
|
+
let restored = 0, skipped = 0, malformed = 0;
|
|
1845
|
+
for (const r of rows) {
|
|
1846
|
+
if (!r || typeof r !== 'object' || !r.type || !r.title) { malformed++; continue; }
|
|
1847
|
+
const project = projOverride || r.project || inferProject();
|
|
1848
|
+
const createdEpoch = Number.isFinite(Number(r.created_at_epoch)) ? Number(r.created_at_epoch) : Date.now();
|
|
1849
|
+
// Durable exact-dup guard — saveObservation's 5-min Jaccard window can't catch a
|
|
1850
|
+
// re-restore of an old-timestamped backup, so gate on project+title+created_at.
|
|
1851
|
+
if (dupCheck.get(project, r.title, createdEpoch)) { skipped++; continue; }
|
|
1852
|
+
if (dryRun) { restored++; continue; }
|
|
1853
|
+
try {
|
|
1854
|
+
let files = [];
|
|
1855
|
+
try { const fm = JSON.parse(r.files_modified || '[]'); if (Array.isArray(fm)) files = fm; } catch { /* leave [] */ }
|
|
1856
|
+
const imp = num(r.importance);
|
|
1857
|
+
const res = saveObservation(db, {
|
|
1858
|
+
content: r.narrative || r.title,
|
|
1859
|
+
title: r.title,
|
|
1860
|
+
type: r.type,
|
|
1861
|
+
importance: imp >= 1 && imp <= 3 ? imp : 1,
|
|
1862
|
+
project,
|
|
1863
|
+
files,
|
|
1864
|
+
lesson_learned: r.lesson_learned || null,
|
|
1865
|
+
now: new Date(createdEpoch),
|
|
1866
|
+
});
|
|
1867
|
+
if (res.kind !== 'saved') { skipped++; continue; } // saveObservation Jaccard dedup
|
|
1868
|
+
// Re-apply the fields saveObservation zeros/derives so the backup is faithful.
|
|
1869
|
+
signalUpdate.run(
|
|
1870
|
+
r.subtitle || '', r.concepts || '', r.facts || '', r.files_read || '[]', r.branch ?? null,
|
|
1871
|
+
num(r.access_count), num(r.cited_count), num(r.uncited_streak), num(r.injection_count),
|
|
1872
|
+
num(r.decay_seen_count), r.last_accessed_at ?? null,
|
|
1873
|
+
res.id,
|
|
1874
|
+
);
|
|
1875
|
+
restored++;
|
|
1876
|
+
} catch (e) {
|
|
1877
|
+
malformed++;
|
|
1878
|
+
if (process.env.CLAUDE_MEM_DEBUG) process.stderr.write(`[mem] restore row failed: ${e.message}\n`);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped, ${malformed} malformed/failed from ${rows.length} row(s).`);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1762
1884
|
// ─── Compress ────────────────────────────────────────────────────────────────
|
|
1763
1885
|
|
|
1764
1886
|
function cmdCompress(db, args) {
|
|
@@ -1876,7 +1998,11 @@ function cmdMaintain(db, args) {
|
|
|
1876
1998
|
|
|
1877
1999
|
// Execute
|
|
1878
2000
|
const VALID_OPS = ['cleanup', 'decay', 'boost', 'demote_pinned', 'dedup', 'purge_stale', 'rebuild_vectors', 'vacuum'];
|
|
1879
|
-
|
|
2001
|
+
// Distinguish flag-absent (use default op set) from flag-present-but-empty
|
|
2002
|
+
// (`--ops ""`, e.g. an unset shell var). The latter previously coerced via `||`
|
|
2003
|
+
// to the destructive default cleanup,decay,boost and EXECUTED it; route it to the
|
|
2004
|
+
// VALID_OPS check below instead so it's rejected like `--ops " "` / `--ops "decay,"`.
|
|
2005
|
+
const opsStr = flags.ops === undefined ? 'cleanup,decay,boost' : String(flags.ops);
|
|
1880
2006
|
const ops = opsStr.split(',').map(s => s.trim());
|
|
1881
2007
|
const invalidOps = ops.filter(op => !VALID_OPS.includes(op));
|
|
1882
2008
|
if (invalidOps.length > 0) {
|
|
@@ -2054,7 +2180,7 @@ function cmdRegistry(_memDb, args) {
|
|
|
2054
2180
|
for (const r of results) {
|
|
2055
2181
|
const badge = r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
|
|
2056
2182
|
const categoryLabel = r.category ? ` [${r.category}]` : '';
|
|
2057
|
-
const isManaged = r.local_path && r.local_path.includes('
|
|
2183
|
+
const isManaged = r.local_path && r.local_path.includes(join(DB_DIR, 'managed') + sep);
|
|
2058
2184
|
const portablePath = isManaged && r.local_path.startsWith(home) ? '~' + r.local_path.slice(home.length) : (r.local_path || '');
|
|
2059
2185
|
let howToUse;
|
|
2060
2186
|
if (isManaged) {
|
|
@@ -2119,6 +2245,12 @@ function cmdRegistry(_memDb, args) {
|
|
|
2119
2245
|
}
|
|
2120
2246
|
|
|
2121
2247
|
if (action === 'import') {
|
|
2248
|
+
// A bare value-less flag parses to boolean `true` (parseArgs); for these string
|
|
2249
|
+
// fields that boolean reaches the SQLite bind in upsertResource and throws a raw
|
|
2250
|
+
// TypeError — same class as the `update` guard above (#8470). Reject up front.
|
|
2251
|
+
for (const key of ['name', 'resource-type', 'invocation-name', 'source', 'repo-url', 'local-path', 'intent-tags', 'domain-tags', 'trigger-patterns', 'capability-summary', 'keywords', 'tech-stack', 'use-cases']) {
|
|
2252
|
+
if (flags[key] === true) { fail(`[mem] --${key} requires a value (received a bare flag with no value).`); return; }
|
|
2253
|
+
}
|
|
2122
2254
|
const name = flags.name;
|
|
2123
2255
|
const resourceType = flags['resource-type'];
|
|
2124
2256
|
if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry import --name N --resource-type skill|agent [--invocation-name I] [--capability-summary S]'); return; }
|
|
@@ -2140,6 +2272,11 @@ function cmdRegistry(_memDb, args) {
|
|
|
2140
2272
|
}
|
|
2141
2273
|
|
|
2142
2274
|
if (action === 'remove') {
|
|
2275
|
+
// Bare value-less --name / --resource-type → boolean true → SQLite-bind crash
|
|
2276
|
+
// on the DELETE below; reject like the import branch and the `update` guard.
|
|
2277
|
+
for (const key of ['name', 'resource-type']) {
|
|
2278
|
+
if (flags[key] === true) { fail(`[mem] --${key} requires a value (received a bare flag with no value).`); return; }
|
|
2279
|
+
}
|
|
2143
2280
|
const name = flags.name;
|
|
2144
2281
|
const resourceType = flags['resource-type'];
|
|
2145
2282
|
if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry remove --name N --resource-type skill|agent'); return; }
|
|
@@ -2414,6 +2551,7 @@ Commands:
|
|
|
2414
2551
|
--concepts T Space-separated concept tags
|
|
2415
2552
|
|
|
2416
2553
|
export Export observations as JSON/JSONL
|
|
2554
|
+
restore <file> Restore observations from an export file (JSON/JSONL); --dry-run to preview
|
|
2417
2555
|
--project P Filter by project
|
|
2418
2556
|
--type T Filter by type
|
|
2419
2557
|
--format F json (default) or jsonl
|
|
@@ -2641,6 +2779,13 @@ async function cmdImportJsonl(db, argv) {
|
|
|
2641
2779
|
out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations, ${totalOrphans} orphan tool_use, ${totalSkip} skipped from ${files.length} file(s)${errorTail}.`);
|
|
2642
2780
|
if (totalPrompts > 0 || totalObs > 0) {
|
|
2643
2781
|
out(`[mem] Try: claude-mem-lite recent 5 --project ${project}`);
|
|
2782
|
+
} else if (totalSkip > 0 && errorCount === 0) {
|
|
2783
|
+
// Nothing imported but every line was skipped — almost always the wrong file
|
|
2784
|
+
// format (import-jsonl ingests Claude Code transcript JSONL, not `export` output,
|
|
2785
|
+
// which is observation-shaped). Pre-fix this exited 0 with no signal, so pointing
|
|
2786
|
+
// it at the wrong file looked like success. Make the no-op explicit (stdout, like
|
|
2787
|
+
// the summary lines above).
|
|
2788
|
+
out(`[mem] Warning: 0 imported, ${totalSkip} line(s) skipped — none matched the expected Claude Code transcript JSONL shape (user/assistant/tool_result). 'export' output is NOT re-importable via import-jsonl.`);
|
|
2644
2789
|
}
|
|
2645
2790
|
}
|
|
2646
2791
|
|
|
@@ -2865,6 +3010,7 @@ export async function run(argv) {
|
|
|
2865
3010
|
case 'delete': cmdDelete(db, cmdArgs); break;
|
|
2866
3011
|
case 'update': cmdUpdate(db, cmdArgs); break;
|
|
2867
3012
|
case 'export': cmdExport(db, cmdArgs); break;
|
|
3013
|
+
case 'restore': cmdRestore(db, cmdArgs); break;
|
|
2868
3014
|
case 'compress': cmdCompress(db, cmdArgs); break;
|
|
2869
3015
|
case 'maintain': cmdMaintain(db, cmdArgs); break;
|
|
2870
3016
|
case 'optimize': await cmdOptimize(db, cmdArgs); break;
|
package/nlp.mjs
CHANGED
|
@@ -218,6 +218,12 @@ export const FTS_STOP_WORDS = new Set([...BASE_STOP_WORDS]);
|
|
|
218
218
|
export function sanitizeFtsQuery(query) {
|
|
219
219
|
if (!query) return null;
|
|
220
220
|
const cleaned = query
|
|
221
|
+
// Strip ASCII control chars / NUL FIRST. A NUL survives tokenization (it's not
|
|
222
|
+
// \s), gets phrase-quoted by expandToken, and then terminates SQLite's C string
|
|
223
|
+
// mid-phrase → FTS5 "unterminated string" throw, breaking the documented
|
|
224
|
+
// "never throws on MATCH" invariant. The metachar class below doesn't cover them.
|
|
225
|
+
// eslint-disable-next-line no-control-regex -- intentional: stripping control chars IS the fix
|
|
226
|
+
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
221
227
|
.replace(/[{}()[\]^~*:"\\]/g, ' ')
|
|
222
228
|
.replace(/(^|\s)-/g, '$1')
|
|
223
229
|
.trim();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.90.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",
|
package/registry-importer.mjs
CHANGED
|
@@ -8,9 +8,12 @@ import { debugLog, isPathConfined } from './utils.mjs';
|
|
|
8
8
|
import { createHash } from 'crypto';
|
|
9
9
|
import { mkdirSync, writeFileSync } from 'fs';
|
|
10
10
|
import { join } from 'path';
|
|
11
|
-
import {
|
|
11
|
+
import { DB_DIR } from './schema.mjs';
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// DATA artifact — managed resources live under the env-aware data dir (DB_DIR),
|
|
14
|
+
// NOT a hardcoded homedir, so GitHub imports land where install.mjs + registry-scanner
|
|
15
|
+
// read them under CLAUDE_MEM_DIR relocation (D#29). Equals homedir when the env is unset.
|
|
16
|
+
const MANAGED_DIR = join(DB_DIR, 'managed');
|
|
14
17
|
|
|
15
18
|
// ─── Tree Discovery ─────────────────────────────────────────────────────────
|
|
16
19
|
|
package/schema.mjs
CHANGED
|
@@ -8,9 +8,17 @@ import { join } from 'path';
|
|
|
8
8
|
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, chmodSync } from 'fs';
|
|
9
9
|
import { OBS_FTS_COLUMNS } from './utils.mjs';
|
|
10
10
|
|
|
11
|
+
// DATA location — DB, managed resources, registry DB, runtime/. Honors
|
|
12
|
+
// CLAUDE_MEM_DIR so users can relocate state to a larger/faster volume.
|
|
11
13
|
export const DB_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
|
|
12
14
|
export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
|
|
13
15
|
export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
|
|
16
|
+
// CODE / install location — server.mjs, hook.mjs, cli.mjs, package.json live
|
|
17
|
+
// here. ALWAYS homedir-rooted: Claude Code's settings.json + MCP registration
|
|
18
|
+
// bake ABSOLUTE paths to server.mjs/hooks, so the code must NOT follow the
|
|
19
|
+
// CLAUDE_MEM_DIR relocation env var (mirrors install.mjs INSTALL_DIR). Equals
|
|
20
|
+
// DB_DIR when CLAUDE_MEM_DIR is unset — the common, non-relocated case.
|
|
21
|
+
export const CODE_DIR = join(homedir(), '.claude-mem-lite');
|
|
14
22
|
|
|
15
23
|
// Increment when schema changes (tables, columns, indexes, FTS, migrations)
|
|
16
24
|
//
|
|
@@ -61,13 +69,15 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
|
|
|
61
69
|
// SQLITE_CORRUPT_VTAB blast radius v27 fixed for the other FTS tables. Version
|
|
62
70
|
// bumped to force one migration pass; the conditional drop below replaces the
|
|
63
71
|
// legacy trigger on existing DBs. LATEST_MIGRATION_COLUMN unchanged (no new column).
|
|
64
|
-
|
|
72
|
+
// v37 (D#26): adds user_prompts.cc_session_id (additive, nullable). LATEST_MIGRATION_COLUMN
|
|
73
|
+
// MOVES to it so the half-migrated-DB self-heal fast-path covers the new column.
|
|
74
|
+
export const CURRENT_SCHEMA_VERSION = 37;
|
|
65
75
|
|
|
66
76
|
// Sentinel column for the LATEST migration set. The fast-path uses this to
|
|
67
77
|
// self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
|
|
68
78
|
// back (observed once in dev during v2.74.0). Update both the column AND
|
|
69
79
|
// (if needed) the table when adding a new migration batch.
|
|
70
|
-
const LATEST_MIGRATION_COLUMN = { table: '
|
|
80
|
+
const LATEST_MIGRATION_COLUMN = { table: 'user_prompts', column: 'cc_session_id' };
|
|
71
81
|
|
|
72
82
|
function hasLatestMigrationColumn(db) {
|
|
73
83
|
try {
|
|
@@ -205,6 +215,14 @@ const MIGRATIONS = [
|
|
|
205
215
|
// share the unrelated injection_count column. Same-source numerator
|
|
206
216
|
// (cited_count) + same-source denominator = meaningful ratio.
|
|
207
217
|
'ALTER TABLE observations ADD COLUMN decay_seen_count INTEGER NOT NULL DEFAULT 0',
|
|
218
|
+
// v37 (D#26 — parallel-session handoff content scoping): the Claude-Code session
|
|
219
|
+
// UUID per user prompt. handleUserPrompt writes hookData.session_id here so
|
|
220
|
+
// buildAndSaveHandoff can scope working_on to ONE CC session — concurrent (and
|
|
221
|
+
// within-12h-TTL sequential) same-project sessions previously merged each other's
|
|
222
|
+
// prompts because getSessionId() is project-scoped (no CC-UUID component). Nullable:
|
|
223
|
+
// legacy rows + non-CC/no-stdin invocations read back NULL and the handoff falls
|
|
224
|
+
// back to its legacy unfiltered query.
|
|
225
|
+
'ALTER TABLE user_prompts ADD COLUMN cc_session_id TEXT DEFAULT NULL',
|
|
208
226
|
];
|
|
209
227
|
|
|
210
228
|
/**
|
|
@@ -355,6 +373,7 @@ export function initSchema(db) {
|
|
|
355
373
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sess_sum_epoch ON session_summaries(created_at_epoch DESC, project)`);
|
|
356
374
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_project_epoch_minhash ON observations(project, created_at_epoch DESC) WHERE minhash_sig IS NOT NULL`);
|
|
357
375
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_prompts_session ON user_prompts(content_session_id)`);
|
|
376
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_prompts_cc ON user_prompts(cc_session_id) WHERE cc_session_id IS NOT NULL`);
|
|
358
377
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_superseded ON observations(superseded_at) WHERE superseded_at IS NOT NULL`);
|
|
359
378
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_branch ON observations(branch) WHERE branch IS NOT NULL`);
|
|
360
379
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sdk_sessions(project)`);
|
|
@@ -11,9 +11,15 @@ import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
|
11
11
|
// CLAUDE_MEM_DIR mirrors pre-tool-recall.js — one env var sandboxes everything.
|
|
12
12
|
const DATA_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
|
|
13
13
|
const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// D#29: all data artifacts follow DATA_DIR (CLAUDE_MEM_DIR-aware), not a hardcoded
|
|
15
|
+
// homedir — previously REGISTRY_DB_PATH/MANAGED_BASE/MARKER pinned homedir while line 12
|
|
16
|
+
// honored the env, so relocated installs opened the wrong DB and the marker never matched
|
|
17
|
+
// the relocated local_path. MANAGED_MARKER is only a coarse LIKE prefilter; the exact
|
|
18
|
+
// MANAGED_BASE prefix check below is the real confinement gate (so LIKE-wildcard chars in
|
|
19
|
+
// a relocated path can at worst over-admit to that gate, never bypass it).
|
|
20
|
+
const REGISTRY_DB_PATH = join(DATA_DIR, 'resource-registry.db');
|
|
21
|
+
const MANAGED_BASE = DATA_DIR;
|
|
22
|
+
const MANAGED_MARKER = join(DATA_DIR, 'managed') + sep;
|
|
17
23
|
|
|
18
24
|
try {
|
|
19
25
|
// Skip if recursive hook
|
|
@@ -8,7 +8,7 @@ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject,
|
|
|
8
8
|
import { citeFactorClause } from '../scoring-sql.mjs';
|
|
9
9
|
import { cjkPrecisionOk } from '../nlp.mjs';
|
|
10
10
|
import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
|
|
11
|
-
import { join } from 'path';
|
|
11
|
+
import { join, sep } from 'path';
|
|
12
12
|
import Database from 'better-sqlite3';
|
|
13
13
|
import { shouldSkip, computeEffectiveLen, detectIntent, shouldSkipByDedup, extractFiles, extractErrorSignature, DEDUP_STALE_MS, matchRegistrySkillName, detectMemOverride } from './prompt-search-utils.mjs';
|
|
14
14
|
|
|
@@ -439,10 +439,18 @@ function loadManagedSkillNames() {
|
|
|
439
439
|
const rdb = new Database(REGISTRY_DB_PATH, { readonly: true });
|
|
440
440
|
rdb.pragma('busy_timeout = 500');
|
|
441
441
|
try {
|
|
442
|
+
// D#29: derive the managed marker from the env-aware data dir, not a hardcoded
|
|
443
|
+
// homedir literal — under CLAUDE_MEM_DIR relocation the stored local_path lives at
|
|
444
|
+
// DB_DIR/managed, so the old literal matched nothing and dropped every managed skill
|
|
445
|
+
// from injection. Coarse LIKE prefilter; resource names are re-validated downstream.
|
|
446
|
+
// D#29: derive the managed marker from the env-aware data dir, not a hardcoded
|
|
447
|
+
// homedir literal — under CLAUDE_MEM_DIR relocation the stored local_path lives at
|
|
448
|
+
// DB_DIR/managed, so the old literal matched nothing and dropped every managed skill
|
|
449
|
+
// from injection. Coarse LIKE prefilter; resource names are re-validated downstream.
|
|
442
450
|
const rows = rdb.prepare(`
|
|
443
451
|
SELECT name FROM resources
|
|
444
|
-
WHERE status = 'active' AND local_path LIKE
|
|
445
|
-
`).all();
|
|
452
|
+
WHERE status = 'active' AND local_path LIKE ?
|
|
453
|
+
`).all(`%${join(DB_DIR, 'managed') + sep}%`);
|
|
446
454
|
return new Set(rows.map(r => r.name.toLowerCase()));
|
|
447
455
|
} finally { rdb.close(); }
|
|
448
456
|
} catch { return new Set(); }
|
package/server.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
|
8
8
|
import { truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, scrubSecrets, cjkBigrams, fmtDate, debugLog, debugCatch, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined, notLowSignalTitleClause } from './utils.mjs';
|
|
9
9
|
import { extractCjkLikePatterns, cjkPrecisionOk } from './nlp.mjs';
|
|
10
10
|
import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
|
|
11
|
-
import { ensureDb, DB_PATH, REGISTRY_DB_PATH } from './schema.mjs';
|
|
11
|
+
import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
|
|
12
12
|
import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
|
|
13
13
|
import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
|
|
14
14
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
@@ -30,7 +30,7 @@ function descriptionOf(name) {
|
|
|
30
30
|
return d;
|
|
31
31
|
}
|
|
32
32
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
33
|
-
import { basename, join } from 'path';
|
|
33
|
+
import { basename, join, sep } from 'path';
|
|
34
34
|
import { homedir } from 'os';
|
|
35
35
|
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
36
36
|
import { searchResources } from './registry-retriever.mjs';
|
|
@@ -475,7 +475,12 @@ server.registerTool(
|
|
|
475
475
|
`SELECT id, compressed_into, superseded_at, memory_session_id, project, importance, last_accessed_at, created_at_epoch, type FROM observations WHERE id IN (${placeholders})`
|
|
476
476
|
).all(...obsIds);
|
|
477
477
|
const rowMap = new Map(fullRows.map(r => [r.id, r]));
|
|
478
|
-
|
|
478
|
+
// Use the explicitly-requested project for tier classification, not the
|
|
479
|
+
// CWD-inferred one — else computeTier's "obs.project === currentProject"
|
|
480
|
+
// (working/active rules) fails for cross-project searches and the tier=
|
|
481
|
+
// filter silently drops valid rows. mem_stats/mem_browse already resolve
|
|
482
|
+
// args.project first; this restores parity.
|
|
483
|
+
const tierCtx = { now: Date.now(), currentProject: args.project || currentProject, currentSessionId: '' };
|
|
479
484
|
const filtered = results.filter(r => {
|
|
480
485
|
if (r.source !== 'obs') return true;
|
|
481
486
|
const full = rowMap.get(r.id);
|
|
@@ -1108,6 +1113,7 @@ server.registerTool(
|
|
|
1108
1113
|
const lowVal = db.prepare(`
|
|
1109
1114
|
SELECT COUNT(*) as c FROM observations
|
|
1110
1115
|
WHERE COALESCE(importance,1) = 1 AND COALESCE(access_count,0) = 0
|
|
1116
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
1111
1117
|
AND created_at_epoch < ? ${projectFilter}
|
|
1112
1118
|
`).get(thirtyDaysAgo, ...baseParams);
|
|
1113
1119
|
|
|
@@ -1500,7 +1506,7 @@ server.registerTool(
|
|
|
1500
1506
|
const lines = results.map(r => {
|
|
1501
1507
|
const qualityBadge = r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
|
|
1502
1508
|
const categoryLabel = r.category ? ` [${r.category}]` : '';
|
|
1503
|
-
const isManaged = r.local_path && r.local_path.includes('
|
|
1509
|
+
const isManaged = r.local_path && r.local_path.includes(join(DB_DIR, 'managed') + sep);
|
|
1504
1510
|
const portablePath = isManaged ? toPortable(r.local_path) : '';
|
|
1505
1511
|
let howToUse;
|
|
1506
1512
|
if (isManaged) {
|
|
@@ -1645,7 +1651,9 @@ server.registerTool(
|
|
|
1645
1651
|
if (!row.local_path) {
|
|
1646
1652
|
return { content: [{ type: 'text', text: `No local_path for ${args.name}` }], isError: true };
|
|
1647
1653
|
}
|
|
1648
|
-
|
|
1654
|
+
// Confine to the env-aware data dir (managed/ relocates with CLAUDE_MEM_DIR, D#29);
|
|
1655
|
+
// === homedir when the env is unset, so non-relocated confinement is unchanged.
|
|
1656
|
+
const enrichBase = DB_DIR;
|
|
1649
1657
|
if (!isPathConfined(row.local_path, enrichBase)) {
|
|
1650
1658
|
return { content: [{ type: 'text', text: `Access denied: path outside managed directory` }], isError: true };
|
|
1651
1659
|
}
|
|
@@ -1713,8 +1721,10 @@ server.registerTool(
|
|
|
1713
1721
|
}
|
|
1714
1722
|
}
|
|
1715
1723
|
|
|
1716
|
-
// 4. Path confinement check — prevent reading arbitrary files via crafted local_path
|
|
1717
|
-
|
|
1724
|
+
// 4. Path confinement check — prevent reading arbitrary files via crafted local_path.
|
|
1725
|
+
// Base is the env-aware data dir (D#29): managed/ relocates with CLAUDE_MEM_DIR and
|
|
1726
|
+
// equals homedir when unset, so this does not weaken the non-relocated confinement.
|
|
1727
|
+
const managedBase = DB_DIR;
|
|
1718
1728
|
if (skillPath && !isPathConfined(skillPath, managedBase)) {
|
|
1719
1729
|
return { content: [{ type: 'text', text: `Access denied: path "${skillPath}" is outside managed directory` }], isError: true };
|
|
1720
1730
|
}
|