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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +11 -9
- package/README.zh-CN.md +8 -8
- package/haiku-client.mjs +20 -10
- package/hook-llm.mjs +4 -3
- package/hook-optimize.mjs +7 -3
- package/hook.mjs +31 -110
- package/lib/citation-tracker.mjs +61 -1
- package/lib/cite-back-hint.mjs +39 -1
- package/lib/compress-core.mjs +118 -0
- package/lib/dedup-constants.mjs +35 -0
- package/lib/maintain-core.mjs +239 -0
- package/lib/save-observation.mjs +1 -1
- package/mem-cli.mjs +51 -249
- package/package.json +5 -2
- package/schema.mjs +25 -2
- package/search-engine.mjs +2 -1
- package/server.mjs +41 -253
- package/source-files.mjs +14 -0
- package/tfidf.mjs +12 -8
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,
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1891
|
-
const
|
|
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
|
|
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
|
|
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
|
|
1991
|
-
|
|
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
|
-
//
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
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:
|
|
2037
|
-
//
|
|
2038
|
-
//
|
|
2039
|
-
|
|
2040
|
-
|
|
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
|
|
2062
|
-
|
|
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
|
|
2076
|
-
//
|
|
2077
|
-
//
|
|
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
|
|
2081
|
-
const
|
|
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
|
-
|
|
2086
|
-
|
|
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
|
|
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
|
|
2125
|
-
|
|
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 (
|
|
1970
|
+
// rebuild_vectors: outside main transaction (maintain-core, shared with MCP).
|
|
2141
1971
|
if (ops.includes('rebuild_vectors')) {
|
|
2142
1972
|
try {
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
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
|
|
2174
|
-
//
|
|
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
|
|
2181
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|