claude-mem-lite 2.88.0 → 2.90.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/cli/activity.mjs +9 -5
- package/cli.mjs +11 -9
- package/haiku-client.mjs +20 -10
- package/hook-handoff.mjs +44 -12
- package/hook-llm.mjs +4 -3
- package/hook-optimize.mjs +7 -3
- package/hook-update.mjs +11 -4
- package/hook.mjs +28 -14
- package/install.mjs +46 -19
- package/lib/citation-tracker.mjs +61 -1
- package/lib/cite-back-hint.mjs +39 -1
- package/lib/cli-flags.mjs +24 -2
- package/lib/compress-core.mjs +24 -4
- package/lib/dedup-constants.mjs +35 -0
- package/lib/maintain-core.mjs +5 -2
- package/lib/save-observation.mjs +1 -1
- package/mem-cli.mjs +163 -17
- package/nlp.mjs +6 -0
- package/package.json +3 -2
- package/schema.mjs +45 -3
- package/search-engine.mjs +2 -1
- package/server.mjs +8 -2
- package/source-files.mjs +5 -0
- package/tfidf.mjs +12 -8
package/mem-cli.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
24
24
|
import { buildSessionContextLines } from './hook-context.mjs';
|
|
25
25
|
import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
|
|
26
|
-
import { parseIntFlag } from './lib/cli-flags.mjs';
|
|
26
|
+
import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
|
|
27
27
|
import { auditMemdir, memdirPath } from './memdir.mjs';
|
|
28
28
|
import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
|
|
29
29
|
import { basename, join } from 'path';
|
|
@@ -34,6 +34,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
|
34
34
|
// move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
|
|
35
35
|
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints } from './cli/common.mjs';
|
|
36
36
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
37
|
+
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
37
38
|
import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
|
|
38
39
|
import {
|
|
39
40
|
insertDeferred, listOpenWithOrdinal, dropDeferred,
|
|
@@ -70,16 +71,16 @@ function cmdSearch(db, args) {
|
|
|
70
71
|
process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
|
|
71
72
|
}
|
|
72
73
|
const minImportance = flags.importance !== undefined ? parseInt(flags.importance, 10) : null;
|
|
73
|
-
|
|
74
|
+
// isNumericToken first: "2abc"→2 / "1e2"→1 would pass the range check and silently
|
|
75
|
+
// filter at a value the user never typed. Reject garbage like out-of-range does.
|
|
76
|
+
if (minImportance !== null && (!isNumericToken(flags.importance) || isNaN(minImportance) || minImportance < 1 || minImportance > 3)) {
|
|
74
77
|
fail(`[mem] Invalid --importance "${flags.importance}". Must be 1, 2, or 3.`);
|
|
75
78
|
return;
|
|
76
79
|
}
|
|
77
80
|
const branch = flags.branch || null;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
const offset = Number.isInteger(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
|
|
81
|
+
// parseIntFlag (min=0) rejects garbage ("2abc"→2, "1e2"→1) the old isInteger check let
|
|
82
|
+
// through, warns once, and falls back to 0 — same WARN-style contract, now garbage-proof.
|
|
83
|
+
const offset = parseIntFlag(flags.offset, { name: '--offset', defaultValue: 0, min: 0 });
|
|
83
84
|
const tier = flags.tier || null;
|
|
84
85
|
if (tier && !['working', 'active', 'archive'].includes(tier)) {
|
|
85
86
|
fail(`[mem] Invalid --tier "${tier}". Use: working, active, archive`);
|
|
@@ -125,8 +126,14 @@ function cmdSearch(db, args) {
|
|
|
125
126
|
// so the post-merge sort has room to pick the best from each (paired-path with
|
|
126
127
|
// server.mjs:377 — without this, obs gets systematically squeezed out by sessions).
|
|
127
128
|
const isCrossSourceMode = !effectiveSource;
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
// Over-fetch from offset 0 and apply --offset ONCE at the final slice (below) in
|
|
130
|
+
// ALL modes — mirrors server.mjs. Pushing OFFSET into the obs hybrid path was
|
|
131
|
+
// unreliable: its AND→OR fallback / vector / concept-cooccurrence stages re-add
|
|
132
|
+
// rows the SQL OFFSET already skipped, so engine-side paging dropped (or
|
|
133
|
+
// duplicated) rows on the --type/--tier/--importance/--branch path (a page that
|
|
134
|
+
// MCP returned came back empty).
|
|
135
|
+
const perSourceLimit = Math.max(limit * 3, offset + limit + 10);
|
|
136
|
+
const perSourceOffset = 0;
|
|
130
137
|
|
|
131
138
|
const results = [];
|
|
132
139
|
// Tracks whether AND returned 0 and OR recovered non-empty. Mirrors server.mjs
|
|
@@ -304,7 +311,10 @@ function cmdSearch(db, args) {
|
|
|
304
311
|
}
|
|
305
312
|
// else 'relevance' keeps BM25 score order (already sorted)
|
|
306
313
|
|
|
307
|
-
// Trim to limit with offset
|
|
314
|
+
// Trim to limit with offset. The engine always received perSourceOffset=0 and
|
|
315
|
+
// over-fetched (see above), so the merged+reranked `results` start at row 0 and
|
|
316
|
+
// the offset is applied exactly ONCE here — for every mode. `total` is the full
|
|
317
|
+
// match count (capped at perSourceLimit), enabling the "N of M" display.
|
|
308
318
|
const total = results.length;
|
|
309
319
|
const paged = results.slice(offset, offset + limit);
|
|
310
320
|
|
|
@@ -388,7 +398,9 @@ function cmdRecent(db, args) {
|
|
|
388
398
|
const { positional, flags } = parseArgs(args);
|
|
389
399
|
const rawArg = positional[0];
|
|
390
400
|
const rawLimit = parseInt(rawArg, 10);
|
|
391
|
-
|
|
401
|
+
// isNumericToken first: "2abc"→2 / "1e2"→1 are positive integers that the bare check
|
|
402
|
+
// accepted silently; the positional path must reject garbage like the --limit flag does.
|
|
403
|
+
const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0;
|
|
392
404
|
if (rawArg !== undefined && !isValid) {
|
|
393
405
|
process.stderr.write(`[mem] Invalid count "${rawArg}" (must be a positive integer); using default 10\n`);
|
|
394
406
|
}
|
|
@@ -691,7 +703,9 @@ function cmdTimeline(db, args) {
|
|
|
691
703
|
const parseWindow = (label, raw) => {
|
|
692
704
|
if (raw === undefined) return 5;
|
|
693
705
|
const n = parseInt(raw, 10);
|
|
694
|
-
|
|
706
|
+
// isNumericToken first: "2abc"→2 / "1e2"→1 are non-negative integers the bare check
|
|
707
|
+
// accepted silently; reject garbage tokens like the negative path already does.
|
|
708
|
+
if (!isNumericToken(raw) || !Number.isInteger(n) || n < 0) {
|
|
695
709
|
process.stderr.write(`[mem] Invalid --${label} "${raw}" (must be a non-negative integer); using default 5\n`);
|
|
696
710
|
return 5;
|
|
697
711
|
}
|
|
@@ -924,7 +938,9 @@ function cmdSave(db, args) {
|
|
|
924
938
|
|
|
925
939
|
// Explicit saves default to importance=2 (notable) — user chose to save
|
|
926
940
|
const rawImp = flags.importance !== undefined ? parseInt(flags.importance, 10) : 2;
|
|
927
|
-
|
|
941
|
+
// isNumericToken first: bare parseInt would coerce "2abc"→2 / "1e2"→1 and persist a
|
|
942
|
+
// wrong importance that silently skews ranking/decay. Float literals still truncate (#8277).
|
|
943
|
+
if (flags.importance !== undefined && (!isNumericToken(flags.importance) || isNaN(rawImp) || rawImp < 1 || rawImp > 3)) {
|
|
928
944
|
fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
|
|
929
945
|
return;
|
|
930
946
|
}
|
|
@@ -1040,6 +1056,12 @@ function cmdDeferAdd(db, args) {
|
|
|
1040
1056
|
return;
|
|
1041
1057
|
}
|
|
1042
1058
|
const priority = flags.priority !== undefined ? parseInt(flags.priority, 10) : 2;
|
|
1059
|
+
// isNumericToken first: bare parseInt would coerce "3xyz"→3 and silently escalate a
|
|
1060
|
+
// deferred item's urgency. Float literals still truncate (#8277).
|
|
1061
|
+
if (flags.priority !== undefined && !isNumericToken(flags.priority)) {
|
|
1062
|
+
fail(`[mem] Invalid --priority "${flags.priority}". Must be 1 (low), 2 (normal), or 3 (urgent).`);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1043
1065
|
if (![1, 2, 3].includes(priority)) {
|
|
1044
1066
|
fail(`[mem] Invalid --priority "${flags.priority}". Must be 1 (low), 2 (normal), or 3 (urgent).`);
|
|
1045
1067
|
return;
|
|
@@ -1254,6 +1276,7 @@ async function cmdStats(db, args) {
|
|
|
1254
1276
|
const lowVal = db.prepare(`
|
|
1255
1277
|
SELECT COUNT(*) as c FROM observations
|
|
1256
1278
|
WHERE COALESCE(importance,1) = 1 AND COALESCE(access_count,0) = 0
|
|
1279
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
1257
1280
|
AND created_at_epoch < ? ${projectFilter}
|
|
1258
1281
|
`).get(thirtyDaysAgo, ...baseParams);
|
|
1259
1282
|
const noiseRatio = obsTotal.c > 0 ? lowVal.c / obsTotal.c : 0;
|
|
@@ -1606,6 +1629,19 @@ function cmdUpdate(db, args) {
|
|
|
1606
1629
|
return;
|
|
1607
1630
|
}
|
|
1608
1631
|
|
|
1632
|
+
// A value-less `--flag` (last arg, or immediately followed by another --flag)
|
|
1633
|
+
// parses to boolean `true` (cli/common.mjs parseArgs). For string-valued fields
|
|
1634
|
+
// that boolean would slip past the string-only empty guards below and reach the
|
|
1635
|
+
// SQLite bind, surfacing a raw "TypeError: SQLite3 can only bind ..." stacktrace
|
|
1636
|
+
// — the same accidental shell-strip class the empty-title guard (#8470) catches.
|
|
1637
|
+
// Reject it cleanly for every string-valued update flag.
|
|
1638
|
+
for (const key of ['title', 'narrative', 'lesson', 'lesson-learned', 'concepts']) {
|
|
1639
|
+
if (flags[key] === true) {
|
|
1640
|
+
fail(`[mem] --${key} requires a value (received a bare flag with no value).`);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1609
1645
|
const updates = [];
|
|
1610
1646
|
const params = [];
|
|
1611
1647
|
if (flags.title !== undefined) {
|
|
@@ -1628,7 +1664,9 @@ function cmdUpdate(db, args) {
|
|
|
1628
1664
|
}
|
|
1629
1665
|
if (flags.importance) {
|
|
1630
1666
|
const imp = parseInt(flags.importance, 10);
|
|
1631
|
-
|
|
1667
|
+
// isNumericToken first: bare parseInt would coerce "2abc"→2 and UPDATE the row to a
|
|
1668
|
+
// wrong importance. Float literals still truncate (#8277).
|
|
1669
|
+
if (!isNumericToken(flags.importance) || isNaN(imp) || imp < 1 || imp > 3) {
|
|
1632
1670
|
fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
|
|
1633
1671
|
return;
|
|
1634
1672
|
}
|
|
@@ -1730,8 +1768,16 @@ function cmdExport(db, args) {
|
|
|
1730
1768
|
return;
|
|
1731
1769
|
}
|
|
1732
1770
|
|
|
1771
|
+
// Full round-trippable column set so `restore` rebuilds observations faithfully —
|
|
1772
|
+
// content + value-signals (access/cited/uncited/injection/decay) + branch + timing.
|
|
1773
|
+
// Additive vs the pre-v2.90 13-col shape; existing `export | jq '.[].title'` consumers
|
|
1774
|
+
// are unaffected. id + memory_session_id are informational (restore remaps id and
|
|
1775
|
+
// buckets under a restore session).
|
|
1733
1776
|
const rows = db.prepare(`
|
|
1734
|
-
SELECT id, project, type, title, subtitle, narrative, concepts, facts,
|
|
1777
|
+
SELECT id, memory_session_id, project, type, title, subtitle, narrative, concepts, facts,
|
|
1778
|
+
files_read, files_modified, lesson_learned, importance, branch,
|
|
1779
|
+
access_count, cited_count, uncited_streak, injection_count, decay_seen_count,
|
|
1780
|
+
last_accessed_at, created_at, created_at_epoch
|
|
1735
1781
|
FROM observations WHERE ${wheres.join(' AND ')}
|
|
1736
1782
|
ORDER BY created_at_epoch DESC LIMIT ?
|
|
1737
1783
|
`).all(...params, limit);
|
|
@@ -1758,6 +1804,83 @@ function cmdExport(db, args) {
|
|
|
1758
1804
|
}
|
|
1759
1805
|
}
|
|
1760
1806
|
|
|
1807
|
+
// ─── Restore ───────────────────────────────────────────────────────────────
|
|
1808
|
+
// Inverse of `export` — the backup/restore half README:690 promises. Reuses
|
|
1809
|
+
// lib/save-observation.mjs so FK / FTS / TF-IDF vector / minhash / files-junction
|
|
1810
|
+
// stay consistent with cmdSave, then a targeted UPDATE re-applies the value-signals
|
|
1811
|
+
// (access/cited/uncited/injection/decay), branch, and concepts/facts/files_read that
|
|
1812
|
+
// saveObservation derives or zeros — so a restored backup keeps its citation-decay
|
|
1813
|
+
// history and original timing (created_at via the `now` param). Source ids are
|
|
1814
|
+
// discarded (local AUTOINCREMENT; export omits related_ids); session provenance
|
|
1815
|
+
// collapses to saveObservation's manual-<project> bucket (documented MVP tradeoff).
|
|
1816
|
+
function cmdRestore(db, argv) {
|
|
1817
|
+
const { positional, flags } = parseArgs(argv);
|
|
1818
|
+
const file = positional[0];
|
|
1819
|
+
if (!file) { fail('[mem] Usage: claude-mem-lite restore <file> [--project P] [--dry-run]'); return; }
|
|
1820
|
+
let raw;
|
|
1821
|
+
try { raw = readFileSync(file, 'utf8'); }
|
|
1822
|
+
catch (e) { fail(`[mem] Cannot read "${file}": ${e.message}`); return; }
|
|
1823
|
+
const trimmed = raw.trim();
|
|
1824
|
+
if (!trimmed) { out('[mem] Empty file — nothing to restore.'); return; }
|
|
1825
|
+
let rows;
|
|
1826
|
+
try {
|
|
1827
|
+
rows = trimmed[0] === '['
|
|
1828
|
+
? JSON.parse(trimmed)
|
|
1829
|
+
: trimmed.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
|
|
1830
|
+
} catch (e) { fail(`[mem] "${file}" is not valid export JSON/JSONL: ${e.message}`); return; }
|
|
1831
|
+
if (!Array.isArray(rows) || rows.length === 0) { out('[mem] No observations in file.'); return; }
|
|
1832
|
+
|
|
1833
|
+
const projOverride = flags.project ? resolveProject(db, flags.project) : null;
|
|
1834
|
+
const dryRun = flags['dry-run'] === true || flags['dry-run'] === 'true';
|
|
1835
|
+
const num = (v) => Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : 0;
|
|
1836
|
+
|
|
1837
|
+
const dupCheck = db.prepare('SELECT id FROM observations WHERE project = ? AND title = ? AND created_at_epoch = ? LIMIT 1');
|
|
1838
|
+
const signalUpdate = db.prepare(`UPDATE observations SET
|
|
1839
|
+
subtitle = ?, concepts = ?, facts = ?, files_read = ?, branch = COALESCE(?, branch),
|
|
1840
|
+
access_count = ?, cited_count = ?, uncited_streak = ?, injection_count = ?,
|
|
1841
|
+
decay_seen_count = ?, last_accessed_at = ?
|
|
1842
|
+
WHERE id = ?`);
|
|
1843
|
+
|
|
1844
|
+
let restored = 0, skipped = 0, malformed = 0;
|
|
1845
|
+
for (const r of rows) {
|
|
1846
|
+
if (!r || typeof r !== 'object' || !r.type || !r.title) { malformed++; continue; }
|
|
1847
|
+
const project = projOverride || r.project || inferProject();
|
|
1848
|
+
const createdEpoch = Number.isFinite(Number(r.created_at_epoch)) ? Number(r.created_at_epoch) : Date.now();
|
|
1849
|
+
// Durable exact-dup guard — saveObservation's 5-min Jaccard window can't catch a
|
|
1850
|
+
// re-restore of an old-timestamped backup, so gate on project+title+created_at.
|
|
1851
|
+
if (dupCheck.get(project, r.title, createdEpoch)) { skipped++; continue; }
|
|
1852
|
+
if (dryRun) { restored++; continue; }
|
|
1853
|
+
try {
|
|
1854
|
+
let files = [];
|
|
1855
|
+
try { const fm = JSON.parse(r.files_modified || '[]'); if (Array.isArray(fm)) files = fm; } catch { /* leave [] */ }
|
|
1856
|
+
const imp = num(r.importance);
|
|
1857
|
+
const res = saveObservation(db, {
|
|
1858
|
+
content: r.narrative || r.title,
|
|
1859
|
+
title: r.title,
|
|
1860
|
+
type: r.type,
|
|
1861
|
+
importance: imp >= 1 && imp <= 3 ? imp : 1,
|
|
1862
|
+
project,
|
|
1863
|
+
files,
|
|
1864
|
+
lesson_learned: r.lesson_learned || null,
|
|
1865
|
+
now: new Date(createdEpoch),
|
|
1866
|
+
});
|
|
1867
|
+
if (res.kind !== 'saved') { skipped++; continue; } // saveObservation Jaccard dedup
|
|
1868
|
+
// Re-apply the fields saveObservation zeros/derives so the backup is faithful.
|
|
1869
|
+
signalUpdate.run(
|
|
1870
|
+
r.subtitle || '', r.concepts || '', r.facts || '', r.files_read || '[]', r.branch ?? null,
|
|
1871
|
+
num(r.access_count), num(r.cited_count), num(r.uncited_streak), num(r.injection_count),
|
|
1872
|
+
num(r.decay_seen_count), r.last_accessed_at ?? null,
|
|
1873
|
+
res.id,
|
|
1874
|
+
);
|
|
1875
|
+
restored++;
|
|
1876
|
+
} catch (e) {
|
|
1877
|
+
malformed++;
|
|
1878
|
+
if (process.env.CLAUDE_MEM_DEBUG) process.stderr.write(`[mem] restore row failed: ${e.message}\n`);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped, ${malformed} malformed/failed from ${rows.length} row(s).`);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1761
1884
|
// ─── Compress ────────────────────────────────────────────────────────────────
|
|
1762
1885
|
|
|
1763
1886
|
function cmdCompress(db, args) {
|
|
@@ -1845,7 +1968,6 @@ function cmdMaintain(db, args) {
|
|
|
1845
1968
|
out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
|
|
1846
1969
|
out(` Pending purge: ${stats.pendingPurge} (compressed originals awaiting cleanup)`);
|
|
1847
1970
|
if (duplicates.length > 0) {
|
|
1848
|
-
const AUTO_MERGE_THRESHOLD = 0.85;
|
|
1849
1971
|
const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
|
|
1850
1972
|
const manualReview = duplicates.filter(d => parseFloat(d.similarity) < AUTO_MERGE_THRESHOLD);
|
|
1851
1973
|
|
|
@@ -1876,7 +1998,11 @@ function cmdMaintain(db, args) {
|
|
|
1876
1998
|
|
|
1877
1999
|
// Execute
|
|
1878
2000
|
const VALID_OPS = ['cleanup', 'decay', 'boost', 'demote_pinned', 'dedup', 'purge_stale', 'rebuild_vectors', 'vacuum'];
|
|
1879
|
-
|
|
2001
|
+
// Distinguish flag-absent (use default op set) from flag-present-but-empty
|
|
2002
|
+
// (`--ops ""`, e.g. an unset shell var). The latter previously coerced via `||`
|
|
2003
|
+
// to the destructive default cleanup,decay,boost and EXECUTED it; route it to the
|
|
2004
|
+
// VALID_OPS check below instead so it's rejected like `--ops " "` / `--ops "decay,"`.
|
|
2005
|
+
const opsStr = flags.ops === undefined ? 'cleanup,decay,boost' : String(flags.ops);
|
|
1880
2006
|
const ops = opsStr.split(',').map(s => s.trim());
|
|
1881
2007
|
const invalidOps = ops.filter(op => !VALID_OPS.includes(op));
|
|
1882
2008
|
if (invalidOps.length > 0) {
|
|
@@ -2119,6 +2245,12 @@ function cmdRegistry(_memDb, args) {
|
|
|
2119
2245
|
}
|
|
2120
2246
|
|
|
2121
2247
|
if (action === 'import') {
|
|
2248
|
+
// A bare value-less flag parses to boolean `true` (parseArgs); for these string
|
|
2249
|
+
// fields that boolean reaches the SQLite bind in upsertResource and throws a raw
|
|
2250
|
+
// TypeError — same class as the `update` guard above (#8470). Reject up front.
|
|
2251
|
+
for (const key of ['name', 'resource-type', 'invocation-name', 'source', 'repo-url', 'local-path', 'intent-tags', 'domain-tags', 'trigger-patterns', 'capability-summary', 'keywords', 'tech-stack', 'use-cases']) {
|
|
2252
|
+
if (flags[key] === true) { fail(`[mem] --${key} requires a value (received a bare flag with no value).`); return; }
|
|
2253
|
+
}
|
|
2122
2254
|
const name = flags.name;
|
|
2123
2255
|
const resourceType = flags['resource-type'];
|
|
2124
2256
|
if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry import --name N --resource-type skill|agent [--invocation-name I] [--capability-summary S]'); return; }
|
|
@@ -2140,6 +2272,11 @@ function cmdRegistry(_memDb, args) {
|
|
|
2140
2272
|
}
|
|
2141
2273
|
|
|
2142
2274
|
if (action === 'remove') {
|
|
2275
|
+
// Bare value-less --name / --resource-type → boolean true → SQLite-bind crash
|
|
2276
|
+
// on the DELETE below; reject like the import branch and the `update` guard.
|
|
2277
|
+
for (const key of ['name', 'resource-type']) {
|
|
2278
|
+
if (flags[key] === true) { fail(`[mem] --${key} requires a value (received a bare flag with no value).`); return; }
|
|
2279
|
+
}
|
|
2143
2280
|
const name = flags.name;
|
|
2144
2281
|
const resourceType = flags['resource-type'];
|
|
2145
2282
|
if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry remove --name N --resource-type skill|agent'); return; }
|
|
@@ -2414,6 +2551,7 @@ Commands:
|
|
|
2414
2551
|
--concepts T Space-separated concept tags
|
|
2415
2552
|
|
|
2416
2553
|
export Export observations as JSON/JSONL
|
|
2554
|
+
restore <file> Restore observations from an export file (JSON/JSONL); --dry-run to preview
|
|
2417
2555
|
--project P Filter by project
|
|
2418
2556
|
--type T Filter by type
|
|
2419
2557
|
--format F json (default) or jsonl
|
|
@@ -2641,6 +2779,13 @@ async function cmdImportJsonl(db, argv) {
|
|
|
2641
2779
|
out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations, ${totalOrphans} orphan tool_use, ${totalSkip} skipped from ${files.length} file(s)${errorTail}.`);
|
|
2642
2780
|
if (totalPrompts > 0 || totalObs > 0) {
|
|
2643
2781
|
out(`[mem] Try: claude-mem-lite recent 5 --project ${project}`);
|
|
2782
|
+
} else if (totalSkip > 0 && errorCount === 0) {
|
|
2783
|
+
// Nothing imported but every line was skipped — almost always the wrong file
|
|
2784
|
+
// format (import-jsonl ingests Claude Code transcript JSONL, not `export` output,
|
|
2785
|
+
// which is observation-shaped). Pre-fix this exited 0 with no signal, so pointing
|
|
2786
|
+
// it at the wrong file looked like success. Make the no-op explicit (stdout, like
|
|
2787
|
+
// the summary lines above).
|
|
2788
|
+
out(`[mem] Warning: 0 imported, ${totalSkip} line(s) skipped — none matched the expected Claude Code transcript JSONL shape (user/assistant/tool_result). 'export' output is NOT re-importable via import-jsonl.`);
|
|
2644
2789
|
}
|
|
2645
2790
|
}
|
|
2646
2791
|
|
|
@@ -2865,6 +3010,7 @@ export async function run(argv) {
|
|
|
2865
3010
|
case 'delete': cmdDelete(db, cmdArgs); break;
|
|
2866
3011
|
case 'update': cmdUpdate(db, cmdArgs); break;
|
|
2867
3012
|
case 'export': cmdExport(db, cmdArgs); break;
|
|
3013
|
+
case 'restore': cmdRestore(db, cmdArgs); break;
|
|
2868
3014
|
case 'compress': cmdCompress(db, cmdArgs); break;
|
|
2869
3015
|
case 'maintain': cmdMaintain(db, cmdArgs); break;
|
|
2870
3016
|
case 'optimize': await cmdOptimize(db, cmdArgs); break;
|
package/nlp.mjs
CHANGED
|
@@ -218,6 +218,12 @@ export const FTS_STOP_WORDS = new Set([...BASE_STOP_WORDS]);
|
|
|
218
218
|
export function sanitizeFtsQuery(query) {
|
|
219
219
|
if (!query) return null;
|
|
220
220
|
const cleaned = query
|
|
221
|
+
// Strip ASCII control chars / NUL FIRST. A NUL survives tokenization (it's not
|
|
222
|
+
// \s), gets phrase-quoted by expandToken, and then terminates SQLite's C string
|
|
223
|
+
// mid-phrase → FTS5 "unterminated string" throw, breaking the documented
|
|
224
|
+
// "never throws on MATCH" invariant. The metachar class below doesn't cover them.
|
|
225
|
+
// eslint-disable-next-line no-control-regex -- intentional: stripping control chars IS the fix
|
|
226
|
+
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
221
227
|
.replace(/[{}()[\]^~*:"\\]/g, ' ')
|
|
222
228
|
.replace(/(^|\s)-/g, '$1')
|
|
223
229
|
.trim();
|
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.90.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": {
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"lib/save-observation.mjs",
|
|
70
70
|
"lib/compress-core.mjs",
|
|
71
71
|
"lib/maintain-core.mjs",
|
|
72
|
+
"lib/dedup-constants.mjs",
|
|
72
73
|
"lib/deferred-work.mjs",
|
|
73
74
|
"lib/upgrade-banner.mjs",
|
|
74
75
|
"lib/scrub-record.mjs",
|
package/schema.mjs
CHANGED
|
@@ -8,9 +8,17 @@ import { join } from 'path';
|
|
|
8
8
|
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, chmodSync } from 'fs';
|
|
9
9
|
import { OBS_FTS_COLUMNS } from './utils.mjs';
|
|
10
10
|
|
|
11
|
+
// DATA location — DB, managed resources, registry DB, runtime/. Honors
|
|
12
|
+
// CLAUDE_MEM_DIR so users can relocate state to a larger/faster volume.
|
|
11
13
|
export const DB_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
|
|
12
14
|
export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
|
|
13
15
|
export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
|
|
16
|
+
// CODE / install location — server.mjs, hook.mjs, cli.mjs, package.json live
|
|
17
|
+
// here. ALWAYS homedir-rooted: Claude Code's settings.json + MCP registration
|
|
18
|
+
// bake ABSOLUTE paths to server.mjs/hooks, so the code must NOT follow the
|
|
19
|
+
// CLAUDE_MEM_DIR relocation env var (mirrors install.mjs INSTALL_DIR). Equals
|
|
20
|
+
// DB_DIR when CLAUDE_MEM_DIR is unset — the common, non-relocated case.
|
|
21
|
+
export const CODE_DIR = join(homedir(), '.claude-mem-lite');
|
|
14
22
|
|
|
15
23
|
// Increment when schema changes (tables, columns, indexes, FTS, migrations)
|
|
16
24
|
//
|
|
@@ -54,13 +62,22 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
|
|
|
54
62
|
// re-runs the v28 observation_vectors cleanup) to clear the backlog leaked while
|
|
55
63
|
// the warm-start fast-path left foreign_keys OFF. LATEST_MIGRATION_COLUMN is
|
|
56
64
|
// unchanged (no new column) — decay_seen_count still exists at v35.
|
|
57
|
-
|
|
65
|
+
// v36 (v2.89.0): no DDL — narrows events_fts_au to `AFTER UPDATE OF title, body`.
|
|
66
|
+
// The events FTS triggers (v2.31) were hand-written inline and inherited the
|
|
67
|
+
// pre-v27 broad `AFTER UPDATE ON events` form, so every importance / accessed_count
|
|
68
|
+
// / citation-decay bump thrashed events_fts (delete+reinsert) and reintroduced the
|
|
69
|
+
// SQLITE_CORRUPT_VTAB blast radius v27 fixed for the other FTS tables. Version
|
|
70
|
+
// bumped to force one migration pass; the conditional drop below replaces the
|
|
71
|
+
// legacy trigger on existing DBs. LATEST_MIGRATION_COLUMN unchanged (no new column).
|
|
72
|
+
// v37 (D#26): adds user_prompts.cc_session_id (additive, nullable). LATEST_MIGRATION_COLUMN
|
|
73
|
+
// MOVES to it so the half-migrated-DB self-heal fast-path covers the new column.
|
|
74
|
+
export const CURRENT_SCHEMA_VERSION = 37;
|
|
58
75
|
|
|
59
76
|
// Sentinel column for the LATEST migration set. The fast-path uses this to
|
|
60
77
|
// self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
|
|
61
78
|
// back (observed once in dev during v2.74.0). Update both the column AND
|
|
62
79
|
// (if needed) the table when adding a new migration batch.
|
|
63
|
-
const LATEST_MIGRATION_COLUMN = { table: '
|
|
80
|
+
const LATEST_MIGRATION_COLUMN = { table: 'user_prompts', column: 'cc_session_id' };
|
|
64
81
|
|
|
65
82
|
function hasLatestMigrationColumn(db) {
|
|
66
83
|
try {
|
|
@@ -198,6 +215,14 @@ const MIGRATIONS = [
|
|
|
198
215
|
// share the unrelated injection_count column. Same-source numerator
|
|
199
216
|
// (cited_count) + same-source denominator = meaningful ratio.
|
|
200
217
|
'ALTER TABLE observations ADD COLUMN decay_seen_count INTEGER NOT NULL DEFAULT 0',
|
|
218
|
+
// v37 (D#26 — parallel-session handoff content scoping): the Claude-Code session
|
|
219
|
+
// UUID per user prompt. handleUserPrompt writes hookData.session_id here so
|
|
220
|
+
// buildAndSaveHandoff can scope working_on to ONE CC session — concurrent (and
|
|
221
|
+
// within-12h-TTL sequential) same-project sessions previously merged each other's
|
|
222
|
+
// prompts because getSessionId() is project-scoped (no CC-UUID component). Nullable:
|
|
223
|
+
// legacy rows + non-CC/no-stdin invocations read back NULL and the handoff falls
|
|
224
|
+
// back to its legacy unfiltered query.
|
|
225
|
+
'ALTER TABLE user_prompts ADD COLUMN cc_session_id TEXT DEFAULT NULL',
|
|
201
226
|
];
|
|
202
227
|
|
|
203
228
|
/**
|
|
@@ -348,6 +373,7 @@ export function initSchema(db) {
|
|
|
348
373
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sess_sum_epoch ON session_summaries(created_at_epoch DESC, project)`);
|
|
349
374
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_project_epoch_minhash ON observations(project, created_at_epoch DESC) WHERE minhash_sig IS NOT NULL`);
|
|
350
375
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_prompts_session ON user_prompts(content_session_id)`);
|
|
376
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_prompts_cc ON user_prompts(cc_session_id) WHERE cc_session_id IS NOT NULL`);
|
|
351
377
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_superseded ON observations(superseded_at) WHERE superseded_at IS NOT NULL`);
|
|
352
378
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_branch ON observations(branch) WHERE branch IS NOT NULL`);
|
|
353
379
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sdk_sessions(project)`);
|
|
@@ -399,6 +425,20 @@ export function initSchema(db) {
|
|
|
399
425
|
}
|
|
400
426
|
} catch { /* non-critical */ }
|
|
401
427
|
|
|
428
|
+
// v36 migration: narrow events_fts_au like the v27 fix above. The events FTS
|
|
429
|
+
// triggers were hand-written inline (below) rather than via ensureFTS, so
|
|
430
|
+
// events_fts_au inherited the broad `AFTER UPDATE ON events` form and fires on
|
|
431
|
+
// every non-indexed bump (importance / accessed_count / citation-decay). Drop
|
|
432
|
+
// the legacy trigger when its stored DDL lacks the scoped `UPDATE OF` clause so
|
|
433
|
+
// the CREATE TRIGGER IF NOT EXISTS below reinstates the scoped form (handles
|
|
434
|
+
// re-run + fresh-DB: undefined row on a fresh DB is a no-op).
|
|
435
|
+
try {
|
|
436
|
+
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='events_fts_au'`).get();
|
|
437
|
+
if (row && row.sql && !/\bAFTER\s+UPDATE\s+OF\s+/i.test(row.sql)) {
|
|
438
|
+
db.exec(`DROP TRIGGER IF EXISTS events_fts_au`);
|
|
439
|
+
}
|
|
440
|
+
} catch { /* non-critical — recreated below */ }
|
|
441
|
+
|
|
402
442
|
// ─── v2.31 T6: events table + FTS5 (activity namespace) ───────────────────
|
|
403
443
|
// Independent namespace for bugfix/lesson/bug/discovery/refactor/feature/
|
|
404
444
|
// observation/decision types. Isolated from observations to avoid polluting
|
|
@@ -443,7 +483,9 @@ export function initSchema(db) {
|
|
|
443
483
|
VALUES ('delete', old.id, COALESCE(old.title,''), COALESCE(old.body,''), old.event_type, old.project);
|
|
444
484
|
END;
|
|
445
485
|
|
|
446
|
-
|
|
486
|
+
-- v36: scoped to title, body (the FTS-indexed columns) so non-indexed bumps
|
|
487
|
+
-- (importance / accessed_count / citation-decay) no longer thrash events_fts.
|
|
488
|
+
CREATE TRIGGER IF NOT EXISTS events_fts_au AFTER UPDATE OF title, body ON events BEGIN
|
|
447
489
|
INSERT INTO events_fts(events_fts, rowid, title, body, event_type, project)
|
|
448
490
|
VALUES ('delete', old.id, COALESCE(old.title,''), COALESCE(old.body,''), old.event_type, old.project);
|
|
449
491
|
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)) {
|
package/server.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
|
36
36
|
import { searchResources } from './registry-retriever.mjs';
|
|
37
37
|
import { probeOtherSources as probeIdSources, parseIdToken, bucketIdTokens } from './lib/id-routing.mjs';
|
|
38
38
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
39
|
+
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
39
40
|
import {
|
|
40
41
|
insertDeferred, listOpenWithOrdinal, dropDeferred,
|
|
41
42
|
resolveDeferredIds, closeDeferredItems,
|
|
@@ -474,7 +475,12 @@ server.registerTool(
|
|
|
474
475
|
`SELECT id, compressed_into, superseded_at, memory_session_id, project, importance, last_accessed_at, created_at_epoch, type FROM observations WHERE id IN (${placeholders})`
|
|
475
476
|
).all(...obsIds);
|
|
476
477
|
const rowMap = new Map(fullRows.map(r => [r.id, r]));
|
|
477
|
-
|
|
478
|
+
// Use the explicitly-requested project for tier classification, not the
|
|
479
|
+
// CWD-inferred one — else computeTier's "obs.project === currentProject"
|
|
480
|
+
// (working/active rules) fails for cross-project searches and the tier=
|
|
481
|
+
// filter silently drops valid rows. mem_stats/mem_browse already resolve
|
|
482
|
+
// args.project first; this restores parity.
|
|
483
|
+
const tierCtx = { now: Date.now(), currentProject: args.project || currentProject, currentSessionId: '' };
|
|
478
484
|
const filtered = results.filter(r => {
|
|
479
485
|
if (r.source !== 'obs') return true;
|
|
480
486
|
const full = rowMap.get(r.id);
|
|
@@ -1107,6 +1113,7 @@ server.registerTool(
|
|
|
1107
1113
|
const lowVal = db.prepare(`
|
|
1108
1114
|
SELECT COUNT(*) as c FROM observations
|
|
1109
1115
|
WHERE COALESCE(importance,1) = 1 AND COALESCE(access_count,0) = 0
|
|
1116
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
1110
1117
|
AND created_at_epoch < ? ${projectFilter}
|
|
1111
1118
|
`).get(thirtyDaysAgo, ...baseParams);
|
|
1112
1119
|
|
|
@@ -1250,7 +1257,6 @@ server.registerTool(
|
|
|
1250
1257
|
` Pending purge (idle-marked): ${stats.pendingPurge}`,
|
|
1251
1258
|
];
|
|
1252
1259
|
if (duplicates.length > 0) {
|
|
1253
|
-
const AUTO_MERGE_THRESHOLD = 0.85;
|
|
1254
1260
|
const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
|
|
1255
1261
|
const manualReview = duplicates.filter(d => parseFloat(d.similarity) < AUTO_MERGE_THRESHOLD);
|
|
1256
1262
|
|
package/source-files.mjs
CHANGED
|
@@ -86,6 +86,11 @@ export const SOURCE_FILES = [
|
|
|
86
86
|
// Statically imported by mem-cli.mjs (cmdMaintain), server.mjs (mem_maintain),
|
|
87
87
|
// and hook.mjs (handleAutoMaintain) — missing it would break maintain on auto-update.
|
|
88
88
|
'lib/maintain-core.mjs',
|
|
89
|
+
// P10 dedup/merge threshold constants — single source of truth for the Jaccard
|
|
90
|
+
// dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
|
|
91
|
+
// hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;
|
|
92
|
+
// missing it from the manifest would break those paths on auto-update.
|
|
93
|
+
'lib/dedup-constants.mjs',
|
|
89
94
|
// v2.70 deferred-work: carry-forward TODO primitives. Statically imported by
|
|
90
95
|
// server.mjs (mem_defer family) and mem-cli.mjs (defer subcommand).
|
|
91
96
|
'lib/deferred-work.mjs',
|
package/tfidf.mjs
CHANGED
|
@@ -10,6 +10,10 @@ import { createHash } from 'crypto';
|
|
|
10
10
|
export const VOCAB_DIM = 512;
|
|
11
11
|
export const MIN_COSINE_SIMILARITY = 0.05;
|
|
12
12
|
export const VECTOR_SCAN_LIMIT = 500;
|
|
13
|
+
// Reciprocal Rank Fusion constant. Higher k flattens the rank-position weighting
|
|
14
|
+
// (BM25 and vector lists contribute more equally); lower k lets the top few ranks
|
|
15
|
+
// dominate. 60 is the de-facto RRF default and balances the two retrievers here.
|
|
16
|
+
export const RRF_K = 60;
|
|
13
17
|
|
|
14
18
|
const VOCAB_STOP_WORDS = new Set([
|
|
15
19
|
...BASE_STOP_WORDS,
|
|
@@ -192,7 +196,7 @@ export function _resetVocabCache() { _vocabCache = null; }
|
|
|
192
196
|
* @param {object} db - better-sqlite3 database
|
|
193
197
|
* @returns {{ terms: Map<string, {index: number, idf: number}>, version: string, dim: number } | null}
|
|
194
198
|
*/
|
|
195
|
-
export function buildVocabulary(db) {
|
|
199
|
+
export function buildVocabulary(db, { dim = VOCAB_DIM } = {}) {
|
|
196
200
|
const rows = db.prepare(`
|
|
197
201
|
SELECT title, narrative, concepts FROM observations
|
|
198
202
|
WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
|
|
@@ -217,7 +221,7 @@ export function buildVocabulary(db) {
|
|
|
217
221
|
.filter(([term, freq]) => !isNoiseTerm(term) && freq >= 2)
|
|
218
222
|
.map(([term, freq]) => ({ term, df: freq, idf: idf(freq), ig: freq * idf(freq) }))
|
|
219
223
|
.sort((a, b) => b.ig - a.ig)
|
|
220
|
-
.slice(0,
|
|
224
|
+
.slice(0, dim);
|
|
221
225
|
|
|
222
226
|
// Build terms map with index and IDF
|
|
223
227
|
const terms = new Map();
|
|
@@ -229,7 +233,7 @@ export function buildVocabulary(db) {
|
|
|
229
233
|
const termList = sortedTerms.map(e => e.term).join(',');
|
|
230
234
|
const version = createHash('md5').update(termList).digest('hex').slice(0, 12);
|
|
231
235
|
|
|
232
|
-
const vocab = { terms, version, dim
|
|
236
|
+
const vocab = { terms, version, dim };
|
|
233
237
|
_vocabCache = vocab;
|
|
234
238
|
return vocab;
|
|
235
239
|
}
|
|
@@ -239,8 +243,8 @@ export function buildVocabulary(db) {
|
|
|
239
243
|
* @param {object} db - better-sqlite3 database
|
|
240
244
|
* @returns {object|null} The new vocabulary
|
|
241
245
|
*/
|
|
242
|
-
export function rebuildVocabulary(db) {
|
|
243
|
-
const vocab = buildVocabulary(db);
|
|
246
|
+
export function rebuildVocabulary(db, opts) {
|
|
247
|
+
const vocab = buildVocabulary(db, opts);
|
|
244
248
|
if (!vocab) return null;
|
|
245
249
|
|
|
246
250
|
const insertStmt = db.prepare(
|
|
@@ -358,7 +362,7 @@ export function cosineSimilarity(a, b) {
|
|
|
358
362
|
const VECTOR_TIME_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
|
|
359
363
|
const VECTOR_MIN_RESULTS = 50; // fallback to full scan if time-window yields fewer
|
|
360
364
|
|
|
361
|
-
export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit = VECTOR_SCAN_LIMIT }) {
|
|
365
|
+
export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit = VECTOR_SCAN_LIMIT, minCosine = MIN_COSINE_SIMILARITY }) {
|
|
362
366
|
if (!queryVec) return [];
|
|
363
367
|
|
|
364
368
|
const now = Date.now();
|
|
@@ -403,7 +407,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
|
|
|
403
407
|
for (const row of rows) {
|
|
404
408
|
const vec = new Float32Array(row.vector.buffer.slice(row.vector.byteOffset, row.vector.byteOffset + row.vector.byteLength));
|
|
405
409
|
const sim = cosineSimilarity(queryVec, vec);
|
|
406
|
-
if (sim >
|
|
410
|
+
if (sim > minCosine) results.push({ id: row.observation_id, similarity: sim });
|
|
407
411
|
}
|
|
408
412
|
results.sort((a, b) => b.similarity - a.similarity);
|
|
409
413
|
return results.slice(0, 20);
|
|
@@ -418,7 +422,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
|
|
|
418
422
|
* @param {number} k - RRF constant (default 60)
|
|
419
423
|
* @returns {{ id: number, rrfScore: number }[]}
|
|
420
424
|
*/
|
|
421
|
-
export function rrfMerge(bm25Results, vectorResults, k =
|
|
425
|
+
export function rrfMerge(bm25Results, vectorResults, k = RRF_K) {
|
|
422
426
|
const scores = new Map();
|
|
423
427
|
bm25Results.forEach((r, i) => {
|
|
424
428
|
scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (k + i + 1));
|