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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/cli.mjs +13 -1
- package/hook-episode.mjs +1 -1
- package/hook-semaphore.mjs +5 -4
- package/hook-update.mjs +1 -1
- package/hook.mjs +1 -1
- package/install.mjs +7 -10
- package/mem-cli.mjs +20 -11
- package/package.json +1 -1
- package/registry-importer.mjs +9 -3
- package/registry.mjs +20 -17
- package/schema.mjs +5 -5
- package/scoring-sql.mjs +3 -3
- package/scripts/post-tool-use.sh +1 -1
- package/scripts/pre-skill-bridge.js +6 -1
- package/scripts/user-prompt-search.js +8 -4
- package/server.mjs +24 -9
- package/tfidf.mjs +2 -2
- package/utils.mjs +16 -1
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
|
|
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 +
|
|
110
|
+
const tmp = target + `.tmp-${process.pid}`;
|
|
111
111
|
const { _fileSet, ...serializable } = episode;
|
|
112
112
|
writeFileSync(tmp, JSON.stringify(serializable));
|
|
113
113
|
try {
|
package/hook-semaphore.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
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 —
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
533
|
-
|
|
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
|
-
|
|
963
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
2020
|
-
case 'enrich': await cmdEnrich(
|
|
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
package/registry-importer.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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 =
|
|
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:
|
|
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 >=
|
|
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 = `(
|
package/scripts/post-tool-use.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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(...
|
|
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
|
/**
|