claude-mem-lite 2.95.1 → 2.97.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.95.1",
13
+ "version": "2.97.0",
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.95.1",
3
+ "version": "2.97.0",
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/common.mjs CHANGED
@@ -98,6 +98,24 @@ export function fmtDateShort(iso) {
98
98
  return iso.slice(0, 10);
99
99
  }
100
100
 
101
+ // Integer epoch-ms time fields on the observations table that `get`/`mem_get`
102
+ // render. Shared by the CLI (mem-cli.mjs) and the MCP server (server.mjs) so the
103
+ // two `get` paths can't drift — pre-2.97 the MCP path printed bare ms
104
+ // (`last_accessed_at: 1781024049720`) while the CLI showed `<ms> (<relative>)`,
105
+ // because the formatter lived only in mem-cli.mjs.
106
+ export const OBS_TIME_FIELDS = ['superseded_at', 'last_accessed_at'];
107
+
108
+ // Pure formatter — null/undefined/non-time pass through; integer time fields
109
+ // render as `<raw> (<relative>)` so callers get both an audit value and a
110
+ // human/LLM-scannable hint, mirroring `recent`/`timeline`/`recall`.
111
+ export function formatObsFieldValue(field, val) {
112
+ if (val === null || val === undefined) return val;
113
+ if (OBS_TIME_FIELDS.includes(field) && typeof val === 'number') {
114
+ return `${val} (${relativeTime(val)})`;
115
+ }
116
+ return val;
117
+ }
118
+
101
119
  // ─── ID Token Parsing ────────────────────────────────────────────────────────
102
120
  // Re-exported from lib/id-routing.mjs so CLI and MCP (server.mjs) share a single
103
121
  // parser — parity per #8050. Keep this re-export for back-compat with the
package/hook-llm.mjs CHANGED
@@ -601,7 +601,12 @@ export async function handleLLMEpisode() {
601
601
  await sleep(delayMs);
602
602
  }
603
603
 
604
- const fileList = episode.files.map(f => basename(f)).join(', ') || '(multiple)';
604
+ // `episode.files` is normally a [] from createEpisode, but a malformed or
605
+ // older-format tmp file can omit it — `.map()` on undefined would throw here,
606
+ // before any cleanup, leaking the tmp file (which is then retried and crashes
607
+ // forever). Guard defensively, mirroring buildImmediateObservation's `|| []`.
608
+ const episodeFiles = Array.isArray(episode.files) ? episode.files : [];
609
+ const fileList = episodeFiles.map(f => basename(f)).join(', ') || '(multiple)';
605
610
 
606
611
  // Defense-in-depth (cso F#4): split static instructions (system) from
607
612
  // per-call data (user). Episode descriptions and file paths come from tool
@@ -622,7 +627,7 @@ search_aliases: 2-6 alternative search terms someone might use to find this memo
622
627
  JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1,"lesson_learned":"non-obvious insight a future session needs, or null","search_aliases":["alt query 1","alt query 2"]}
623
628
  ${SHARED_OBS_SCHEMA_TAIL}`;
624
629
  const user = `Tool: ${e.tool}
625
- File: ${episode.files.join(', ') || 'unknown'}
630
+ File: ${episodeFiles.join(', ') || 'unknown'}
626
631
  Action: ${e.desc}
627
632
  Error: ${e.isError ? 'yes' : 'no'}`;
628
633
  prompt = { system, user };
@@ -743,7 +748,7 @@ ${actionList}`;
743
748
  narrative: truncate(parsed.narrative || '', 500),
744
749
  concepts: Array.isArray(parsed.concepts) ? parsed.concepts.slice(0, 10) : [],
745
750
  facts: Array.isArray(parsed.facts) ? parsed.facts.slice(0, 10) : [],
746
- files: episode.files,
751
+ files: episodeFiles,
747
752
  filesRead: episode.filesRead || [],
748
753
  // v2.33.1: when lesson is low-signal, don't trust Haiku's importance
