claude-mem-lite 2.28.1 → 2.28.2

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.28.1",
13
+ "version": "2.28.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.28.1",
3
+ "version": "2.28.2",
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/cli.mjs CHANGED
@@ -16,7 +16,19 @@ if (cmd === '--version' || cmd === '-v') {
16
16
  } else if (CLI_COMMANDS.has(cmd)) {
17
17
  const { run } = await import('./mem-cli.mjs');
18
18
  await run(process.argv.slice(2));
19
- } else if (!cmd || INSTALL_COMMANDS.has(cmd)) {
19
+ } else if (!cmd) {
20
+ // No command: show CLI help if installed, install help if not
21
+ const { existsSync } = await import('fs');
22
+ const { join } = await import('path');
23
+ const dbPath = join(process.env.HOME || '', '.claude-mem-lite', 'claude-mem-lite.db');
24
+ if (existsSync(dbPath)) {
25
+ const { run } = await import('./mem-cli.mjs');
26
+ await run(['help']);
27
+ } else {
28
+ const { main } = await import('./install.mjs');
29
+ await main([]);
30
+ }
31
+ } else if (INSTALL_COMMANDS.has(cmd)) {
20
32
  const { main } = await import('./install.mjs');
21
33
  await main(process.argv.slice(2));
22
34
  } else {
package/hook-episode.mjs CHANGED
@@ -107,7 +107,7 @@ export function readEpisode() {
107
107
  */
108
108
  export function writeEpisode(episode) {
109
109
  const target = episodeFile();
110
- const tmp = target + '.tmp';
110
+ const tmp = target + `.tmp-${process.pid}`;
111
111
  const { _fileSet, ...serializable } = episode;
112
112
  writeFileSync(tmp, JSON.stringify(serializable));
113
113
  try {
@@ -2,7 +2,7 @@
2
2
  // Limits concurrent claude -p calls to prevent resource contention
3
3
 
4
4
  import { join } from 'path';
5
- import { readFileSync, writeFileSync, unlinkSync, readdirSync, openSync, closeSync, writeSync, constants as fsConstants } from 'fs';
5
+ import { readFileSync, unlinkSync, readdirSync, openSync, closeSync, writeSync, constants as fsConstants } from 'fs';
6
6
  import { RUNTIME_DIR } from './hook-shared.mjs';
7
7
 
8
8
  export const LLM_SEM_MAX = 2;
@@ -21,7 +21,7 @@ export async function acquireLLMSlot() {
21
21
 
22
22
  while (Date.now() < deadline) {
23
23
  // Acquire-then-verify: atomically create our slot first, then check total count
24
- let created = false;
24
+ let created;
25
25
  try {
26
26
  let fd;
27
27
  try {
@@ -33,8 +33,9 @@ export async function acquireLLMSlot() {
33
33
  if (fd !== undefined) closeSync(fd);
34
34
  }
35
35
  } catch {
36
- // Slot file already exists for this PID — update timestamp
37
- try { writeFileSync(slotFile, JSON.stringify({ pid: process.pid, ts: Date.now() })); created = true; } catch {}
36
+ // Slot file already exists for this PID — stale cleanup should have removed it;
37
+ // retry and let O_CREAT|O_EXCL succeed on next iteration (avoid non-atomic fallback)
38
+ continue;
38
39
  }
39
40
 
40
41
  if (!created) { await sleepMs(200 + Math.random() * 800); continue; }
package/hook-update.mjs CHANGED
@@ -214,7 +214,7 @@ async function downloadAndInstall(tarballUrl) {
214
214
 
215
215
  // Download tarball via curl (available on all supported platforms)
216
216
  // Validate URL to prevent command injection via crafted tarball URLs
217
- if (!/^https:\/\/[a-zA-Z0-9./-]+$/.test(tarballUrl)) {
217
+ if (!/^https:\/\/(?:api\.)?github\.com\/[a-zA-Z0-9./_-]+$/.test(tarballUrl)) {
218
218
  debugLog('WARN', 'hook-update', `Rejected suspicious tarball URL: ${tarballUrl}`);
219
219
  return false;
220
220
  }
package/hook.mjs CHANGED
@@ -37,7 +37,7 @@ import { getVocabulary } from './tfidf.mjs';
37
37
  // Prevent recursive hooks from background claude -p calls
38
38
  // Background workers (llm-episode, llm-summary) are exempt — they're ours
39
39
  const event = process.argv[2];
40
- const BG_EVENTS = new Set(['llm-episode', 'llm-summary']);
40
+ const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress']);
41
41
 
42
42
  // Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
43
43
  // install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
package/install.mjs CHANGED
@@ -529,8 +529,9 @@ async function install() {
529
529
  }
530
530
 
531
531
  // 6. Install pre-installed resources (skills + agents)
532
- log('Setting up skill/agent registry...');
533
- try {
532
+ if (process.env.CLAUDE_MEM_SKIP_REPOS) {
533
+ ok('Skill/agent registry: skipped (CLAUDE_MEM_SKIP_REPOS)');
534
+ } else try {
534
535
  const manifestPath = join(INSTALL_DIR, 'registry', 'preinstalled.json');
535
536
  if (!existsSync(manifestPath)) {
536
537
  // For git-clone mode, check PROJECT_DIR
@@ -565,7 +566,7 @@ async function install() {
565
566
  };
566
567
 
567
568
  for (const [repoUrl, entries] of repos) {
568
- const repoName = repoUrl.split('/').slice(-2).join('-');
569
+ const repoName = repoUrl.split('/').slice(-2).join('-').replace(/[^a-zA-Z0-9._-]/g, '_');
569
570
  const clonePath = join(managedDir, 'repos', repoName);
570
571
  let repoReady = false;
571
572
 
@@ -959,12 +960,8 @@ async function status() {
959
960
 
960
961
  // CLI
961
962
  try {
962
- const cliVer = execSync('claude-mem-lite --help 2>/dev/null && echo OK', { encoding: 'utf8', timeout: 5000 });
963
- if (cliVer.includes('OK')) {
964
- ok('CLI: claude-mem-lite command available');
965
- } else {
966
- warn('CLI: command not on PATH');
967
- }
963
+ execFileSync('claude-mem-lite', ['--help'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
964
+ ok('CLI: claude-mem-lite command available');
968
965
  } catch {
969
966
  warn('CLI: command not on PATH — run install again to create symlink');
970
967
  }
@@ -1087,7 +1084,7 @@ async function doctor() {
1087
1084
 
1088
1085
  // Check for stale processes
1089
1086
  try {
1090
- const procs = execSync('pgrep -af "chroma|claude-mem.*worker" 2>/dev/null', { encoding: 'utf8' }).trim();
1087
+ const procs = execFileSync('pgrep', ['-af', 'chroma|claude-mem.*worker'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' }).trim();
1091
1088
  // Filter out the pgrep process itself (matches its own pattern)
1092
1089
  const real = procs.split('\n').filter(l => !l.includes('pgrep'));
1093
1090
  if (real.length > 0) {
package/mem-cli.mjs CHANGED
@@ -52,7 +52,7 @@ function out(text) {
52
52
  }
53
53
 
54
54
  function fail(text) {
55
- process.stdout.write(text + '\n');
55
+ process.stderr.write(text + '\n');
56
56
  process.exitCode = 1;
57
57
  }
58
58
 
@@ -115,13 +115,15 @@ function cmdSearch(db, args) {
115
115
  fail(`[mem] Invalid --sort "${sort}". Use: relevance, time, importance`);
116
116
  return;
117
117
  }
118
+ const useOr = flags.or === true || flags.or === 'true';
118
119
 
119
120
  if (source && !['observations', 'sessions', 'prompts'].includes(source)) {
120
121
  fail(`[mem] Invalid --source "${source}". Use: observations, sessions, prompts`);
121
122
  return;
122
123
  }
123
124
 
124
- const ftsQuery = sanitizeFtsQuery(query);
125
+ let ftsQuery = sanitizeFtsQuery(query);
126
+ if (ftsQuery && useOr) ftsQuery = relaxFtsQueryToOr(ftsQuery) || ftsQuery;
125
127
  if (!ftsQuery) {
126
128
  fail(`[mem] No valid search terms in "${query}"`);
127
129
  return;
@@ -449,7 +451,7 @@ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minIm
449
451
  function cmdRecent(db, args) {
450
452
  const { positional, flags } = parseArgs(args);
451
453
  const rawLimit = parseInt(positional[0], 10);
452
- const limit = Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 10);
454
+ const limit = (Number.isInteger(rawLimit) && rawLimit > 0) ? rawLimit : 10;
453
455
  const project = flags.project ? resolveProject(db, flags.project) : inferProject();
454
456
 
455
457
  const params = [];
@@ -511,7 +513,9 @@ function cmdRecall(db, args) {
511
513
  // Update access_count for recalled observations (aligned with MCP mem_recall)
512
514
  const recalledIds = rows.map(r => r.id);
513
515
  const recallPh = recalledIds.map(() => '?').join(',');
514
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${recallPh})`).run(Date.now(), ...recalledIds);
516
+ try {
517
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${recallPh})`).run(Date.now(), ...recalledIds);
518
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
515
519
 
516
520
  out(`[mem] History for ${filename} (${rows.length}):`);
517
521
  for (const r of rows) {
@@ -588,8 +592,10 @@ function cmdGet(db, args) {
588
592
  }
589
593
 
590
594
  // Update access_count + auto-boost (aligned with MCP mem_get)
591
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`).run(Date.now(), ...ids);
592
- autoBoostIfNeeded(db, ids);
595
+ try {
596
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`).run(Date.now(), ...ids);
597
+ autoBoostIfNeeded(db, ids);
598
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
593
599
 
594
600
  const rows = db.prepare(`
595
601
  SELECT * FROM observations
@@ -680,7 +686,9 @@ function cmdTimeline(db, args) {
680
686
  }
681
687
 
682
688
  // Update access_count for anchor (aligned with MCP mem_timeline)
683
- db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
689
+ try {
690
+ db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
691
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
684
692
 
685
693
  // Get anchor epoch
686
694
  const anchorRow = db.prepare('SELECT created_at_epoch, project FROM observations WHERE id = ?').get(anchorId);
@@ -1786,6 +1794,7 @@ Commands:
1786
1794
  --offset N Skip first N results (pagination)
1787
1795
  --tier T Filter by tier (working|active|archive, observations only)
1788
1796
  --sort S Sort: relevance (default), time, importance
1797
+ --or Use OR instead of AND between search terms
1789
1798
 
1790
1799
  recent [N] Show N most recent observations (default 10)
1791
1800
  --project P Filter by project
@@ -1869,7 +1878,7 @@ DB: ${DB_PATH}`);
1869
1878
 
1870
1879
  // ─── Import (GitHub) ────────────────────────────────────────────────────────
1871
1880
 
1872
- async function cmdImport(db, argv) {
1881
+ async function cmdImport(argv) {
1873
1882
  const { positional, flags } = parseArgs(argv);
1874
1883
  const url = positional[0];
1875
1884
 
@@ -1923,7 +1932,7 @@ async function cmdImport(db, argv) {
1923
1932
 
1924
1933
  // ─── Enrich ─────────────────────────────────────────────────────────────────
1925
1934
 
1926
- async function cmdEnrich(db, argv) {
1935
+ async function cmdEnrich(argv) {
1927
1936
  const { positional, flags } = parseArgs(argv);
1928
1937
  const name = positional[0];
1929
1938
 
@@ -2016,8 +2025,8 @@ export async function run(argv) {
2016
2025
  case 'context': cmdContext(db, cmdArgs); break;
2017
2026
  case 'browse': cmdBrowse(db, cmdArgs); break;
2018
2027
  case 'registry': cmdRegistry(db, cmdArgs); break;
2019
- case 'import': await cmdImport(db, cmdArgs); break;
2020
- case 'enrich': await cmdEnrich(db, cmdArgs); break;
2028
+ case 'import': await cmdImport(cmdArgs); break;
2029
+ case 'enrich': await cmdEnrich(cmdArgs); break;
2021
2030
  default:
2022
2031
  out(`[mem] Unknown command: ${cmd}`);
2023
2032
  out('[mem] Run "claude-mem-lite help" for usage');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.28.1",
3
+ "version": "2.28.2",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { parseGitHubUrl, buildTreeUrl, buildContentUrl, buildRepoUrl, buildHeaders } from './registry-github.mjs';
6
6
  import { upsertResource } from './registry.mjs';
7
- import { debugLog } from './utils.mjs';
7
+ 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';
@@ -276,7 +276,14 @@ export async function importFromGitHub(db, url, opts = {}) {
276
276
  const { frontmatter, body } = parseFrontmatter(content);
277
277
 
278
278
  // Root skill naming: use frontmatter name if present, else repo name for root, else discovered name
279
- const name = frontmatter.name || (item.name === 'root' ? repo : item.name);
279
+ const rawName = frontmatter.name || (item.name === 'root' ? repo : item.name);
280
+ const name = rawName.replace(/[^a-zA-Z0-9._-]/g, '_');
281
+ // Path traversal guard: reject names that would escape managed directory
282
+ const typeDir = item.type === 'agent' ? 'agents' : 'skills';
283
+ if (!isPathConfined(join(managedDir, typeDir, name), managedDir)) {
284
+ debugLog('WARN', 'importer', `Rejected path-traversal name: ${rawName}`);
285
+ continue;
286
+ }
280
287
  const description = frontmatter.description || '';
281
288
  const fullText = `${name} ${description} ${body}`;
282
289
 
@@ -294,7 +301,6 @@ export async function importFromGitHub(db, url, opts = {}) {
294
301
  }
295
302
 
296
303
  // 5e. Download to managed directory
297
- const typeDir = item.type === 'agent' ? 'agents' : 'skills';
298
304
  const destDir = join(managedDir, typeDir, name);
299
305
  mkdirSync(destDir, { recursive: true });
300
306
  const fileName = item.type === 'agent' ? 'AGENT.md' : 'SKILL.md';
package/registry.mjs CHANGED
@@ -200,23 +200,26 @@ export function ensureRegistryDb(dbPath) {
200
200
  const resSchema = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='resources'`).get();
201
201
  if (resSchema?.sql && !resSchema.sql.includes("'github'")) {
202
202
  db.pragma('foreign_keys = OFF');
203
- db.transaction(() => {
204
- const hasOld = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='resources_old'`).get();
205
- if (hasOld) db.exec(`DROP TABLE resources_old`);
206
- // Drop FTS triggers first (reference resources table)
207
- db.exec(`DROP TRIGGER IF EXISTS res_fts_insert`);
208
- db.exec(`DROP TRIGGER IF EXISTS res_fts_update`);
209
- db.exec(`DROP TRIGGER IF EXISTS res_fts_delete`);
210
- db.exec(`ALTER TABLE resources RENAME TO resources_old`);
211
- db.exec(RESOURCES_SCHEMA);
212
- // Copy all existing data
213
- const cols = db.prepare("PRAGMA table_info(resources_old)").all().map(c => c.name);
214
- const newCols = new Set(db.prepare("PRAGMA table_info(resources)").all().map(c => c.name));
215
- const common = cols.filter(c => newCols.has(c)).join(', ');
216
- db.exec(`INSERT INTO resources (${common}) SELECT ${common} FROM resources_old`);
217
- db.exec(`DROP TABLE resources_old`);
218
- })();
219
- db.pragma('foreign_keys = ON');
203
+ try {
204
+ db.transaction(() => {
205
+ const hasOld = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='resources_old'`).get();
206
+ if (hasOld) db.exec(`DROP TABLE resources_old`);
207
+ // Drop FTS triggers first (reference resources table)
208
+ db.exec(`DROP TRIGGER IF EXISTS res_fts_insert`);
209
+ db.exec(`DROP TRIGGER IF EXISTS res_fts_update`);
210
+ db.exec(`DROP TRIGGER IF EXISTS res_fts_delete`);
211
+ db.exec(`ALTER TABLE resources RENAME TO resources_old`);
212
+ db.exec(RESOURCES_SCHEMA);
213
+ // Copy all existing data
214
+ const cols = db.prepare("PRAGMA table_info(resources_old)").all().map(c => c.name);
215
+ const newCols = new Set(db.prepare("PRAGMA table_info(resources)").all().map(c => c.name));
216
+ const common = cols.filter(c => newCols.has(c)).join(', ');
217
+ db.exec(`INSERT INTO resources (${common}) SELECT ${common} FROM resources_old`);
218
+ db.exec(`DROP TABLE resources_old`);
219
+ })();
220
+ } finally {
221
+ db.pragma('foreign_keys = ON');
222
+ }
220
223
  }
221
224
  } catch (e) { debugCatch(e, 'resources-source-check-migration'); }
222
225
 
package/schema.mjs CHANGED
@@ -13,7 +13,7 @@ export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
13
13
  export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
14
14
 
15
15
  // Increment when schema changes (tables, columns, indexes, FTS, migrations)
16
- export const CURRENT_SCHEMA_VERSION = 19;
16
+ export const CURRENT_SCHEMA_VERSION = 20;
17
17
 
18
18
  const CORE_SCHEMA = `
19
19
  CREATE TABLE IF NOT EXISTS sdk_sessions (
@@ -173,11 +173,11 @@ export function initSchema(db) {
173
173
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sdk_sessions(project)`);
174
174
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_not_compressed ON observations(created_at_epoch DESC) WHERE COALESCE(compressed_into, 0) = 0`);
175
175
 
176
- // FTS5 migration: add lesson_learned column to observations_fts (one-time)
177
- // Detect old FTS5 table missing lesson_learned and recreate with full column set
176
+ // FTS5 migration: recreate observations_fts when columns are missing (one-time)
177
+ // Detect old FTS5 table missing lesson_learned or search_aliases and recreate with full column set
178
178
  try {
179
179
  const ftsDdl = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='observations_fts'`).get();
180
- if (ftsDdl && !ftsDdl.sql.includes('lesson_learned')) {
180
+ if (ftsDdl && (!ftsDdl.sql.includes('lesson_learned') || !ftsDdl.sql.includes('search_aliases'))) {
181
181
  db.exec(`DROP TRIGGER IF EXISTS observations_ai`);
182
182
  db.exec(`DROP TRIGGER IF EXISTS observations_ad`);
183
183
  db.exec(`DROP TRIGGER IF EXISTS observations_au`);
@@ -284,7 +284,7 @@ export function initSchema(db) {
284
284
  // Strategy 2: substring match for aliases (e.g., "claude-mem-lite" → match project containing "mem")
285
285
  // Extract the most distinctive token from the short name for fuzzy matching
286
286
  if (!canonical) {
287
- const tokens = shortName.split(/[-_.]/).filter(t => t.length >= 3);
287
+ const tokens = shortName.split(/[-_.]/).filter(t => t.length >= 5);
288
288
  for (const token of tokens) {
289
289
  canonical = db.prepare(
290
290
  `SELECT project FROM observations WHERE project LIKE ? AND project LIKE '%--_%'
package/scoring-sql.mjs CHANGED
@@ -18,14 +18,14 @@ export const DEFAULT_DECAY_HALF_LIFE_MS = 14 * 86400000;
18
18
  // Single source of truth for FTS5 BM25 weight expressions.
19
19
  // Column order must match ensureFTS() calls in schema.mjs.
20
20
 
21
- /** observations_fts BM25 weights: title=10, subtitle=5, narrative=5, text=3, facts=3, concepts=2, lesson_learned=8 */
22
- export const OBS_BM25 = 'bm25(observations_fts, 10, 5, 5, 3, 3, 2, 8)';
21
+ /** observations_fts BM25 weights: title=10, subtitle=5, narrative=5, text=3, facts=3, concepts=2, lesson_learned=8, search_aliases=5 */
22
+ export const OBS_BM25 = 'bm25(observations_fts, 10, 5, 5, 3, 3, 2, 8, 5)';
23
23
 
24
24
  /** session_summaries_fts BM25 weights: request=5, investigated=3, learned=3, completed=3, next_steps=2, notes=1, remaining_items=1 */
25
25
  export const SESS_BM25 = 'bm25(session_summaries_fts, 5, 3, 3, 3, 2, 1, 1)';
26
26
 
27
27
  /** FTS5 columns for observations (must match BM25 weight order) */
28
- export const OBS_FTS_COLUMNS = ['title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned'];
28
+ export const OBS_FTS_COLUMNS = ['title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases'];
29
29
 
30
30
  /** SQL CASE for type-differentiated recency decay half-lives (milliseconds) */
31
31
  export const TYPE_DECAY_CASE = `(
@@ -8,7 +8,7 @@
8
8
  [[ -n "$CLAUDE_MEM_HOOK_RUNNING" ]] && exit 0
9
9
 
10
10
  # Read stdin (tool hook JSON)
11
- input=$(cat)
11
+ input=$(head -c 262144)
12
12
 
13
13
  # Extract tool_name via bash regex — no subprocess
14
14
  if [[ "$input" =~ \"tool_name\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
@@ -4,10 +4,11 @@
4
4
  // Lightweight standalone (~30ms): only imports better-sqlite3, fs, path, os
5
5
 
6
6
  import { existsSync, readFileSync } from 'fs';
7
- import { join } from 'path';
7
+ import { join, resolve, sep } from 'path';
8
8
  import { homedir } from 'os';
9
9
 
10
10
  const REGISTRY_DB_PATH = join(homedir(), '.claude-mem-lite', 'resource-registry.db');
11
+ const MANAGED_BASE = join(homedir(), '.claude-mem-lite');
11
12
  const MANAGED_MARKER = '/.claude-mem-lite/managed/';
12
13
 
13
14
  try {
@@ -59,6 +60,10 @@ try {
59
60
 
60
61
  if (!existsSync(skillPath)) process.exit(0);
61
62
 
63
+ // Path confinement check — prevent LIKE bypass via '../' in local_path
64
+ const resolvedPath = resolve(skillPath);
65
+ if (resolvedPath !== MANAGED_BASE && !resolvedPath.startsWith(MANAGED_BASE + sep)) process.exit(0);
66
+
62
67
  // Read and output
63
68
  const content = readFileSync(skillPath, 'utf8');
64
69
  // Token budget: ~4 chars per token, 4000 token limit = 16000 chars
@@ -4,8 +4,8 @@
4
4
  // Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
5
5
 
6
6
  import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE } from '../utils.mjs';
8
- import { writeFileSync, readFileSync, existsSync } from 'fs';
7
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, isPathConfined } from '../utils.mjs';
8
+ import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { homedir } from 'os';
11
11
  import Database from 'better-sqlite3';
@@ -198,10 +198,12 @@ function loadSkillContent(skillName) {
198
198
 
199
199
  let path = row.local_path;
200
200
  if (!path.endsWith('.md')) {
201
- // Only skills can be directory paths (9 cases); agents always have full .md paths
202
201
  const candidate = join(path, 'SKILL.md');
203
202
  if (existsSync(candidate)) path = candidate;
204
203
  }
204
+ // Path confinement check — prevent LIKE bypass via '../' in local_path
205
+ const managedBase = join(homedir(), '.claude-mem-lite');
206
+ if (!isPathConfined(path, managedBase)) return null;
205
207
  if (!existsSync(path)) return null;
206
208
 
207
209
  const portablePath = toPortablePath(path);
@@ -232,7 +234,9 @@ function setSkillCooldown(name) {
232
234
  try {
233
235
  const data = getSkillCooldown();
234
236
  data[name] = Date.now();
235
- writeFileSync(SKILL_COOLDOWN_FILE, JSON.stringify(data));
237
+ const tmp = SKILL_COOLDOWN_FILE + `.tmp-${process.pid}`;
238
+ writeFileSync(tmp, JSON.stringify(data));
239
+ renameSync(tmp, SKILL_COOLDOWN_FILE);
236
240
  } catch { /* silent */ }
237
241
  }
238
242
 
package/server.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
- import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, getCurrentBranch, DEFAULT_DECAY_HALF_LIFE_MS } from './utils.mjs';
7
+ import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, getCurrentBranch, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined } from './utils.mjs';
8
8
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
9
9
  import { ensureDb, DB_PATH, REGISTRY_DB_PATH, checkFTSIntegrity, rebuildFTS } from './schema.mjs';
10
10
  import { reRankWithContext, markSuperseded, extractPRFTerms, expandQueryByConcepts, autoBoostIfNeeded, runIdleCleanup } from './server-internals.mjs';
@@ -758,7 +758,9 @@ server.registerTool(
758
758
  }
759
759
 
760
760
  // Update access_count for anchor (aligned with CLI timeline)
761
- db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
761
+ try {
762
+ db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
763
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
762
764
 
763
765
  const projectFilter = args.project ? 'AND project = ?' : '';
764
766
  const baseParams = args.project ? [args.project] : [];
@@ -818,11 +820,12 @@ server.registerTool(
818
820
  prefix = 'P#';
819
821
  } else {
820
822
  // Increment access_count for retrieved observations (batch UPDATE)
821
- db.prepare(
822
- `UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`
823
- ).run(Date.now(), ...args.ids);
824
- // Auto-boost importance for frequently accessed observations
825
- autoBoostIfNeeded(db, args.ids);
823
+ try {
824
+ db.prepare(
825
+ `UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`
826
+ ).run(Date.now(), ...args.ids);
827
+ autoBoostIfNeeded(db, args.ids);
828
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
826
829
  rows = db.prepare(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...args.ids);
827
830
  allFields = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
828
831
  prefix = '#';
@@ -1668,6 +1671,10 @@ server.registerTool(
1668
1671
  if (!row.local_path) {
1669
1672
  return { content: [{ type: 'text', text: `No local_path for ${args.name}` }], isError: true };
1670
1673
  }
1674
+ const enrichBase = join(homedir(), '.claude-mem-lite');
1675
+ if (!isPathConfined(row.local_path, enrichBase)) {
1676
+ return { content: [{ type: 'text', text: `Access denied: path outside managed directory` }], isError: true };
1677
+ }
1671
1678
 
1672
1679
  const { enrichResource } = await import('./registry-enricher.mjs');
1673
1680
  try {
@@ -1735,7 +1742,13 @@ server.registerTool(
1735
1742
  }
1736
1743
  }
1737
1744
 
1738
- // 4. Read content
1745
+ // 4. Path confinement check — prevent reading arbitrary files via crafted local_path
1746
+ const managedBase = join(homedir(), '.claude-mem-lite');
1747
+ if (skillPath && !isPathConfined(skillPath, managedBase)) {
1748
+ return { content: [{ type: 'text', text: `Access denied: path "${skillPath}" is outside managed directory` }], isError: true };
1749
+ }
1750
+
1751
+ // 5. Read content
1739
1752
  let content;
1740
1753
  try {
1741
1754
  content = readFileSync(skillPath, 'utf8');
@@ -1885,7 +1898,9 @@ server.registerTool(
1885
1898
  // Update access_count for recalled observations
1886
1899
  const recalledIds = rows.map(r => r.id);
1887
1900
  const ph = recalledIds.map(() => '?').join(',');
1888
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...recalledIds);
1901
+ try {
1902
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...recalledIds);
1903
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
1889
1904
 
1890
1905
  const lines = [`History for ${filename} (${rows.length} observation${rows.length !== 1 ? 's' : ''}):\n`];
1891
1906
  for (const r of rows) {
package/tfidf.mjs CHANGED
@@ -381,7 +381,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
381
381
 
382
382
  // Fallback: if time-window yields too few, scan without time constraint
383
383
  if (rows.length < VECTOR_MIN_RESULTS) {
384
- params.push(limit);
384
+ const fallbackParams = [...params, limit];
385
385
  rows = db.prepare(`
386
386
  SELECT ov.observation_id, ov.vector
387
387
  FROM observation_vectors ov
@@ -389,7 +389,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
389
389
  WHERE ${wheres.join(' AND ')}
390
390
  ORDER BY o.created_at_epoch DESC
391
391
  LIMIT ?
392
- `).all(...params);
392
+ `).all(...fallbackParams);
393
393
  }
394
394
 
395
395
  const results = [];
package/utils.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // Used by server.mjs, hook.mjs, and tests
3
3
 
4
4
 
5
- import { basename, dirname } from 'path';
5
+ import { basename, dirname, resolve, sep } from 'path';
6
6
  import { execSync } from 'child_process';
7
7
 
8
8
  // ─── Re-exports from extracted modules ──────────────────────────────────────
@@ -27,6 +27,21 @@ export const COMPRESSED_AUTO = -1;
27
27
  /** compressed_into sentinel: pending user-confirmed purge (marked by idle cleanup) */
28
28
  export const COMPRESSED_PENDING_PURGE = -2;
29
29
 
30
+ // ─── Path Safety ──────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Check if a resolved path is confined within an allowed base directory.
34
+ * Prevents path traversal attacks via '../' sequences.
35
+ * @param {string} candidate Path to check
36
+ * @param {string} allowedBase Base directory the path must stay within
37
+ * @returns {boolean} true if safe
38
+ */
39
+ export function isPathConfined(candidate, allowedBase) {
40
+ const resolved = resolve(candidate);
41
+ const base = resolve(allowedBase);
42
+ return resolved === base || resolved.startsWith(base + sep);
43
+ }
44
+
30
45
  // ─── Token Estimation ─────────────────────────────────────────────────────
31
46
 
32
47
  /**