claude-mem-lite 2.87.0 → 2.88.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.
package/mem-cli.mjs CHANGED
@@ -4,17 +4,22 @@
4
4
 
5
5
  import { homedir } from 'os';
6
6
  import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, jaccardSimilarity, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, isoWeekKey, COMPRESSED_PENDING_PURGE, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause } from './utils.mjs';
7
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, scrubSecrets, cjkBigrams, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause } from './utils.mjs';
8
8
  import { cjkPrecisionOk } from './nlp.mjs';
9
9
  import { extractCjkLikePatterns } from './nlp.mjs';
10
10
  import { resolveProject } from './project-utils.mjs';
11
11
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
12
- import { getVocabulary, computeVector, rebuildVocabulary, _resetVocabCache } from './tfidf.mjs';
12
+ import { getVocabulary, computeVector, _resetVocabCache } from './tfidf.mjs';
13
13
  import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
14
14
  import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
15
15
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
16
16
  import { searchResources } from './registry-retriever.mjs';
17
- import { scrubRecord } from './lib/scrub-record.mjs';
17
+ import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
18
+ import {
19
+ cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
20
+ purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
21
+ OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
22
+ } from './lib/maintain-core.mjs';
18
23
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
19
24
  import { buildSessionContextLines } from './hook-context.mjs';
20
25
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
@@ -1772,33 +1777,15 @@ function cmdCompress(db, args) {
1772
1777
  }
1773
1778
  const cutoff = Date.now() - ageDays * 86400000;
1774
1779
  const project = flags.project ? resolveProject(db, flags.project) : null;
1775
- const projectFilter = project ? 'AND project = ?' : '';
1776
- const baseParams = project ? [project] : [];
1777
1780
 
1778
- const candidates = db.prepare(`
1779
- SELECT id, project, type, title, created_at, created_at_epoch
1780
- FROM observations
1781
- WHERE COALESCE(importance, 1) = 1
1782
- AND COALESCE(access_count, 0) = 0
1783
- AND created_at_epoch < ?
1784
- AND compressed_into IS NULL
1785
- ${projectFilter}
1786
- ORDER BY project, created_at_epoch
1787
- `).all(cutoff, ...baseParams);
1781
+ const candidates = selectCompressionCandidates(db, { cutoff, project });
1788
1782
 
1789
1783
  if (candidates.length === 0) {
1790
1784
  out('[mem] No candidates for compression.');
1791
1785
  return;
1792
1786
  }
1793
1787
 
1794
- // Group by project + ISO week
1795
- const groups = new Map();
1796
- for (const c of candidates) {
1797
- const key = `${c.project}::${isoWeekKey(c.created_at_epoch)}`;
1798
- if (!groups.has(key)) groups.set(key, []);
1799
- groups.get(key).push(c);
1800
- }
1801
- const compressableGroups = [...groups.entries()].filter(([, obs]) => obs.length >= 3);
1788
+ const compressableGroups = groupByProjectWeek(candidates);
1802
1789
 
