claude-mem-lite 3.42.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/export-columns.mjs +26 -0
- package/lib/stats-quality.mjs +15 -0
- package/mem-cli.mjs +14 -10
- package/package.json +2 -1
- package/project-utils.mjs +13 -5
- package/registry-recommend.mjs +15 -2
- package/search-engine.mjs +11 -2
- package/server.mjs +67 -50
- package/source-files.mjs +34 -6
|
@@ -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 });
|
|
@@ -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/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);
|
|
@@ -1677,15 +1684,12 @@ function cmdExport(db, args) {
|
|
|
1677
1684
|
// content + value-signals (access/cited/uncited/injection/decay) + branch + timing.
|
|
1678
1685
|
// `search_aliases` is an FTS5-indexed column (BM25 weight 5) — dropping it on
|
|
1679
1686
|
// export silently lost the LLM-generated alternate query terms on restore, so a
|
|
1680
|
-
// restored memory became unfindable by its aliases.
|
|
1681
|
-
//
|
|
1682
|
-
//
|
|
1683
|
-
//
|
|
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).
|
|
1684
1691
|
const rows = db.prepare(`
|
|
1685
|
-
SELECT
|
|
1686
|
-
files_read, files_modified, lesson_learned, search_aliases, importance, branch,
|
|
1687
|
-
access_count, cited_count, uncited_streak, injection_count, decay_seen_count,
|
|
1688
|
-
last_accessed_at, created_at, created_at_epoch
|
|
1692
|
+
SELECT ${EXPORT_COLUMNS_SQL}
|
|
1689
1693
|
FROM observations WHERE ${wheres.join(' AND ')}
|
|
1690
1694
|
ORDER BY created_at_epoch DESC LIMIT ?
|
|
1691
1695
|
`).all(...params, limit);
|
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/search-engine.mjs
CHANGED
|
@@ -35,6 +35,15 @@ const SIMPLE_SCORE = `${OBS_BM25}
|
|
|
35
35
|
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
36
36
|
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))`;
|
|
37
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';
|
|
46
|
+
|
|
38
47
|
export function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset, includeNoise } = {}) {
|
|
39
48
|
const scoreExpr = scoring === 'full' ? FULL_SCORE : SIMPLE_SCORE;
|
|
40
49
|
const mult = multiplier ? ` * ${multiplier}` : '';
|
|
@@ -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/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
|
];
|