claude-mem-lite 2.71.4 → 2.72.0

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.71.4",
13
+ "version": "2.72.0",
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.71.4",
3
+ "version": "2.72.0",
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/hook-optimize.mjs CHANGED
@@ -74,12 +74,13 @@ export function rebuildVector(db, obsId, textParts) {
74
74
  *
75
75
  * @param {object} db better-sqlite3 database handle
76
76
  * @param {number} limit max candidates to return
77
- * @param {{ scope?: 'narrow' | 'wide' }} [opts]
77
+ * @param {{ scope?: 'narrow' | 'wide', project?: string }} [opts] Optional project filter (e.g. inferProject()-resolved name) narrows candidates to a single project — opt-in to preserve prior cross-project default.
78
78
  */
79
- export function findReenrichCandidates(db, limit = 10, { scope = 'narrow' } = {}) {
79
+ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', project } = {}) {
80
+ const projectClause = project ? 'AND project = ?' : '';
80
81
  if (scope === 'wide') {
81
- return db.prepare(`
82
- SELECT id, title, narrative, type, subtitle, concepts, facts
82
+ const stmt = db.prepare(`
83
+ SELECT id, title, narrative, type, subtitle, concepts, facts, project
83
84
  FROM observations
84
85
  WHERE COALESCE(compressed_into, 0) = 0
85
86
  AND superseded_at IS NULL
@@ -88,14 +89,16 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow' } = {}
88
89
  AND (lesson_learned IS NULL OR lesson_learned = '')
89
90
  AND LENGTH(COALESCE(narrative, '')) > 100
90
91
  AND ${notLowSignalTitleClause('')}
92
+ ${projectClause}
91
93
  ORDER BY
92
94
  CASE type WHEN 'decision' THEN 0 WHEN 'bugfix' THEN 1 WHEN 'refactor' THEN 2 ELSE 3 END,
93
95
  created_at_epoch DESC
94
96
  LIMIT ?
95
- `).all(limit);
97
+ `);
98
+ return project ? stmt.all(project, limit) : stmt.all(limit);
96
99
  }
97
- return db.prepare(`
98
- SELECT id, title, narrative, type, subtitle
100
+ const stmt = db.prepare(`
101
+ SELECT id, title, narrative, type, subtitle, project
99
102
  FROM observations
100
103
  WHERE COALESCE(compressed_into, 0) = 0
101
104
  AND (concepts IS NULL OR concepts = '')
@@ -103,13 +106,15 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow' } = {}
103
106
  AND lesson_learned IS NULL
104
107
  AND search_aliases IS NULL
105
108
  AND optimized_at IS NULL
109
+ ${projectClause}
106
110
  ORDER BY created_at_epoch DESC
107
111
  LIMIT ?
108
- `).all(limit);
112
+ `);
113
+ return project ? stmt.all(project, limit) : stmt.all(limit);
109
114
  }
110
115
 
111
- export async function executeReenrich(db, limit = 10, { scope = 'narrow' } = {}) {
112
- const candidates = findReenrichCandidates(db, limit, { scope });
116
+ export async function executeReenrich(db, limit = 10, { scope = 'narrow', project } = {}) {
117
+ const candidates = findReenrichCandidates(db, limit, { scope, project });
113
118
  if (candidates.length === 0) return { processed: 0, skipped: 0 };
114
119
 
115
120
  let processed = 0, skipped = 0;
@@ -206,14 +211,17 @@ export function shouldRunNormalize() {
206
211
  }
207
212
  }
208
213
 
209
- export function extractUniqueConcepts(db, limit = 500) {
210
- const rows = db.prepare(`
214
+ export function extractUniqueConcepts(db, limit = 500, { project } = {}) {
215
+ const projectClause = project ? 'AND project = ?' : '';
216
+ const stmt = db.prepare(`
211
217
  SELECT concepts FROM observations
212
218
  WHERE COALESCE(compressed_into, 0) = 0
213
219
  AND concepts IS NOT NULL AND concepts != ''
220
+ ${projectClause}
214
221
  ORDER BY created_at_epoch DESC
215
222
  LIMIT 2000
216
- `).all();
223
+ `);
224
+ const rows = project ? stmt.all(project) : stmt.all();
217
225
 
218
226
  const conceptSet = new Set();
219
227
  for (const row of rows) {
@@ -304,10 +312,10 @@ export function applyNormalization(db, groups) {
304
312
  return { updated };
305
313
  }
306
314
 
307
- export async function executeNormalize(db, force = false) {
315
+ export async function executeNormalize(db, force = false, { project } = {}) {
308
316
  if (!force && !shouldRunNormalize()) return { skipped: true, reason: 'gate' };
309
317
 
310
- const concepts = extractUniqueConcepts(db);
318
+ const concepts = extractUniqueConcepts(db, 500, { project });
311
319
  if (concepts.length < 5) return { skipped: true, reason: 'too few concepts' };
312
320
 
313
321
  const groups = await identifySynonymGroups(concepts);
@@ -326,18 +334,21 @@ const MERGE_TIME_WINDOW_MS = 30 * 86400000;
326
334
  const MERGE_JACCARD_LOW = 0.4;
327
335
  const MERGE_JACCARD_HIGH = 0.85;
328
336
 
329
- export function findMergeCandidates(db, maxClusters = 5) {
337
+ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
330
338
  const cutoff = Date.now() - MERGE_TIME_WINDOW_MS;
331
- const rows = db.prepare(`
332
- SELECT id, title, narrative, project, access_count, created_at_epoch, minhash_sig
339
+ const projectClause = project ? 'AND project = ?' : '';
340
+ const stmt = db.prepare(`
341
+ SELECT id, title, narrative, project, type, access_count, created_at_epoch, minhash_sig
333
342
  FROM observations
334
343
  WHERE COALESCE(compressed_into, 0) = 0
335
344
  AND optimized_at IS NULL
336
345
  AND title IS NOT NULL AND title != ''
337
346
  AND created_at_epoch > ?
347
+ ${projectClause}
338
348
  ORDER BY created_at_epoch DESC
339
349
  LIMIT 200
340
- `).all(cutoff);
350
+ `);
351
+ const rows = project ? stmt.all(cutoff, project) : stmt.all(cutoff);
341
352
 
342
353
  const used = new Set();
343
354
  const clusters = [];
@@ -450,8 +461,8 @@ Return ONLY valid JSON:
450
461
  }
451
462
  }
452
463
 
453
- export async function executeClusterMerge(db, maxClusters = 5) {
454
- const clusters = findMergeCandidates(db, maxClusters);
464
+ export async function executeClusterMerge(db, maxClusters = 5, { project } = {}) {
465
+ const clusters = findMergeCandidates(db, maxClusters, { project });
455
466
  if (clusters.length === 0) return { processed: 0, merged: 0 };
456
467
 
457
468
  let merged = 0;
@@ -468,17 +479,20 @@ export async function executeClusterMerge(db, maxClusters = 5) {
468
479
  const COMPRESS_TIME_SPLIT_MS = 14 * 86400000;
469
480
  const COMPRESS_COSINE_THRESHOLD = 0.3;
470
481
 
471
- export function findSmartCompressCandidates(db, ageDays = 30) {
482
+ export function findSmartCompressCandidates(db, ageDays = 30, { project } = {}) {
472
483
  const cutoff = Date.now() - ageDays * 86400000;
473
- return db.prepare(`
484
+ const projectClause = project ? 'AND project = ?' : '';
485
+ const stmt = db.prepare(`
474
486
  SELECT id, title, narrative, lesson_learned, project, type, created_at_epoch
475
487
  FROM observations
476
488
  WHERE COALESCE(compressed_into, 0) = 0
477
489
  AND COALESCE(importance, 1) = 1
478
490
  AND COALESCE(access_count, 0) = 0
479
491
  AND created_at_epoch < ?
492
+ ${projectClause}
480
493
  ORDER BY project, created_at_epoch
481
- `).all(cutoff);
494
+ `);
495
+ return project ? stmt.all(cutoff, project) : stmt.all(cutoff);
482
496
  }
483
497
 
484
498
  export function clusterForCompression(candidates, db) {
@@ -642,8 +656,8 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
642
656
  }
643
657
  }
644
658
 
645
- export async function executeSmartCompress(db, maxClusters = 5) {
646
- const candidates = findSmartCompressCandidates(db);
659
+ export async function executeSmartCompress(db, maxClusters = 5, { project } = {}) {
660
+ const candidates = findSmartCompressCandidates(db, 30, { project });
647
661
  if (candidates.length < 3) return { processed: 0, compressed: 0 };
648
662
 
649
663
  const clusters = clusterForCompression(candidates, db);
@@ -661,23 +675,34 @@ export async function executeSmartCompress(db, maxClusters = 5) {
661
675
 
662
676
  // ─── Pipeline Orchestrator ──────────────────────────────────────────────────
663
677
 
664
- export function optimizePreview(db) {
665
- const reenrich = findReenrichCandidates(db, 1000).length;
678
+ /**
679
+ * @param {object} db better-sqlite3 database handle
680
+ * @param {{ project?: string, detail?: boolean }} [opts]
681
+ * project: scope all candidate finders to a single project (opt-in; default scans all).
682
+ * detail: when true, also return `mergeClusters` / `reenrichSamples` / `compressSamples`
683
+ * arrays alongside the aggregate counts so callers (CLI --verbose, MCP detail mode)
684
+ * can render auditable previews without re-running the finders. The candidate-count
685
+ * arms still call the finders with high limits — detail mode does NOT widen scope,
686
+ * it surfaces the same rows the counts already crossed.
687
+ */
688
+ export function optimizePreview(db, { project, detail = false } = {}) {
689
+ const reenrichCandidates = findReenrichCandidates(db, 1000, { project });
690
+ const reenrich = reenrichCandidates.length;
666
691
  // R-7: also report the widened-scope candidate count so users can see how many
667
692
  // bugfix/refactor/feature/decision observations are eligible for lesson backfill.
668
- const reenrichWide = findReenrichCandidates(db, 5000, { scope: 'wide' }).length;
693
+ const reenrichWide = findReenrichCandidates(db, 5000, { scope: 'wide', project }).length;
669
694
 
670
- const concepts = extractUniqueConcepts(db);
695
+ const concepts = extractUniqueConcepts(db, 500, { project });
671
696
  const normalizeReady = shouldRunNormalize() && concepts.length >= 5;
672
697
 
673
- const mergeClusters = findMergeCandidates(db, 50);
698
+ const mergeClusters = findMergeCandidates(db, 50, { project });
674
699
  const clusterMerge = mergeClusters.length;
675
700
 
676
- const compressCandidates = findSmartCompressCandidates(db);
701
+ const compressCandidates = findSmartCompressCandidates(db, 30, { project });
677
702
  const compressClusters = clusterForCompression(compressCandidates, db);
678
703
  const smartCompress = compressClusters.length;
679
704
 
680
- return {
705
+ const result = {
681
706
  reenrich,
682
707
  reenrichWide,
683
708
  normalize: normalizeReady ? concepts.length : 0,
@@ -686,6 +711,15 @@ export function optimizePreview(db) {
686
711
  smartCompress,
687
712
  total: reenrich + (normalizeReady ? 1 : 0) + clusterMerge + smartCompress,
688
713
  };
714
+ if (detail) {
715
+ // Caps avoid dumping arbitrarily large arrays into CLI/MCP output — 20 picks
716
+ // a sample size big enough to be auditable but small enough to fit a terminal
717
+ // page. Callers that need more can drop --verbose and run the finders directly.
718
+ result.mergeClusters = mergeClusters;
719
+ result.reenrichSamples = reenrichCandidates.slice(0, 20);
720
+ result.compressSamples = compressClusters.slice(0, 5);
721
+ }
722
+ return result;
689
723
  }
690
724
 
691
725
  /**
@@ -701,8 +735,10 @@ export function optimizePreview(db) {
701
735
  * @param {boolean} [opts.force=false] Bypass time-based gates (e.g. normalize interval).
702
736
  * @param {'narrow'|'wide'} [opts.reenrichScope='narrow'] Scope for the re-enrich task.
703
737
  * 'wide' targets bugfix/refactor/feature/decision with narrative but no lesson (R-7).
738
+ * @param {string} [opts.project] Filter all tasks to a single project. Opt-in;
739
+ * absence preserves the prior all-projects default.
704
740
  */
705
- export async function optimizeRun(db, { tasks, maxItems = 15, force = false, reenrichScope = 'narrow' } = {}) {
741
+ export async function optimizeRun(db, { tasks, maxItems = 15, force = false, reenrichScope = 'narrow', project } = {}) {
706
742
  const allTasks = ['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'];
707
743
  const selectedTasks = tasks && tasks.length > 0 ? tasks : allTasks;
708
744
  // Single-task mode: give that task the full budget. Distribution only makes sense
@@ -716,16 +752,16 @@ export async function optimizeRun(db, { tasks, maxItems = 15, force = false, ree
716
752
  try {
717
753
  switch (task) {
718
754
  case 're-enrich':
719
- results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope });
755
+ results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope, project });
720
756
  break;
721
757
  case 'normalize':
722
- results.normalize = await executeNormalize(db, force);
758
+ results.normalize = await executeNormalize(db, force, { project });
723
759
  break;
724
760
  case 'cluster-merge':
725
- results.clusterMerge = await executeClusterMerge(db, budget.clusterMerge);
761
+ results.clusterMerge = await executeClusterMerge(db, budget.clusterMerge, { project });
726
762
  break;
727
763
  case 'smart-compress':
728
- results.smartCompress = await executeSmartCompress(db, budget.smartCompress);
764
+ results.smartCompress = await executeSmartCompress(db, budget.smartCompress, { project });
729
765
  break;
730
766
  }
731
767
  } catch (e) {
package/mem-cli.mjs CHANGED
@@ -2383,6 +2383,8 @@ Commands:
2383
2383
  --task T Comma-separated: re-enrich,normalize,cluster-merge,smart-compress
2384
2384
  --max N Max items per task (1-100, default 15)
2385
2385
  --scope S re-enrich scope: narrow (default) or wide
2386
+ --project P Limit to a single project (.|current = inferProject())
2387
+ --verbose / -v Preview also dumps cluster contents + re-enrich samples
2386
2388
 
2387
2389
  doctor Environment diagnostics and benchmarks
2388
2390
  --benchmark Run perf benchmark and emit JSON
@@ -2617,6 +2619,7 @@ async function cmdEnrich(argv) {
2617
2619
  async function cmdOptimize(db, args) {
2618
2620
  const run = args.includes('--run');
2619
2621
  const runAll = args.includes('--run-all');
2622
+ const verbose = args.includes('--verbose') || args.includes('-v');
2620
2623
  // T2-P1-D: --task accepts a single task or a comma-separated list, parity with MCP memOptimizeSchema.tasks.
2621
2624
  const VALID_TASKS = ['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'];
2622
2625
  const taskIdx = args.indexOf('--task');
@@ -2647,23 +2650,56 @@ async function cmdOptimize(db, args) {
2647
2650
  // lesson_learned (the "Haiku judged 'none'" cases). Default 'narrow' preserves old behavior.
2648
2651
  const scopeIdx = args.indexOf('--scope');
2649
2652
  const reenrichScope = scopeIdx >= 0 && args[scopeIdx + 1] === 'wide' ? 'wide' : 'narrow';
2653
+ // --project <name> filters all 4 tasks to one project. Opt-in; absence
2654
+ // preserves prior cross-project default. `.` or `current` auto-resolve via
2655
+ // inferProject() so users don't need to remember the exact name.
2656
+ const projectIdx = args.indexOf('--project');
2657
+ let project;
2658
+ if (projectIdx >= 0 && args[projectIdx + 1]) {
2659
+ const raw = args[projectIdx + 1];
2660
+ project = (raw === '.' || raw === 'current') ? inferProject() : raw;
2661
+ }
2650
2662
 
2651
2663
  if (!run && !runAll) {
2652
- const preview = optimizePreview(db);
2664
+ const preview = optimizePreview(db, { project, detail: verbose });
2653
2665
  out('[mem] 🔍 LLM Optimization Preview:');
2666
+ if (project) out(` Project filter: ${project}`);
2654
2667
  out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}`);
2655
2668
  out(` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`);
2656
2669
  out(` Cluster-merge: ${preview.clusterMerge} clusters`);
2657
2670
  out(` Smart-compress: ${preview.smartCompress} clusters`);
2658
2671
  out(` Total: ${preview.total} items`);
2672
+ if (verbose) {
2673
+ out('');
2674
+ if (preview.mergeClusters && preview.mergeClusters.length > 0) {
2675
+ out('─── Cluster-merge details ───');
2676
+ for (const [i, cluster] of preview.mergeClusters.entries()) {
2677
+ out(` Cluster ${i + 1} (${cluster.length} obs, project=${cluster[0]?.project || '?'}):`);
2678
+ for (const obs of cluster) out(` #${obs.id} [${obs.type || 'change'}] ${truncate(obs.title || '(untitled)', 100)}`);
2679
+ }
2680
+ }
2681
+ if (preview.reenrichSamples && preview.reenrichSamples.length > 0) {
2682
+ out('─── Re-enrich sample (first 20) ───');
2683
+ for (const obs of preview.reenrichSamples) {
2684
+ out(` #${obs.id} [${obs.type || 'change'}] (project=${obs.project || '?'}) ${truncate(obs.title || '(untitled)', 100)}`);
2685
+ }
2686
+ }
2687
+ if (preview.compressSamples && preview.compressSamples.length > 0) {
2688
+ out('─── Smart-compress sample (first 5 clusters) ───');
2689
+ for (const [i, cluster] of preview.compressSamples.entries()) {
2690
+ out(` Cluster ${i + 1} (${cluster.observations?.length || 0} obs, project=${cluster.project || '?'})`);
2691
+ }
2692
+ }
2693
+ }
2659
2694
  out('');
2660
2695
  out('Run with --run to execute, --run-all to bypass gates.');
2661
2696
  out('For R-7 backfill: --run --task re-enrich --scope wide --max N');
2697
+ out('Scope: --project <name|.|current> to limit; --verbose for cluster details.');
2662
2698
  return;
2663
2699
  }
2664
2700
 
2665
- out(`[mem] Running LLM optimization${reenrichScope === 'wide' ? ' (scope: wide)' : ''}...`);
2666
- const results = await optimizeRun(db, { tasks, maxItems, force: runAll, reenrichScope });
2701
+ out(`[mem] Running LLM optimization${reenrichScope === 'wide' ? ' (scope: wide)' : ''}${project ? ` (project: ${project})` : ''}...`);
2702
+ const results = await optimizeRun(db, { tasks, maxItems, force: runAll, reenrichScope, project });
2667
2703
 
2668
2704
  if (results.reenrich) out(` Re-enrich: ${results.reenrich.processed || 0} processed, ${results.reenrich.skipped || 0} skipped`);
2669
2705
  if (results.normalize) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.71.4",
3
+ "version": "2.72.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/server.mjs CHANGED
@@ -1576,15 +1576,33 @@ server.registerTool(
1576
1576
  const action = args.action || 'preview';
1577
1577
 
1578
1578
  if (action === 'preview') {
1579
- const preview = optimizePreview(db);
1579
+ const preview = optimizePreview(db, { project: args.project, detail: args.detail === true });
1580
1580
  const lines = [
1581
1581
  `🔍 LLM Optimization Preview:`,
1582
+ ];
1583
+ if (args.project) lines.push(` Project filter: ${args.project}`);
1584
+ lines.push(
1582
1585
  ` Re-enrich candidates: ${preview.reenrich}`,
1583
1586
  ` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`,
1584
1587
  ` Cluster-merge candidates: ${preview.clusterMerge} clusters`,
1585
1588
  ` Smart-compress candidates: ${preview.smartCompress} clusters`,
1586
1589
  ` Total: ${preview.total} items`,
1587
- ];
1590
+ );
1591
+ if (args.detail === true) {
1592
+ if (preview.mergeClusters && preview.mergeClusters.length > 0) {
1593
+ lines.push('', '─── Cluster-merge details ───');
1594
+ for (const [i, cluster] of preview.mergeClusters.entries()) {
1595
+ lines.push(` Cluster ${i + 1} (${cluster.length} obs, project=${cluster[0]?.project || '?'}):`);
1596
+ for (const obs of cluster) lines.push(` #${obs.id} [${obs.type || 'change'}] ${truncate(obs.title || '(untitled)', 100)}`);
1597
+ }
1598
+ }
1599
+ if (preview.reenrichSamples && preview.reenrichSamples.length > 0) {
1600
+ lines.push('', '─── Re-enrich sample (first 20) ───');
1601
+ for (const obs of preview.reenrichSamples) {
1602
+ lines.push(` #${obs.id} [${obs.type || 'change'}] (project=${obs.project || '?'}) ${truncate(obs.title || '(untitled)', 100)}`);
1603
+ }
1604
+ }
1605
+ }
1588
1606
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1589
1607
  }
1590
1608
 
@@ -1596,6 +1614,7 @@ server.registerTool(
1596
1614
  // T2-P0-B: scope parity with CLI (--scope wide). When omitted, optimizeRun defaults
1597
1615
  // to narrow via its own code; passing through keeps that fallback intact.
1598
1616
  reenrichScope: args.scope,
1617
+ project: args.project,
1599
1618
  });
1600
1619
 
1601
1620
  const lines = ['🔧 LLM Optimization Results:'];
package/tool-schemas.mjs CHANGED
@@ -197,6 +197,8 @@ export const memOptimizeSchema = {
197
197
  .describe('Maximum LLM calls across all tasks (default: 15)'),
198
198
  scope: z.enum(['narrow', 'wide']).optional().default('narrow')
199
199
  .describe("Re-enrich scope: narrow=narrative-only candidates (default); wide=R-7 backfill (bugfix/refactor/feature/decision with narrative but lesson_learned='none'). CLI parity: --scope wide."),
200
+ project: z.string().optional().describe('Filter all 4 tasks to a single project. Default: scan all projects.'),
201
+ detail: coerceBool.optional().describe('preview action only — include cluster contents + re-enrich/compress sample arrays alongside aggregate counts.'),
200
202
  };
201
203
 
202
204
  export const memMaintainSchema = {