749
754
  // inflation. v2.54.0: extended from {change, discovery} to all types
@@ -752,7 +757,12 @@ ${actionList}`;
752
757
  // imp=2-3 even when lesson is null after retry. Keep `decision` exempt:
753
758
  // it's rare (39 obs / 94.9% hit-rate) and the retry path already gave
754
759
  // it a second chance; a no-lesson decision is still a worthwhile signal.
755
- importance: isLessonLowSignal && parsed.type !== 'decision'
760
+ // `!retryRecovered`: when the P3 retry recovered a substantive lesson,
761
+ // the obs is no longer low-signal — capping it to 1 would negate the
762
+ // retry's entire purpose (a recovered bugfix lesson would silently drop
763
+ // out of --importance 2 searches and the working tier). Gate the cap on
764
+ // the *effective* low-signal state, not the pre-retry flag.
765
+ importance: isLessonLowSignal && !retryRecovered && parsed.type !== 'decision'
756
766
  ? Math.min(ruleImportance, 1)
757
767
  : Math.max(ruleImportance, clampImportance(parsed.importance)),
758
768
  lessonLearned,
package/hook-update.mjs CHANGED
@@ -155,8 +155,22 @@ function isPluginMode() {
155
155
  // ── Dev Mode Detection ─────────────────────────────────────
156
156
  function isDevMode() {
157
157
  try {
158
- const serverPath = join(INSTALL_DIR, 'server.mjs');
159
- return existsSync(serverPath) && lstatSync(serverPath).isSymbolicLink();
158
+ // A dev checkout always carries a .git dir. This catches a whole-directory
159
+ // symlink (~/.claude-mem-lite -> /repo): lstat on server.mjs there follows the
160
+ // intermediate symlink and sees a regular file, so the per-file probe below
161
+ // would return false and auto-update would clobber the working tree.
162
+ if (existsSync(join(INSTALL_DIR, '.git'))) return true;
163
+ // Standard `install --dev` symlinks individual source files; checking several
164
+ // core files (not just server.mjs) survives the drift case where one file was
165
+ // replaced by a plain copy while the install is still symlink-provisioned —
166
+ // mirrors lib/doctor-drift.mjs's "any symlink ⇒ dev" detection. A copy-based
167
+ // real install (install.mjs non-dev) has no symlinks and no .git, so this
168
+ // cannot false-positive into never auto-updating.
169
+ for (const f of ['server.mjs', 'hook.mjs', 'cli.mjs', 'mem-cli.mjs']) {
170
+ const p = join(INSTALL_DIR, f);
171
+ if (existsSync(p) && lstatSync(p).isSymbolicLink()) return true;
172
+ }
173
+ return false;
160
174
  } catch { return false; }
161
175
  }
162
176
 
package/install.mjs CHANGED
@@ -302,6 +302,43 @@ function isDevInstall() {
302
302
  }
303
303
  }
304
304
 
305
+ // Decide what to check out of a registry repo. We only ever copy the manifest's
306
+ // `entry.path` subdirs into managed/skills|agents, so the working tree never
307
+ // needs the rest of the repo — a partial+sparse clone fetches just those subtrees
308
+ // (e.g. davila7/claude-code-templates: 197MB whole repo → a few MB of 3 paths).
309
+ // Returns { full, paths }: `full` forces a normal checkout when any entry maps to
310
+ // the repo root ('.') — sparse buys nothing there. Unsafe paths ('..'/absolute)
311
+ // are dropped here exactly as the copy loop drops them, so they never reach
312
+ // `sparse-checkout set`. Pure + exported for unit testing.
313
+ export function planRepoSparsePaths(entries) {
314
+ let needsFull = false;
315
+ const paths = [];
316
+ for (const e of entries || []) {
317
+ const p = e && e.path;
318
+ if (!p || p === '.' || p === './') { needsFull = true; continue; }
319
+ if (isAbsolute(p) || String(p).includes('..')) continue; // unsafe — skipped at copy too
320
+ const norm = String(p).replace(/^\.\//, '').replace(/\/+$/, '');
321
+ if (norm && !paths.includes(norm)) paths.push(norm);
322
+ }
323
+ // No usable sparse paths (all root or all unsafe) → a full checkout is the only
324
+ // thing that can produce content; sparse would be an empty, pointless tree.
325
+ return { full: needsFull || paths.length === 0, paths };
326
+ }
327
+
328
+ // True only for a clone this code produced (partial-clone promisor + sparse-checkout
329
+ // both on). A legacy full clone returns false → caller re-clones it slim. Detection
330
+ // erring false only costs a one-time re-clone (the dir is a rebuildable cache), so
331
+ // over-eager migration is safe; under-eager just keeps a fat clone one more cycle.
332
+ function isPartialSparseClone(clonePath) {
333
+ const cfg = (key) => {
334
+ try {
335
+ return execFileSync('git', ['-C', clonePath, 'config', '--get', key],
336
+ { encoding: 'utf8', stdio: 'pipe' }).trim();
337
+ } catch { return ''; } // missing key → git exits non-zero → treat as unset
338
+ };
339
+ return cfg('remote.origin.promisor') === 'true' && cfg('core.sparseCheckout') === 'true';
340
+ }
341
+
305
342
  // ─── Install ────────────────────────────────────────────────────────────────
306
343
 
307
344
  async function install() {
@@ -748,11 +785,39 @@ async function install() {
748
785
  const clonePath = join(managedDir, 'repos', repoName);
749
786
  let repoReady = false;
750
787
 
788
+ const plan = planRepoSparsePaths(entries);
789
+ const cloneUrl = `${repoUrl.replace(/\.git$/, '')}.git`;
790
+ // Clone only what we extract: a partial (blob:none) + sparse clone fetches
791
+ // just the manifest subpaths' subtrees instead of the whole repo. Falls
792
+ // back to a plain shallow clone if partial-clone/sparse-checkout is
793
+ // unsupported (old git/server) — identical to the prior behavior.
794
+ const cloneSlim = () => {
795
+ if (plan.full) {
796
+ execFileSync('git', ['clone', '--depth', '1', cloneUrl, clonePath], { stdio: 'pipe', timeout: 30000 });
797
+ return;
798
+ }
799
+ try {
800
+ execFileSync('git', ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, clonePath], { stdio: 'pipe', timeout: 30000 });
801
+ execFileSync('git', ['-C', clonePath, 'sparse-checkout', 'set', '--no-cone', ...plan.paths], { stdio: 'pipe', timeout: 30000 });
802
+ execFileSync('git', ['-C', clonePath, 'checkout'], { stdio: 'pipe', timeout: 30000 });
803
+ } catch {
804
+ try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
805
+ execFileSync('git', ['clone', '--depth', '1', cloneUrl, clonePath], { stdio: 'pipe', timeout: 30000 });
806
+ }
807
+ };
808
+
809
+ // Migrate a legacy full clone: drop it so the fresh-clone path below
810
+ // rebuilds it slim. managed/repos is a rebuildable cache, so this loses
811
+ // nothing and reclaims the bulk of its footprint on the next install run.
812
+ if (!plan.full && existsSync(clonePath) && !isPartialSparseClone(clonePath)) {
813
+ try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
814
+ }
815
+
751
816
  if (!existsSync(clonePath)) {
752
- // Fresh clone
817
+ // Fresh clone (also the rebuild path for a just-migrated legacy clone)
753
818
  try {
754
819
  mkdirSync(join(managedDir, 'repos'), { recursive: true });
755
- execFileSync('git', ['clone', '--depth', '1', `${repoUrl.replace(/\.git$/, '')}.git`, clonePath], { stdio: 'pipe', timeout: 30000 });
820
+ cloneSlim();
756
821
  cloned++;
757
822
  repoReady = true;
758
823
  } catch (err) {
@@ -767,6 +832,11 @@ async function install() {
767
832
  } else {
768
833
  // Update existing: fetch latest and fast-forward
769
834
  try {
835
+ // Re-assert the sparse set so a newer manifest that adds a subpath to
836
+ // an already-slim clone checks it out (idempotent; no-op for full clones).
837
+ if (!plan.full && isPartialSparseClone(clonePath)) {
838
+ try { execFileSync('git', ['-C', clonePath, 'sparse-checkout', 'set', '--no-cone', ...plan.paths], { stdio: 'pipe', timeout: 30000 }); } catch {}
839
+ }
770
840
  const localHash = execFileSync('git', ['-C', clonePath, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: 'pipe' }).trim();
771
841
  execFileSync('git', ['-C', clonePath, 'fetch', '--depth', '1', 'origin'], { stdio: 'pipe', timeout: 30000 });
772
842
  const remoteHash = execFileSync('git', ['-C', clonePath, 'rev-parse', 'FETCH_HEAD'], { encoding: 'utf8', stdio: 'pipe' }).trim();
package/mem-cli.mjs CHANGED
@@ -33,7 +33,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
33
33
  // v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
34
34
  // router + remaining-command bodies during the incremental split. Future work:
35
35
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
36
- import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags } from './cli/common.mjs';
36
+ import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
37
37
  import { saveObservation } from './lib/save-observation.mjs';
38
38
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
39
39
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
@@ -559,24 +559,12 @@ function cmdRecall(db, args) {
559
559
 
560
560
  const OBS_FIELDS = ['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'];
561
561
 
562
- // Integer-typed time-epoch fields on the observations table that the `get`
563
- // command renders. Callers expect raw ms (audit) AND a relative-time hint
564
- // (human-scan), so formatObsFieldValue emits both. Other epoch fields like
565
- // `created_at_epoch` / `optimized_at` / `last_injected_at` aren't in
566
- // OBS_FIELDS so they don't surface via `get`.
567
- export const OBS_TIME_FIELDS = ['superseded_at', 'last_accessed_at'];
568
-
569
- // Pure formatter — null/undefined/non-time pass through; time fields on
570
- // integer values render as `<raw> (<relative>)` mirroring the convention
571
- // already used by `recent` / `timeline` / `recall`. Pre-2.63.0 the get
572
- // path printed bare ms (e.g. `last_accessed_at: 1778357330957`).
573
- export function formatObsFieldValue(field, val) {
574
- if (val === null || val === undefined) return val;
575
- if (OBS_TIME_FIELDS.includes(field) && typeof val === 'number') {
576
- return `${val} (${relativeTime(val)})`;
577
- }
578
- return val;
579
- }
562
+ // Time-field formatting moved to cli/common.mjs so the CLI `get` and the MCP
563
+ // `mem_get` (server.mjs) share one source and can't drift (the drift bug:
564
+ // MCP printed bare ms while CLI showed `<ms> (<relative>)`). Imported at the
565
+ // top; re-exported here for back-compat with existing importers
566
+ // (tests/get-time-format.test.mjs).
567
+ export { OBS_TIME_FIELDS, formatObsFieldValue };
580
568
 
581
569
  function renderObsRows(db, ids, requestedFields) {
582
570
  const placeholders = ids.map(() => '?').join(',');
@@ -1797,12 +1785,15 @@ function cmdExport(db, args) {
1797
1785
 
1798
1786
  // Full round-trippable column set so `restore` rebuilds observations faithfully —
1799
1787
  // content + value-signals (access/cited/uncited/injection/decay) + branch + timing.
1800
- // Additive vs the pre-v2.90 13-col shape; existing `export | jq '.[].title'` consumers
1801
- // are unaffected. id + memory_session_id are informational (restore remaps id and
1802
- // buckets under a restore session).
1788
+ // `search_aliases` is an FTS5-indexed column (BM25 weight 5) dropping it on
1789
+ // export silently lost the LLM-generated alternate query terms on restore, so a
1790
+ // restored memory became unfindable by its aliases. Additive vs the pre-v2.90
1791
+ // 13-col shape; existing `export | jq '.[].title'` consumers are unaffected.
1792
+ // id + memory_session_id are informational (restore remaps id and buckets under
1793
+ // a restore session).
1803
1794
  const rows = db.prepare(`
