claude-mem-lite 2.87.0 → 2.89.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';
@@ -29,6 +34,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
29
34
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
30
35
  import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints } from './cli/common.mjs';
31
36
  import { saveObservation } from './lib/save-observation.mjs';
37
+ import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
32
38
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
33
39
  import {
34
40
  insertDeferred, listOpenWithOrdinal, dropDeferred,
@@ -1772,33 +1778,15 @@ function cmdCompress(db, args) {
1772
1778
  }
1773
1779
  const cutoff = Date.now() - ageDays * 86400000;
1774
1780
  const project = flags.project ? resolveProject(db, flags.project) : null;
1775
- const projectFilter = project ? 'AND project = ?' : '';
1776
- const baseParams = project ? [project] : [];
1777
1781
 
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);
1782
+ const candidates = selectCompressionCandidates(db, { cutoff, project });
1788
1783
 
1789
1784
  if (candidates.length === 0) {
1790
1785
  out('[mem] No candidates for compression.');
1791
1786
  return;
1792
1787
  }
1793
1788
 
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);
1789
+ const compressableGroups = groupByProjectWeek(candidates);
1802
1790
 
1803
1791
  if (preview) {
1804
1792
  const totalCandidates = compressableGroups.reduce((s, [, obs]) => s + obs.length, 0);
@@ -1817,46 +1805,12 @@ function cmdCompress(db, args) {
1817
1805
  return;
1818
1806
  }
1819
1807
 
1820
- // Execute compression
1808
+ // Execute compression — one transaction over all groups (the hook transacts per group).
1821
1809
  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
1810
  db.transaction(() => {
1828
1811
  for (const [key, obs] of compressableGroups) {
1829
1812
  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;
1813
+ totalCompressed += compressGroup(db, proj, obs).compressed;
1860
1814
  }
1861
1815
  })();
1862
1816
 
@@ -1876,60 +1830,12 @@ function cmdMaintain(db, args) {
1876
1830
  const project = flags.project ? resolveProject(db, flags.project) : null;
1877
1831
  const projectFilter = project ? 'AND project = ?' : '';
1878
1832
  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
1833
 
1887
1834
  if (action === 'scan') {
1888
1835
  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);
1836
+ const mctx = { projectFilter, baseParams, staleAge };
1837
+ const duplicates = findDuplicates(db, mctx);
1838
+ const stats = maintenanceStats(db, mctx);
1933
1839
 
1934
1840
  out(`[mem] Maintenance scan:`);
1935
1841
  out(` Total active: ${stats.total}`);
@@ -1938,9 +1844,8 @@ function cmdMaintain(db, args) {
1938
1844
  out(` Broken (no title/narrative): ${stats.broken}`);
1939
1845
  out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
1940
1846
  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)`);
1847
+ out(` Pending purge: ${stats.pendingPurge} (compressed originals awaiting cleanup)`);
1942
1848
  if (duplicates.length > 0) {
1943
- const AUTO_MERGE_THRESHOLD = 0.85;
1944
1849
  const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
1945
1850
  const manualReview = duplicates.filter(d => parseFloat(d.similarity) < AUTO_MERGE_THRESHOLD);
1946
1851
 
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.87.0",
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.",
3
+ "version": "2.89.0",
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",
7
7
  "engines": {
@@ -67,6 +67,9 @@
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",
72
+ "lib/dedup-constants.mjs",
70
73
  "lib/deferred-work.mjs",
71
74
  "lib/upgrade-banner.mjs",
72
75
  "lib/scrub-record.mjs",
package/schema.mjs CHANGED
@@ -54,7 +54,14 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
54
54
  // re-runs the v28 observation_vectors cleanup) to clear the backlog leaked while
55
55
  // the warm-start fast-path left foreign_keys OFF. LATEST_MIGRATION_COLUMN is
56
56
  // unchanged (no new column) — decay_seen_count still exists at v35.
57
- export const CURRENT_SCHEMA_VERSION = 35;
57
+ // v36 (v2.89.0): no DDL — narrows events_fts_au to `AFTER UPDATE OF title, body`.
58
+ // The events FTS triggers (v2.31) were hand-written inline and inherited the
59
+ // pre-v27 broad `AFTER UPDATE ON events` form, so every importance / accessed_count
60
+ // / citation-decay bump thrashed events_fts (delete+reinsert) and reintroduced the
61
+ // SQLITE_CORRUPT_VTAB blast radius v27 fixed for the other FTS tables. Version
62
+ // bumped to force one migration pass; the conditional drop below replaces the
63
+ // legacy trigger on existing DBs. LATEST_MIGRATION_COLUMN unchanged (no new column).
64
+ export const CURRENT_SCHEMA_VERSION = 36;
58
65
 
59
66
  // Sentinel column for the LATEST migration set. The fast-path uses this to
60
67
  // self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
@@ -399,6 +406,20 @@ export function initSchema(db) {
399
406
  }
400
407
  } catch { /* non-critical */ }
401
408
 
409
+ // v36 migration: narrow events_fts_au like the v27 fix above. The events FTS
410
+ // triggers were hand-written inline (below) rather than via ensureFTS, so
411
+ // events_fts_au inherited the broad `AFTER UPDATE ON events` form and fires on
412
+ // every non-indexed bump (importance / accessed_count / citation-decay). Drop
413
+ // the legacy trigger when its stored DDL lacks the scoped `UPDATE OF` clause so
414
+ // the CREATE TRIGGER IF NOT EXISTS below reinstates the scoped form (handles
415
+ // re-run + fresh-DB: undefined row on a fresh DB is a no-op).
416
+ try {
417
+ const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='events_fts_au'`).get();
418
+ if (row && row.sql && !/\bAFTER\s+UPDATE\s+OF\s+/i.test(row.sql)) {
419
+ db.exec(`DROP TRIGGER IF EXISTS events_fts_au`);
420
+ }
421
+ } catch { /* non-critical — recreated below */ }
422
+
402
423
  // ─── v2.31 T6: events table + FTS5 (activity namespace) ───────────────────
