claude-mem-lite 3.48.0 → 3.50.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 +27 -13
- package/lib/activity.mjs +2 -1
- package/lib/deferred-work.mjs +137 -0
- package/lib/events-injection.mjs +12 -3
- package/lib/export-columns.mjs +4 -1
- package/lib/id-routing.mjs +26 -0
- 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 +72 -117
- package/package.json +4 -1
- package/registry.mjs +1 -1
- package/schema.mjs +1 -1
- package/scripts/prompt-search-utils.mjs +27 -0
- package/scripts/user-prompt-search.js +72 -13
- package/server.mjs +66 -102
- package/source-files.mjs +13 -0
- package/tool-schemas.mjs +9 -5
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.50.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.50.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,32 @@ 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. Boundary:
|
|
944
|
+
// at budget.reenrich === 1, `half` floors to 1 (the whole budget), so with ≥1
|
|
945
|
+
// alias candidate the main scope gets 0 that cycle — pre-existing v3.43 semantics
|
|
946
|
+
// (reachable only via manual `optimize --max ≤4`; the daily path runs reenrich=6),
|
|
947
|
+
// and the starved scope self-corrects next cycle.
|
|
948
|
+
// An explicit --scope aliases still runs exactly that one scope (below).
|
|
949
|
+
const half = Math.max(1, Math.floor(budget.reenrich / 2));
|
|
950
|
+
const aliasBudget = Math.min(half, findReenrichCandidates(db, half, { scope: 'aliases', project }).length);
|
|
951
|
+
const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget, { scope: reenrichScope, project });
|
|
952
|
+
const aliasRes = aliasBudget > 0
|
|
953
|
+
? await executeReenrich(db, aliasBudget, { scope: 'aliases', project })
|
|
954
|
+
: { processed: 0, skipped: 0 };
|
|
941
955
|
results.reenrich = {
|
|
942
|
-
processed: (
|
|
943
|
-
skipped: (
|
|
944
|
-
byScope: {
|
|
956
|
+
processed: (mainRes.processed || 0) + (aliasRes.processed || 0),
|
|
957
|
+
skipped: (mainRes.skipped || 0) + (aliasRes.skipped || 0),
|
|
958
|
+
byScope: { [reenrichScope]: mainRes, aliases: aliasRes },
|
|
945
959
|
};
|
|
946
960
|
} else {
|
|
947
961
|
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/deferred-work.mjs
CHANGED
|
@@ -74,6 +74,143 @@ export function dropDeferred(db, id, reason) {
|
|
|
74
74
|
return { changed: r.changes };
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Fetch full deferred_work rows by raw id — ANY status, input order preserved,
|
|
79
|
+
* missing ids omitted. This is the read half of the D# surface: `defer list`
|
|
80
|
+
* stays title-only by design (dashboard noise budget), so `get D#N` is where
|
|
81
|
+
* the detail field becomes readable at all.
|
|
82
|
+
* @param {Database} db
|
|
83
|
+
* @param {number[]} ids Raw deferred_work ids
|
|
84
|
+
* @returns {Array<object>} Full rows
|
|
85
|
+
*/
|
|
86
|
+
export function getDeferredByIds(db, ids) {
|
|
87
|
+
if (!Array.isArray(ids) || ids.length === 0) return [];
|
|
88
|
+
const stmt = db.prepare(`SELECT * FROM deferred_work WHERE id = ?`);
|
|
89
|
+
const rows = [];
|
|
90
|
+
for (const id of ids) {
|
|
91
|
+
if (!Number.isInteger(id) || id <= 0) continue;
|
|
92
|
+
const r = stmt.get(id);
|
|
93
|
+
if (r) rows.push(r);
|
|
94
|
+
}
|
|
95
|
+
return rows;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Render one deferred_work row with FULL detail (never truncated) — shared by
|
|
100
|
+
* CLI cmdGet and MCP mem_get so the two surfaces cannot drift.
|
|
101
|
+
* @param {object} row Full deferred_work row (from getDeferredByIds)
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function formatDeferredDetail(row) {
|
|
105
|
+
const pTag = row.priority === 3 ? '🔴' : row.priority === 1 ? '⚪' : '🟡';
|
|
106
|
+
const lines = [`── D#${row.id} ── deferred (${row.status}) ${pTag} [P${row.priority}]`];
|
|
107
|
+
lines.push(`project: ${row.project}`);
|
|
108
|
+
lines.push(`title: ${row.title}`);
|
|
109
|
+
if (row.detail) lines.push(`detail: ${row.detail}`);
|
|
110
|
+
if (row.files) {
|
|
111
|
+
try {
|
|
112
|
+
const f = JSON.parse(row.files);
|
|
113
|
+
if (Array.isArray(f) && f.length > 0) lines.push(`files: ${f.join(', ')}`);
|
|
114
|
+
} catch { /* legacy non-JSON files value — skip rather than render garbage */ }
|
|
115
|
+
}
|
|
116
|
+
if (row.created_at_epoch) lines.push(`created: ${new Date(row.created_at_epoch).toISOString()}`);
|
|
117
|
+
if (row.status === 'dropped' && row.drop_reason) lines.push(`drop_reason: ${row.drop_reason}`);
|
|
118
|
+
if (row.status === 'done' && row.closed_by_obs_id) lines.push(`closed_by: #${row.closed_by_obs_id}`);
|
|
119
|
+
return lines.join('\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* P2 search leg: make deferred items reachable from mem_search / CLI search.
|
|
124
|
+
*
|
|
125
|
+
* Two channels, merged direct-first and deduped:
|
|
126
|
+
* 1. Explicit "D#N" refs in the query → direct id lookup, ANY status
|
|
127
|
+
* (an explicit reference deserves the row even if closed), project-scoped.
|
|
128
|
+
* 2. Keyword match over OPEN items' title+detail — JS substring matching
|
|
129
|
+
* (never SQL LIKE, so %/_ are literals and wildcard injection is
|
|
130
|
+
* structurally impossible; corpus is ≤50 open rows per project).
|
|
131
|
+
* Multi-token queries need ceil(n/2) token hits so one generic token
|
|
132
|
+
* ("test", "lesson") can't drag the trailer into every search.
|
|
133
|
+
*
|
|
134
|
+
* Date bounds are deliberately NOT applied — open items are few, current by
|
|
135
|
+
* definition, and the trailer is labeled as deferred work, not search results.
|
|
136
|
+
*
|
|
137
|
+
* @param {Database} db
|
|
138
|
+
* @param {string} query Raw user query
|
|
139
|
+
* @param {string} project Project scope (required — trailer is project-local)
|
|
140
|
+
* @param {{limit?: number}} [opts]
|
|
141
|
+
* @returns {Array<object>} Full rows, direct refs first, capped at limit
|
|
142
|
+
*/
|
|
143
|
+
export function searchDeferredWork(db, query, project, { limit = 3 } = {}) {
|
|
144
|
+
if (!query || typeof query !== 'string' || !project) return [];
|
|
145
|
+
|
|
146
|
+
const refIds = [];
|
|
147
|
+
const refRe = /\bD#(\d+)\b/gi;
|
|
148
|
+
let m;
|
|
149
|
+
while ((m = refRe.exec(query)) !== null) {
|
|
150
|
+
const id = parseInt(m[1], 10);
|
|
151
|
+
if (id > 0 && !refIds.includes(id)) refIds.push(id);
|
|
152
|
+
}
|
|
153
|
+
const direct = refIds.length > 0
|
|
154
|
+
? getDeferredByIds(db, refIds).filter(r => r.project === project)
|
|
155
|
+
: [];
|
|
156
|
+
|
|
157
|
+
const tokens = query
|
|
158
|
+
.replace(/\bD#\d+\b/gi, ' ')
|
|
159
|
+
.toLowerCase()
|
|
160
|
+
.split(/\s+/)
|
|
161
|
+
.filter(t => t.length >= 2)
|
|
162
|
+
.slice(0, 8);
|
|
163
|
+
|
|
164
|
+
let keyword = [];
|
|
165
|
+
if (tokens.length > 0) {
|
|
166
|
+
const open = db.prepare(`
|
|
167
|
+
SELECT * FROM deferred_work
|
|
168
|
+
WHERE project = ? AND status = 'open'
|
|
169
|
+
ORDER BY priority DESC, created_at_epoch ASC
|
|
170
|
+
LIMIT 50
|
|
171
|
+
`).all(project);
|
|
172
|
+
const need = Math.max(1, Math.ceil(tokens.length / 2));
|
|
173
|
+
keyword = open
|
|
174
|
+
.map(r => {
|
|
175
|
+
const hay = `${r.title || ''} ${r.detail || ''}`.toLowerCase();
|
|
176
|
+
return { r, matched: tokens.filter(t => hay.includes(t)).length };
|
|
177
|
+
})
|
|
178
|
+
.filter(x => x.matched >= need)
|
|
179
|
+
.sort((a, b) => b.matched - a.matched)
|
|
180
|
+
.map(x => x.r);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const seen = new Set();
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const r of [...direct, ...keyword]) {
|
|
186
|
+
if (seen.has(r.id)) continue;
|
|
187
|
+
seen.add(r.id);
|
|
188
|
+
out.push(r);
|
|
189
|
+
if (out.length >= limit) break;
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Render the deferred search trailer — appended AFTER (and never counted in)
|
|
196
|
+
* the main result set. Title-only lines; `invokeHint` names the surface's
|
|
197
|
+
* full-detail reader (CLI `get D#N` / MCP mem_get).
|
|
198
|
+
* @param {Array<object>} rows From searchDeferredWork
|
|
199
|
+
* @param {string} invokeHint e.g. 'claude-mem-lite get D#<id>'
|
|
200
|
+
* @returns {string[]} Lines (empty array when rows is empty)
|
|
201
|
+
*/
|
|
202
|
+
export function formatDeferredSearchTrailer(rows, invokeHint) {
|
|
203
|
+
if (!Array.isArray(rows) || rows.length === 0) return [];
|
|
204
|
+
const lines = [`[mem] Deferred work matches — full detail: ${invokeHint}`];
|
|
205
|
+
for (const r of rows) {
|
|
206
|
+
const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
|
|
207
|
+
const statusTag = r.status === 'open' ? '' : ` [${r.status}]`;
|
|
208
|
+
const title = (r.title || '(untitled)').length > 80 ? `${r.title.slice(0, 80)}…` : (r.title || '(untitled)');
|
|
209
|
+
lines.push(` D#${r.id} ${pTag} [P${r.priority}]${statusTag} ${title}`);
|
|
210
|
+
}
|
|
211
|
+
return lines;
|
|
212
|
+
}
|
|
213
|
+
|
|
77
214
|
/**
|
|
78
215
|
* Resolve mixed ordinal (int) + raw-id ("D#<n>") tokens to real deferred_work
|
|
79
216
|
* ids, validated against caller project + status='open'.
|
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',
|
package/lib/id-routing.mjs
CHANGED
|
@@ -55,6 +55,32 @@ export function bucketIdTokens(tokens, { explicit = null, defaultSource = 'obs'
|
|
|
55
55
|
return { bySrc, invalid };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Peel D#N deferred-work tokens off a mixed get-token list BEFORE bucketing.
|
|
60
|
+
* D# is a get-only read surface: deferred rows live outside the observation
|
|
61
|
+
* timeline, so they never enter bucketIdTokens' obs/session/prompt/event
|
|
62
|
+
* buckets, are exempt from `source` forcing, and stay rejected by the
|
|
63
|
+
* delete/timeline schemas. Requires the `#` (bare "D92" is prose, not a token).
|
|
64
|
+
*
|
|
65
|
+
* @param {Array<string|number>} tokens Mixed input
|
|
66
|
+
* @returns {{deferredIds: number[], rest: Array<string|number>}} deferredIds
|
|
67
|
+
* deduped in input order; rest preserved for bucketIdTokens.
|
|
68
|
+
*/
|
|
69
|
+
export function splitDeferredTokens(tokens) {
|
|
70
|
+
const deferredIds = [];
|
|
71
|
+
const rest = [];
|
|
72
|
+
for (const raw of tokens) {
|
|
73
|
+
const m = typeof raw === 'string' ? /^[Dd]#(\d+)$/.exec(raw.trim()) : null;
|
|
74
|
+
if (m) {
|
|
75
|
+
const id = parseInt(m[1], 10);
|
|
76
|
+
if (id > 0 && !deferredIds.includes(id)) deferredIds.push(id);
|
|
77
|
+
} else {
|
|
78
|
+
rest.push(raw);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { deferredIds, rest };
|
|
82
|
+
}
|
|
83
|
+
|
|
58
84
|
/**
|
|
59
85
|
* Probe the observations / session_summaries / user_prompts tables for any
|
|
60
86
|
* of the given numeric IDs, excluding the sources the caller already queried.
|
|
@@ -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
|
+
}
|