1804
1795
  SELECT id, memory_session_id, project, type, title, subtitle, narrative, concepts, facts,
1805
- files_read, files_modified, lesson_learned, importance, branch,
1796
+ files_read, files_modified, lesson_learned, search_aliases, importance, branch,
1806
1797
  access_count, cited_count, uncited_streak, injection_count, decay_seen_count,
1807
1798
  last_accessed_at, created_at, created_at_epoch
1808
1799
  FROM observations WHERE ${wheres.join(' AND ')}
@@ -1863,7 +1854,7 @@ function cmdRestore(db, argv) {
1863
1854
 
1864
1855
  const dupCheck = db.prepare('SELECT id FROM observations WHERE project = ? AND title = ? AND created_at_epoch = ? LIMIT 1');
1865
1856
  const signalUpdate = db.prepare(`UPDATE observations SET
1866
- subtitle = ?, concepts = ?, facts = ?, files_read = ?, branch = COALESCE(?, branch),
1857
+ subtitle = ?, concepts = ?, facts = ?, search_aliases = ?, files_read = ?, branch = COALESCE(?, branch),
1867
1858
  access_count = ?, cited_count = ?, uncited_streak = ?, injection_count = ?,
1868
1859
  decay_seen_count = ?, last_accessed_at = ?
1869
1860
  WHERE id = ?`);
@@ -1893,8 +1884,10 @@ function cmdRestore(db, argv) {
1893
1884
  });
