claude-mem-lite 3.41.0 → 3.43.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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-optimize.mjs +17 -1
- package/lib/citation-tracker.mjs +3 -2
- package/lib/export-columns.mjs +26 -0
- package/lib/import-jsonl.mjs +5 -1
- package/lib/search-core.mjs +6 -0
- package/lib/stats-quality.mjs +15 -0
- package/mem-cli.mjs +48 -20
- package/package.json +2 -1
- package/project-utils.mjs +13 -5
- package/registry-recommend.mjs +15 -2
- package/registry.mjs +7 -2
- package/search-engine.mjs +13 -4
- package/secret-scrub.mjs +28 -12
- package/server.mjs +67 -50
- package/source-files.mjs +34 -6
- package/tool-schemas.mjs +5 -3
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.43.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.43.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/hook-optimize.mjs
CHANGED
|
@@ -929,7 +929,23 @@ export async function optimizeRun(db, { tasks, maxItems = 15, force = false, ree
|
|
|
929
929
|
try {
|
|
930
930
|
switch (task) {
|
|
931
931
|
case 're-enrich':
|
|
932
|
-
|
|
932
|
+
if (reenrichScope === 'narrow') {
|
|
933
|
+
// P1-2: the default maintenance pass covers BOTH narrow (fill lesson/concepts on
|
|
934
|
+
// fully-degraded rows) AND aliases (backfill search_aliases on lesson-bearing
|
|
935
|
+
// manual saves that narrow+wide both skip — mem_save writes no aliases, so without
|
|
936
|
+
// this they stay paraphrase-unfindable). Split the budget so neither starves.
|
|
937
|
+
// An explicit --scope wide|aliases still runs exactly that one scope (below).
|
|
938
|
+
const aliasBudget = Math.max(1, Math.floor(budget.reenrich / 2));
|
|
939
|
+
const narrowRes = await executeReenrich(db, budget.reenrich - aliasBudget, { scope: 'narrow', project });
|
|
940
|
+
const aliasRes = await executeReenrich(db, aliasBudget, { scope: 'aliases', project });
|
|
941
|
+
results.reenrich = {
|
|
942
|
+
processed: (narrowRes.processed || 0) + (aliasRes.processed || 0),
|
|
943
|
+
skipped: (narrowRes.skipped || 0) + (aliasRes.skipped || 0),
|
|
944
|
+
byScope: { narrow: narrowRes, aliases: aliasRes },
|
|
945
|
+
};
|
|
946
|
+
} else {
|
|
947
|
+
results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope, project });
|
|
948
|
+
}
|
|
933
949
|
break;
|
|
934
950
|
case 'normalize':
|
|
935
951
|
results.normalize = await executeNormalize(db, force, { project });
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -626,9 +626,10 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
626
626
|
// cite-rate reads N/2 for a single injected-then-cited obs instead of N/1.
|
|
627
627
|
const updatePromote = db.prepare(`
|
|
628
628
|
UPDATE observations
|
|
629
|
-
SET importance = MIN(?, importance + 1),
|
|
629
|
+
SET importance = MIN(?, COALESCE(importance, 1) + 1),
|
|
630
630
|
cited_count = cited_count + 1,
|
|
631
631
|
uncited_streak = 0,
|
|
632
|
+
demoted_at = NULL,
|
|
632
633
|
last_decided_session_id = ?,
|
|
633
634
|
last_cited_session_id = ?,
|
|
634
635
|
decay_seen_count = decay_seen_count + ?
|
|
@@ -655,7 +656,7 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
655
656
|
`);
|
|
656
657
|
const updateDemote = db.prepare(`
|
|
657
658
|
UPDATE observations
|
|
658
|
-
SET importance = MAX(?, importance - 1),
|
|
659
|
+
SET importance = MAX(?, COALESCE(importance, 1) - 1),
|
|
659
660
|
uncited_streak = 0,
|
|
660
661
|
last_decided_session_id = ?,
|
|
661
662
|
demoted_at = ?,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Single source of truth for the observation columns that `export` emits and `restore`
|
|
2
|
+
// reads back. Both the CLI (`cmdExport` in mem-cli.mjs) and the MCP `mem_export` tool
|
|
3
|
+
// (server.mjs) build their SELECT from this list so the two surfaces can never drift.
|
|
4
|
+
//
|
|
5
|
+
// v3.42 audit HIGH-2: the MCP export handler had its own narrower 16-column SELECT while
|
|
6
|
+
// the CLI export carried 24 — missing exactly the columns cmdRestore reads back
|
|
7
|
+
// (text / files_read / search_aliases / cited_count / uncited_streak / injection_count /
|
|
8
|
+
// decay_seen_count / last_accessed_at). A backup taken via MCP export then restored via CLI
|
|
9
|
+
// silently collapsed every empty-`narrative` row (import-jsonl / cold-start bodies live in
|
|
10
|
+
// `text`) to its bare title — unrecoverable AND unsearchable. Sharing one list closes that
|
|
11
|
+
// twin-drift class permanently.
|
|
12
|
+
//
|
|
13
|
+
// Full round-trippable set: content + value-signals (access/cited/uncited/injection/decay)
|
|
14
|
+
// + branch + timing. `id` + `memory_session_id` are informational (restore remaps id and
|
|
15
|
+
// buckets under a synthetic restore session). Session-idempotency keys (last_decided/
|
|
16
|
+
// last_cited_session_id, demoted_at, optimized_at) are intentionally NOT exported — they are
|
|
17
|
+
// meaningless after a row is re-bucketed under a restore session.
|
|
18
|
+
export const EXPORT_COLUMNS = [
|
|
19
|
+
'id', 'memory_session_id', 'project', 'type', 'title', 'subtitle', 'narrative', 'text',
|
|
20
|
+
'concepts', 'facts', 'files_read', 'files_modified', 'lesson_learned', 'search_aliases',
|
|
21
|
+
'importance', 'branch', 'access_count', 'cited_count', 'uncited_streak', 'injection_count',
|
|
22
|
+
'decay_seen_count', 'last_accessed_at', 'created_at', 'created_at_epoch',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// The SELECT column fragment (comma-joined) — drop into `SELECT ${EXPORT_COLUMNS_SQL} FROM …`.
|
|
26
|
+
export const EXPORT_COLUMNS_SQL = EXPORT_COLUMNS.join(', ');
|
package/lib/import-jsonl.mjs
CHANGED
|
@@ -147,7 +147,11 @@ export async function importJsonl(db, path, { project }) {
|
|
|
147
147
|
const mb = (n) => Math.round(n / (1024 * 1024));
|
|
148
148
|
throw new Error(`transcript too large (${mb(st.size)}MB > ${mb(MAX_IMPORT_BYTES)}MB cap); split it (e.g. split -l 50000 into parts) and import the parts`);
|
|
149
149
|
}
|
|
150
|
-
|
|
150
|
+
// Strip a leading UTF-8 BOM — Node's utf8 read leaves it on, so line 1 would become
|
|
151
|
+
// a U+FEFF-prefixed "{...}" and fail JSON.parse (silently dropped as "skipped"). Real CC
|
|
152
|
+
// are BOM-less, but an editor-touched or re-encoded file can carry one.
|
|
153
|
+
const rawText = readFileSync(path, 'utf8');
|
|
154
|
+
const lines = (rawText.charCodeAt(0) === 0xFEFF ? rawText.slice(1) : rawText).split('\n');
|
|
151
155
|
const seenPrompts = new Set();
|
|
152
156
|
const seenObs = new Set();
|
|
153
157
|
// Pre-seed dedup sets from existing rows so a second run on the same file
|
package/lib/search-core.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { sanitizeFtsQuery, relaxFtsQueryToOr, SESS_BM25, DEFAULT_DECAY_HALF_LIFE
|
|
|
28
28
|
import { cjkPrecisionOk, extractCjkLikePatterns } from '../nlp.mjs';
|
|
29
29
|
import { computeTier } from '../tier.mjs';
|
|
30
30
|
import { countSearchTotal, attachBodyTokens } from '../search-engine.mjs';
|
|
31
|
+
import { notLowSignalTitleClause } from '../scoring-sql.mjs';
|
|
31
32
|
|
|
32
33
|
/** Sanitize a user query to FTS5 syntax; optionally force OR semantics. */
|
|
33
34
|
export function buildSearchFtsQuery(query, { or = false } = {}) {
|
|
@@ -459,6 +460,11 @@ export async function coreRunSearchPipeline(ctx, opts) {
|
|
|
459
460
|
// ── Type-list fallback (MCP): obs_type set + 0 matches → list recent of that type ──
|
|
460
461
|
if (obsTypeFallback && results.length === 0 && obsType) {
|
|
461
462
|
const typeWheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL', 'type = ?'];
|
|
463
|
+
// Mirror the FTS path's low-signal filter (buildObsFtsQuery): this fallback is still a
|
|
464
|
+
// SEARCH surface, so degraded titles ("Modified X", "Error: …") must not lead it —
|
|
465
|
+
// acute for obs_type='change', the noise band. (The no-query recent-listing in
|
|
466
|
+
// searchObservationsHybrid deliberately does NOT filter — it mirrors mem_recent.)
|
|
467
|
+
if (!includeNoise) typeWheres.push(notLowSignalTitleClause(''));
|
|
462
468
|
const typeParams = [obsType];
|
|
463
469
|
if (project) { typeWheres.push('project = ?'); typeParams.push(project); }
|
|
464
470
|
if (epochFrom !== null) { typeWheres.push('created_at_epoch >= ?'); typeParams.push(epochFrom); }
|
package/lib/stats-quality.mjs
CHANGED
|
@@ -7,6 +7,21 @@ import { notLowSignalTitleClause } from '../scoring-sql.mjs';
|
|
|
7
7
|
import { truncate } from '../format-utils.mjs';
|
|
8
8
|
import { COMPRESSED_PENDING_PURGE } from '../utils.mjs';
|
|
9
9
|
|
|
10
|
+
// v3.42 F7: noise/low-signal ratios must divide by the LIVE (non-compressed) observation
|
|
11
|
+
// count, NOT the all-rows total. Both numerators (lowVal, lowSignalTitle) already exclude
|
|
12
|
+
// compressed rows (`compressed_into = 0`); dividing by an all-inclusive denominator
|
|
13
|
+
// systematically UNDER-reports noise on a compress-heavy mature store (the store looks
|
|
14
|
+
// cleaner than it is right before a maintain/compress decision). Shared by the CLI `stats`
|
|
15
|
+
// and MCP `mem_stats` main gauges so the two can't drift. Returns 0 when there is no live
|
|
16
|
+
// corpus (all-compressed / empty).
|
|
17
|
+
export function computeNoiseGauge({ liveTotal, lowValCount, lowSignalCount }) {
|
|
18
|
+
const denom = liveTotal > 0 ? liveTotal : 0;
|
|
19
|
+
return {
|
|
20
|
+
noiseRatio: denom ? lowValCount / denom : 0,
|
|
21
|
+
lowSignalRatio: denom ? lowSignalCount / denom : 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
export function computeQualityStats(db, { project, days }) {
|
|
11
26
|
const projectFilter = project ? 'AND project = ?' : '';
|
|
12
27
|
const baseParams = project ? [project] : [];
|
package/mem-cli.mjs
CHANGED
|
@@ -40,6 +40,8 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
|
40
40
|
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
|
|
41
41
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
42
42
|
import { rebuildObservationDerived } from './lib/observation-write.mjs';
|
|
43
|
+
import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
|
|
44
|
+
import { computeNoiseGauge } from './lib/stats-quality.mjs';
|
|
43
45
|
import { recallByFile } from './lib/recall-core.mjs';
|
|
44
46
|
import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
|
|
45
47
|
import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
|
|
@@ -1133,7 +1135,6 @@ async function cmdStats(db, args) {
|
|
|
1133
1135
|
AND COALESCE(compressed_into, 0) = 0
|
|
1134
1136
|
AND created_at_epoch < ? ${projectFilter}
|
|
1135
1137
|
`).get(thirtyDaysAgo, ...baseParams);
|
|
1136
|
-
const noiseRatio = obsTotal.c > 0 ? lowVal.c / obsTotal.c : 0;
|
|
1137
1138
|
// Low-signal-title population: template / tool-log titles (Modified, Worked on,
|
|
1138
1139
|
// Error while working, Error:, node/npm/npx …) that the retrieval layer already
|
|
1139
1140
|
// filters out by default. The imp=1 "Low-value" metric above structurally can't
|
|
@@ -1145,7 +1146,13 @@ async function cmdStats(db, args) {
|
|
|
1145
1146
|
WHERE NOT ${buildNotLowSignalSql()}
|
|
1146
1147
|
AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
|
|
1147
1148
|
`).get(...baseParams);
|
|
1148
|
-
|
|
1149
|
+
// F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
|
|
1150
|
+
// obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
|
|
1151
|
+
// Shared with the MCP mem_stats gauge via computeNoiseGauge (lib/stats-quality.mjs).
|
|
1152
|
+
const liveTotal = db.prepare(
|
|
1153
|
+
`SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
|
|
1154
|
+
).get(...baseParams);
|
|
1155
|
+
const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
|
|
1149
1156
|
const compressedCount = db.prepare(
|
|
1150
1157
|
`SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
|
|
1151
1158
|
).get(...baseParams);
|
|
@@ -1538,7 +1545,15 @@ function cmdUpdate(db, args) {
|
|
|
1538
1545
|
}
|
|
1539
1546
|
updates.push('title = ?'); params.push(scrubSecrets(flags.title));
|
|
1540
1547
|
}
|
|
1541
|
-
if (flags.narrative !== undefined) {
|
|
1548
|
+
if (flags.narrative !== undefined) {
|
|
1549
|
+
// Reject empty (mirror --title): an explicit '' would blank the narrative
|
|
1550
|
+
// irrecoverably (update takes no snapshot). Omit the flag to leave it unchanged.
|
|
1551
|
+
if (typeof flags.narrative === 'string' && flags.narrative.trim() === '') {
|
|
1552
|
+
fail('[mem] --narrative cannot be empty. Omit the flag to leave it unchanged.');
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
updates.push('narrative = ?'); params.push(scrubSecrets(flags.narrative));
|
|
1556
|
+
}
|
|
1542
1557
|
if (flags.type) {
|
|
1543
1558
|
const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
|
|
1544
1559
|
if (!validTypes.has(flags.type)) {
|
|
@@ -1566,13 +1581,23 @@ function cmdUpdate(db, args) {
|
|
|
1566
1581
|
fail(`[mem] --lesson too long (${rawLesson.length} chars, max 500).`);
|
|
1567
1582
|
return;
|
|
1568
1583
|
}
|
|
1584
|
+
if (typeof rawLesson === 'string' && rawLesson.trim() === '') {
|
|
1585
|
+
fail('[mem] --lesson cannot be empty. Omit the flag to leave it unchanged.');
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1569
1588
|
updates.push('lesson_learned = ?');
|
|
1570
1589
|
params.push(scrubSecrets(rawLesson));
|
|
1571
1590
|
}
|
|
1572
1591
|
// Scrub like the sibling text fields above (title/narrative/lesson) and the MCP twin
|
|
1573
1592
|
// mem_update — concepts is a scrub-target + FTS-indexed column, so a raw secret here
|
|
1574
1593
|
// lands searchable + exportable (rebuildObservationDerived folds it into `text`).
|
|
1575
|
-
if (flags.concepts !== undefined) {
|
|
1594
|
+
if (flags.concepts !== undefined) {
|
|
1595
|
+
if (typeof flags.concepts === 'string' && flags.concepts.trim() === '') {
|
|
1596
|
+
fail('[mem] --concepts cannot be empty. Omit the flag to leave it unchanged.');
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
updates.push('concepts = ?'); params.push(scrubSecrets(flags.concepts));
|
|
1600
|
+
}
|
|
1576
1601
|
|
|
1577
1602
|
if (updates.length === 0) {
|
|
1578
1603
|
fail('[mem] No fields to update. Use --title, --type, --importance, --lesson/--lesson-learned, --narrative, --concepts');
|
|
@@ -1659,15 +1684,12 @@ function cmdExport(db, args) {
|
|
|
1659
1684
|
// content + value-signals (access/cited/uncited/injection/decay) + branch + timing.
|
|
1660
1685
|
// `search_aliases` is an FTS5-indexed column (BM25 weight 5) — dropping it on
|
|
1661
1686
|
// export silently lost the LLM-generated alternate query terms on restore, so a
|
|
1662
|
-
// restored memory became unfindable by its aliases.
|
|
1663
|
-
//
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1687
|
+
// restored memory became unfindable by its aliases. id + memory_session_id are
|
|
1688
|
+
// informational (restore remaps id and buckets under a restore session).
|
|
1689
|
+
// EXPORT_COLUMNS_SQL is the single source of truth shared with the MCP mem_export
|
|
1690
|
+
// tool (server.mjs) so the two export surfaces can never drift (v3.42 HIGH-2).
|
|
1666
1691
|
const rows = db.prepare(`
|
|
1667
|
-
SELECT
|
|
1668
|
-
files_read, files_modified, lesson_learned, search_aliases, importance, branch,
|
|
1669
|
-
access_count, cited_count, uncited_streak, injection_count, decay_seen_count,
|
|
1670
|
-
last_accessed_at, created_at, created_at_epoch
|
|
1692
|
+
SELECT ${EXPORT_COLUMNS_SQL}
|
|
1671
1693
|
FROM observations WHERE ${wheres.join(' AND ')}
|
|
1672
1694
|
ORDER BY created_at_epoch DESC LIMIT ?
|
|
1673
1695
|
`).all(...params, limit);
|
|
@@ -1740,6 +1762,7 @@ function cmdRestore(db, argv) {
|
|
|
1740
1762
|
|
|
1741
1763
|
const dupCheck = db.prepare('SELECT id FROM observations WHERE project = ? AND title = ? AND created_at_epoch = ? LIMIT 1');
|
|
1742
1764
|
const signalUpdate = db.prepare(`UPDATE observations SET
|
|
1765
|
+
text = COALESCE(?, text),
|
|
1743
1766
|
subtitle = ?, concepts = ?, facts = ?, search_aliases = ?, files_read = ?, branch = COALESCE(?, branch),
|
|
1744
1767
|
access_count = ?, cited_count = ?, uncited_streak = ?, injection_count = ?,
|
|
1745
1768
|
decay_seen_count = ?, last_accessed_at = ?
|
|
@@ -1770,15 +1793,20 @@ function cmdRestore(db, argv) {
|
|
|
1770
1793
|
});
|
|
1771
1794
|
if (res.kind !== 'saved') { skipped++; continue; } // saveObservation Jaccard dedup
|
|
1772
1795
|
// Re-apply the fields saveObservation zeros/derives so the backup is faithful.
|
|
1773
|
-
//
|
|
1774
|
-
//
|
|
1775
|
-
//
|
|
1776
|
-
// (
|
|
1777
|
-
//
|
|
1778
|
-
//
|
|
1779
|
-
//
|
|
1780
|
-
// paths
|
|
1796
|
+
// `text` is the observation BODY and its own FTS5 column — import-jsonl / cold-start
|
|
1797
|
+
// rows keep the body there with an empty narrative, so saveObservation (content =
|
|
1798
|
+
// narrative || title) would collapse it to the bare title. COALESCE re-applies the
|
|
1799
|
+
// exported body (NULL when absent → keep saveObservation's derived text, so old
|
|
1800
|
+
// backups without the column degrade gracefully). search_aliases is its own FTS5
|
|
1801
|
+
// column too, so this UPDATE re-syncs the index (via the observations FTS triggers)
|
|
1802
|
+
// and restored body/aliases stay searchable. Scrub the FTS-indexed text fields on
|
|
1803
|
+
// the way in — the sibling ingest paths (import-jsonl, compress-core) scrub as
|
|
1804
|
+
// defense-in-depth, and restore is the only rewrite path that skipped it. A backup
|
|
1805
|
+
// made before a SECRET_PATTERNS entry existed would otherwise re-index an old secret
|
|
1806
|
+
// in text/facts/concepts. files_read/branch are paths/identifiers, not scrub-target
|
|
1807
|
+
// text — left as-is.
|
|
1781
1808
|
signalUpdate.run(
|
|
1809
|
+
r.text ? scrubSecrets(r.text) : null,
|
|
1782
1810
|
scrubSecrets(r.subtitle || ''), scrubSecrets(r.concepts || ''), scrubSecrets(r.facts || ''),
|
|
1783
1811
|
r.search_aliases === null || r.search_aliases === undefined ? null : scrubSecrets(r.search_aliases),
|
|
1784
1812
|
r.files_read || '[]', r.branch ?? null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.43.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"lib/err-sampler.mjs",
|
|
69
69
|
"lib/hook-telemetry.mjs",
|
|
70
70
|
"lib/resolve-data-dir.mjs",
|
|
71
|
+
"lib/export-columns.mjs",
|
|
71
72
|
"lib/file-intel.mjs",
|
|
72
73
|
"lib/reread-guard.mjs",
|
|
73
74
|
"lib/metrics.mjs",
|
package/project-utils.mjs
CHANGED
|
@@ -49,11 +49,19 @@ export function resolveProject(db, name) {
|
|
|
49
49
|
).get(`%--${name}%`);
|
|
50
50
|
if (prefixed) { _cache.set(name, prefixed.project); return prefixed.project; }
|
|
51
51
|
|
|
52
|
-
// 3)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
// 3) Whole-token match: the name is a complete hyphen-delimited component of the base
|
|
53
|
+
// (e.g. "graph" → "projects--code-graph-mcp", "mcp" → "…-mcp"). Steps 1/2 already cover
|
|
54
|
+
// the exact base and the base *prefix*; this adds interior/trailing whole tokens ONLY.
|
|
55
|
+
// v3.42 F3: the old `%name%` substring fallback matched mid-token ("test" inside
|
|
56
|
+
// "loop-testing") and returned the highest-COUNT unrelated project, so `--project test`
|
|
57
|
+
// silently queried the wrong project. Require a hyphen boundary: an interior token
|
|
58
|
+
// (`%-name-%`) or a trailing token (`%-name`) so "test" no longer matches "testing".
|
|
59
|
+
const token = db.prepare(
|
|
60
|
+
`SELECT project FROM observations
|
|
61
|
+
WHERE (project LIKE '%-' || ? || '-%' OR project LIKE '%-' || ?)
|
|
62
|
+
GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
|
|
63
|
+
).get(name, name);
|
|
64
|
+
if (token) { _cache.set(name, token.project); return token.project; }
|
|
57
65
|
|
|
58
66
|
// 4) Fallback: synthesize canonical form from current directory
|
|
59
67
|
const inferred = inferProject();
|
package/registry-recommend.mjs
CHANGED
|
@@ -349,16 +349,29 @@ export function formatFunnel(s) {
|
|
|
349
349
|
if (typeof s.sessions === 'number') {
|
|
350
350
|
const mp = s.matched && s.matched.pass
|
|
351
351
|
? `${s.matched.adopt}/${s.matched.pass} (${(100 * s.matched.precision).toFixed(0)}%)` : 'n/a';
|
|
352
|
-
|
|
352
|
+
// v3.42 F5: lift = adoptGivenPass / baseRate is a ratio over `passSessions` observations.
|
|
353
|
+
// Below LIFT_MIN_PASS_SESSIONS the numerator is 1-2 sessions of noise (a single
|
|
354
|
+
// pass→adopt yields lift 7.0 at passSessions=1) — displaying it top-ranked next to a
|
|
355
|
+
// 30-session skill misleads the shadow→live flip decision. Gate the DISPLAY on a minimum
|
|
356
|
+
// observation count and report how many were suppressed (never a silent cap).
|
|
357
|
+
const finite = Object.entries(s.lift || {}).filter(([, v]) => Number.isFinite(v.lift));
|
|
358
|
+
const shown = finite.filter(([, v]) => (v.passSessions || 0) >= LIFT_MIN_PASS_SESSIONS);
|
|
359
|
+
const suppressed = finite.length - shown.length;
|
|
360
|
+
const liftRows = shown
|
|
353
361
|
.sort((a, b) => b[1].lift - a[1].lift).slice(0, 5)
|
|
354
362
|
.map(([k, v]) => `${k}(lift ${v.lift.toFixed(2)}; ${v.hitSessions}/${v.passSessions} pass→adopt vs base ${(100 * v.baseRate).toFixed(0)}%)`)
|
|
355
363
|
.join(', ') || '(none)';
|
|
364
|
+
const suppressNote = suppressed > 0 ? ` [${suppressed} hidden: passSessions<${LIFT_MIN_PASS_SESSIONS}]` : '';
|
|
356
365
|
lines.push(` sessions=${s.sessions} matched precision (in-session PASS→adopt): ${mp}`);
|
|
357
|
-
lines.push(` targeting lift (>1 = gate beats organic base rate): ${liftRows}`);
|
|
366
|
+
lines.push(` targeting lift (>1 = gate beats organic base rate, min n=${LIFT_MIN_PASS_SESSIONS}): ${liftRows}${suppressNote}`);
|
|
358
367
|
}
|
|
359
368
|
return lines.join('\n');
|
|
360
369
|
}
|
|
361
370
|
|
|
371
|
+
// Minimum passSessions before a per-skill lift is trustworthy enough to display. Below this,
|
|
372
|
+
// adoptGivenPass is computed over 1-2 sessions and the lift is noise (F5).
|
|
373
|
+
export const LIFT_MIN_PASS_SESSIONS = 3;
|
|
374
|
+
|
|
362
375
|
/** Human-readable threshold sweep for `registry recommend-stats --sweep`. */
|
|
363
376
|
export function formatSweep(grid) {
|
|
364
377
|
const lines = ['gate threshold sweep (floor × margin → pass / matched precision):'];
|
package/registry.mjs
CHANGED
|
@@ -390,11 +390,16 @@ const UPSERT_SQL = `
|
|
|
390
390
|
capability_summary=CASE WHEN excluded.capability_summary != '' THEN excluded.capability_summary ELSE capability_summary END,
|
|
391
391
|
input_type=CASE WHEN excluded.input_type != '' THEN excluded.input_type ELSE input_type END,
|
|
392
392
|
output_type=CASE WHEN excluded.output_type != '' THEN excluded.output_type ELSE output_type END,
|
|
393
|
-
|
|
393
|
+
-- Preserve-on-empty (same class as the FTS text columns above): a partial re-import
|
|
394
|
+
-- omits prerequisites/complexity, so upsertResource supplies their DEFAULTS ('{}' /
|
|
395
|
+
-- 'intermediate'). No CLI flag sets these (only a full re-index does), so treating the
|
|
396
|
+
-- default as the "absent" sentinel is safe: a real re-index sends non-default values,
|
|
397
|
+
-- which still overwrite; a metadata edit sends the default, which now preserves.
|
|
398
|
+
prerequisites=CASE WHEN excluded.prerequisites NOT IN ('', '{}') THEN excluded.prerequisites ELSE prerequisites END,
|
|
394
399
|
keywords=CASE WHEN excluded.keywords != '' THEN excluded.keywords ELSE keywords END,
|
|
395
400
|
tech_stack=CASE WHEN excluded.tech_stack != '' THEN excluded.tech_stack ELSE tech_stack END,
|
|
396
401
|
use_cases=CASE WHEN excluded.use_cases != '' THEN excluded.use_cases ELSE use_cases END,
|
|
397
|
-
complexity=excluded.complexity,
|
|
402
|
+
complexity=CASE WHEN excluded.complexity NOT IN ('', 'intermediate') THEN excluded.complexity ELSE complexity END,
|
|
398
403
|
indexed_at=excluded.indexed_at, updated_at=datetime('now')
|
|
399
404
|
`;
|
|
400
405
|
|
package/search-engine.mjs
CHANGED
|
@@ -27,13 +27,22 @@ const FULL_SCORE = `${OBS_BM25}
|
|
|
27
27
|
* (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
|
|
28
28
|
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
29
29
|
* (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
|
|
30
|
-
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
|
|
30
|
+
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))`;
|
|
31
31
|
|
|
32
32
|
const SIMPLE_SCORE = `${OBS_BM25}
|
|
33
33
|
* (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
|
|
34
34
|
* ${TYPE_QUALITY_CASE}
|
|
35
35
|
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
36
|
-
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
|
|
36
|
+
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))`;
|
|
37
|
+
|
|
38
|
+
// Shared column set for fetching an observation surfaced by the vector arm — used by BOTH
|
|
39
|
+
// the RRF-merge branch (FTS also had results) and the FTS-empty fallback branch. Single
|
|
40
|
+
// source so the two can't drift. v3.42 F4: the fallback branch's SELECT had dropped
|
|
41
|
+
// lesson_learned while its RRF twin kept it, so a vector-only hit returned
|
|
42
|
+
// lesson_learned: undefined — losing the lesson content AND the 1.5× lesson scoring boost
|
|
43
|
+
// downstream. Both branches build `{ …, date: obs.created_at, lesson_learned: obs.lesson_learned }`
|
|
44
|
+
// so the SELECT must carry created_at + lesson_learned.
|
|
45
|
+
export const VEC_HIT_OBS_COLS = 'id, type, title, subtitle, project, created_at, created_at_epoch, importance, files_modified, branch, lesson_learned';
|
|
37
46
|
|
|
38
47
|
export function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset, includeNoise } = {}) {
|
|
39
48
|
const scoreExpr = scoring === 'full' ? FULL_SCORE : SIMPLE_SCORE;
|
|
@@ -432,7 +441,7 @@ export function searchObservationsHybrid(db, ctx) {
|
|
|
432
441
|
const resultMap = new Map(results.map(r => [r.id, r]));
|
|
433
442
|
for (const vr of vecResults) {
|
|
434
443
|
if (!resultMap.has(vr.id)) {
|
|
435
|
-
const obs = db.prepare(
|
|
444
|
+
const obs = db.prepare(`SELECT ${VEC_HIT_OBS_COLS} FROM observations WHERE id = ?`).get(vr.id);
|
|
436
445
|
if (!obs) continue;
|
|
437
446
|
if (epochFrom !== null && obs.created_at_epoch < epochFrom) continue;
|
|
438
447
|
if (epochTo !== null && obs.created_at_epoch > epochTo) continue;
|
|
@@ -450,7 +459,7 @@ export function searchObservationsHybrid(db, ctx) {
|
|
|
450
459
|
} else {
|
|
451
460
|
// FTS5 found nothing but vector found results
|
|
452
461
|
for (const vr of vecResults) {
|
|
453
|
-
const obs = db.prepare(
|
|
462
|
+
const obs = db.prepare(`SELECT ${VEC_HIT_OBS_COLS} FROM observations WHERE id = ?`).get(vr.id);
|
|
454
463
|
if (!obs) continue;
|
|
455
464
|
if (epochFrom !== null && obs.created_at_epoch < epochFrom) continue;
|
|
456
465
|
if (epochTo !== null && obs.created_at_epoch > epochTo) continue;
|
package/secret-scrub.mjs
CHANGED
|
@@ -44,6 +44,11 @@ export const SECRET_PATTERNS = [
|
|
|
44
44
|
// low-FP decision that `topsecret=` / `access_token_count:` are non-credentials
|
|
45
45
|
// (#8283 + utils.test.mjs:1089-1100); bare `pwd` is omitted so `PWD=` (a path) survives.
|
|
46
46
|
[/((?:\b|_)(?:api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|pgpassword|pgpass|mysql_pwd)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
47
|
+
// Space-separated credential CLI flag: `--password <value>` (long-form). The KV
|
|
48
|
+
// patterns above require `=`/`:`; the shell long-flag form uses a space. Long-form
|
|
49
|
+
// only — `-p`/`-u` short flags collide with unit/user/update flags (too FP-risky).
|
|
50
|
+
// `(?!-)` stops it eating a following `--flag` when --password has no value.
|
|
51
|
+
[/(--(?:password|passwd)[=\s]+)(?!-)[^\s'"]{6,}/gi, '$1***'],
|
|
47
52
|
// Bare-key QUOTED values — `api_key="..."`, `password: '...'`. The unquoted KV
|
|
48
53
|
// patterns above stop at `'`/`"` (excluded from their value class), so a quoted
|
|
49
54
|
// value matched 0 chars and slipped through. Consumes the opening quote, the value,
|
|
@@ -68,35 +73,46 @@ export const SECRET_PATTERNS = [
|
|
|
68
73
|
[/\bsk-(?:proj|ant|ant-api\d{2})-[a-zA-Z0-9_-]{8,}\b/g, '***'],
|
|
69
74
|
[/\bsk-[a-zA-Z0-9_-]{20,}\b/g, '***'],
|
|
70
75
|
// GitHub tokens (ghp_, gho_, github_pat_)
|
|
71
|
-
[/\b(?:ghp_|gho_|ghs_|ghr_)[a-zA-Z0-9_]{30,}\b/g, '***'],
|
|
76
|
+
[/\b(?:ghp_|gho_|ghs_|ghr_|ghu_)[a-zA-Z0-9_]{30,}\b/g, '***'],
|
|
72
77
|
[/\bgithub_pat_[a-zA-Z0-9_]{22,}\b/g, '***'],
|
|
73
78
|
// GitLab tokens (glpat-)
|
|
74
79
|
[/\bglpat-[a-zA-Z0-9_-]{20,}\b/g, '***'],
|
|
75
|
-
// Slack tokens (xox[
|
|
76
|
-
[/\
|
|
80
|
+
// Slack tokens (xox[bpasr]-, xapp-, xoxe-)
|
|
81
|
+
[/\b(?:xox[bpasr]|xapp|xoxe)-[a-zA-Z0-9-]{10,}\b/g, '***'],
|
|
82
|
+
// Slack incoming-webhook URL — the path after /services/ is the shared secret.
|
|
83
|
+
[/(https:\/\/hooks\.slack\.com\/services\/)[A-Za-z0-9/]+/g, '$1***'],
|
|
77
84
|
// JWT tokens (eyJ...eyJ...)
|
|
78
85
|
[/\beyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+\b/g, '***'],
|
|
79
86
|
// PEM private key blocks. `[A-Z0-9 ]*` covers every armor label — RSA/EC/DSA/
|
|
80
87
|
// OPENSSH plus ENCRYPTED and PGP (… PRIVATE KEY BLOCK) — that the fixed
|
|
81
88
|
// alternation missed; the block delimiters make FP impossible.
|
|
82
89
|
[/-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g, '***PEM_KEY***'],
|
|
83
|
-
// Long hex strings in assignments (e.g. SECRET_KEY=abc123def456...)
|
|
84
|
-
|
|
90
|
+
// Long hex strings in credential assignments (e.g. SECRET_KEY=abc123def456...).
|
|
91
|
+
// `hash` deliberately excluded: `hash: <40hex>` / `hash=<md5>` are git SHAs and
|
|
92
|
+
// checksums (real, preserved data in this hash-heavy repo), not credentials.
|
|
93
|
+
[/(\b(?:key|secret|token)\s*[=:]\s*)[0-9a-f]{32,}\b/gi, '$1***'],
|
|
85
94
|
// Google Cloud API keys (AIza...)
|
|
86
95
|
[/\bAIza[A-Za-z0-9_-]{35}\b/g, '***'],
|
|
87
|
-
//
|
|
88
|
-
|
|
96
|
+
// Authorization header credentials — Bearer (opaque), Basic (base64 user:pass),
|
|
97
|
+
// and GitHub's `token` scheme all carry secrets after the scheme word.
|
|
98
|
+
[/(Authorization:\s*(?:Bearer|Basic|token)\s+)[^\s,;'"}\]]+/gi, '$1***'],
|
|
89
99
|
// Supabase / generic long base64 keys (40+ chars, common in env vars)
|
|
90
100
|
[/(\b(?:SUPABASE_KEY|SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL|REDIS_URL)\s*[=:]\s*)[^\s,;'"}\]]+/gi, '$1***'],
|
|
91
101
|
// Basic auth in URLs (https://user:password@host). ftp/ftps added — file-drop
|
|
92
|
-
// creds are a common leak shape the https-only form missed.
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
|
|
102
|
+
// creds are a common leak shape the https-only form missed. The userinfo run
|
|
103
|
+
// EXCLUDES `:` (`[^@/\s:]+`) so the two runs can't overlap on a colon — the
|
|
104
|
+
// overlapping form caused O(n²) catastrophic backtracking on a colon-heavy
|
|
105
|
+
// non-terminating input (an availability DoS on the synchronous prompt path).
|
|
106
|
+
[/(https?|ftps?):\/\/[^@/\s:]+:[^@/\s]+@/gi, '$1://***:***@'],
|
|
107
|
+
// Database connection strings (postgres, mysql, mariadb, mssql, mongodb, redis,
|
|
108
|
+
// amqp) incl. their TLS/alias variants (rediss/amqps/mssql/sqlserver) — managed
|
|
109
|
+
// cloud DBs almost always use the TLS scheme, which the base-only list leaked.
|
|
110
|
+
[/\b(postgres(?:ql)?|mysql|mariadb|mssql|sqlserver|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^\s,;'"}\]]+/gi, '$1://***'],
|
|
96
111
|
// npm tokens (npm_...)
|
|
97
112
|
[/\bnpm_[a-zA-Z0-9]{36,}\b/g, '***'],
|
|
98
|
-
// Stripe keys (sk_live_, rk_live_, pk_live_, sk_test_, pk_test_)
|
|
113
|
+
// Stripe keys (sk_live_, rk_live_, pk_live_, sk_test_, pk_test_) + webhook signing secret (whsec_)
|
|
99
114
|
[/\b[srp]k_(?:live|test)_[a-zA-Z0-9]{20,}\b/g, '***'],
|
|
115
|
+
[/\bwhsec_[a-zA-Z0-9]{20,}\b/g, '***'],
|
|
100
116
|
// SendGrid API keys: SG.<22>.<43> — two dots at fixed offsets make this
|
|
101
117
|
// structurally unmistakable; near-zero false-positive risk.
|
|
102
118
|
[/\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g, '***'],
|
package/server.mjs
CHANGED
|
@@ -44,6 +44,8 @@ import { searchResources } from './registry-retriever.mjs';
|
|
|
44
44
|
import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
|
|
45
45
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
46
46
|
import { rebuildObservationDerived } from './lib/observation-write.mjs';
|
|
47
|
+
import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
|
|
48
|
+
import { computeNoiseGauge } from './lib/stats-quality.mjs';
|
|
47
49
|
import { recallByFile } from './lib/recall-core.mjs';
|
|
48
50
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
49
51
|
import {
|
|
@@ -251,15 +253,14 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, orFall
|
|
|
251
253
|
// Exported for tests: runs the full mem_search pipeline against an explicit db
|
|
252
254
|
// with an optional injected llm (deepSearch dependency). The MCP tool handler
|
|
253
255
|
// calls this with the module db and the default llm.
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
// resolveProject() against the real (module) DB, not the test DB.
|
|
256
|
+
// v3.42 F3: resolveProject now runs against the injected `db` param (not the module db), so
|
|
257
|
+
// a project: arg through this seam resolves against the TEST db — real test isolation.
|
|
257
258
|
export async function handleSearchForTest(db, args, { llm, rerankLlm } = {}) {
|
|
258
259
|
return runSearchPipeline(db, args, { llm, rerankLlm });
|
|
259
260
|
}
|
|
260
261
|
|
|
261
262
|
async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
|
|
262
|
-
if (args.project) args = { ...args, project:
|
|
263
|
+
if (args.project) args = { ...args, project: _resolveProjectShared(db, args.project) };
|
|
263
264
|
const limit = args.limit ?? 20;
|
|
264
265
|
const offset = args.offset ?? 0;
|
|
265
266
|
// args.or: force OR from the start (CLI `search --or` parity). The default path
|
|
@@ -352,15 +353,15 @@ server.registerTool(
|
|
|
352
353
|
|
|
353
354
|
// ─── Tool: mem_recent ────────────────────────────────────────────────────────
|
|
354
355
|
|
|
355
|
-
// In-process test seam (mirrors handleSearchForTest, #8743): threads an injected
|
|
356
|
-
//
|
|
357
|
-
//
|
|
356
|
+
// In-process test seam (mirrors handleSearchForTest, #8743): threads an injected db through
|
|
357
|
+
// the SAME body the registered handler runs. v3.42 F3: a `project` arg now resolves against
|
|
358
|
+
// the injected db (not the module db), so :memory: test isolation is actually achieved.
|
|
358
359
|
export async function handleRecentForTest(db, args) {
|
|
359
360
|
return runRecent(db, args);
|
|
360
361
|
}
|
|
361
362
|
|
|
362
363
|
async function runRecent(db, args) {
|
|
363
|
-
if (args.project) args = { ...args, project:
|
|
364
|
+
if (args.project) args = { ...args, project: _resolveProjectShared(db, args.project) };
|
|
364
365
|
const limit = args.limit ?? 10;
|
|
365
366
|
const project = args.project || inferProject();
|
|
366
367
|
|
|
@@ -906,7 +907,6 @@ server.registerTool(
|
|
|
906
907
|
AND created_at_epoch < ? ${projectFilter}
|
|
907
908
|
`).get(thirtyDaysAgo, ...baseParams);
|
|
908
909
|
|
|
909
|
-
const noiseRatio = obsTotal.c > 0 ? lowVal.c / obsTotal.c : 0;
|
|
910
910
|
// Low-signal-title population (template / tool-log titles the read-side filter
|
|
911
911
|
// already excludes). The imp=1 "Low-value" metric can't see these, so the
|
|
912
912
|
// gauge under-reports real noise without it. See lib/low-signal-patterns.mjs.
|
|
@@ -915,7 +915,12 @@ server.registerTool(
|
|
|
915
915
|
WHERE NOT ${buildNotLowSignalSql()}
|
|
916
916
|
AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
|
|
917
917
|
`).get(...baseParams);
|
|
918
|
-
|
|
918
|
+
// F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
|
|
919
|
+
// obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
|
|
920
|
+
const liveTotal = db.prepare(
|
|
921
|
+
`SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
|
|
922
|
+
).get(...baseParams);
|
|
923
|
+
const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
|
|
919
924
|
const compressedCount = db.prepare(`
|
|
920
925
|
SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}
|
|
921
926
|
`).get(...baseParams);
|
|
@@ -1622,52 +1627,64 @@ server.registerTool(
|
|
|
1622
1627
|
|
|
1623
1628
|
// ─── Tool: mem_export ────────────────────────────────────────────────────────
|
|
1624
1629
|
|
|
1630
|
+
// In-process test seam (mirrors handleRecentForTest, #8743): threads an injected db
|
|
1631
|
+
// through the SAME body the registered handler runs. NOTE: a `project` arg is still
|
|
1632
|
+
// resolved via resolveProject() against the MODULE db, not the injected one.
|
|
1633
|
+
export async function handleExportForTest(db, args) {
|
|
1634
|
+
return runExport(db, args);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
async function runExport(db, args) {
|
|
1638
|
+
const wheres = [];
|
|
1639
|
+
const params = [];
|
|
1640
|
+
if (!args.include_compressed) wheres.push('COALESCE(compressed_into, 0) = 0');
|
|
1641
|
+
wheres.push('superseded_at IS NULL');
|
|
1642
|
+
if (args.project) { wheres.push('project = ?'); params.push(_resolveProjectShared(db, args.project)); }
|
|
1643
|
+
if (args.type) { wheres.push('type = ?'); params.push(args.type); }
|
|
1644
|
+
// T3-P1-A: surface invalid dates instead of silently dropping the filter — mirrors
|
|
1645
|
+
// mem_search, which threw. A dropped filter can quietly expand the export blast radius.
|
|
1646
|
+
if (args.date_from) {
|
|
1647
|
+
const epoch = new Date(args.date_from).getTime();
|
|
1648
|
+
if (isNaN(epoch)) throw new Error(`Invalid date_from: "${args.date_from}" (use ISO 8601 or YYYY-MM-DD)`);
|
|
1649
|
+
wheres.push('created_at_epoch >= ?');
|
|
1650
|
+
params.push(epoch);
|
|
1651
|
+
}
|
|
1652
|
+
if (args.date_to) {
|
|
1653
|
+
const d = args.date_to.length === 10 ? args.date_to + 'T23:59:59.999Z' : args.date_to;
|
|
1654
|
+
const epoch = new Date(d).getTime();
|
|
1655
|
+
if (isNaN(epoch)) throw new Error(`Invalid date_to: "${args.date_to}" (use ISO 8601 or YYYY-MM-DD)`);
|
|
1656
|
+
wheres.push('created_at_epoch <= ?');
|
|
1657
|
+
params.push(epoch);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
const where = wheres.length > 0 ? 'WHERE ' + wheres.join(' AND ') : '';
|
|
1661
|
+
const exportLimit = Math.min(args.limit ?? 200, 1000);
|
|
1662
|
+
// T3-P2-B: probe limit+1 so we can tell "user hit their own limit with more waiting" from
|
|
1663
|
+
// "user got exactly what existed". Trim to exportLimit before rendering.
|
|
1664
|
+
// EXPORT_COLUMNS_SQL: shared with CLI cmdExport — the full round-trippable set restore
|
|
1665
|
+
// reads back (v3.42 HIGH-2: this handler used to carry a narrower 16-col SELECT, silently
|
|
1666
|
+
// dropping text/aliases/citation-signals on the advertised MCP backup→restore flow).
|
|
1667
|
+
const probed = db.prepare(`SELECT ${EXPORT_COLUMNS_SQL} FROM observations ${where} ORDER BY created_at_epoch DESC LIMIT ?`).all(...params, exportLimit + 1);
|
|
1668
|
+
const rows = probed.slice(0, exportLimit);
|
|
1669
|
+
const moreAvailable = probed.length > exportLimit;
|
|
1670
|
+
|
|
1671
|
+
if (rows.length === 0) return { content: [{ type: 'text', text: 'No observations found matching the criteria.' }] };
|
|
1672
|
+
|
|
1673
|
+
const output = args.format === 'jsonl'
|
|
1674
|
+
? rows.map(r => JSON.stringify(r)).join('\n')
|
|
1675
|
+
: JSON.stringify(rows, null, 2);
|
|
1676
|
+
|
|
1677
|
+
const cap = moreAvailable ? `\nNote: Results capped at ${exportLimit}. Use date_from/date_to or increase limit (max 1000) to export more.` : '';
|
|
1678
|
+
return { content: [{ type: 'text', text: `Exported ${rows.length} observations:${cap}\n${output}` }] };
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1625
1681
|
server.registerTool(
|
|
1626
1682
|
'mem_export',
|
|
1627
1683
|
{
|
|
1628
1684
|
description: descriptionOf('mem_export'),
|
|
1629
1685
|
inputSchema: memExportSchema,
|
|
1630
1686
|
},
|
|
1631
|
-
safeHandler(async (args) =>
|
|
1632
|
-
const wheres = [];
|
|
1633
|
-
const params = [];
|
|
1634
|
-
if (!args.include_compressed) wheres.push('COALESCE(compressed_into, 0) = 0');
|
|
1635
|
-
wheres.push('superseded_at IS NULL');
|
|
1636
|
-
if (args.project) { wheres.push('project = ?'); params.push(resolveProject(args.project)); }
|
|
1637
|
-
if (args.type) { wheres.push('type = ?'); params.push(args.type); }
|
|
1638
|
-
// T3-P1-A: surface invalid dates instead of silently dropping the filter — mirrors
|
|
1639
|
-
// mem_search, which threw. A dropped filter can quietly expand the export blast radius.
|
|
1640
|
-
if (args.date_from) {
|
|
1641
|
-
const epoch = new Date(args.date_from).getTime();
|
|
1642
|
-
if (isNaN(epoch)) throw new Error(`Invalid date_from: "${args.date_from}" (use ISO 8601 or YYYY-MM-DD)`);
|
|
1643
|
-
wheres.push('created_at_epoch >= ?');
|
|
1644
|
-
params.push(epoch);
|
|
1645
|
-
}
|
|
1646
|
-
if (args.date_to) {
|
|
1647
|
-
const d = args.date_to.length === 10 ? args.date_to + 'T23:59:59.999Z' : args.date_to;
|
|
1648
|
-
const epoch = new Date(d).getTime();
|
|
1649
|
-
if (isNaN(epoch)) throw new Error(`Invalid date_to: "${args.date_to}" (use ISO 8601 or YYYY-MM-DD)`);
|
|
1650
|
-
wheres.push('created_at_epoch <= ?');
|
|
1651
|
-
params.push(epoch);
|
|
1652
|
-
}
|
|
1653
|
-
|
|
1654
|
-
const where = wheres.length > 0 ? 'WHERE ' + wheres.join(' AND ') : '';
|
|
1655
|
-
const exportLimit = Math.min(args.limit ?? 200, 1000);
|
|
1656
|
-
// T3-P2-B: probe limit+1 so we can tell "user hit their own limit with more waiting" from
|
|
1657
|
-
// "user got exactly what existed". Trim to exportLimit before rendering.
|
|
1658
|
-
const probed = db.prepare(`SELECT id, project, type, title, subtitle, narrative, concepts, facts, lesson_learned, importance, files_modified, branch, access_count, memory_session_id, created_at, created_at_epoch FROM observations ${where} ORDER BY created_at_epoch DESC LIMIT ?`).all(...params, exportLimit + 1);
|
|
1659
|
-
const rows = probed.slice(0, exportLimit);
|
|
1660
|
-
const moreAvailable = probed.length > exportLimit;
|
|
1661
|
-
|
|
1662
|
-
if (rows.length === 0) return { content: [{ type: 'text', text: 'No observations found matching the criteria.' }] };
|
|
1663
|
-
|
|
1664
|
-
const output = args.format === 'jsonl'
|
|
1665
|
-
? rows.map(r => JSON.stringify(r)).join('\n')
|
|
1666
|
-
: JSON.stringify(rows, null, 2);
|
|
1667
|
-
|
|
1668
|
-
const cap = moreAvailable ? `\nNote: Results capped at ${exportLimit}. Use date_from/date_to or increase limit (max 1000) to export more.` : '';
|
|
1669
|
-
return { content: [{ type: 'text', text: `Exported ${rows.length} observations:${cap}\n${output}` }] };
|
|
1670
|
-
})
|
|
1687
|
+
safeHandler(async (args) => runExport(db, args))
|
|
1671
1688
|
);
|
|
1672
1689
|
|
|
1673
1690
|
// ─── Tool: mem_recall ────────────────────────────────────────────────────────
|
package/source-files.mjs
CHANGED
|
@@ -170,6 +170,10 @@ export const SOURCE_FILES = [
|
|
|
170
170
|
// mem-cli.mjs::cmdImportJsonl; listed here so source-files-sync.test.mjs
|
|
171
171
|
// and the npm tarball ship it on every release.
|
|
172
172
|
'lib/import-jsonl.mjs',
|
|
173
|
+
// v3.42 HIGH-2: single source of truth for the export/restore round-trippable column
|
|
174
|
+
// set. Statically imported by server.mjs (mem_export) and mem-cli.mjs (cmdExport) so the
|
|
175
|
+
// two export surfaces can't drift. Must ship or auto-update breaks export on either.
|
|
176
|
+
'lib/export-columns.mjs',
|
|
173
177
|
];
|
|
174
178
|
|
|
175
179
|
/**
|
|
@@ -199,15 +203,39 @@ export const HOOK_SCRIPT_FILES = [
|
|
|
199
203
|
'hook-launcher.mjs',
|
|
200
204
|
];
|
|
201
205
|
|
|
206
|
+
// Executable scripts that are NOT direct-install hook scripts (so they don't belong in
|
|
207
|
+
// HOOK_SCRIPT_FILES, which install.mjs materializes into ~/.claude-mem-lite/scripts/) but
|
|
208
|
+
// ARE run at runtime and MUST be signed:
|
|
209
|
+
// - launch.mjs / launch-preflight.mjs: the plugin MCP server (.mcp.json runs
|
|
210
|
+
// ${CLAUDE_PLUGIN_ROOT}/scripts/launch.mjs; launch.mjs imports launch-preflight.mjs).
|
|
211
|
+
// install.mjs::dedupePluginCacheAndHooks copies BOTH from the tarball into every plugin
|
|
212
|
+
// cache version dir during repair() — an unsigned launcher let a release published
|
|
213
|
+
// without the signing key (reusing the real manifest+sig) swap launch.mjs and gain RCE
|
|
214
|
+
// as the MCP server. v3.42 audit HIGH-1.
|
|
215
|
+
// - setup.sh: run on plugin SessionStart via hooks.json. Signed for defense-in-depth
|
|
216
|
+
// (no tarball→executed-path propagation today, but it ships in files[] and is executed).
|
|
217
|
+
// These ship via package.json files[] directly, not via HOOK_SCRIPT_FILES' copy path, so
|
|
218
|
+
// listing them here changes ONLY what is signed/verified, not what install materializes.
|
|
219
|
+
// Module-internal (spread into RELEASE_SIGNED_FILES below); not exported — no external
|
|
220
|
+
// consumer, and the signing test asserts coverage via the built manifest, not this list.
|
|
221
|
+
const LAUNCHER_SCRIPT_FILES = [
|
|
222
|
+
'launch.mjs',
|
|
223
|
+
'launch-preflight.mjs',
|
|
224
|
+
'setup.sh',
|
|
225
|
+
];
|
|
226
|
+
|
|
202
227
|
// The complete set of files the release signature MUST cover: every runtime .mjs
|
|
203
228
|
// (SOURCE_FILES) PLUS the executable hook scripts (copyReleaseIntoStaging installs these
|
|
204
|
-
// into the live dir and they run on every hook fire)
|
|
205
|
-
// NOT in the signed manifest, so an attacker able to
|
|
206
|
-
// signing key — could swap a hook script (e.g.
|
|
207
|
-
// every SOURCE_FILES hash still matched, and
|
|
208
|
-
// RCE on the next hook fire.
|
|
209
|
-
//
|
|
229
|
+
// into the live dir and they run on every hook fire) PLUS the launcher/setup scripts.
|
|
230
|
+
// HOOK_SCRIPT_FILES were historically NOT in the signed manifest, so an attacker able to
|
|
231
|
+
// PUBLISH a release — but without the signing key — could swap a hook script (e.g.
|
|
232
|
+
// post-tool-use.sh / hook-launcher.mjs) while every SOURCE_FILES hash still matched, and
|
|
233
|
+
// fail-closed verification would still pass → RCE on the next hook fire. The MCP launcher
|
|
234
|
+
// (LAUNCHER_SCRIPT_FILES) was the same gap reopened for the plugin+repair path (v3.42
|
|
235
|
+
// HIGH-1). Keys are ROOT-relative, matching the extracted-tarball layout that
|
|
236
|
+
// verifyReleaseFiles hashes against.
|
|
210
237
|
export const RELEASE_SIGNED_FILES = [
|
|
211
238
|
...SOURCE_FILES,
|
|
212
239
|
...HOOK_SCRIPT_FILES.map(name => `scripts/${name}`),
|
|
240
|
+
...LAUNCHER_SCRIPT_FILES.map(name => `scripts/${name}`),
|
|
213
241
|
];
|
package/tool-schemas.mjs
CHANGED
|
@@ -237,13 +237,15 @@ export const memUpdateSchema = {
|
|
|
237
237
|
// CLI parity (cmdUpdate): empty/whitespace title would render as `(untitled)`
|
|
238
238
|
// in every listing — reject here like the CLI does, instead of persisting it.
|
|
239
239
|
title: z.string().refine(s => s.trim() !== '', 'title cannot be empty').optional().describe('New title'),
|
|
240
|
-
|
|
240
|
+
// Reject empty content fields (parity with `title` above + cmdUpdate): an explicit
|
|
241
|
+
// '' would blank narrative/lesson/concepts irrecoverably (mem_update takes no snapshot).
|
|
242
|
+
narrative: z.string().refine(s => s.trim() !== '', 'narrative cannot be empty').optional().describe('New narrative/content'),
|
|
241
243
|
type: OBS_TYPE_ENUM.optional().describe('New observation type'),
|
|
242
244
|
importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('New importance (1-3)'),
|
|
243
245
|
// 500-char cap mirrors memSaveSchema + cmdUpdate — update was the one path
|
|
244
246
|
// that let overlong lessons leak into the DB via MCP.
|
|
245
|
-
lesson_learned: z.string().max(500).optional().describe('Add or update lesson learned'),
|
|
246
|
-
concepts: z.string().optional().describe('Space-separated concept tags'),
|
|
247
|
+
lesson_learned: z.string().max(500).refine(s => s.trim() !== '', 'lesson_learned cannot be empty').optional().describe('Add or update lesson learned'),
|
|
248
|
+
concepts: z.string().refine(s => s.trim() !== '', 'concepts cannot be empty').optional().describe('Space-separated concept tags'),
|
|
247
249
|
};
|
|
248
250
|
|
|
249
251
|
export const memExportSchema = {
|