403
424
  // Independent namespace for bugfix/lesson/bug/discovery/refactor/feature/
404
425
  // observation/decision types. Isolated from observations to avoid polluting
@@ -443,7 +464,9 @@ export function initSchema(db) {
443
464
  VALUES ('delete', old.id, COALESCE(old.title,''), COALESCE(old.body,''), old.event_type, old.project);
444
465
  END;
445
466
 
446
- CREATE TRIGGER IF NOT EXISTS events_fts_au AFTER UPDATE ON events BEGIN
467
+ -- v36: scoped to title, body (the FTS-indexed columns) so non-indexed bumps
468
+ -- (importance / accessed_count / citation-decay) no longer thrash events_fts.
469
+ CREATE TRIGGER IF NOT EXISTS events_fts_au AFTER UPDATE OF title, body ON events BEGIN
447
470
  INSERT INTO events_fts(events_fts, rowid, title, body, event_type, project)
448
471
  VALUES ('delete', old.id, COALESCE(old.title,''), COALESCE(old.body,''), old.event_type, old.project);
449
472
  INSERT INTO events_fts(rowid, title, body, event_type, project)
package/search-engine.mjs CHANGED
@@ -257,11 +257,12 @@ export function searchObservationsHybrid(db, ctx) {
257
257
  project: args.project ?? null,
258
258
  type: args.obs_type ?? null,
259
259
  vocabVersion: vocab.version,
260
+ minCosine: ctx.minCosine, // undefined → MIN_COSINE_SIMILARITY (benchmark sweep override)
260
261
  });
261
262
  if (vecResults.length === 0) return results;
262
263
 
263
264
  if (results.length > 0) {
264
- const rrfRanking = rrfMerge(results, vecResults);
265
+ const rrfRanking = rrfMerge(results, vecResults, ctx.rrfK); // undefined → RRF_K
265
266
  const resultMap = new Map(results.map(r => [r.id, r]));
266
267
  for (const vr of vecResults) {
267
268
  if (!resultMap.has(vr.id)) {