claude-mem-lite 2.34.6 → 2.36.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 +71 -3
- package/hook.mjs +18 -0
- package/install.mjs +29 -6
- package/lib/citation-tracker.mjs +82 -0
- package/lib/doctor-drift.mjs +46 -0
- package/lib/low-signal-patterns.mjs +139 -0
- package/lib/stats-quality.mjs +130 -0
- package/mem-cli.mjs +12 -126
- package/package.json +5 -1
- package/scoring-sql.mjs +7 -20
- package/scripts/pre-skill-bridge.js +2 -1
- package/scripts/pre-tool-recall.js +12 -2
- package/server.mjs +31 -2
- package/source-files.mjs +4 -0
- package/tool-schemas.mjs +2 -0
- package/utils.mjs +7 -2
package/hook-llm.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
sessionFile, getSessionId, openDb, callLLM, sleep,
|
|
17
17
|
} from './hook-shared.mjs';
|
|
18
18
|
import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
|
|
19
|
+
import { isNoiseObservation } from './lib/low-signal-patterns.mjs';
|
|
19
20
|
|
|
20
21
|
// T9: memdir-incompatible types live in the `events` table, not `observations`.
|
|
21
22
|
// Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
|
|
@@ -69,6 +70,14 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
69
70
|
VALUES (?, ?, ?, ?, ?, 'active')
|
|
70
71
|
`).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
|
|
71
72
|
|
|
73
|
+
// P0: write-side noise block — LOW_SIGNAL title with no recoverable signal
|
|
74
|
+
// (no lesson, importance<2, empty facts, thin narrative) is dropped before
|
|
75
|
+
// dedup/MinHash/vector work. Opt-out: CLAUDE_MEM_KEEP_LOW_SIGNAL=1.
|
|
76
|
+
if (isNoiseObservation(obs)) {
|
|
77
|
+
debugLog('saveObservation', `dropped noise: ${truncate(obs.title || '', 60)}`);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
// Three-tier dedup — returns null (not throw) for dedup hits
|
|
73
82
|
// Tier 1 (fast): 5-min Jaccard on titles
|
|
74
83
|
const fiveMinAgo = now.getTime() - DEDUP_WINDOW_MS;
|
|
@@ -371,8 +380,9 @@ export function buildDegradedTitle(episode) {
|
|
|
371
380
|
}
|
|
372
381
|
|
|
373
382
|
if (files.length > 0) {
|
|
374
|
-
const
|
|
375
|
-
const
|
|
383
|
+
const uniqueNames = [...new Set(files.map(f => basename(f)))];
|
|
384
|
+
const names = uniqueNames.slice(0, 3).join(', ');
|
|
385
|
+
const suffix = uniqueNames.length > 3 ? ` +${uniqueNames.length - 3} more` : '';
|
|
376
386
|
if (hasError) {
|
|
377
387
|
// Include the triggering command for richer context: "Error: dispatch.mjs — npm test failed"
|
|
378
388
|
const errEntry = episode.entries.find(e => e.isError);
|
|
@@ -463,6 +473,38 @@ export function buildImmediateObservation(episode) {
|
|
|
463
473
|
};
|
|
464
474
|
}
|
|
465
475
|
|
|
476
|
+
// ─── Lesson retry prompt (P3) ───────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Build a lesson-focused retry prompt after Haiku's first pass for
|
|
480
|
+
* bugfix/decision returned null/empty/'none'. Narrow ask: one non-obvious
|
|
481
|
+
* insight a future session would benefit from — either root cause (bugfix)
|
|
482
|
+
* or tradeoff (decision).
|
|
483
|
+
*
|
|
484
|
+
* @param {object} episode
|
|
485
|
+
* @param {object} firstPass — parsed first-pass response (title, type, narrative)
|
|
486
|
+
* @returns {string} prompt
|
|
487
|
+
*/
|
|
488
|
+
export function buildLessonRetryPrompt(episode, firstPass) {
|
|
489
|
+
const actionList = episode.entries.map((e, i) =>
|
|
490
|
+
`${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`
|
|
491
|
+
).join('\n');
|
|
492
|
+
const typeHint = firstPass.type === 'bugfix'
|
|
493
|
+
? 'For this bugfix: what was the root cause + how to spot it next time? Example: "FTS5 trigger fires on any UPDATE — wrap access_count writes in try/catch."'
|
|
494
|
+
: 'For this decision: what tradeoff was made + why? Example: "Chose single-source module over schema column because 1 drift point, not 4."';
|
|
495
|
+
return `A ${firstPass.type} episode just completed. First-pass title: "${firstPass.title || 'untitled'}".
|
|
496
|
+
|
|
497
|
+
Actions:
|
|
498
|
+
${actionList}
|
|
499
|
+
|
|
500
|
+
${typeHint}
|
|
501
|
+
|
|
502
|
+
If the work was purely mechanical with no insight worth remembering, reply {"lesson":"none"}.
|
|
503
|
+
Otherwise reply in 12-280 chars.
|
|
504
|
+
|
|
505
|
+
Reply ONLY valid JSON, no markdown fences: {"lesson":"..."}`;
|
|
506
|
+
}
|
|
507
|
+
|
|
466
508
|
// ─── Background: LLM Episode Extraction (Tier 2 F) ──────────────────────────
|
|
467
509
|
|
|
468
510
|
export async function handleLLMEpisode() {
|
|
@@ -505,6 +547,7 @@ Action: ${e.desc}
|
|
|
505
547
|
Error: ${e.isError ? 'yes' : 'no'}
|
|
506
548
|
|
|
507
549
|
JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1,"lesson_learned":"non-obvious insight or 'none' if routine","search_aliases":["alt query 1","alt query 2"]}
|
|
550
|
+
type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y because Z" / rejected an approach (e.g. "Rejected schema migration — single-source module + sync test instead"; "Heterogeneous hook events → heterogeneous context budgets"). bugfix = prior-failing path fixed with a named root cause. feature = new user-visible capability. refactor = behavior unchanged but structure improved. discovery = learned how a system works (read-heavy, no writes). change = routine edit with no new principle (default if unsure and nothing else fits).
|
|
508
551
|
Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
|
|
509
552
|
importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
|
|
510
553
|
lesson_learned: REQUIRED field. State what was learned that isn't obvious from reading the code. Examples: "FTS5 porter stemmer doesn't tokenize CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". If purely routine with nothing learned, write "none" (not null).
|
|
@@ -522,6 +565,7 @@ Actions (${episode.entries.length} total):
|
|
|
522
565
|
${actionList}
|
|
523
566
|
|
|
524
567
|
JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"coherent ≤80 char summary","narrative":"what was done, why, and outcome (3-5 sentences)","concepts":["keyword1","keyword2"],"facts":["specific fact 1","specific fact 2"],"importance":1,"lesson_learned":"non-obvious insight or 'none' if routine","search_aliases":["alt query 1","alt query 2"]}
|
|
568
|
+
type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y because Z" / rejected an approach (e.g. "Rejected schema migration — single-source module + sync test instead"; "Heterogeneous hook events → heterogeneous context budgets"). bugfix = prior-failing path fixed with a named root cause. feature = new user-visible capability. refactor = behavior unchanged but structure improved. discovery = learned how a system works (read-heavy, no writes). change = routine edit with no new principle (default if unsure and nothing else fits).
|
|
525
569
|
Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
|
|
526
570
|
importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
|
|
527
571
|
lesson_learned: REQUIRED field. State what was learned that isn't obvious from reading the code. Examples: "FTS5 porter stemmer doesn't tokenize CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". If purely routine with nothing learned, write "none" (not null).
|
|
@@ -569,7 +613,31 @@ search_aliases: 2-6 alternative search terms someone might use to find this memo
|
|
|
569
613
|
const rawLesson = typeof parsed.lesson_learned === 'string' ? parsed.lesson_learned.trim() : '';
|
|
570
614
|
const lowSignalLesson = new Set(['none', '', 'n/a', 'null', 'todo', 'tbd', 'na', '-', 'nothing', 'nil']);
|
|
571
615
|
const isLessonLowSignal = lowSignalLesson.has(rawLesson.toLowerCase()) || rawLesson.length < 12;
|
|
572
|
-
|
|
616
|
+
let lessonLearned = isLessonLowSignal ? null : rawLesson.slice(0, 500);
|
|
617
|
+
|
|
618
|
+
// P3: for bugfix/decision, retry once with a lesson-focused prompt.
|
|
619
|
+
// These types have the highest reuse value (~72.7% hit-rate vs change
|
|
620
|
+
// ~16.5%), and Haiku's first pass writes NULL ~70% of the time for
|
|
621
|
+
// curated observations. Retry budget: 1 extra callLLM per bugfix/decision
|
|
622
|
+
// episode. Opt-out: CLAUDE_MEM_NO_LESSON_RETRY=1.
|
|
623
|
+
if (isLessonLowSignal &&
|
|
624
|
+
(parsed.type === 'bugfix' || parsed.type === 'decision') &&
|
|
625
|
+
!process.env.CLAUDE_MEM_NO_LESSON_RETRY) {
|
|
626
|
+
try {
|
|
627
|
+
const retryPrompt = buildLessonRetryPrompt(episode, parsed);
|
|
628
|
+
const retryRaw = callLLM(retryPrompt, 10000);
|
|
629
|
+
if (retryRaw) {
|
|
630
|
+
const retry = parseJsonFromLLM(retryRaw);
|
|
631
|
+
const retryLesson = typeof retry?.lesson === 'string' ? retry.lesson.trim() : '';
|
|
632
|
+
const retryIsLow = lowSignalLesson.has(retryLesson.toLowerCase()) || retryLesson.length < 12;
|
|
633
|
+
if (!retryIsLow) {
|
|
634
|
+
lessonLearned = retryLesson.slice(0, 500);
|
|
635
|
+
debugLog('DEBUG', 'llm-episode', `lesson-retry: recovered ${retryLesson.length}-char lesson for ${parsed.type}`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
} catch (e) { debugCatch(e, 'lesson-retry'); }
|
|
639
|
+
}
|
|
640
|
+
|
|
573
641
|
const searchAliases = Array.isArray(parsed.search_aliases)
|
|
574
642
|
? parsed.search_aliases.slice(0, 6).join(' ')
|
|
575
643
|
: null;
|
package/hook.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
spawnBackground,
|
|
43
43
|
} from './hook-shared.mjs';
|
|
44
44
|
import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation } from './hook-llm.mjs';
|
|
45
|
+
import { extractCitationsFromTranscript, bumpCitationAccess } from './lib/citation-tracker.mjs';
|
|
45
46
|
import { searchRelevantMemories } from './hook-memory.mjs';
|
|
46
47
|
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
47
48
|
import { checkForUpdate } from './hook-update.mjs';
|
|
@@ -344,12 +345,16 @@ async function handleStop() {
|
|
|
344
345
|
// This is the stable CC identifier — the mem plugin's file-based getSessionId()
|
|
345
346
|
// collides across parallel sessions for the same project (see docs/bug.txt).
|
|
346
347
|
let ccSessionId = null;
|
|
348
|
+
let transcriptPath = null;
|
|
347
349
|
try {
|
|
348
350
|
const raw = await readStdin();
|
|
349
351
|
const hookData = JSON.parse(raw.text);
|
|
350
352
|
if (typeof hookData?.session_id === 'string' && hookData.session_id.length > 0) {
|
|
351
353
|
ccSessionId = hookData.session_id;
|
|
352
354
|
}
|
|
355
|
+
if (typeof hookData?.transcript_path === 'string' && hookData.transcript_path.length > 0) {
|
|
356
|
+
transcriptPath = hookData.transcript_path;
|
|
357
|
+
}
|
|
353
358
|
} catch { /* stdin unavailable — fall back to local session id */ }
|
|
354
359
|
|
|
355
360
|
// Capture session info BEFORE cleanup. All DB lookups use the mem-internal id
|
|
@@ -448,6 +453,19 @@ async function handleStop() {
|
|
|
448
453
|
}
|
|
449
454
|
}
|
|
450
455
|
} catch (e) { debugCatch(e, 'handleStop-fast-summary'); }
|
|
456
|
+
|
|
457
|
+
// P4: scan transcript for `#NN` observation citations in assistant text
|
|
458
|
+
// and bump access_count for matched rows. Closes the loop on the "cite #NN"
|
|
459
|
+
// contract — before P4 this was a one-way obligation with no feedback.
|
|
460
|
+
try {
|
|
461
|
+
if (transcriptPath && !process.env.CLAUDE_MEM_NO_CITATION_TRACK) {
|
|
462
|
+
const ids = extractCitationsFromTranscript(transcriptPath);
|
|
463
|
+
if (ids.size > 0) {
|
|
464
|
+
const n = bumpCitationAccess(db, ids, project);
|
|
465
|
+
debugLog('DEBUG', 'handleStop', `citations: ${ids.size} ids scanned, ${n} obs bumped`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
} catch (e) { debugCatch(e, 'handleStop-citation-track'); }
|
|
451
469
|
} finally {
|
|
452
470
|
db.close();
|
|
453
471
|
}
|
package/install.mjs
CHANGED
|
@@ -1065,11 +1065,13 @@ async function doctor() {
|
|
|
1065
1065
|
}
|
|
1066
1066
|
|
|
1067
1067
|
// Dependencies
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1068
|
+
try {
|
|
1069
|
+
const Database = (await import('better-sqlite3')).default;
|
|
1070
|
+
const probe = new Database(':memory:');
|
|
1071
|
+
probe.close();
|
|
1072
|
+
ok('better-sqlite3: verified (import + open OK)');
|
|
1073
|
+
} catch (e) {
|
|
1074
|
+
fail(`better-sqlite3: import/init failed (${e.message})`);
|
|
1073
1075
|
issues++;
|
|
1074
1076
|
}
|
|
1075
1077
|
|
|
@@ -1193,6 +1195,26 @@ async function doctor() {
|
|
|
1193
1195
|
warn('Update state: failed to read');
|
|
1194
1196
|
}
|
|
1195
1197
|
|
|
1198
|
+
// Dev drift: in dev-mode installs, all SOURCE_FILES entries should be
|
|
1199
|
+
// symlinks. A plain file means an earlier install (or manual cp) copied it,
|
|
1200
|
+
// so edits in the repo won't propagate to INSTALL_DIR — hook runtime and
|
|
1201
|
+
// test runtime silently diverge.
|
|
1202
|
+
try {
|
|
1203
|
+
const { checkDevDrift } = await import('./lib/doctor-drift.mjs');
|
|
1204
|
+
const r = checkDevDrift(INSTALL_DIR, SOURCE_FILES);
|
|
1205
|
+
if (r.drift) {
|
|
1206
|
+
const names = r.details.join(', ');
|
|
1207
|
+
const suffix = r.plainCount > r.details.length ? ` +${r.plainCount - r.details.length} more` : '';
|
|
1208
|
+
warn(`Dev drift: ${r.plainCount} non-symlink file(s) in dev install: ${names}${suffix} (re-run: node install.mjs install --dev)`);
|
|
1209
|
+
issues++;
|
|
1210
|
+
} else if (r.devMode) {
|
|
1211
|
+
ok(`Dev drift: clean (${r.symlinkCount} symlinks, 0 plain)`);
|
|
1212
|
+
}
|
|
1213
|
+
// Prod (all plain) install: no message — dev-drift is a dev-only concern.
|
|
1214
|
+
} catch (e) {
|
|
1215
|
+
warn('Dev drift: check failed — ' + e.message);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1196
1218
|
// Stale temp files
|
|
1197
1219
|
try {
|
|
1198
1220
|
const runtimeDir = join(INSTALL_DIR, 'runtime');
|
|
@@ -1225,7 +1247,8 @@ async function doctor() {
|
|
|
1225
1247
|
const Database = (await import('better-sqlite3')).default;
|
|
1226
1248
|
const db = new Database(DB_PATH, { readonly: true });
|
|
1227
1249
|
const obsCount = db.prepare('SELECT COUNT(*) as cnt FROM observations').get()?.cnt || 0;
|
|
1228
|
-
|
|
1250
|
+
// Align with stats / MCP mem_stats: session_summaries, not sdk_sessions
|
|
1251
|
+
const sessCount = db.prepare('SELECT COUNT(*) as cnt FROM session_summaries').get()?.cnt || 0;
|
|
1229
1252
|
db.close();
|
|
1230
1253
|
ok(`DB stats: ${sizeMB}MB, ${obsCount} observations, ${sessCount} sessions`);
|
|
1231
1254
|
} catch (e) {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Citation tracker (P4): scan Claude Code transcript for `#NN` observation-id
|
|
2
|
+
// citations in assistant text, then bulk-increment access_count for matched rows.
|
|
3
|
+
//
|
|
4
|
+
// Closes the loop on the CLAUDE.md "cite #NN" contract — before P4, citations
|
|
5
|
+
// were a one-way obligation with no measurable feedback. Now each honored
|
|
6
|
+
// citation bumps access_count, making contract compliance observable via
|
|
7
|
+
// mem_stats and preventing cited lessons from decaying into dead memory.
|
|
8
|
+
//
|
|
9
|
+
// FTS5 caveat (project_non_obvious.md): observations_au trigger fires on any
|
|
10
|
+
// column UPDATE including access_count. Per-row UPDATEs wrapped in try-catch
|
|
11
|
+
// to prevent SQLITE_CORRUPT_VTAB cascades from stopping the whole scan.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync } from 'fs';
|
|
14
|
+
import { debugCatch } from '../utils.mjs';
|
|
15
|
+
|
|
16
|
+
// `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
|
|
17
|
+
// Bounded to 1-7 digits to skip URL fragments, markdown anchors, etc.
|
|
18
|
+
const CITATION_RE = /#(\d{1,7})\b/g;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a Claude Code transcript .jsonl and extract unique observation IDs
|
|
22
|
+
* cited inside assistant text blocks.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} transcriptPath Path to transcript file (.jsonl)
|
|
25
|
+
* @returns {Set<number>} unique IDs referenced as `#NN` in assistant text
|
|
26
|
+
*/
|
|
27
|
+
export function extractCitationsFromTranscript(transcriptPath) {
|
|
28
|
+
const ids = new Set();
|
|
29
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return ids;
|
|
30
|
+
let raw;
|
|
31
|
+
try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
|
|
32
|
+
for (const line of raw.split('\n')) {
|
|
33
|
+
if (!line.trim()) continue;
|
|
34
|
+
let entry;
|
|
35
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
36
|
+
// Claude Code transcript: one JSON per line with type='assistant' | 'user' | ...
|
|
37
|
+
if (entry.type !== 'assistant' || !entry.message) continue;
|
|
38
|
+
const content = entry.message.content;
|
|
39
|
+
if (!Array.isArray(content)) continue;
|
|
40
|
+
for (const block of content) {
|
|
41
|
+
if (block.type !== 'text' || typeof block.text !== 'string') continue;
|
|
42
|
+
CITATION_RE.lastIndex = 0;
|
|
43
|
+
let m;
|
|
44
|
+
while ((m = CITATION_RE.exec(block.text))) {
|
|
45
|
+
const id = Number(m[1]);
|
|
46
|
+
if (Number.isInteger(id) && id > 0 && id < 1e7) ids.add(id);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return ids;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Increment `access_count` (and `last_accessed_at`) for each cited observation
|
|
55
|
+
* that belongs to `project`. Returns the count of successful increments.
|
|
56
|
+
*
|
|
57
|
+
* Per-row UPDATE in try-catch so a single FTS-corrupted row can't abort the
|
|
58
|
+
* scan. Cross-project IDs are silently ignored by the WHERE clause.
|
|
59
|
+
*
|
|
60
|
+
* @param {import('better-sqlite3').Database} db
|
|
61
|
+
* @param {Iterable<number>} ids
|
|
62
|
+
* @param {string} project
|
|
63
|
+
* @returns {number} count of rows incremented
|
|
64
|
+
*/
|
|
65
|
+
export function bumpCitationAccess(db, ids, project) {
|
|
66
|
+
if (!db || !ids || !project) return 0;
|
|
67
|
+
const idList = Array.isArray(ids) ? ids : [...ids];
|
|
68
|
+
if (idList.length === 0) return 0;
|
|
69
|
+
const stmt = db.prepare(`
|
|
70
|
+
UPDATE observations SET access_count = access_count + 1, last_accessed_at = ?
|
|
71
|
+
WHERE id = ? AND project = ?
|
|
72
|
+
`);
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
let n = 0;
|
|
75
|
+
for (const id of idList) {
|
|
76
|
+
try {
|
|
77
|
+
const result = stmt.run(now, id, project);
|
|
78
|
+
if (result.changes > 0) n++;
|
|
79
|
+
} catch (e) { debugCatch(e, `bumpCitationAccess-id-${id}`); }
|
|
80
|
+
}
|
|
81
|
+
return n;
|
|
82
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Dev-drift check: in dev-mode installs (symlinked to project repo), every
|
|
2
|
+
// managed source file in INSTALL_DIR should be a symlink. A regular file
|
|
3
|
+
// means an earlier install copied it (e.g. install.mjs before it was added
|
|
4
|
+
// to SOURCE_FILES) or someone ran `cp` manually — edits won't propagate
|
|
5
|
+
// from the repo, testing vs runtime will silently diverge.
|
|
6
|
+
//
|
|
7
|
+
// Returns: { devMode, drift, details } — devMode=false when no symlinks
|
|
8
|
+
// detected (prod copy install), drift=true when in dev-mode AND at least
|
|
9
|
+
// one SOURCE_FILES entry is a plain file.
|
|
10
|
+
|
|
11
|
+
import { existsSync, lstatSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
|
|
14
|
+
export function checkDevDrift(installDir, sourceFiles) {
|
|
15
|
+
if (!existsSync(installDir)) {
|
|
16
|
+
return { devMode: false, drift: false, symlinkCount: 0, plainCount: 0, plainFiles: [], missingCount: 0, details: [] };
|
|
17
|
+
}
|
|
18
|
+
const symlinkFiles = [];
|
|
19
|
+
const plainFiles = [];
|
|
20
|
+
const missing = [];
|
|
21
|
+
for (const rel of sourceFiles) {
|
|
22
|
+
const p = join(installDir, rel);
|
|
23
|
+
if (!existsSync(p)) { missing.push(rel); continue; }
|
|
24
|
+
try {
|
|
25
|
+
const st = lstatSync(p);
|
|
26
|
+
if (st.isSymbolicLink()) symlinkFiles.push(rel);
|
|
27
|
+
else plainFiles.push(rel);
|
|
28
|
+
} catch {
|
|
29
|
+
missing.push(rel);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// devMode detection: if ≥1 symlink exists among source files, consider
|
|
33
|
+
// this a dev install. (Prod install is all plain files → drift=false
|
|
34
|
+
// because there's nothing to drift from.)
|
|
35
|
+
const devMode = symlinkFiles.length > 0;
|
|
36
|
+
const drift = devMode && plainFiles.length > 0;
|
|
37
|
+
return {
|
|
38
|
+
devMode,
|
|
39
|
+
drift,
|
|
40
|
+
symlinkCount: symlinkFiles.length,
|
|
41
|
+
plainCount: plainFiles.length,
|
|
42
|
+
plainFiles,
|
|
43
|
+
missingCount: missing.length,
|
|
44
|
+
details: plainFiles.slice(0, 5),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Single source of truth for LOW_SIGNAL title patterns.
|
|
2
|
+
//
|
|
3
|
+
// "LOW_SIGNAL" = hook-llm fallback titles written when Haiku summarization
|
|
4
|
+
// is unavailable or skipped ("Modified X", "Worked on X", "Reviewed N files:",
|
|
5
|
+
// raw "Error: ..." logs, bare "node/npm/npx <cmd>" etc.). Empirical data:
|
|
6
|
+
// ~544 such entries in production, 18 ever accessed (3.3% retrieval rate).
|
|
7
|
+
//
|
|
8
|
+
// Three consumers must stay in sync (pre-β: by hand-mirrored comments);
|
|
9
|
+
// post-β: all three derive from this module.
|
|
10
|
+
// 1. utils.mjs::LOW_SIGNAL_TITLE — regex for write-side importance cap
|
|
11
|
+
// 2. scoring-sql.mjs::notLowSignalTitleClause — SQL NOT LIKE chain for read-side filter
|
|
12
|
+
// 3. scripts/pre-tool-recall.js — inline SQL (standalone cold-start script)
|
|
13
|
+
//
|
|
14
|
+
// This module is intentionally dependency-free so scripts/pre-tool-recall.js can
|
|
15
|
+
// import it without inflating the ~30ms cold-start budget.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Each entry has:
|
|
19
|
+
* - like: SQLite LIKE pattern (anchored; % = any chars)
|
|
20
|
+
* - regex: JS regex source fragment (MUST match the same title set as `like`)
|
|
21
|
+
*
|
|
22
|
+
* Adding/removing entries requires updating the sync test (tests/low-signal-sync.test.mjs).
|
|
23
|
+
*/
|
|
24
|
+
export const LOW_SIGNAL_PATTERNS = [
|
|
25
|
+
{ like: 'Modified %', regex: '^Modified ' },
|
|
26
|
+
{ like: 'Worked on %', regex: '^Worked on ' },
|
|
27
|
+
{ like: 'Reviewed % files:%', regex: '^Reviewed \\d+ files:' },
|
|
28
|
+
{ like: 'Codebase exploration%', regex: '^Codebase exploration' },
|
|
29
|
+
{ like: 'Error while working%', regex: '^Error while working' },
|
|
30
|
+
{ like: 'Error in %', regex: '^Error in ' },
|
|
31
|
+
{ like: 'Error: %', regex: '^Error: ' },
|
|
32
|
+
{ like: '# %', regex: '^# ' },
|
|
33
|
+
{ like: 'node %', regex: '^node ' },
|
|
34
|
+
{ like: 'npm %', regex: '^npm ' },
|
|
35
|
+
{ like: 'npx %', regex: '^npx ' },
|
|
36
|
+
{ like: '(no description)%', regex: '^\\(no description\\)' },
|
|
37
|
+
{ like: '%(error)', regex: '\\(error\\)$' },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the combined regex that matches ANY LOW_SIGNAL pattern.
|
|
42
|
+
* Equivalent to the hand-written `LOW_SIGNAL_TITLE` before β refactor.
|
|
43
|
+
*/
|
|
44
|
+
export function buildLowSignalRegex() {
|
|
45
|
+
const src = LOW_SIGNAL_PATTERNS.map(p => `(?:${p.regex})`).join('|');
|
|
46
|
+
return new RegExp(src);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the SQL NOT LIKE clause chain, optionally prefixed with a table alias.
|
|
51
|
+
* Output is a single parenthesized AND-chain — safe to combine with other AND/OR.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} [alias=''] Table alias (e.g. 'o') — empty for unqualified.
|
|
54
|
+
* @returns {string} SQL boolean expression
|
|
55
|
+
*/
|
|
56
|
+
export function buildNotLowSignalSql(alias = '') {
|
|
57
|
+
const p = alias ? `${alias}.` : '';
|
|
58
|
+
const clauses = LOW_SIGNAL_PATTERNS.map(({ like }) => `${p}title NOT LIKE '${like}'`);
|
|
59
|
+
return '(\n ' + clauses.join('\n AND ') + '\n )';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Cached singleton — isNoiseObservation is called once per observation insert.
|
|
63
|
+
const _LOW_SIG_RE = buildLowSignalRegex();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Detect narrative that is raw tool-output passthrough, not human/LLM prose (P2).
|
|
67
|
+
*
|
|
68
|
+
* `buildImmediateObservation` constructs narrative as
|
|
69
|
+
* `episode.entries.map(e => e.desc).join('; ')` where each desc is
|
|
70
|
+
* "cmd → stdout/stderr" from `scripts/post-tool-use.sh`. Such narratives
|
|
71
|
+
* have characteristic fingerprints (arrows, stack traces, diffs, test
|
|
72
|
+
* failure banners, absent sentence prose) that Haiku/user-written narratives
|
|
73
|
+
* don't. This check treats passthrough narratives as zero-signal for the
|
|
74
|
+
* purposes of isNoiseObservation.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} narrative
|
|
77
|
+
* @returns {boolean} true = raw tool output, not substantive narrative
|
|
78
|
+
*/
|
|
79
|
+
function _isLikelyToolOutputPassthrough(narrative) {
|
|
80
|
+
if (!narrative || narrative.length < 80) return false;
|
|
81
|
+
// post-tool-use.sh formats entries as "cmd → output"; presence of " → " in
|
|
82
|
+
// a long narrative is near-diagnostic of raw entry-desc passthrough.
|
|
83
|
+
if (/ → /.test(narrative)) return true;
|
|
84
|
+
// Stack-trace fingerprints that never appear in curated narratives.
|
|
85
|
+
if (/\n\s+at .+:\d+:\d+/.test(narrative)) return true;
|
|
86
|
+
if (/node:internal\//.test(narrative)) return true;
|
|
87
|
+
// Raw diff output.
|
|
88
|
+
if (/(^|\n)diff --git |(^|\n)@@ -\d/.test(narrative)) return true;
|
|
89
|
+
// Test-runner failure banners.
|
|
90
|
+
if (/(^|\n)\s*FAIL\s+|AssertionError|TypeError: |SyntaxError: /.test(narrative)) return true;
|
|
91
|
+
// Absent sentence prose + multi-"; " is the buildImmediateObservation join signature.
|
|
92
|
+
const hasSentenceBreaks = /\. [A-Z]/.test(narrative);
|
|
93
|
+
const semiJoins = (narrative.match(/; /g) || []).length;
|
|
94
|
+
if (!hasSentenceBreaks && semiJoins >= 2) return true;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Write-side noise filter (P0/P2). Returns true when an observation has a
|
|
100
|
+
* LOW_SIGNAL title AND no recoverable downstream signal — caller should skip
|
|
101
|
+
* insertion.
|
|
102
|
+
*
|
|
103
|
+
* Contract: a low-signal title is kept if ANY of these carry signal:
|
|
104
|
+
* - lesson_learned set and not 'none'
|
|
105
|
+
* - importance >= 2
|
|
106
|
+
* - facts has >=1 non-empty string
|
|
107
|
+
* - narrative >= 40 chars AND not raw stderr / tool-output passthrough (P2)
|
|
108
|
+
*
|
|
109
|
+
* Opt-out: env `CLAUDE_MEM_KEEP_LOW_SIGNAL=1` disables filter (preserves
|
|
110
|
+
* pre-v2.36 behavior — every observation is inserted regardless of signal).
|
|
111
|
+
*
|
|
112
|
+
* @param {object} obs Observation shape: { title, facts, narrative, lessonLearned|lesson_learned, importance }
|
|
113
|
+
* @param {object} [env=process.env] Environment (injected for testability)
|
|
114
|
+
* @returns {boolean} true = noise, caller should drop
|
|
115
|
+
*/
|
|
116
|
+
export function isNoiseObservation(obs, env = process.env) {
|
|
117
|
+
if (env && env.CLAUDE_MEM_KEEP_LOW_SIGNAL === '1') return false;
|
|
118
|
+
const title = (obs && obs.title) || '';
|
|
119
|
+
if (!_LOW_SIG_RE.test(title)) return false;
|
|
120
|
+
|
|
121
|
+
const lesson = obs.lessonLearned ?? obs.lesson_learned;
|
|
122
|
+
if (lesson && String(lesson).trim() && String(lesson).trim().toLowerCase() !== 'none') return false;
|
|
123
|
+
|
|
124
|
+
if ((obs.importance ?? 1) >= 2) return false;
|
|
125
|
+
|
|
126
|
+
if (Array.isArray(obs.facts) &&
|
|
127
|
+
obs.facts.filter(f => typeof f === 'string' && f.trim().length > 0).length >= 1) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const narrative = (obs.narrative || '').trim();
|
|
132
|
+
if (narrative.length >= 40 &&
|
|
133
|
+
!/^Error[: ]/i.test(narrative) &&
|
|
134
|
+
!_isLikelyToolOutputPassthrough(narrative)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Shared quality-dashboard computation — used by both mem-cli.mjs (CLI
|
|
2
|
+
// `stats --quality`) and server.mjs (MCP `mem_stats({quality: true})`).
|
|
3
|
+
// Splits pure data aggregation from text rendering so MCP handlers don't
|
|
4
|
+
// collide with CLI's `out()` stdout-write pattern.
|
|
5
|
+
|
|
6
|
+
import { notLowSignalTitleClause } from '../scoring-sql.mjs';
|
|
7
|
+
import { truncate } from '../format-utils.mjs';
|
|
8
|
+
|
|
9
|
+
export function computeQualityStats(db, { project, days }) {
|
|
10
|
+
const projectFilter = project ? 'AND project = ?' : '';
|
|
11
|
+
const baseParams = project ? [project] : [];
|
|
12
|
+
const cutoff = Date.now() - days * 86400000;
|
|
13
|
+
|
|
14
|
+
// LOW_SIGNAL match = NOT notLowSignal. Shared helper keeps SQL in sync
|
|
15
|
+
// with scoring-sql.mjs and pre-tool-recall.js Edit-fallback filter.
|
|
16
|
+
const lowSignalIsMatchExpr = `NOT ${notLowSignalTitleClause('')}`;
|
|
17
|
+
|
|
18
|
+
// Narrative-text proxy for bugfix investigations that never landed a fix.
|
|
19
|
+
const unresolvedNarrativeExpr = `(
|
|
20
|
+
LOWER(COALESCE(narrative,'')) LIKE '%not yet identified%'
|
|
21
|
+
OR LOWER(COALESCE(narrative,'')) LIKE '%not yet resolved%'
|
|
22
|
+
OR LOWER(COALESCE(narrative,'')) LIKE '%not yet fixed%'
|
|
23
|
+
OR LOWER(COALESCE(narrative,'')) LIKE '%root cause not%'
|
|
24
|
+
OR LOWER(COALESCE(narrative,'')) LIKE '%still fail%'
|
|
25
|
+
OR LOWER(COALESCE(narrative,'')) LIKE '%errors persisted%'
|
|
26
|
+
OR LOWER(COALESCE(narrative,'')) LIKE '%persisted on retry%'
|
|
27
|
+
)`;
|
|
28
|
+
|
|
29
|
+
const windowRow = db.prepare(`
|
|
30
|
+
SELECT
|
|
31
|
+
COUNT(*) as total,
|
|
32
|
+
SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson,
|
|
33
|
+
SUM(CASE WHEN ${lowSignalIsMatchExpr} THEN 1 ELSE 0 END) as low_signal,
|
|
34
|
+
SUM(CASE WHEN type = 'bugfix' THEN 1 ELSE 0 END) as bugfix_total,
|
|
35
|
+
SUM(CASE WHEN type = 'bugfix' AND ${unresolvedNarrativeExpr} THEN 1 ELSE 0 END) as bugfix_unresolved
|
|
36
|
+
FROM observations
|
|
37
|
+
WHERE created_at_epoch >= ? ${projectFilter}
|
|
38
|
+
`).get(cutoff, ...baseParams);
|
|
39
|
+
|
|
40
|
+
const allTimeRow = db.prepare(`
|
|
41
|
+
SELECT
|
|
42
|
+
COUNT(*) as total,
|
|
43
|
+
SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson,
|
|
44
|
+
SUM(CASE WHEN ${lowSignalIsMatchExpr} THEN 1 ELSE 0 END) as low_signal
|
|
45
|
+
FROM observations
|
|
46
|
+
WHERE 1=1 ${projectFilter}
|
|
47
|
+
`).get(...baseParams);
|
|
48
|
+
|
|
49
|
+
const typeRows = db.prepare(`
|
|
50
|
+
SELECT
|
|
51
|
+
type,
|
|
52
|
+
COUNT(*) as total,
|
|
53
|
+
SUM(CASE WHEN COALESCE(access_count, 0) > 0 THEN 1 ELSE 0 END) as accessed,
|
|
54
|
+
SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson
|
|
55
|
+
FROM observations
|
|
56
|
+
WHERE created_at_epoch >= ? ${projectFilter}
|
|
57
|
+
GROUP BY type
|
|
58
|
+
ORDER BY total DESC
|
|
59
|
+
`).all(cutoff, ...baseParams);
|
|
60
|
+
|
|
61
|
+
const topLessons = db.prepare(`
|
|
62
|
+
SELECT id, type, title, lesson_learned, COALESCE(access_count, 0) as ac
|
|
63
|
+
FROM observations
|
|
64
|
+
WHERE lesson_learned IS NOT NULL AND lesson_learned != ''
|
|
65
|
+
AND COALESCE(access_count, 0) > 0
|
|
66
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
67
|
+
${projectFilter}
|
|
68
|
+
ORDER BY ac DESC
|
|
69
|
+
LIMIT 5
|
|
70
|
+
`).all(...baseParams);
|
|
71
|
+
|
|
72
|
+
return { windowRow, allTimeRow, typeRows, topLessons, project, days };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatQualityReport(data) {
|
|
76
|
+
const { windowRow, allTimeRow, typeRows, topLessons, project, days } = data;
|
|
77
|
+
const pct = (n, d) => d > 0 ? (100 * n / d).toFixed(1) : '0.0';
|
|
78
|
+
const scope = project ? ` — ${project}` : '';
|
|
79
|
+
const lines = [];
|
|
80
|
+
lines.push(`[mem] Quality snapshot${scope} — window: ${days}d`);
|
|
81
|
+
lines.push('────────────────────────────────────────────────────');
|
|
82
|
+
lines.push(` Writes (${days}d): ${windowRow.total} observations`);
|
|
83
|
+
|
|
84
|
+
const lessonPct = pct(windowRow.with_lesson, windowRow.total);
|
|
85
|
+
const allLessonPct = pct(allTimeRow.with_lesson, allTimeRow.total);
|
|
86
|
+
lines.push(` Lesson rate: ${windowRow.with_lesson} / ${windowRow.total} (${lessonPct}%) [all-time: ${allTimeRow.with_lesson} / ${allTimeRow.total} = ${allLessonPct}%]`);
|
|
87
|
+
|
|
88
|
+
const noisePct = pct(windowRow.low_signal, windowRow.total);
|
|
89
|
+
const allNoisePct = pct(allTimeRow.low_signal, allTimeRow.total);
|
|
90
|
+
lines.push(` LOW_SIGNAL: ${windowRow.low_signal} / ${windowRow.total} (${noisePct}%) [all-time: ${allTimeRow.low_signal} / ${allTimeRow.total} = ${allNoisePct}%]`);
|
|
91
|
+
|
|
92
|
+
if (windowRow.bugfix_total > 0) {
|
|
93
|
+
const unresolvedPct = pct(windowRow.bugfix_unresolved, windowRow.bugfix_total);
|
|
94
|
+
lines.push(` Unresolved bugfix: ${windowRow.bugfix_unresolved} / ${windowRow.bugfix_total} (${unresolvedPct}%) [investigation-only narratives — should trend ↓ with R-6 manual-save contract]`);
|
|
95
|
+
}
|
|
96
|
+
lines.push('');
|
|
97
|
+
|
|
98
|
+
if (typeRows.length > 0) {
|
|
99
|
+
lines.push(` Type breakdown (${days}d):`);
|
|
100
|
+
for (const r of typeRows) {
|
|
101
|
+
const hit = pct(r.accessed, r.total);
|
|
102
|
+
const lp = pct(r.with_lesson, r.total);
|
|
103
|
+
const typeLabel = r.type.padEnd(10);
|
|
104
|
+
lines.push(` ${typeLabel}${String(r.total).padStart(5)} hit ${hit.padStart(5)}% lesson ${lp.padStart(5)}%`);
|
|
105
|
+
}
|
|
106
|
+
lines.push('');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (topLessons.length > 0) {
|
|
110
|
+
lines.push(' Top accessed lessons (all-time):');
|
|
111
|
+
for (const l of topLessons) {
|
|
112
|
+
const t = truncate(l.lesson_learned, 80);
|
|
113
|
+
lines.push(` #${l.id} [${l.type}] (${l.ac}x) ${t}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// R-2 watchdog — format matches historical cmdStats for test stability
|
|
119
|
+
const lessonNum = parseFloat(lessonPct);
|
|
120
|
+
const noiseNum = parseFloat(noisePct);
|
|
121
|
+
const lessonGap = (lessonNum - 15).toFixed(1);
|
|
122
|
+
const noiseGap = (noiseNum - 30).toFixed(1);
|
|
123
|
+
const lessonStatus = lessonNum >= 15 ? '✅' : '🔴';
|
|
124
|
+
const noiseStatus = noiseNum <= 30 ? '✅' : '🔴';
|
|
125
|
+
lines.push(' Targets (R-2 watchdog):');
|
|
126
|
+
lines.push(` ${lessonStatus} Lesson rate ≥ 15% → currently ${lessonPct}% (gap ${lessonGap >= 0 ? '+' : ''}${lessonGap}pp)`);
|
|
127
|
+
lines.push(` ${noiseStatus} LOW_SIGNAL ≤ 30% → currently ${noisePct}% (gap ${noiseGap >= 0 ? '+' : ''}${noiseGap}pp)`);
|
|
128
|
+
|
|
129
|
+
return lines.join('\n');
|
|
130
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -902,132 +902,17 @@ function cmdSave(db, args) {
|
|
|
902
902
|
// Targets (aspirational, not enforced):
|
|
903
903
|
// - Lesson rate ≥ 15% (current baseline ~4.4%)
|
|
904
904
|
// - LOW_SIGNAL rate ≤ 30% (current baseline ~49.4%)
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
// that flips the sign so we count titles that DO match the LOW_SIGNAL regex.
|
|
913
|
-
const lowSignalIsMatchExpr = `NOT ${notLowSignalTitleClause('')}`;
|
|
914
|
-
|
|
915
|
-
// Unresolved-bugfix detection: narrative-text proxies for "investigation in progress,
|
|
916
|
-
// never reached a fix". Heuristic — false positives possible (e.g. a real lesson noting
|
|
917
|
-
// "the bug persists in legacy clients"), but the directional signal is what we care about.
|
|
918
|
-
// R-7 micro-experiment surfaced this pollution: ~3/5 of randomly-sampled bugfix narratives
|
|
919
|
-
// explicitly ended with "root cause not yet identified".
|
|
920
|
-
const unresolvedNarrativeExpr = `(
|
|
921
|
-
LOWER(COALESCE(narrative,'')) LIKE '%not yet identified%'
|
|
922
|
-
OR LOWER(COALESCE(narrative,'')) LIKE '%not yet resolved%'
|
|
923
|
-
OR LOWER(COALESCE(narrative,'')) LIKE '%not yet fixed%'
|
|
924
|
-
OR LOWER(COALESCE(narrative,'')) LIKE '%root cause not%'
|
|
925
|
-
OR LOWER(COALESCE(narrative,'')) LIKE '%still fail%'
|
|
926
|
-
OR LOWER(COALESCE(narrative,'')) LIKE '%errors persisted%'
|
|
927
|
-
OR LOWER(COALESCE(narrative,'')) LIKE '%persisted on retry%'
|
|
928
|
-
)`;
|
|
929
|
-
|
|
930
|
-
// In-window aggregates
|
|
931
|
-
const windowRow = db.prepare(`
|
|
932
|
-
SELECT
|
|
933
|
-
COUNT(*) as total,
|
|
934
|
-
SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson,
|
|
935
|
-
SUM(CASE WHEN ${lowSignalIsMatchExpr} THEN 1 ELSE 0 END) as low_signal,
|
|
936
|
-
SUM(CASE WHEN type = 'bugfix' THEN 1 ELSE 0 END) as bugfix_total,
|
|
937
|
-
SUM(CASE WHEN type = 'bugfix' AND ${unresolvedNarrativeExpr} THEN 1 ELSE 0 END) as bugfix_unresolved
|
|
938
|
-
FROM observations
|
|
939
|
-
WHERE created_at_epoch >= ? ${projectFilter}
|
|
940
|
-
`).get(cutoff, ...baseParams);
|
|
941
|
-
|
|
942
|
-
// All-time aggregates (context for recent numbers)
|
|
943
|
-
const allTimeRow = db.prepare(`
|
|
944
|
-
SELECT
|
|
945
|
-
COUNT(*) as total,
|
|
946
|
-
SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson,
|
|
947
|
-
SUM(CASE WHEN ${lowSignalIsMatchExpr} THEN 1 ELSE 0 END) as low_signal
|
|
948
|
-
FROM observations
|
|
949
|
-
WHERE 1=1 ${projectFilter}
|
|
950
|
-
`).get(...baseParams);
|
|
951
|
-
|
|
952
|
-
// Per-type: count, hit rate (access_count > 0), lesson rate
|
|
953
|
-
const typeRows = db.prepare(`
|
|
954
|
-
SELECT
|
|
955
|
-
type,
|
|
956
|
-
COUNT(*) as total,
|
|
957
|
-
SUM(CASE WHEN COALESCE(access_count, 0) > 0 THEN 1 ELSE 0 END) as accessed,
|
|
958
|
-
SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson
|
|
959
|
-
FROM observations
|
|
960
|
-
WHERE created_at_epoch >= ? ${projectFilter}
|
|
961
|
-
GROUP BY type
|
|
962
|
-
ORDER BY total DESC
|
|
963
|
-
`).all(cutoff, ...baseParams);
|
|
964
|
-
|
|
965
|
-
// Top-5 most-accessed lessons (all-time, this project scope)
|
|
966
|
-
const topLessons = db.prepare(`
|
|
967
|
-
SELECT id, type, title, lesson_learned, COALESCE(access_count, 0) as ac
|
|
968
|
-
FROM observations
|
|
969
|
-
WHERE lesson_learned IS NOT NULL AND lesson_learned != ''
|
|
970
|
-
AND COALESCE(access_count, 0) > 0
|
|
971
|
-
AND COALESCE(compressed_into, 0) = 0
|
|
972
|
-
${projectFilter}
|
|
973
|
-
ORDER BY ac DESC
|
|
974
|
-
LIMIT 5
|
|
975
|
-
`).all(...baseParams);
|
|
976
|
-
|
|
977
|
-
const pct = (n, d) => d > 0 ? (100 * n / d).toFixed(1) : '0.0';
|
|
978
|
-
const scope = project ? ` — ${project}` : '';
|
|
979
|
-
out(`[mem] Quality snapshot${scope} — window: ${days}d`);
|
|
980
|
-
out('────────────────────────────────────────────────────');
|
|
981
|
-
out(` Writes (${days}d): ${windowRow.total} observations`);
|
|
982
|
-
|
|
983
|
-
const lessonPct = pct(windowRow.with_lesson, windowRow.total);
|
|
984
|
-
const allLessonPct = pct(allTimeRow.with_lesson, allTimeRow.total);
|
|
985
|
-
out(` Lesson rate: ${windowRow.with_lesson} / ${windowRow.total} (${lessonPct}%) [all-time: ${allTimeRow.with_lesson} / ${allTimeRow.total} = ${allLessonPct}%]`);
|
|
986
|
-
|
|
987
|
-
const noisePct = pct(windowRow.low_signal, windowRow.total);
|
|
988
|
-
const allNoisePct = pct(allTimeRow.low_signal, allTimeRow.total);
|
|
989
|
-
out(` LOW_SIGNAL: ${windowRow.low_signal} / ${windowRow.total} (${noisePct}%) [all-time: ${allTimeRow.low_signal} / ${allTimeRow.total} = ${allNoisePct}%]`);
|
|
990
|
-
|
|
991
|
-
if (windowRow.bugfix_total > 0) {
|
|
992
|
-
const unresolvedPct = pct(windowRow.bugfix_unresolved, windowRow.bugfix_total);
|
|
993
|
-
out(` Unresolved bugfix: ${windowRow.bugfix_unresolved} / ${windowRow.bugfix_total} (${unresolvedPct}%) [investigation-only narratives — should trend ↓ with R-6 manual-save contract]`);
|
|
994
|
-
}
|
|
995
|
-
out('');
|
|
996
|
-
|
|
997
|
-
if (typeRows.length > 0) {
|
|
998
|
-
out(` Type breakdown (${days}d):`);
|
|
999
|
-
for (const r of typeRows) {
|
|
1000
|
-
const hit = pct(r.accessed, r.total);
|
|
1001
|
-
const lp = pct(r.with_lesson, r.total);
|
|
1002
|
-
const typeLabel = r.type.padEnd(10);
|
|
1003
|
-
// padStart(5) on count so rows align up to 5-digit totals (99999).
|
|
1004
|
-
out(` ${typeLabel}${String(r.total).padStart(5)} hit ${hit.padStart(5)}% lesson ${lp.padStart(5)}%`);
|
|
1005
|
-
}
|
|
1006
|
-
out('');
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
if (topLessons.length > 0) {
|
|
1010
|
-
out(' Top accessed lessons (all-time):');
|
|
1011
|
-
for (const l of topLessons) {
|
|
1012
|
-
const t = truncate(l.lesson_learned, 80);
|
|
1013
|
-
out(` #${l.id} [${l.type}] (${l.ac}x) ${t}`);
|
|
1014
|
-
}
|
|
1015
|
-
out('');
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
// R-2 watchdog — explicit targets make progress legible.
|
|
1019
|
-
const lessonNum = parseFloat(lessonPct);
|
|
1020
|
-
const noiseNum = parseFloat(noisePct);
|
|
1021
|
-
const lessonGap = (lessonNum - 15).toFixed(1);
|
|
1022
|
-
const noiseGap = (noiseNum - 30).toFixed(1);
|
|
1023
|
-
const lessonStatus = lessonNum >= 15 ? '✅' : '🔴';
|
|
1024
|
-
const noiseStatus = noiseNum <= 30 ? '✅' : '🔴';
|
|
1025
|
-
out(' Targets (R-2 watchdog):');
|
|
1026
|
-
out(` ${lessonStatus} Lesson rate ≥ 15% → currently ${lessonPct}% (gap ${lessonGap >= 0 ? '+' : ''}${lessonGap}pp)`);
|
|
1027
|
-
out(` ${noiseStatus} LOW_SIGNAL ≤ 30% → currently ${noisePct}% (gap ${noiseGap >= 0 ? '+' : ''}${noiseGap}pp)`);
|
|
905
|
+
// Batch A CLI↔MCP alignment: CLI `stats --quality` and MCP `mem_stats({quality:true})`
|
|
906
|
+
// share the same computation + formatting via lib/stats-quality.mjs. This wrapper
|
|
907
|
+
// keeps the cmdStats call-site unchanged (stays sync-compatible) by delegating
|
|
908
|
+
// to a dynamic import + sync function chain inside an async caller.
|
|
909
|
+
async function renderQualityReport(db, { project, days }) {
|
|
910
|
+
const { computeQualityStats, formatQualityReport } = await import('./lib/stats-quality.mjs');
|
|
911
|
+
out(formatQualityReport(computeQualityStats(db, { project, days })));
|
|
1028
912
|
}
|
|
1029
913
|
|
|
1030
|
-
|
|
914
|
+
|
|
915
|
+
async function cmdStats(db, args) {
|
|
1031
916
|
const { flags } = parseArgs(args);
|
|
1032
917
|
const project = flags.project ? resolveProject(db, flags.project) : null;
|
|
1033
918
|
const days = parseInt(flags.days, 10) || 30;
|
|
@@ -1036,7 +921,7 @@ function cmdStats(db, args) {
|
|
|
1036
921
|
// the baseline metric dashboard for the future Haiku prompt A/B test.
|
|
1037
922
|
const quality = flags.quality === true || flags.quality === 'true';
|
|
1038
923
|
if (quality) {
|
|
1039
|
-
renderQualityReport(db, { project, days });
|
|
924
|
+
await renderQualityReport(db, { project, days });
|
|
1040
925
|
return;
|
|
1041
926
|
}
|
|
1042
927
|
|
|
@@ -2025,6 +1910,7 @@ Commands:
|
|
|
2025
1910
|
--tier T Filter by tier (working|active|archive, observations only)
|
|
2026
1911
|
--sort S Sort: relevance (default), time, importance
|
|
2027
1912
|
--or Use OR instead of AND between search terms
|
|
1913
|
+
--include-noise Include hook-llm fallback titles ("Modified X", raw error logs)
|
|
2028
1914
|
|
|
2029
1915
|
recent [N] Show N most recent observations (default 10)
|
|
2030
1916
|
--project P Filter by project
|
|
@@ -2467,7 +2353,7 @@ export async function run(argv) {
|
|
|
2467
2353
|
case 'maintain': cmdMaintain(db, cmdArgs); break;
|
|
2468
2354
|
case 'optimize': await cmdOptimize(db, cmdArgs); break;
|
|
2469
2355
|
case 'fts-check': cmdFtsCheck(db, cmdArgs); break;
|
|
2470
|
-
case 'stats': cmdStats(db, cmdArgs); break;
|
|
2356
|
+
case 'stats': await cmdStats(db, cmdArgs); break;
|
|
2471
2357
|
case 'context': cmdContext(db, cmdArgs); break;
|
|
2472
2358
|
case 'browse': cmdBrowse(db, cmdArgs); break;
|
|
2473
2359
|
case 'registry': cmdRegistry(db, cmdArgs); break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.36.0",
|
|
4
4
|
"description": "Lightweight persistent memory system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -47,6 +47,10 @@
|
|
|
47
47
|
"lib/plan-reader.mjs",
|
|
48
48
|
"lib/startup-dashboard.mjs",
|
|
49
49
|
"lib/doctor-benchmark.mjs",
|
|
50
|
+
"lib/doctor-drift.mjs",
|
|
51
|
+
"lib/stats-quality.mjs",
|
|
52
|
+
"lib/low-signal-patterns.mjs",
|
|
53
|
+
"lib/citation-tracker.mjs",
|
|
50
54
|
"registry.mjs",
|
|
51
55
|
"registry-retriever.mjs",
|
|
52
56
|
"registry-indexer.mjs",
|
package/scoring-sql.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// scoring-sql.mjs — SQL constants for BM25 scoring and temporal decay.
|
|
2
2
|
// Extracted from utils.mjs for focused module boundaries.
|
|
3
3
|
|
|
4
|
+
import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
|
|
5
|
+
|
|
4
6
|
// ─── Type-Differentiated Recency Decay ──────────────────────────────────────
|
|
5
7
|
|
|
6
8
|
/** Recency half-life per observation type (in milliseconds) */
|
|
@@ -74,25 +76,10 @@ export const TYPE_QUALITY_CASE = `(
|
|
|
74
76
|
* @param {string} [alias='o'] Table alias for the observations row. Use '' for unqualified.
|
|
75
77
|
* @returns {string} SQL boolean expression (already parenthesized; safe to combine with AND/OR)
|
|
76
78
|
*/
|
|
79
|
+
// β refactor (#7877 applied): delegated to lib/low-signal-patterns.mjs.
|
|
80
|
+
// The SQL path (this), the regex path (utils.mjs::LOW_SIGNAL_TITLE), and the
|
|
81
|
+
// pre-tool-recall.js inline SQL now all derive from one authoritative
|
|
82
|
+
// pattern list. Previously hand-mirrored with "keep in sync" comments.
|
|
77
83
|
export function notLowSignalTitleClause(alias = 'o') {
|
|
78
|
-
|
|
79
|
-
// Bug #2 fix: replace `title != '(error)'` (exact match only) with
|
|
80
|
-
// `title NOT LIKE '%(error)'` (suffix match) so titles like
|
|
81
|
-
// "gh release list ... (error)" — produced when makeEntryDesc tags a failed
|
|
82
|
-
// tool invocation — are excluded too. The LIKE form subsumes the exact match.
|
|
83
|
-
// Keep in sync with LOW_SIGNAL_TITLE regex in utils.mjs.
|
|
84
|
-
return `(
|
|
85
|
-
${p}title NOT LIKE 'Modified %'
|
|
86
|
-
AND ${p}title NOT LIKE 'Worked on %'
|
|
87
|
-
AND ${p}title NOT LIKE 'Reviewed % files:%'
|
|
88
|
-
AND ${p}title NOT LIKE 'Error while working%'
|
|
89
|
-
AND ${p}title NOT LIKE 'Error in %'
|
|
90
|
-
AND ${p}title NOT LIKE 'Error: %'
|
|
91
|
-
AND ${p}title NOT LIKE '# %'
|
|
92
|
-
AND ${p}title NOT LIKE 'node %'
|
|
93
|
-
AND ${p}title NOT LIKE 'npm %'
|
|
94
|
-
AND ${p}title NOT LIKE 'npx %'
|
|
95
|
-
AND ${p}title NOT LIKE '(no description)%'
|
|
96
|
-
AND ${p}title NOT LIKE '%(error)'
|
|
97
|
-
)`;
|
|
84
|
+
return buildNotLowSignalSql(alias);
|
|
98
85
|
}
|
|
@@ -70,10 +70,11 @@ try {
|
|
|
70
70
|
// (notably sdscc) silently drop plain-text stdout from PreToolUse — the previous
|
|
71
71
|
// console.log() form would render on stock CC but no-op on those variants.
|
|
72
72
|
// Token budget: ~4 chars per token, 4000 token limit = 16000 chars.
|
|
73
|
+
const portablePath = resolvedPath.startsWith(homedir()) ? '~' + resolvedPath.slice(homedir().length) : resolvedPath;
|
|
73
74
|
let additionalContext;
|
|
74
75
|
if (content.length > 16000) {
|
|
75
76
|
const summary = content.slice(0, 800);
|
|
76
|
-
additionalContext = `<skill-bridge name="${row.name}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated.
|
|
77
|
+
additionalContext = `<skill-bridge name="${row.name}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Read("${portablePath}") to load full content.`;
|
|
77
78
|
} else {
|
|
78
79
|
additionalContext = `<skill-bridge name="${row.name}" source="managed">\n${content}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
|
|
79
80
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// claude-mem-lite: PreToolUse file recall — injects lessons before Edit/Write
|
|
3
|
-
// Lightweight standalone (~30ms): only imports better-sqlite3, fs, path, os
|
|
3
|
+
// Lightweight standalone (~30ms): only imports better-sqlite3, fs, path, os,
|
|
4
|
+
// and the pure-data lib/low-signal-patterns.mjs (zero runtime deps, ~1ms overhead).
|
|
4
5
|
// Safety: readonly DB, exit 0 always, 3s timeout
|
|
5
6
|
|
|
6
7
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
|
|
7
8
|
import { basename, join } from 'path';
|
|
8
9
|
import { homedir } from 'os';
|
|
10
|
+
import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
|
|
9
11
|
|
|
10
12
|
// CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR env overrides allow tests and debug tools to
|
|
11
13
|
// point the hook at an isolated DB + cooldown dir without touching the user's real state.
|
|
@@ -149,11 +151,19 @@ try {
|
|
|
149
151
|
// fallback — decision/bugfix WITHOUT lesson add context noise to passive Reads
|
|
150
152
|
// where the agent isn't committed to a change). Edit/Write keep the wider
|
|
151
153
|
// filter for decision-point context.
|
|
154
|
+
// LOW_SIGNAL title patterns — auto-generated hook-llm fallback titles carry
|
|
155
|
+
// no actionable guidance. β refactor (#7877 applied): derived from
|
|
156
|
+
// lib/low-signal-patterns.mjs so this cold-start script, utils.mjs regex,
|
|
157
|
+
// and scoring-sql.mjs SQL share one authoritative list.
|
|
158
|
+
const notLowSignalSql = buildNotLowSignalSql('o');
|
|
159
|
+
// Edit: bugfix/decision without lesson_learned is admitted only when the
|
|
160
|
+
// title isn't a LOW_SIGNAL auto-fallback (those carry pipe-delimited raw
|
|
161
|
+
// output or filename-stubs, no guidance value for the about-to-Edit agent).
|
|
152
162
|
const typeFallback = isRead
|
|
153
163
|
? 'AND o.lesson_learned IS NOT NULL AND o.lesson_learned != \'\''
|
|
154
164
|
: `AND (
|
|
155
165
|
(o.lesson_learned IS NOT NULL AND o.lesson_learned != '')
|
|
156
|
-
OR o.type IN ('bugfix', 'decision')
|
|
166
|
+
OR (o.type IN ('bugfix', 'decision') AND ${notLowSignalSql})
|
|
157
167
|
)`;
|
|
158
168
|
const obsLimit = isRead ? 1 : 2;
|
|
159
169
|
const rows = db.prepare(`
|
package/server.mjs
CHANGED
|
@@ -105,9 +105,22 @@ const RECENCY_HALF_LIFE_MS = DEFAULT_DECAY_HALF_LIFE_MS;
|
|
|
105
105
|
|
|
106
106
|
// ─── MCP Server ─────────────────────────────────────────────────────────────
|
|
107
107
|
|
|
108
|
+
// Emit one-line instructions-mode trace on stderr so debugging the "why did
|
|
109
|
+
// the server send BASE instead of BASE+VERBOSE?" path doesn't require reading
|
|
110
|
+
// three files (server.mjs → hook-shared.mjs → memdir.mjs). CLAUDE_MEM_QUIET_TRACE=0
|
|
111
|
+
// opts out. stderr doesn't pollute the MCP stdio protocol channel.
|
|
112
|
+
const _quiet = effectiveQuiet();
|
|
113
|
+
if (process.env.CLAUDE_MEM_QUIET_TRACE !== '0') {
|
|
114
|
+
const reason = process.env.MEM_QUIET_HOOKS === '1'
|
|
115
|
+
? 'env:MEM_QUIET_HOOKS=1'
|
|
116
|
+
: _quiet ? 'adopted:MEMORY.md-sentinel' : 'none';
|
|
117
|
+
const mode = _quiet ? 'BASE' : 'BASE+VERBOSE';
|
|
118
|
+
process.stderr.write(`[mem] instructions: ${mode} reason=${reason}\n`);
|
|
119
|
+
}
|
|
120
|
+
|
|
108
121
|
const server = new McpServer(
|
|
109
122
|
{ name: 'claude-mem-lite', version: PKG_VERSION },
|
|
110
|
-
{ instructions: buildServerInstructions(
|
|
123
|
+
{ instructions: buildServerInstructions(_quiet) },
|
|
111
124
|
);
|
|
112
125
|
|
|
113
126
|
// Track MCP request activity for idle-time cleanup (see idle timer below)
|
|
@@ -558,7 +571,13 @@ server.registerTool(
|
|
|
558
571
|
if (args.project) args = { ...args, project: resolveProject(args.project) };
|
|
559
572
|
const limit = args.limit ?? 20;
|
|
560
573
|
const offset = args.offset ?? 0;
|
|
561
|
-
|
|
574
|
+
// args.or (Batch A CLI↔MCP alignment): force OR from start, matching
|
|
575
|
+
// CLI `search --or`. The default path still does AND with OR-fallback
|
|
576
|
+
// inside searchObservations when AND returns 0.
|
|
577
|
+
let ftsQuery = sanitizeFtsQuery(args.query);
|
|
578
|
+
if (ftsQuery && args.or) {
|
|
579
|
+
ftsQuery = relaxFtsQueryToOr(ftsQuery) || ftsQuery;
|
|
580
|
+
}
|
|
562
581
|
const searchType = args.type;
|
|
563
582
|
const currentProject = inferProject();
|
|
564
583
|
|
|
@@ -1083,6 +1102,16 @@ server.registerTool(
|
|
|
1083
1102
|
safeHandler(async (args) => {
|
|
1084
1103
|
if (args.project) args = { ...args, project: resolveProject(args.project) };
|
|
1085
1104
|
const days = args.days ?? 30;
|
|
1105
|
+
|
|
1106
|
+
// Batch A CLI↔MCP alignment: quality:true → quality dashboard (lesson
|
|
1107
|
+
// rate, LOW_SIGNAL rate, per-type hit/lesson %, top lessons, R-2 watchdog).
|
|
1108
|
+
// Same computation + format as CLI `stats --quality` via lib/stats-quality.mjs.
|
|
1109
|
+
if (args.quality) {
|
|
1110
|
+
const { computeQualityStats, formatQualityReport } = await import('./lib/stats-quality.mjs');
|
|
1111
|
+
const data = computeQualityStats(db, { project: args.project, days });
|
|
1112
|
+
return { content: [{ type: 'text', text: formatQualityReport(data) }] };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1086
1115
|
const cutoff = Date.now() - days * 86400000;
|
|
1087
1116
|
const projectFilter = args.project ? 'AND project = ?' : '';
|
|
1088
1117
|
const baseParams = args.project ? [args.project] : [];
|
package/source-files.mjs
CHANGED
|
@@ -34,6 +34,10 @@ export const SOURCE_FILES = [
|
|
|
34
34
|
'lib/git-state.mjs',
|
|
35
35
|
'lib/startup-dashboard.mjs',
|
|
36
36
|
'lib/doctor-benchmark.mjs',
|
|
37
|
+
'lib/doctor-drift.mjs',
|
|
38
|
+
'lib/stats-quality.mjs',
|
|
39
|
+
'lib/low-signal-patterns.mjs',
|
|
40
|
+
'lib/citation-tracker.mjs',
|
|
37
41
|
// v2.32 invited-memory: memdir primitives + adopt/unadopt CLI
|
|
38
42
|
'memdir.mjs',
|
|
39
43
|
'adopt-content.mjs',
|
package/tool-schemas.mjs
CHANGED
|
@@ -42,6 +42,7 @@ export const memSearchSchema = {
|
|
|
42
42
|
offset: coerceInt.pipe(z.number().int().min(0)).optional().describe('Offset for pagination'),
|
|
43
43
|
sort: z.enum(['relevance', 'time', 'importance']).optional().describe('Sort order: relevance (default, BM25), time (newest first), importance (highest first)'),
|
|
44
44
|
include_noise: z.boolean().optional().describe('Include hook-llm fallback titles ("Modified X", "Worked on X", raw error logs) — hidden by default as they have ~3% access rate'),
|
|
45
|
+
or: coerceBool.optional().describe('Force OR semantics between query terms from the start (default: AND with automatic OR-fallback when AND returns 0). Aligns with CLI --or.'),
|
|
45
46
|
};
|
|
46
47
|
|
|
47
48
|
export const memRecentSchema = {
|
|
@@ -81,6 +82,7 @@ export const memSaveSchema = {
|
|
|
81
82
|
export const memStatsSchema = {
|
|
82
83
|
project: z.string().optional().describe('Filter by project'),
|
|
83
84
|
days: coerceInt.pipe(z.number().int().min(1).max(365)).optional().describe('Look back N days (default 30)'),
|
|
85
|
+
quality: coerceBool.optional().describe('Return quality dashboard (lesson rate, LOW_SIGNAL rate, per-type hit/lesson %, top-accessed lessons, R-2 watchdog) instead of default stats. Aligns with CLI --quality.'),
|
|
84
86
|
};
|
|
85
87
|
|
|
86
88
|
export const memCompressSchema = {
|
package/utils.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { basename, dirname, resolve, sep } from 'path';
|
|
6
6
|
import { execSync } from 'child_process';
|
|
7
|
+
import { buildLowSignalRegex } from './lib/low-signal-patterns.mjs';
|
|
7
8
|
|
|
8
9
|
// ─── Re-exports from extracted modules ──────────────────────────────────────
|
|
9
10
|
// Backward compatibility: all consumers import from utils.mjs
|
|
@@ -94,8 +95,12 @@ export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']);
|
|
|
94
95
|
// 2. \(error\)$ — title ends with '(error)' (Bug #2 fix: previously this was
|
|
95
96
|
// inside the prefix group with a meaningless $, so only the exact title '(error)' matched.
|
|
96
97
|
// Tool-fragment titles like 'gh release list ... (error)' leaked through.)
|
|
97
|
-
//
|
|
98
|
-
|
|
98
|
+
//
|
|
99
|
+
// β refactor (#7877 applied): derived from lib/low-signal-patterns.mjs so the
|
|
100
|
+
// regex path (this) and the SQL NOT LIKE path (scoring-sql.mjs::notLowSignalTitleClause)
|
|
101
|
+
// and pre-tool-recall.js inline SQL all share one authoritative pattern list.
|
|
102
|
+
// Previously these were hand-mirrored via "keep in sync" comments.
|
|
103
|
+
export const LOW_SIGNAL_TITLE = buildLowSignalRegex();
|
|
99
104
|
|
|
100
105
|
export function computeRuleImportance(episode) {
|
|
101
106
|
let importance = 1;
|