claude-mem-lite 3.48.0 → 3.49.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-llm.mjs +2 -1
- package/hook-optimize.mjs +23 -13
- package/lib/activity.mjs +2 -1
- package/lib/events-injection.mjs +12 -3
- package/lib/export-columns.mjs +4 -1
- package/lib/obs-types.mjs +12 -0
- package/lib/save-nudge.mjs +22 -0
- package/lib/search-core.mjs +41 -15
- package/lib/stats-core.mjs +113 -0
- package/mem-cli.mjs +14 -110
- package/package.json +4 -1
- package/registry.mjs +1 -1
- package/schema.mjs +1 -1
- package/scripts/user-prompt-search.js +4 -1
- package/server.mjs +9 -94
- package/source-files.mjs +13 -0
- package/tool-schemas.mjs +2 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.49.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.49.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-llm.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
|
|
22
22
|
import { isNoiseObservation, capNoiseImportance, isLowYieldChangeObs } from './lib/low-signal-patterns.mjs';
|
|
23
23
|
import { episodeHasSignificantContent } from './hook-episode.mjs';
|
|
24
|
+
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
24
25
|
|
|
25
26
|
// T9: memdir-incompatible types live in the `events` table, not `observations`.
|
|
26
27
|
// Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
|
|
@@ -771,7 +772,7 @@ ${actionList}`;
|
|
|
771
772
|
const ruleImportance = computeRuleImportance(episode);
|
|
772
773
|
|
|
773
774
|
let obs;
|
|
774
|
-
const validTypes =
|
|
775
|
+
const validTypes = OBS_TYPE_SET;
|
|
775
776
|
|
|
776
777
|
const gotSlot = await acquireLLMSlot();
|
|
777
778
|
if (gotSlot) {
|
package/hook-optimize.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { scrubRecord } from './lib/scrub-record.mjs';
|
|
|
15
15
|
import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from './tfidf.mjs';
|
|
16
16
|
import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
17
17
|
import { DB_DIR } from './schema.mjs';
|
|
18
|
+
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
18
19
|
|
|
19
20
|
const RUNTIME_DIR = join(DB_DIR, 'runtime');
|
|
20
21
|
|
|
@@ -150,7 +151,7 @@ export async function executeReenrich(db, limit = 10, { scope = 'narrow', projec
|
|
|
150
151
|
if (candidates.length === 0) return { processed: 0, skipped: 0 };
|
|
151
152
|
|
|
152
153
|
let processed = 0, skipped = 0;
|
|
153
|
-
const validTypes =
|
|
154
|
+
const validTypes = OBS_TYPE_SET;
|
|
154
155
|
|
|
155
156
|
for (const cand of candidates) {
|
|
156
157
|
const gotSlot = await acquireLLMSlot();
|
|
@@ -929,19 +930,28 @@ export async function optimizeRun(db, { tasks, maxItems = 15, force = false, ree
|
|
|
929
930
|
try {
|
|
930
931
|
switch (task) {
|
|
931
932
|
case 're-enrich':
|
|
932
|
-
if (reenrichScope === 'narrow') {
|
|
933
|
-
// P1-2: the
|
|
934
|
-
// fully-degraded rows
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
933
|
+
if (reenrichScope === 'narrow' || reenrichScope === 'wide') {
|
|
934
|
+
// P1-2 (v3.43) + audit 2026-07-17 P4: the maintenance pass covers BOTH the main
|
|
935
|
+
// scope (narrow = fill lesson/concepts on fully-degraded rows; wide = lesson
|
|
936
|
+
// backfill on substantive event-typed rows) AND aliases (backfill search_aliases
|
|
937
|
+
// on lesson-bearing manual saves that narrow+wide both skip — mem_save writes no
|
|
938
|
+
// aliases, so without this they stay paraphrase-unfindable). v3.43 hung the split
|
|
939
|
+
// only on the DEFAULT 'narrow' branch, but the DAILY auto path (handleLLMOptimize
|
|
940
|
+
// via auto-maintain) passes 'wide' explicitly — so aliases never had a cadence and
|
|
941
|
+
// live coverage crawled at ~15%. The split is ADAPTIVE: aliases takes at most half
|
|
942
|
+
// the budget and only what its candidate pool actually holds, so a zero-candidate
|
|
943
|
+
// aliases pass costs nothing and the main scope keeps its full budget.
|
|
944
|
+
// An explicit --scope aliases still runs exactly that one scope (below).
|
|
945
|
+
const half = Math.max(1, Math.floor(budget.reenrich / 2));
|
|
946
|
+
const aliasBudget = Math.min(half, findReenrichCandidates(db, half, { scope: 'aliases', project }).length);
|
|
947
|
+
const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget, { scope: reenrichScope, project });
|
|
948
|
+
const aliasRes = aliasBudget > 0
|
|
949
|
+
? await executeReenrich(db, aliasBudget, { scope: 'aliases', project })
|
|
950
|
+
: { processed: 0, skipped: 0 };
|
|
941
951
|
results.reenrich = {
|
|
942
|
-
processed: (
|
|
943
|
-
skipped: (
|
|
944
|
-
byScope: {
|
|
952
|
+
processed: (mainRes.processed || 0) + (aliasRes.processed || 0),
|
|
953
|
+
skipped: (mainRes.skipped || 0) + (aliasRes.skipped || 0),
|
|
954
|
+
byScope: { [reenrichScope]: mainRes, aliases: aliasRes },
|
|
945
955
|
};
|
|
946
956
|
} else {
|
|
947
957
|
results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope, project });
|
package/lib/activity.mjs
CHANGED
|
@@ -8,10 +8,11 @@ import { sanitizeFtsQuery } from '../utils.mjs';
|
|
|
8
8
|
import { scrubRecord } from './scrub-record.mjs';
|
|
9
9
|
import { saveObservation } from './save-observation.mjs';
|
|
10
10
|
import { notLowSignalTitleClause } from '../scoring-sql.mjs';
|
|
11
|
+
import { OBS_TYPE_SET } from './obs-types.mjs';
|
|
11
12
|
|
|
12
13
|
// Observation types (mirrors the observations.type enum) — events carry a wider
|
|
13
14
|
// set, so promotion maps the extras (lesson/bug/observation) onto valid obs types.
|
|
14
|
-
const OBS_TYPES =
|
|
15
|
+
const OBS_TYPES = OBS_TYPE_SET;
|
|
15
16
|
const EVENT_TO_OBS_TYPE = { bug: 'bugfix', lesson: 'discovery', observation: 'discovery' };
|
|
16
17
|
|
|
17
18
|
/**
|
package/lib/events-injection.mjs
CHANGED
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
// event-typed memory (bugfix/decision/lesson/discovery/…) out of `observations`
|
|
5
5
|
// and into the `events` table, so after Haiku enrichment the high-value history
|
|
6
6
|
// lives ONLY in events. v3.44 wired events into mem_search + PreToolUse recall,
|
|
7
|
-
// but the
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// but the passive injection surfaces still read only observations — so the promoted
|
|
8
|
+
// memories were unreachable at prompt/session time.
|
|
9
|
+
//
|
|
10
|
+
// Wiring (v3.48.0): SessionStart context (hook-context.mjs, recency) + UserPromptSubmit
|
|
11
|
+
// path B (hook.mjs memory-context, FTS). Path A (user-prompt-search.js) is INTENTIONALLY
|
|
12
|
+
// not wired: both UserPromptSubmit hooks fire on every prompt and there is no cross-hook
|
|
13
|
+
// events dedup, so wiring path A too would double-inject the same event. Do NOT "complete"
|
|
14
|
+
// the wiring by adding events here to path A.
|
|
10
15
|
//
|
|
11
16
|
// id-space discipline: events share the numeric id space with observations but are a
|
|
12
17
|
// separate table, so injected events are rendered with an `E#` prefix. The
|
|
@@ -82,6 +87,10 @@ export function recentInjectableEvents(db, { project, limit = DEFAULT_LIMIT, min
|
|
|
82
87
|
*/
|
|
83
88
|
export function renderInjectableEvent(row) {
|
|
84
89
|
const title = neutralizeContextDelimiters((row.title || '').slice(0, TITLE_MAX));
|
|
90
|
+
// row.type is a frozen event_type enum (saveEvent is reached only via
|
|
91
|
+
// hook-llm.mjs behind `if (EVENT_TYPE_SET.has(summary.type))`), so it carries no
|
|
92
|
+
// injection markers and is intentionally NOT defanged — mirrors the un-defanged
|
|
93
|
+
// `[type]` for observations. If an unvalidated event writer is ever added, defang it.
|
|
85
94
|
const head = `E#${row.id} [${row.type}] ${title}`;
|
|
86
95
|
if (row.lesson_learned) {
|
|
87
96
|
const lesson = neutralizeContextDelimiters(row.lesson_learned.trim().slice(0, LESSON_MAX));
|
package/lib/export-columns.mjs
CHANGED
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
// + branch + timing. `id` + `memory_session_id` are informational (restore remaps id and
|
|
15
15
|
// buckets under a synthetic restore session). Session-idempotency keys (last_decided/
|
|
16
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.
|
|
17
|
+
// meaningless after a row is re-bucketed under a restore session. Also intentionally NOT
|
|
18
|
+
// exported: `related_ids` (holds observation ids, stale/dangling after restore remaps ids)
|
|
19
|
+
// and `discovery_tokens` (a derived retrieval metric, rebuilt by the live system; exporting
|
|
20
|
+
// it would freeze a stale value into backups).
|
|
18
21
|
export const EXPORT_COLUMNS = [
|
|
19
22
|
'id', 'memory_session_id', 'project', 'type', 'title', 'subtitle', 'narrative', 'text',
|
|
20
23
|
'concepts', 'facts', 'files_read', 'files_modified', 'lesson_learned', 'search_aliases',
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Single source of truth for the observation `type` vocabulary.
|
|
2
|
+
//
|
|
3
|
+
// Audit 2026-07-17 MED-3: this list was hardcoded verbatim in 10 JS sites + the SQL
|
|
4
|
+
// CHECK constraint (schema.mjs observations DDL) — the project's #1 bug class
|
|
5
|
+
// (fix-lands-on-one-surface) sitting latent on the core type vocabulary. Every
|
|
6
|
+
// validator now imports from here; the SQL CHECK stays a literal (SQLite DDL cannot
|
|
7
|
+
// interpolate at migration time) and is locked to this list by
|
|
8
|
+
// tests/obs-types-invariant.test.mjs, which also fails if a new hardcoded copy of
|
|
9
|
+
// the list appears anywhere else in the runtime source.
|
|
10
|
+
export const OBS_TYPES = Object.freeze(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
|
|
11
|
+
|
|
12
|
+
export const OBS_TYPE_SET = new Set(OBS_TYPES);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Save-time lesson nudge (audit 2026-07-17 P4): bugfix/decision are the types whose
|
|
2
|
+
// value lives in the lesson (root cause + fix / constraint + tradeoff), yet they are
|
|
3
|
+
// exactly where lessonless writes concentrate (14d live data: bugfix 18.8% / decision
|
|
4
|
+
// 28.7% lessonless, worsening). The nudge fires in the SAVE RESPONSE — the one moment
|
|
5
|
+
// the model still has the context to write the lesson — and names the exact follow-up
|
|
6
|
+
// call, so acting on it costs one tool call. Zero LLM cost; the async backfill arm
|
|
7
|
+
// (optimizeRun wide/aliases re-enrich) stays the safety net for saves that ignore it.
|
|
8
|
+
//
|
|
9
|
+
// Shared by server.mjs (mem_save) and mem-cli.mjs (cmdSave) — one gate, two phrasings,
|
|
10
|
+
// no twin drift.
|
|
11
|
+
const NUDGE_TYPES = new Set(['bugfix', 'decision']);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{ type: string, id: number, lessonCaptured: boolean, surface: 'mcp'|'cli' }} p
|
|
15
|
+
* @returns {string} nudge text to append ('' when no nudge applies)
|
|
16
|
+
*/
|
|
17
|
+
export function buildLessonNudge({ type, id, lessonCaptured, surface }) {
|
|
18
|
+
if (lessonCaptured || !NUDGE_TYPES.has(type)) return '';
|
|
19
|
+
return surface === 'cli'
|
|
20
|
+
? `\n[mem] ⚠ ${type} #${id} saved without a lesson — capture the root cause + fix while it's fresh: claude-mem-lite update ${id} --lesson "<root cause + fix>"`
|
|
21
|
+
: ` ⚠ Saved without lesson_learned — a ${type} is worth keeping only with its lesson. Capture it now: mem_update(id=${id}, lesson_learned="<root cause + fix>").`;
|
|
22
|
+
}
|
package/lib/search-core.mjs
CHANGED
|
@@ -245,31 +245,57 @@ export function searchEventsFts(db, { ftsQuery, project = null, projectBoost = n
|
|
|
245
245
|
`).all(...params);
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
// A lone match has no within-source spread to normalize against
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
248
|
+
// A lone match has no within-source spread to normalize against (MED-5, v3.48.0).
|
|
249
|
+
// A magnitude-blind constant (-0.5) fixed the dominating direction — an incidental
|
|
250
|
+
// lone hit no longer outranks a strongly-matched page — but buried the opposite,
|
|
251
|
+
// equally common shape (audit 2026-07-17 MED-1): events are the CANONICAL store for
|
|
252
|
+
// promoted bugfix/decision memories AND a low-cardinality source, so "the best
|
|
253
|
+
// answer is one strong event" is routine, and within-source max-normalization pins
|
|
254
|
+
// every multi-hit source's best to -1 regardless of absolute strength.
|
|
255
|
+
//
|
|
256
|
+
// Fix: band the lone hit by its raw magnitude RELATIVE to the global raw max across
|
|
257
|
+
// all scored rows — the cross-source signal the per-source normalization destroys.
|
|
258
|
+
// obs/event/session BM25 live on comparable scales (weighted bm25 × decay ×
|
|
259
|
+
// project-boost), so the ratio is meaningful there; small-scale sources (prompts,
|
|
260
|
+
// bm25 ≈ -1) can only be penalized by this comparison, never inflated — the safe
|
|
261
|
+
// direction. Bands (ratio = |lone| / globalMaxAbs, first match wins):
|
|
262
|
+
// ratio ≥ 1 → -1.05 the lone hit IS the strongest raw match anywhere: rank it
|
|
263
|
+
// strictly ahead of every normalized -1 (ties in raw
|
|
264
|
+
// strength resolve toward the lone hit — the [-1, 0] band
|
|
265
|
+
// convention is deliberately exceeded here)
|
|
266
|
+
// ratio ≥ 0.5 → -0.75 comparable to the best: above neutral, below the leaders
|
|
267
|
+
// ratio ≥ 0.1 → -0.5 the MED-5 neutral mid
|
|
268
|
+
// ratio < 0.1 → -0.25 grazing match: sink it
|
|
269
|
+
// score === 0 rows (the prompts CJK LIKE fallback — zero confidence) are excluded
|
|
270
|
+
// from normalization entirely and keep their 0, sorting last in the ascending
|
|
271
|
+
// merge (audit L3: the old clamp promoted a lone 0-score row to the neutral mid).
|
|
272
|
+
const SINGLE_MATCH_BANDS = [
|
|
273
|
+
[1.0, -1.05],
|
|
274
|
+
[0.5, -0.75],
|
|
275
|
+
[0.1, -0.5],
|
|
276
|
+
[0, -0.25],
|
|
277
|
+
];
|
|
258
278
|
|
|
259
279
|
/**
|
|
260
280
|
* Normalize each source's BM25 scores to [-1, 0] before cross-source merge.
|
|
261
281
|
* Prevents observations (BM25 can reach -40) from systematically outranking
|
|
262
282
|
* sessions (-6) and prompts (-1) regardless of relevance. A source with a single
|
|
263
|
-
* scored row is
|
|
264
|
-
* it has no within-source spread to normalize against.
|
|
265
|
-
* place; callers re-sort afterwards.
|
|
283
|
+
* scored row is banded by its magnitude relative to the global raw max (see
|
|
284
|
+
* SINGLE_MATCH_BANDS) — it has no within-source spread to normalize against.
|
|
285
|
+
* Mutates `results` in place; callers re-sort afterwards.
|
|
266
286
|
*/
|
|
267
287
|
export function normalizeCrossSourceScores(results, sourceKey) {
|
|
288
|
+
const scored = results.filter((r) => r.score !== null && r.score !== undefined && r.score !== 0);
|
|
289
|
+
const globalMaxAbs = scored.length ? Math.max(...scored.map((r) => Math.abs(r.score))) : 0;
|
|
268
290
|
for (const src of ['obs', 'session', 'prompt', 'event']) {
|
|
269
|
-
|
|
291
|
+
// score === 0 stays out: for multi-row sources 0/maxAbs would be 0 anyway, and a
|
|
292
|
+
// lone 0-score row must not be banded upward (it only occurs as the prompts LIKE
|
|
293
|
+
// fallback, which never coexists with real prompt FTS hits).
|
|
294
|
+
const srcResults = results.filter((r) => r[sourceKey] === src && r.score !== null && r.score !== undefined && r.score !== 0);
|
|
270
295
|
if (srcResults.length === 0) continue;
|
|
271
296
|
if (srcResults.length === 1) {
|
|
272
|
-
srcResults[0].score
|
|
297
|
+
const ratio = globalMaxAbs > 0 ? Math.abs(srcResults[0].score) / globalMaxAbs : 0;
|
|
298
|
+
srcResults[0].score = SINGLE_MATCH_BANDS.find(([floor]) => ratio >= floor)[1];
|
|
273
299
|
continue;
|
|
274
300
|
}
|
|
275
301
|
const maxAbs = Math.max(...srcResults.map((r) => Math.abs(r.score)));
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// lib/stats-core.mjs — shared primary stats feed for CLI `stats` and MCP `mem_stats`.
|
|
2
|
+
//
|
|
3
|
+
// Audit 2026-07-17 MED-4: these ~15 COUNT/GROUP-BY queries were hand-copied
|
|
4
|
+
// byte-equivalent between server.mjs (mem_stats) and mem-cli.mjs (cmdStats) — the
|
|
5
|
+
// same twin-drift class the `--quality` sub-report already closed via
|
|
6
|
+
// lib/stats-quality.mjs, and delete orchestration via lib/delete-core.mjs. One
|
|
7
|
+
// computation, two renderers: callers keep their own formatting (MCP text block /
|
|
8
|
+
// CLI console+JSON) plus any surface-only extras (CLI hookErrors24h).
|
|
9
|
+
import { inferProject } from '../utils.mjs';
|
|
10
|
+
import { buildNotLowSignalSql } from './low-signal-patterns.mjs';
|
|
11
|
+
import { TIER_CASE_SQL, tierSqlParams } from '../tier.mjs';
|
|
12
|
+
import { computeNoiseGauge } from './stats-quality.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compute the primary stats feed. Row shapes are returned exactly as the twin
|
|
16
|
+
* blocks produced them ({c}/{t}/{v} single-row objects, arrays for distributions)
|
|
17
|
+
* so both call sites render with minimal diff.
|
|
18
|
+
*/
|
|
19
|
+
export function computeStatsFeed(db, { project = null, days = 30, now = Date.now() } = {}) {
|
|
20
|
+
const cutoff = now - days * 86400000;
|
|
21
|
+
const projectFilter = project ? 'AND project = ?' : '';
|
|
22
|
+
const baseParams = project ? [project] : [];
|
|
23
|
+
|
|
24
|
+
// Total counts (session_summaries, not sdk_sessions — CLI↔MCP aligned)
|
|
25
|
+
const obsTotal = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
|
|
26
|
+
const sessTotal = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`).get(...baseParams);
|
|
27
|
+
const promptTotal = project
|
|
28
|
+
? db.prepare('SELECT COUNT(*) as c FROM user_prompts p JOIN sdk_sessions s ON p.content_session_id = s.content_session_id WHERE s.project = ?').get(project)
|
|
29
|
+
: db.prepare('SELECT COUNT(*) as c FROM user_prompts').get();
|
|
30
|
+
|
|
31
|
+
// Recent counts
|
|
32
|
+
const obsRecent = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
|
|
33
|
+
const sessRecent = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
|
|
34
|
+
|
|
35
|
+
// Type distribution (recent)
|
|
36
|
+
const types = db.prepare(`
|
|
37
|
+
SELECT type, COUNT(*) as c FROM observations
|
|
38
|
+
WHERE created_at_epoch >= ? ${projectFilter}
|
|
39
|
+
GROUP BY type ORDER BY c DESC
|
|
40
|
+
`).all(cutoff, ...baseParams);
|
|
41
|
+
|
|
42
|
+
// Top projects (global view — skipped when filtering by single project)
|
|
43
|
+
const projects = project ? [] : db.prepare(`
|
|
44
|
+
SELECT project, COUNT(*) as c FROM observations
|
|
45
|
+
GROUP BY project ORDER BY c DESC LIMIT 20
|
|
46
|
+
`).all();
|
|
47
|
+
|
|
48
|
+
// Daily activity (last 7 days)
|
|
49
|
+
const daily = db.prepare(`
|
|
50
|
+
SELECT date(created_at) as day, COUNT(*) as c FROM observations
|
|
51
|
+
WHERE created_at_epoch >= ? ${projectFilter}
|
|
52
|
+
GROUP BY day ORDER BY day DESC LIMIT 7
|
|
53
|
+
`).all(now - 7 * 86400000, ...baseParams);
|
|
54
|
+
|
|
55
|
+
// Data health
|
|
56
|
+
const tokenEst = db.prepare(`
|
|
57
|
+
SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
|
|
58
|
+
FROM observations WHERE 1=1 ${projectFilter}
|
|
59
|
+
`).get(...baseParams);
|
|
60
|
+
const avgImp = db.prepare(`SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
|
|
61
|
+
|
|
62
|
+
const thirtyDaysAgo = now - 30 * 86400000;
|
|
63
|
+
// v3.23 noise-gauge de-blinding: `<= 1` makes the imp=0 dormant population visible
|
|
64
|
+
// (decay floor + LLM low-signal filter push ~half the live corpus to 0);
|
|
65
|
+
// injection_count=0 mirrors decay's NEVER-INJECTED guard so injected-but-decayed
|
|
66
|
+
// pinned noise isn't miscounted as "never used".
|
|
67
|
+
const lowVal = db.prepare(`
|
|
68
|
+
SELECT COUNT(*) as c FROM observations
|
|
69
|
+
WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
|
|
70
|
+
AND COALESCE(injection_count,0) = 0
|
|
71
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
72
|
+
AND created_at_epoch < ? ${projectFilter}
|
|
73
|
+
`).get(thirtyDaysAgo, ...baseParams);
|
|
74
|
+
// Low-signal-title population (template / tool-log titles the read-side filter
|
|
75
|
+
// already excludes). The imp≤1 "Low-value" metric can't see these, so the gauge
|
|
76
|
+
// under-reports real noise without it. Same source as lib/low-signal-patterns.mjs.
|
|
77
|
+
const lowSignalTitle = db.prepare(`
|
|
78
|
+
SELECT COUNT(*) as c FROM observations
|
|
79
|
+
WHERE NOT ${buildNotLowSignalSql()}
|
|
80
|
+
AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
|
|
81
|
+
`).get(...baseParams);
|
|
82
|
+
// F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
|
|
83
|
+
// obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
|
|
84
|
+
const liveTotal = db.prepare(
|
|
85
|
+
`SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
|
|
86
|
+
).get(...baseParams);
|
|
87
|
+
const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
|
|
88
|
+
const compressedCount = db.prepare(
|
|
89
|
+
`SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
|
|
90
|
+
).get(...baseParams);
|
|
91
|
+
const supersededOnlyCount = db.prepare(
|
|
92
|
+
`SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}`
|
|
93
|
+
).get(...baseParams);
|
|
94
|
+
|
|
95
|
+
// Tier distribution
|
|
96
|
+
const tierCtx = { now, currentProject: project || inferProject(), currentSessionId: '' };
|
|
97
|
+
const tdParams = tierSqlParams(tierCtx);
|
|
98
|
+
const tierDist = db.prepare(`
|
|
99
|
+
SELECT tier, COUNT(*) as c FROM (
|
|
100
|
+
SELECT ${TIER_CASE_SQL} as tier FROM observations
|
|
101
|
+
WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
|
|
102
|
+
) GROUP BY tier ORDER BY tier
|
|
103
|
+
`).all(...tdParams, ...baseParams);
|
|
104
|
+
const tierMap = Object.fromEntries(tierDist.map((r) => [r.tier, r.c]));
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
obsTotal, sessTotal, promptTotal, obsRecent, sessRecent,
|
|
108
|
+
types, projects, daily,
|
|
109
|
+
tokenEst, avgImp, lowVal, lowSignalTitle, liveTotal,
|
|
110
|
+
noiseRatio, lowSignalRatio, compressedCount, supersededOnlyCount,
|
|
111
|
+
tierMap,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -15,7 +15,6 @@ import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
|
15
15
|
import { searchResources } from './registry-retriever.mjs';
|
|
16
16
|
import { computeFunnel, formatFunnel, computeSweep, formatSweep, DEFAULT_SWEEP_FLOORS, DEFAULT_SWEEP_MARGINS } from './registry-recommend.mjs';
|
|
17
17
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
18
|
-
import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
|
|
19
18
|
import {
|
|
20
19
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
21
20
|
recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
|
|
@@ -25,6 +24,9 @@ import {
|
|
|
25
24
|
} from './lib/maintain-core.mjs';
|
|
26
25
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
27
26
|
import { deleteObservations } from './lib/delete-core.mjs';
|
|
27
|
+
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
28
|
+
import { computeStatsFeed } from './lib/stats-core.mjs';
|
|
29
|
+
import { buildLessonNudge } from './lib/save-nudge.mjs';
|
|
28
30
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
29
31
|
import { buildSessionContextLines } from './hook-context.mjs';
|
|
30
32
|
import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
|
|
@@ -42,7 +44,6 @@ import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatP
|
|
|
42
44
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
43
45
|
import { rebuildObservationDerived, normalizeScope } from './lib/observation-write.mjs';
|
|
44
46
|
import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
|
|
45
|
-
import { computeNoiseGauge } from './lib/stats-quality.mjs';
|
|
46
47
|
import { recallByFile } from './lib/recall-core.mjs';
|
|
47
48
|
import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
|
|
48
49
|
import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
|
|
@@ -72,7 +73,7 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
72
73
|
|
|
73
74
|
const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
|
|
74
75
|
const type = flags.type || null;
|
|
75
|
-
const validObsTypes =
|
|
76
|
+
const validObsTypes = OBS_TYPE_SET;
|
|
76
77
|
if (type && !validObsTypes.has(type)) {
|
|
77
78
|
fail(`[mem] Invalid --type "${type}". Valid: ${[...validObsTypes].join(', ')}`);
|
|
78
79
|
return;
|
|
@@ -369,7 +370,7 @@ function cmdRecent(db, args) {
|
|
|
369
370
|
// try this for "show recent bugfixes". Mirror cmdSearch's enum validation.
|
|
370
371
|
const type = flags.type || null;
|
|
371
372
|
if (type) {
|
|
372
|
-
const validObsTypes =
|
|
373
|
+
const validObsTypes = OBS_TYPE_SET;
|
|
373
374
|
if (!validObsTypes.has(type)) {
|
|
374
375
|
fail(`[mem] Invalid --type "${type}". Valid: ${[...validObsTypes].join(', ')}`);
|
|
375
376
|
return;
|
|
@@ -803,7 +804,7 @@ function cmdSave(db, args) {
|
|
|
803
804
|
if (rejectBareStringFlags(flags, ['title', 'files', 'lesson', 'lesson-learned', 'project', 'type'])) return;
|
|
804
805
|
|
|
805
806
|
const type = flags.type || 'discovery';
|
|
806
|
-
const validTypes =
|
|
807
|
+
const validTypes = OBS_TYPE_SET;
|
|
807
808
|
if (!validTypes.has(type)) {
|
|
808
809
|
fail(`[mem] Invalid type "${type}". Valid: ${[...validTypes].join(', ')}`);
|
|
809
810
|
return;
|
|
@@ -912,7 +913,7 @@ function cmdSave(db, args) {
|
|
|
912
913
|
const supersededNote = result.supersededIds && result.supersededIds.length > 0
|
|
913
914
|
? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
|
|
914
915
|
: '';
|
|
915
|
-
out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}`);
|
|
916
|
+
out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}${buildLessonNudge({ type: result.type, id: result.id, lessonCaptured: result.lessonCaptured, surface: 'cli' })}`);
|
|
916
917
|
}
|
|
917
918
|
|
|
918
919
|
// ─── cmdDefer (sub-dispatch: add | list | drop) ──────────────────────────────
|
|
@@ -1112,98 +1113,12 @@ async function cmdStats(db, args) {
|
|
|
1112
1113
|
return;
|
|
1113
1114
|
}
|
|
1114
1115
|
|
|
1115
|
-
const projectFilter = project ? 'AND project = ?' : '';
|
|
1116
|
-
const baseParams = project ? [project] : [];
|
|
1117
|
-
|
|
1118
1116
|
const now = Date.now();
|
|
1119
|
-
const
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
).get(...baseParams);
|
|
1125
|
-
const sessTotal = db.prepare(
|
|
1126
|
-
`SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`
|
|
1127
|
-
).get(...baseParams);
|
|
1128
|
-
const promptTotal = project
|
|
1129
|
-
? db.prepare('SELECT COUNT(*) as c FROM user_prompts p JOIN sdk_sessions s ON p.content_session_id = s.content_session_id WHERE s.project = ?').get(project)
|
|
1130
|
-
: db.prepare('SELECT COUNT(*) as c FROM user_prompts').get();
|
|
1131
|
-
|
|
1132
|
-
// Recent counts
|
|
1133
|
-
const obsRecent = db.prepare(
|
|
1134
|
-
`SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`
|
|
1135
|
-
).get(cutoff, ...baseParams);
|
|
1136
|
-
const sessRecent = db.prepare(
|
|
1137
|
-
`SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`
|
|
1138
|
-
).get(cutoff, ...baseParams);
|
|
1139
|
-
|
|
1140
|
-
// Type distribution (recent)
|
|
1141
|
-
const types = db.prepare(`
|
|
1142
|
-
SELECT type, COUNT(*) as c FROM observations
|
|
1143
|
-
WHERE created_at_epoch >= ? ${projectFilter}
|
|
1144
|
-
GROUP BY type ORDER BY c DESC
|
|
1145
|
-
`).all(cutoff, ...baseParams);
|
|
1146
|
-
|
|
1147
|
-
// Top projects (global view — skipped when filtering by single project; aligned with MCP)
|
|
1148
|
-
const projects = project ? [] : db.prepare(`
|
|
1149
|
-
SELECT project, COUNT(*) as c FROM observations
|
|
1150
|
-
GROUP BY project ORDER BY c DESC LIMIT 20
|
|
1151
|
-
`).all();
|
|
1152
|
-
|
|
1153
|
-
// Daily activity (last 7 days; aligned with MCP mem_stats)
|
|
1154
|
-
const daily = db.prepare(`
|
|
1155
|
-
SELECT date(created_at) as day, COUNT(*) as c FROM observations
|
|
1156
|
-
WHERE created_at_epoch >= ? ${projectFilter}
|
|
1157
|
-
GROUP BY day ORDER BY day DESC LIMIT 7
|
|
1158
|
-
`).all(now - 7 * 86400000, ...baseParams);
|
|
1159
|
-
|
|
1160
|
-
// Data health (aligned with MCP mem_stats)
|
|
1161
|
-
const tokenEst = db.prepare(`
|
|
1162
|
-
SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
|
|
1163
|
-
FROM observations WHERE 1=1 ${projectFilter}
|
|
1164
|
-
`).get(...baseParams);
|
|
1165
|
-
const avgImp = db.prepare(
|
|
1166
|
-
`SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`
|
|
1167
|
-
).get(...baseParams);
|
|
1168
|
-
const thirtyDaysAgo = now - 30 * 86400000;
|
|
1169
|
-
// v3.23 noise-gauge de-blinding: the prior `importance = 1` predicate was
|
|
1170
|
-
// structurally blind to imp=0 — decay's floor + the LLM low-signal filter push
|
|
1171
|
-
// dormant rows to 0, which on a real store is ~half the live corpus. The gauge
|
|
1172
|
-
// therefore reported "0.0% noise" while the store was dormant-heavy. `<= 1` makes
|
|
1173
|
-
// imp=0 visible; injection_count=0 mirrors decay's NEVER-INJECTED guard so an
|
|
1174
|
-
// injected-but-decayed row (pinned noise, tracked separately) is not miscounted
|
|
1175
|
-
// as "never used".
|
|
1176
|
-
const lowVal = db.prepare(`
|
|
1177
|
-
SELECT COUNT(*) as c FROM observations
|
|
1178
|
-
WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
|
|
1179
|
-
AND COALESCE(injection_count,0) = 0
|
|
1180
|
-
AND COALESCE(compressed_into, 0) = 0
|
|
1181
|
-
AND created_at_epoch < ? ${projectFilter}
|
|
1182
|
-
`).get(thirtyDaysAgo, ...baseParams);
|
|
1183
|
-
// Low-signal-title population: template / tool-log titles (Modified, Worked on,
|
|
1184
|
-
// Error while working, Error:, node/npm/npx …) that the retrieval layer already
|
|
1185
|
-
// filters out by default. The imp=1 "Low-value" metric above structurally can't
|
|
1186
|
-
// see these — they often carry inflated importance and recent access — so the
|
|
1187
|
-
// health gauge under-reports real noise without this line. Same LOW_SIGNAL
|
|
1188
|
-
// pattern source as the read-side filter (lib/low-signal-patterns.mjs).
|
|
1189
|
-
const lowSignalTitle = db.prepare(`
|
|
1190
|
-
SELECT COUNT(*) as c FROM observations
|
|
1191
|
-
WHERE NOT ${buildNotLowSignalSql()}
|
|
1192
|
-
AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
|
|
1193
|
-
`).get(...baseParams);
|
|
1194
|
-
// F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
|
|
1195
|
-
// obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
|
|
1196
|
-
// Shared with the MCP mem_stats gauge via computeNoiseGauge (lib/stats-quality.mjs).
|
|
1197
|
-
const liveTotal = db.prepare(
|
|
1198
|
-
`SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
|
|
1199
|
-
).get(...baseParams);
|
|
1200
|
-
const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
|
|
1201
|
-
const compressedCount = db.prepare(
|
|
1202
|
-
`SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
|
|
1203
|
-
).get(...baseParams);
|
|
1204
|
-
const supersededOnlyCount = db.prepare(
|
|
1205
|
-
`SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}`
|
|
1206
|
-
).get(...baseParams);
|
|
1117
|
+
const {
|
|
1118
|
+
obsTotal, sessTotal, promptTotal, obsRecent, sessRecent,
|
|
1119
|
+
types, projects, daily, tokenEst, avgImp, lowVal, lowSignalTitle,
|
|
1120
|
+
noiseRatio, lowSignalRatio, compressedCount, supersededOnlyCount, tierMap,
|
|
1121
|
+
} = computeStatsFeed(db, { project, days, now });
|
|
1207
1122
|
|
|
1208
1123
|
// Hook self-observation: count PreToolUse / Skill-bridge script failures
|
|
1209
1124
|
// recorded in the last 24h. Surfaces silent breakage (DB corruption,
|
|
@@ -1211,17 +1126,6 @@ async function cmdStats(db, args) {
|
|
|
1211
1126
|
// failure mode that left code-graph's matcher bug undetected for 10 sessions.
|
|
1212
1127
|
const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - 86400000);
|
|
1213
1128
|
|
|
1214
|
-
// Tier distribution (aligned with MCP mem_stats)
|
|
1215
|
-
const tierCtx = { now, currentProject: project || inferProject(), currentSessionId: '' };
|
|
1216
|
-
const tdParams = tierSqlParams(tierCtx);
|
|
1217
|
-
const tierDist = db.prepare(`
|
|
1218
|
-
SELECT tier, COUNT(*) as c FROM (
|
|
1219
|
-
SELECT ${TIER_CASE_SQL} as tier FROM observations
|
|
1220
|
-
WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
|
|
1221
|
-
) GROUP BY tier ORDER BY tier
|
|
1222
|
-
`).all(...tdParams, ...baseParams);
|
|
1223
|
-
const tierMap = Object.fromEntries(tierDist.map(r => [r.tier, r.c]));
|
|
1224
|
-
|
|
1225
1129
|
if (jsonOutput) {
|
|
1226
1130
|
out(JSON.stringify({
|
|
1227
1131
|
project,
|
|
@@ -1570,7 +1474,7 @@ function cmdUpdate(db, args) {
|
|
|
1570
1474
|
updates.push('narrative = ?'); params.push(scrubSecrets(flags.narrative));
|
|
1571
1475
|
}
|
|
1572
1476
|
if (flags.type) {
|
|
1573
|
-
const validTypes =
|
|
1477
|
+
const validTypes = OBS_TYPE_SET;
|
|
1574
1478
|
if (!validTypes.has(flags.type)) {
|
|
1575
1479
|
fail(`[mem] Invalid type "${flags.type}". Valid: ${[...validTypes].join(', ')}`);
|
|
1576
1480
|
return;
|
|
@@ -1654,7 +1558,7 @@ function cmdExport(db, args) {
|
|
|
1654
1558
|
if (flags.type) {
|
|
1655
1559
|
// Reject unknown types — silently returning [] for `--type bogus` looked like a
|
|
1656
1560
|
// legitimate empty filter result, hiding the typo. Mirrors cmdSearch / cmdSave / cmdUpdate.
|
|
1657
|
-
const validObsTypes =
|
|
1561
|
+
const validObsTypes = OBS_TYPE_SET;
|
|
1658
1562
|
if (!validObsTypes.has(flags.type)) {
|
|
1659
1563
|
fail(`[mem] Invalid --type "${flags.type}". Valid: ${[...validObsTypes].join(', ')}`);
|
|
1660
1564
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.49.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",
|
|
@@ -92,6 +92,9 @@
|
|
|
92
92
|
"lib/db-backup.mjs",
|
|
93
93
|
"lib/delete-core.mjs",
|
|
94
94
|
"lib/events-injection.mjs",
|
|
95
|
+
"lib/stats-core.mjs",
|
|
96
|
+
"lib/obs-types.mjs",
|
|
97
|
+
"lib/save-nudge.mjs",
|
|
95
98
|
"lib/maintain-core.mjs",
|
|
96
99
|
"lib/dedup-constants.mjs",
|
|
97
100
|
"lib/deferred-work.mjs",
|
package/registry.mjs
CHANGED
|
@@ -376,7 +376,7 @@ const UPSERT_SQL = `
|
|
|
376
376
|
repo_url=CASE WHEN excluded.repo_url != '' THEN excluded.repo_url ELSE repo_url END,
|
|
377
377
|
repo_stars=CASE WHEN excluded.repo_stars > 0 THEN excluded.repo_stars ELSE repo_stars END,
|
|
378
378
|
local_path=CASE WHEN excluded.local_path != '' THEN excluded.local_path ELSE local_path END,
|
|
379
|
-
file_hash=excluded.file_hash,
|
|
379
|
+
file_hash=CASE WHEN excluded.file_hash IS NOT NULL AND excluded.file_hash != '' THEN excluded.file_hash ELSE file_hash END,
|
|
380
380
|
invocation_name=CASE WHEN excluded.invocation_name != '' THEN excluded.invocation_name ELSE invocation_name END,
|
|
381
381
|
-- Preserve-on-empty (mirror repo_stars/invocation_name above): a PARTIAL re-upsert --
|
|
382
382
|
-- e.g. "registry import --name X --capability-summary ...", where mem-cli defaults every
|
package/schema.mjs
CHANGED
|
@@ -176,7 +176,7 @@ const CORE_SCHEMA = `
|
|
|
176
176
|
memory_session_id TEXT NOT NULL,
|
|
177
177
|
project TEXT NOT NULL,
|
|
178
178
|
text TEXT,
|
|
179
|
-
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
|
|
179
|
+
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')), -- keep in sync with lib/obs-types.mjs OBS_TYPES (locked by tests/obs-types-invariant.test.mjs)
|
|
180
180
|
title TEXT,
|
|
181
181
|
subtitle TEXT,
|
|
182
182
|
facts TEXT,
|
|
@@ -787,8 +787,11 @@ async function main() {
|
|
|
787
787
|
if (matched) {
|
|
788
788
|
const cooldown = getSkillCooldown();
|
|
789
789
|
if (!cooldown[matched]) {
|
|
790
|
+
// Registry skill names come from third-party repos (tools/adopt import) — an
|
|
791
|
+
// untrusted boundary like every other DB-derived string on this surface.
|
|
792
|
+
const safeName = neutralizeContextDelimiters(matched);
|
|
790
793
|
process.stdout.write(
|
|
791
|
-
`\n[mem] Skill "${
|
|
794
|
+
`\n[mem] Skill "${safeName}" may apply — invoke via SkillTool or run: claude-mem-lite registry show ${safeName}\n`
|
|
792
795
|
);
|
|
793
796
|
setSkillCooldown(matched);
|
|
794
797
|
}
|
package/server.mjs
CHANGED
|
@@ -12,7 +12,6 @@ import { reRankWithContext, autoBoostIfNeeded, runIdleCleanup, buildServerInstru
|
|
|
12
12
|
import { searchObservationsHybrid } from './search-engine.mjs';
|
|
13
13
|
import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
|
|
14
14
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
15
|
-
import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
|
|
16
15
|
import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
|
|
17
16
|
import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
|
|
18
17
|
import {
|
|
@@ -26,6 +25,8 @@ import { snapshotDb } from './lib/db-backup.mjs';
|
|
|
26
25
|
import { deleteObservations } from './lib/delete-core.mjs';
|
|
27
26
|
import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
|
|
28
27
|
import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
28
|
+
import { computeStatsFeed } from './lib/stats-core.mjs';
|
|
29
|
+
import { buildLessonNudge } from './lib/save-nudge.mjs';
|
|
29
30
|
import { formatObsFieldValue } from './cli/common.mjs';
|
|
30
31
|
import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
|
|
31
32
|
|
|
@@ -46,7 +47,6 @@ import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-ro
|
|
|
46
47
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
47
48
|
import { rebuildObservationDerived } from './lib/observation-write.mjs';
|
|
48
49
|
import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
|
|
49
|
-
import { computeNoiseGauge } from './lib/stats-quality.mjs';
|
|
50
50
|
import { recallByFile } from './lib/recall-core.mjs';
|
|
51
51
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
52
52
|
import {
|
|
@@ -750,7 +750,8 @@ server.registerTool(
|
|
|
750
750
|
const supersededNote = result.supersededIds && result.supersededIds.length > 0
|
|
751
751
|
? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
|
|
752
752
|
: '';
|
|
753
|
-
|
|
753
|
+
const nudge = buildLessonNudge({ type: result.type, id: result.id, lessonCaptured: result.lessonCaptured, surface: 'mcp' });
|
|
754
|
+
return { content: [{ type: 'text', text: `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}${closedNote}${supersededNote}${nudge}` }] };
|
|
754
755
|
})
|
|
755
756
|
);
|
|
756
757
|
|
|
@@ -848,97 +849,11 @@ server.registerTool(
|
|
|
848
849
|
return { content: [{ type: 'text', text: formatQualityReport(data) }] };
|
|
849
850
|
}
|
|
850
851
|
|
|
851
|
-
const
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
const obsTotal = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
|
|
857
|
-
const sessTotal = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`).get(...baseParams);
|
|
858
|
-
const promptTotal = args.project
|
|
859
|
-
? db.prepare(`SELECT COUNT(*) as c FROM user_prompts p JOIN sdk_sessions s ON p.content_session_id = s.content_session_id WHERE s.project = ?`).get(args.project)
|
|
860
|
-
: db.prepare(`SELECT COUNT(*) as c FROM user_prompts`).get();
|
|
861
|
-
|
|
862
|
-
// Recent counts
|
|
863
|
-
const obsRecent = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
|
|
864
|
-
const sessRecent = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
|
|
865
|
-
|
|
866
|
-
// Type distribution (recent)
|
|
867
|
-
const types = db.prepare(`
|
|
868
|
-
SELECT type, COUNT(*) as c FROM observations
|
|
869
|
-
WHERE created_at_epoch >= ? ${projectFilter}
|
|
870
|
-
GROUP BY type ORDER BY c DESC
|
|
871
|
-
`).all(cutoff, ...baseParams);
|
|
872
|
-
|
|
873
|
-
// Projects (global view — skipped when filtering by single project)
|
|
874
|
-
const projects = args.project ? [] : db.prepare(`
|
|
875
|
-
SELECT project, COUNT(*) as c FROM observations
|
|
876
|
-
GROUP BY project ORDER BY c DESC
|
|
877
|
-
LIMIT 20
|
|
878
|
-
`).all();
|
|
879
|
-
|
|
880
|
-
// Daily activity (last 7 days)
|
|
881
|
-
const daily = db.prepare(`
|
|
882
|
-
SELECT date(created_at) as day, COUNT(*) as c FROM observations
|
|
883
|
-
WHERE created_at_epoch >= ? ${projectFilter}
|
|
884
|
-
GROUP BY day ORDER BY day DESC
|
|
885
|
-
LIMIT 7
|
|
886
|
-
`).all(Date.now() - 7 * 86400000, ...baseParams);
|
|
887
|
-
|
|
888
|
-
// Health metrics
|
|
889
|
-
const tokenEst = db.prepare(`
|
|
890
|
-
SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
|
|
891
|
-
FROM observations WHERE 1=1 ${projectFilter}
|
|
892
|
-
`).get(...baseParams);
|
|
893
|
-
|
|
894
|
-
const avgImp = db.prepare(`
|
|
895
|
-
SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}
|
|
896
|
-
`).get(...baseParams);
|
|
897
|
-
|
|
898
|
-
const thirtyDaysAgo = Date.now() - 30 * 86400000;
|
|
899
|
-
// v3.23 noise-gauge de-blinding (mirrors mem-cli cmdStats): `<= 1` makes the
|
|
900
|
-
// imp=0 dormant population visible (decay floor + LLM low-signal filter push
|
|
901
|
-
// ~half the live corpus to 0); injection_count=0 mirrors decay's NEVER-INJECTED
|
|
902
|
-
// guard so injected-but-decayed pinned noise isn't miscounted as "never used".
|
|
903
|
-
const lowVal = db.prepare(`
|
|
904
|
-
SELECT COUNT(*) as c FROM observations
|
|
905
|
-
WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
|
|
906
|
-
AND COALESCE(injection_count,0) = 0
|
|
907
|
-
AND COALESCE(compressed_into, 0) = 0
|
|
908
|
-
AND created_at_epoch < ? ${projectFilter}
|
|
909
|
-
`).get(thirtyDaysAgo, ...baseParams);
|
|
910
|
-
|
|
911
|
-
// Low-signal-title population (template / tool-log titles the read-side filter
|
|
912
|
-
// already excludes). The imp=1 "Low-value" metric can't see these, so the
|
|
913
|
-
// gauge under-reports real noise without it. See lib/low-signal-patterns.mjs.
|
|
914
|
-
const lowSignalTitle = db.prepare(`
|
|
915
|
-
SELECT COUNT(*) as c FROM observations
|
|
916
|
-
WHERE NOT ${buildNotLowSignalSql()}
|
|
917
|
-
AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
|
|
918
|
-
`).get(...baseParams);
|
|
919
|
-
// F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
|
|
920
|
-
// obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
|
|
921
|
-
const liveTotal = db.prepare(
|
|
922
|
-
`SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
|
|
923
|
-
).get(...baseParams);
|
|
924
|
-
const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
|
|
925
|
-
const compressedCount = db.prepare(`
|
|
926
|
-
SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}
|
|
927
|
-
`).get(...baseParams);
|
|
928
|
-
const supersededOnlyCount = db.prepare(`
|
|
929
|
-
SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}
|
|
930
|
-
`).get(...baseParams);
|
|
931
|
-
|
|
932
|
-
// Tier distribution
|
|
933
|
-
const tierCtx = { now: Date.now(), currentProject: args.project || inferProject(), currentSessionId: '' };
|
|
934
|
-
const tdParams = tierSqlParams(tierCtx);
|
|
935
|
-
const tierDist = db.prepare(`
|
|
936
|
-
SELECT tier, COUNT(*) as c FROM (
|
|
937
|
-
SELECT ${TIER_CASE_SQL} as tier FROM observations
|
|
938
|
-
WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
|
|
939
|
-
) GROUP BY tier ORDER BY tier
|
|
940
|
-
`).all(...tdParams, ...baseParams);
|
|
941
|
-
const tierMap = Object.fromEntries(tierDist.map(r => [r.tier, r.c]));
|
|
852
|
+
const {
|
|
853
|
+
obsTotal, sessTotal, promptTotal, obsRecent, sessRecent,
|
|
854
|
+
types, projects, daily, tokenEst, avgImp, lowVal, lowSignalTitle,
|
|
855
|
+
noiseRatio, lowSignalRatio, compressedCount, supersededOnlyCount, tierMap,
|
|
856
|
+
} = computeStatsFeed(db, { project: args.project || null, days });
|
|
942
857
|
|
|
943
858
|
const lines = [
|
|
944
859
|
`Memory Statistics${args.project ? ` (project: ${args.project})` : ''}:`,
|
package/source-files.mjs
CHANGED
|
@@ -162,6 +162,19 @@ export const SOURCE_FILES = [
|
|
|
162
162
|
// (cmdDelete) — extracted to kill the byte-duplicated twin. Missing it from the
|
|
163
163
|
// manifest would leave both delete surfaces unsigned/broken on auto-update.
|
|
164
164
|
'lib/delete-core.mjs',
|
|
165
|
+
// Shared primary stats feed (audit 2026-07-17 MED-4) — statically imported by
|
|
166
|
+
// server.mjs (mem_stats) and mem-cli.mjs (cmdStats); killed the ~80-line
|
|
167
|
+
// byte-duplicated twin. Missing it breaks both stats surfaces on auto-update.
|
|
168
|
+
'lib/stats-core.mjs',
|
|
169
|
+
// Observation `type` vocabulary single source (audit 2026-07-17 MED-3) —
|
|
170
|
+
// statically imported by tool-schemas.mjs, mem-cli.mjs, hook-llm.mjs,
|
|
171
|
+
// hook-optimize.mjs, lib/activity.mjs. Missing it breaks every save/validate
|
|
172
|
+
// path on auto-update.
|
|
173
|
+
'lib/obs-types.mjs',
|
|
174
|
+
// Save-time lesson nudge (audit 2026-07-17 P4) — statically imported by server.mjs
|
|
175
|
+
// (mem_save) and mem-cli.mjs (cmdSave). Missing it breaks both save surfaces on
|
|
176
|
+
// auto-update.
|
|
177
|
+
'lib/save-nudge.mjs',
|
|
165
178
|
// P10 dedup/merge threshold constants — single source of truth for the Jaccard
|
|
166
179
|
// dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
|
|
167
180
|
// hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;
|
package/tool-schemas.mjs
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { CLI_INVOKE } from './cli-path.mjs';
|
|
6
|
+
import { OBS_TYPES } from './lib/obs-types.mjs';
|
|
6
7
|
|
|
7
|
-
export const OBS_TYPE_ENUM = z.enum([
|
|
8
|
+
export const OBS_TYPE_ENUM = z.enum([...OBS_TYPES]);
|
|
8
9
|
|
|
9
10
|
// LLM-friendly coercion: accept string numbers and normalize to proper types
|
|
10
11
|
const coerceInt = z.preprocess(
|