1894
1885
  if (res.kind !== 'saved') { skipped++; continue; } // saveObservation Jaccard dedup
1895
1886
  // Re-apply the fields saveObservation zeros/derives so the backup is faithful.
1887
+ // search_aliases is its own FTS5 column, so this UPDATE re-syncs the index
1888
+ // (via the observations FTS triggers) and restored aliases stay searchable.
1896
1889
  signalUpdate.run(
1897
- r.subtitle || '', r.concepts || '', r.facts || '', r.files_read || '[]', r.branch ?? null,
1890
+ r.subtitle || '', r.concepts || '', r.facts || '', r.search_aliases ?? null, r.files_read || '[]', r.branch ?? null,
1898
1891
  num(r.access_count), num(r.cited_count), num(r.uncited_streak), num(r.injection_count),
1899
1892
  num(r.decay_seen_count), r.last_accessed_at ?? null,
1900
1893
  res.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.95.1",
3
+ "version": "2.97.0",
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/server.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  } from './lib/maintain-core.mjs';
21
21
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
22
22
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
23
+ import { formatObsFieldValue } from './cli/common.mjs';
23
24
  import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
24
25
 
25
26
  // Lookup helper: all user-facing tool descriptions live in tool-schemas.mjs
@@ -801,8 +802,12 @@ server.registerTool(
801
802
  const val = row[f];
802
803
  if (val === null || val === undefined || val === '') continue;
803
804
  if (f === 'text' && row.narrative && typeof val === 'string' && val.startsWith(row.narrative)) continue;
805
+ // Shared formatter (cli/common.mjs) renders epoch-ms time fields as
806
+ // `<ms> (<relative>)` — parity with the CLI `get` path so an LLM reader
807
+ // gets a scannable hint instead of a bare millisecond integer.
808
+ const display = formatObsFieldValue(f, val);
804
809
  const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
805
- lines.push(`${f}: ${typeof val === 'string' && val.length > maxLen ? val.slice(0, maxLen) + '…' : val}`);
810
+ lines.push(`${f}: ${typeof display === 'string' && display.length > maxLen ? display.slice(0, maxLen) + '…' : display}`);
806
811
  }
807
812
  sections.push(lines.join('\n'));
808
813
  }