1803
1790
  if (preview) {
1804
1791
  const totalCandidates = compressableGroups.reduce((s, [, obs]) => s + obs.length, 0);
@@ -1817,46 +1804,12 @@ function cmdCompress(db, args) {
1817
1804
  return;
1818
1805
  }
1819
1806
 
1820
- // Execute compression
1807
+ // Execute compression — one transaction over all groups (the hook transacts per group).
1821
1808
  let totalCompressed = 0;
1822
- const insertSummary = db.prepare(`
1823
- INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
1824
- VALUES (?, ?, ?, ?, ?, '', ?, '', '', '[]', '[]', 2, ?, ?)
1825
- `);
1826
-
1827
1809
  db.transaction(() => {
1828
1810
  for (const [key, obs] of compressableGroups) {
1829
1811
  const [proj] = key.split('::');
1830
- const types = {};
1831
- for (const o of obs) types[o.type] = (types[o.type] || 0) + 1;
1832
- const dominantType = Object.entries(types).sort((a, b) => b[1] - a[1])[0][0];
1833
- const title = `Weekly summary: ${obs.length} ${dominantType} observations`;
1834
- const narrative = obs.map(o => `- ${o.title || '(untitled)'}`).join('\n');
1835
- const sessionId = `compress-${proj}`;
1836
-
1837
- const sortedEpochs = obs.map(o => o.created_at_epoch).sort((a, b) => a - b);
1838
- const medianEpoch = sortedEpochs[Math.floor(sortedEpochs.length / 2)];
1839
- const medianDate = new Date(medianEpoch);
1840
-
1841
- const now = new Date();
1842
- db.prepare(`
1843
- INSERT OR IGNORE INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
1844
- VALUES (?, ?, ?, ?, ?, 'active')
1845
- `).run(sessionId, sessionId, proj, now.toISOString(), now.getTime());
1846
-
1847
- // Defense-in-depth: source rows already scrubbed at original ingest, but
1848
- // the new compressed narrative is constructed here and re-persisted.
1849
- const safe = scrubRecord('observations', { text: narrative, title, narrative });
1850
- const summaryResult = insertSummary.run(
1851
- sessionId, proj, safe.text, dominantType, safe.title, safe.narrative,
1852
- medianDate.toISOString(), medianEpoch
1853
- );
1854
- const summaryId = Number(summaryResult.lastInsertRowid);
1855
-
1856
- const obsIds = obs.map(o => o.id);
1857
- const obsPh = obsIds.map(() => '?').join(',');
1858
- db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${obsPh})`).run(summaryId, ...obsIds);
1859
- totalCompressed += obs.length;
1812
+ totalCompressed += compressGroup(db, proj, obs).compressed;
1860
1813
  }
1861
1814
  })();
1862
1815
 
@@ -1876,60 +1829,12 @@ function cmdMaintain(db, args) {
1876
1829
  const project = flags.project ? resolveProject(db, flags.project) : null;
1877
1830
  const projectFilter = project ? 'AND project = ?' : '';
1878
1831
  const baseParams = project ? [project] : [];
1879
- const STALE_AGE_MS = 30 * 86400000;
1880
- const SCAN_LIMIT = 500;
1881
- const SIMILARITY_THRESHOLD = 0.7;
1882
- // demote_pinned threshold: a memory injected this many times with zero
1883
- // citations is "pinned noise" the regular `decay` op can't touch (decay
1884
- // protects injection_count > 0). 8 aligns with the noise-penalty tier-2 cut.
1885
- const PINNED_INJ_THRESHOLD = 8;
1886
1832
 
1887
1833
  if (action === 'scan') {
1888
1834
  const staleAge = Date.now() - STALE_AGE_MS;
1889
-
1890
- // Find near-duplicates (MinHash pre-filter → Jaccard)
1891
- const recent = db.prepare(`
1892
- SELECT id, title, importance, access_count, created_at_epoch
1893
- FROM observations
1894
- WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}
1895
- ORDER BY created_at_epoch DESC LIMIT ${SCAN_LIMIT}
1896
- `).all(...baseParams);
1897
-
1898
- const titles = recent.map(r => (r.title || '').trim());
1899
- const minhashes = titles.map(t => t ? computeMinHash(t) : null);
1900
- const duplicates = [];
1901
- for (let i = 0; i < recent.length && duplicates.length < 50; i++) {
1902
- if (!titles[i] || !minhashes[i]) continue;
1903
- for (let j = i + 1; j < recent.length; j++) {
1904
- if (!titles[j] || !minhashes[j]) continue;
1905
- if (estimateJaccardFromMinHash(minhashes[i], minhashes[j]) < 0.5) continue;
1906
- const sim = jaccardSimilarity(titles[i], titles[j]);
1907
- if (sim > SIMILARITY_THRESHOLD) {
1908
- duplicates.push({ a: recent[i], b: recent[j], similarity: sim.toFixed(2) });
1909
- }
1910
- if (duplicates.length >= 50) break;
1911
- }
1912
- }
1913
-
1914
- const stats = db.prepare(`
1915
- SELECT
1916
- COUNT(*) as total,
1917
- COALESCE(SUM(CASE WHEN COALESCE(importance, 1) = 1 AND COALESCE(access_count, 0) = 0
1918
- AND created_at_epoch < ? THEN 1 ELSE 0 END), 0) as stale,
1919
- COALESCE(SUM(CASE WHEN (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
1920
- THEN 1 ELSE 0 END), 0) as broken,
1921
- COALESCE(SUM(CASE WHEN COALESCE(access_count, 0) > 3 AND COALESCE(importance, 1) < 3
1922
- THEN 1 ELSE 0 END), 0) as boostable,
1923
- COALESCE(SUM(CASE WHEN COALESCE(injection_count, 0) >= ${PINNED_INJ_THRESHOLD}
1924
- AND COALESCE(cited_count, 0) = 0 AND COALESCE(importance, 1) > 1
1925
- THEN 1 ELSE 0 END), 0) as pinned
1926
- FROM observations
1927
- WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}
1928
- `).get(staleAge, ...baseParams);
1929
-
1930
- const pendingPurge = db.prepare(
1931
- `SELECT COUNT(*) as count FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} ${projectFilter}`
1932
- ).get(...baseParams);
1835
+ const mctx = { projectFilter, baseParams, staleAge };
1836
+ const duplicates = findDuplicates(db, mctx);
1837
+ const stats = maintenanceStats(db, mctx);
1933
1838
 
1934
1839
  out(`[mem] Maintenance scan:`);
1935
1840
  out(` Total active: ${stats.total}`);
@@ -1938,7 +1843,7 @@ function cmdMaintain(db, args) {
1938
1843
  out(` Broken (no title/narrative): ${stats.broken}`);
1939
1844
  out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
1940
1845
  out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
1941
- out(` Pending purge: ${pendingPurge.count} (compressed originals awaiting cleanup)`);
1846
+ out(` Pending purge: ${stats.pendingPurge} (compressed originals awaiting cleanup)`);
1942
1847
  if (duplicates.length > 0) {
1943
1848
  const AUTO_MERGE_THRESHOLD = 0.85;
1944
1849
  const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
@@ -1979,7 +1884,7 @@ function cmdMaintain(db, args) {
1979
1884
  return;
1980
1885
  }
1981
1886
  const staleAge = Date.now() - STALE_AGE_MS;
1982
- const OP_CAP = 1000;
1887
+ const mctx = { projectFilter, baseParams, staleAge, opCap: OP_CAP };
1983
1888
  const results = [];
1984
1889
 
1985
1890
  // T2-P1-B: surface the OP_CAP hit so users know to re-run, matching MCP mem_maintain.
@@ -1987,108 +1892,43 @@ function cmdMaintain(db, args) {
1987
1892
 
1988
1893
  db.transaction(() => {
1989
1894
  if (ops.includes('cleanup')) {
1990
- const deleted = db.prepare(`
1991
- DELETE FROM observations WHERE id IN (
1992
- SELECT id FROM observations
1993
- WHERE COALESCE(compressed_into, 0) = 0
1994
- AND (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
1995
- ${projectFilter} LIMIT ${OP_CAP}
1996
- )
1997
- `).run(...baseParams);
1998
- results.push(`Cleaned up ${deleted.changes} broken observations${capHint(deleted.changes)}`);
1895
+ const deleted = cleanupBroken(db, mctx);
1896
+ results.push(`Cleaned up ${deleted} broken observations${capHint(deleted)}`);
1999
1897
  }
2000
1898
 
2001
1899
  if (ops.includes('decay')) {
2002
- // v2.56.0 #4: parity with hook.mjs auto-maintain — injection_count > 0
2003
- // protects from decay/mark-idle, treating hook injection as first-class
2004
- // engagement alongside access_count.
2005
- const decayed = db.prepare(`
2006
- UPDATE observations SET importance = MAX(1, COALESCE(importance, 1) - 1)
2007
- WHERE id IN (
2008
- SELECT id FROM observations
2009
- WHERE COALESCE(compressed_into, 0) = 0
2010
- AND COALESCE(importance, 1) > 1
2011
- AND COALESCE(access_count, 0) = 0
2012
- AND COALESCE(injection_count, 0) = 0
2013
- AND created_at_epoch < ?
2014
- ${projectFilter} LIMIT ${OP_CAP}
2015
- )
2016
- `).run(staleAge, ...baseParams);
2017
-
2018
- // Mark importance=1, never-accessed, never-injected, old → pending-purge.
2019
- const idleMarked = db.prepare(`
2020
- UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
2021
- WHERE id IN (
2022
- SELECT id FROM observations
2023
- WHERE COALESCE(compressed_into, 0) = 0
2024
- AND COALESCE(importance, 1) = 1
2025
- AND COALESCE(access_count, 0) = 0
2026
- AND COALESCE(injection_count, 0) = 0
2027
- AND created_at_epoch < ?
2028
- ${projectFilter} LIMIT ${OP_CAP}
2029
- )
2030
- `).run(staleAge, ...baseParams);
2031
- const decayCap = (decayed.changes >= OP_CAP || idleMarked.changes >= OP_CAP) ? ' (cap reached, re-run for more)' : '';
2032
- results.push(`Decayed ${decayed.changes} stale observations, marked ${idleMarked.changes} idle as pending-purge${decayCap}`);
1900
+ // injection_count>0 protected (maintain-core; shared with MCP + hook auto-maintain).
1901
+ const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
1902
+ const decayCap = (decayed >= OP_CAP || idleMarked >= OP_CAP) ? ' (cap reached, re-run for more)' : '';
1903
+ results.push(`Decayed ${decayed} stale observations, marked ${idleMarked} idle as pending-purge${decayCap}`);
2033
1904
  }
2034
1905
 
2035
1906
  if (ops.includes('demote_pinned')) {
2036
- // Repair the citation-decay blind spot: the `decay` op above PROTECTS
2037
- // injection_count > 0 rows, so a memory injected many times but never
2038
- // cited stays pinned at max importance and keeps dominating injection
2039
- // forever (the entrenched-noise pool the extractor bug let accumulate).
2040
- // Target the inverse signal heavy injection, zero citations — and drop
2041
- // importance to 1 in a SINGLE pass. Injection priority is binary
2042
- // (importance >= 2 → full weight; hook-memory.mjs), so a gentle 3→2 step
2043
- // would leave the obs dominating injection just the same; only reaching 1
2044
- // actually de-ranks it. Floors at 1 (not 0/purge) so a later boost (access)
2045
- // or a genuine cite can still rescue a useful entry.
2046
- const demoted = db.prepare(`
2047
- UPDATE observations SET importance = 1
2048
- WHERE id IN (
2049
- SELECT id FROM observations
2050
- WHERE COALESCE(compressed_into, 0) = 0
2051
- AND COALESCE(injection_count, 0) >= ${PINNED_INJ_THRESHOLD}
2052
- AND COALESCE(cited_count, 0) = 0
2053
- AND COALESCE(importance, 1) > 1
2054
- ${projectFilter} LIMIT ${OP_CAP}
2055
- )
2056
- `).run(...baseParams);
2057
- results.push(`Demoted ${demoted.changes} pinned-but-uncited observations to importance 1 (inj>=${PINNED_INJ_THRESHOLD}, cited=0)${capHint(demoted.changes)}`);
1907
+ // Repair the citation-decay blind spot: decay protects injection_count>0, so a
1908
+ // heavily-injected-but-uncited memory stays pinned at max importance forever.
1909
+ // demotePinned (maintain-core) drops it to 1 in one pass. Floor 1, not purge.
1910
+ const demoted = demotePinned(db, mctx);
1911
+ results.push(`Demoted ${demoted} pinned-but-uncited observations to importance 1 (inj>=${PINNED_INJ_THRESHOLD}, cited=0)${capHint(demoted)}`);
2058
1912
  }
2059
1913
 
2060
1914
  if (ops.includes('boost')) {
2061
- const boosted = db.prepare(`
2062
- UPDATE observations SET importance = MIN(3, COALESCE(importance, 1) + 1)
2063
- WHERE id IN (
2064
- SELECT id FROM observations
2065
- WHERE COALESCE(compressed_into, 0) = 0
2066
- AND COALESCE(access_count, 0) > 3
2067
- AND COALESCE(importance, 1) < 3
2068
- ${projectFilter} LIMIT ${OP_CAP}
2069
- )
2070
- `).run(...baseParams);
2071
- results.push(`Boosted ${boosted.changes} frequently-accessed observations${capHint(boosted.changes)}`);
1915
+ const boosted = boostAccessed(db, mctx);
1916
+ results.push(`Boosted ${boosted} frequently-accessed observations${capHint(boosted)}`);
2072
1917
  }
2073
1918
 
2074
1919
  if (ops.includes('dedup') && flags['merge-ids']) {
2075
- // Parse merge-ids: "keepId:removeId1:removeId2,keepId2:removeId3" format.
2076
- // Surface malformed segments (non-numeric tokens, single-element pairs) instead of
2077
- // silently dropping them, so typos like "abc:def" don't hide behind "Merged 0".
2078
- let totalMerged = 0;
1920
+ // Parse "keepId:removeId1:removeId2,keepId2:removeId3"; surface malformed segments
1921
+ // (non-numeric / single-element) instead of silently dropping them. The merge SQL
1922
+ // itself lives in maintain-core mergeDuplicates (shared with MCP).
2079
1923
  const invalidSegments = [];
2080
- const mergeStmt = db.prepare('UPDATE observations SET compressed_into = ? WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
2081
- const rawSegments = flags['merge-ids'].split(',').map(s => s.trim()).filter(Boolean);
2082
- for (const seg of rawSegments) {
1924
+ const groups = [];
1925
+ for (const seg of flags['merge-ids'].split(',').map(s => s.trim()).filter(Boolean)) {
2083
1926
  const parts = seg.split(':').map(s => s.trim());
2084
1927
  const nums = parts.map(p => Number(p));
2085
- const badToken = parts.length < 2 || nums.some(n => !Number.isFinite(n) || n <= 0);
2086
- if (badToken) { invalidSegments.push(seg); continue; }
2087
- const [keepId, ...removeIds] = nums;
2088
- for (const removeId of removeIds) {
2089
- totalMerged += mergeStmt.run(keepId, removeId).changes;
2090
- }
1928
+ if (parts.length < 2 || nums.some(n => !Number.isFinite(n) || n <= 0)) { invalidSegments.push(seg); continue; }
1929
+ groups.push(nums);
2091
1930
  }
1931
+ const totalMerged = mergeDuplicates(db, groups);
2092
1932
  if (invalidSegments.length) {
2093
1933
  results.push(`Warning: ignored ${invalidSegments.length} malformed --merge-ids segment(s): ${invalidSegments.join(', ')} (expected keepId:removeId[:removeId...] with positive integers)`);
2094
1934
  }
@@ -2107,11 +1947,7 @@ function cmdMaintain(db, args) {
2107
1947
  // --confirm so a mis-typed `maintain execute --ops purge_stale` can't wipe rows silently.
2108
1948
  const confirmed = flags.confirm === true || flags.confirm === 'true';
2109
1949
  if (!confirmed) {
2110
- const previewRow = db.prepare(`
2111
- SELECT COUNT(*) AS candidates, MIN(created_at_epoch) AS oldest, MAX(created_at_epoch) AS newest
2112
- FROM observations
2113
- WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ? ${projectFilter}
2114
- `).get(retainCutoff, ...baseParams);
1950
+ const previewRow = purgeStalePreview(db, mctx, retainCutoff);
2115
1951
  const pushLines = [`purge_stale preview (no --confirm):`,
2116
1952
  ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`];
2117
1953
  if (previewRow.candidates > 0) {
@@ -2121,14 +1957,8 @@ function cmdMaintain(db, args) {
2121
1957
  pushLines.push(` To delete, re-run with --confirm.`);
2122
1958
  results.push(pushLines.join('\n'));
2123
1959
  } else {
2124
- const purged = db.prepare(`
2125
- DELETE FROM observations WHERE id IN (
2126
- SELECT id FROM observations
2127
- WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ?
2128
- ${projectFilter} LIMIT ${OP_CAP}
2129
- )
2130
- `).run(retainCutoff, ...baseParams);
2131
- results.push(`Purged ${purged.changes} stale observations (retained last ${retainDays} days)${capHint(purged.changes)}`);
1960
+ const purged = purgeStale(db, mctx, retainCutoff);
1961
+ results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)${capHint(purged)}`);
2132
1962
  }
2133
1963
  }
2134
1964
  })();
@@ -2137,52 +1967,24 @@ function cmdMaintain(db, args) {
2137
1967
  db.exec("INSERT INTO observations_fts(observations_fts) VALUES('optimize')");
2138
1968
  results.push('FTS5 index optimized');
2139
1969
 
2140
- // rebuild_vectors: outside main transaction (aligned with MCP mem_maintain)
1970
+ // rebuild_vectors: outside main transaction (maintain-core, shared with MCP).
2141
1971
  if (ops.includes('rebuild_vectors')) {
2142
1972
  try {
2143
- _resetVocabCache();
2144
- const vocab = rebuildVocabulary(db);
2145
- if (!vocab) {
2146
- results.push('Vectors: no observations to build vocabulary from');
2147
- } else {
2148
- const allObs = db.prepare(`
2149
- SELECT id, title, narrative, concepts FROM observations
2150
- WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
2151
- `).all();
2152
- let updated = 0;
2153
- const insertStmt = db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)');
2154
- const vecNow = Date.now();
2155
- db.transaction(() => {
2156
- db.prepare('DELETE FROM observation_vectors').run();
2157
- for (const obs of allObs) {
2158
- const text = [obs.title || '', obs.narrative || '', obs.concepts || ''].filter(Boolean).join(' ');
2159
- const vec = computeVector(text, vocab);
2160
- if (vec) {
2161
- insertStmt.run(obs.id, Buffer.from(vec.buffer), vocab.version, vecNow);
2162
- updated++;
2163
- }
2164
- }
2165
- })();
2166
- results.push(`Vectors: rebuilt vocabulary (${vocab.terms.size} terms), updated ${updated}/${allObs.length} vectors`);
2167
- }
1973
+ const r = rebuildVectors(db);
1974
+ results.push(r.ok
1975
+ ? `Vectors: rebuilt vocabulary (${r.terms} terms), updated ${r.updated}/${r.total} vectors`
1976
+ : `Vectors: ${r.reason}`);
2168
1977
  } catch (e) {
2169
1978
  results.push(`Vectors: rebuild failed — ${e.message}`);
2170
1979
  }
2171
1980
  }
2172
1981
 
2173
- // vacuum: reclaim freelist pages left behind by DELETEs (purge_stale / cleanup
2174
- // / dedup). DELETE only grows the freelist; the file never shrinks without
2175
- // VACUUM, which is absent everywhere else (auto_vacuum=0). Must run OUTSIDE any
2176
- // transaction. Whole-DB regardless of --project. Reports freelist before/after
2177
- // as the §7 reclaim metric.
1982
+ // vacuum: reclaim freelist pages left by DELETEs. Whole-DB, outside any transaction.
1983
+ // maintain-core, shared with MCP. Reports freelist before/after as the §7 reclaim metric.
2178
1984
  if (ops.includes('vacuum')) {
2179
1985
  try {
2180
- const pageSize = db.pragma('page_size', { simple: true });
2181
- const freeBefore = db.pragma('freelist_count', { simple: true });
2182
- db.exec('VACUUM');
2183
- const freeAfter = db.pragma('freelist_count', { simple: true });
2184
- const reclaimedMB = ((Math.max(0, freeBefore - freeAfter) * pageSize) / 1048576).toFixed(1);
2185
- results.push(`VACUUM: reclaimed ~${reclaimedMB}MB (freelist ${freeBefore} → ${freeAfter} pages)`);
1986
+ const v = vacuum(db);
1987
+ results.push(`VACUUM: reclaimed ~${v.reclaimedMB}MB (freelist ${v.freeBefore} ${v.freeAfter} pages)`);
2186
1988
  } catch (e) {
2187
1989
  results.push(`VACUUM failed — ${e.message}`);
2188
1990
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.87.0",
3
+ "version": "2.88.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. Alternative to claude-mem with 600x lower cost.",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -67,6 +67,8 @@
67
67
  "lib/binding-probe.mjs",
68
68
  "lib/mem-override.mjs",
69
69
  "lib/save-observation.mjs",
70
+ "lib/compress-core.mjs",
71
+ "lib/maintain-core.mjs",
70
72
  "lib/deferred-work.mjs",
71
73
  "lib/upgrade-banner.mjs",
72
74
  "lib/scrub-record.mjs",