claude-mem-lite 3.75.0 → 3.75.1
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/haiku-client.mjs +7 -58
- package/hook-episode.mjs +44 -20
- package/hook-memory.mjs +21 -4
- package/hook.mjs +81 -24
- package/lib/citation-tracker.mjs +52 -5
- package/lib/persist-reminder.mjs +8 -8
- package/lib/task-imperative.mjs +8 -1
- package/lib/ups-query.mjs +26 -0
- package/mem-cli.mjs +10 -5
- package/memdir.mjs +5 -1
- package/npm-shrinkwrap.json +3 -2
- package/package.json +3 -1
- package/schema.mjs +3 -1
- package/scripts/pre-agent-inject.sh +0 -0
- package/scripts/user-prompt-search.js +6 -15
- package/source-files.mjs +3 -0
- package/utils.mjs +13 -5
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.75.
|
|
13
|
+
"version": "3.75.1",
|
|
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.75.
|
|
3
|
+
"version": "3.75.1",
|
|
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/haiku-client.mjs
CHANGED
|
@@ -190,8 +190,14 @@ export async function callHaiku(prompt, { timeout = 10000, maxTokens = 500, temp
|
|
|
190
190
|
// out-of-credit key must not silently drop background summaries.
|
|
191
191
|
let primary = null;
|
|
192
192
|
try {
|
|
193
|
+
// callModelAPI, not a second copy of it: the two were byte-identical apart from
|
|
194
|
+
// where the model id came from (MODEL_MAP[model] vs resolveModel().api — the same
|
|
195
|
+
// value, since resolveModel().cli is a MODEL_MAP key) and a hardcoded 'haiku-api'
|
|
196
|
+
// log label that lied under CLAUDE_MEM_MODEL=sonnet. Two copies of an HTTP client
|
|
197
|
+
// means every proxy fix has to land twice, on the path where missing the proxy is
|
|
198
|
+
// the difference between 1.4s and 13.5s.
|
|
193
199
|
primary = mode === 'api'
|
|
194
|
-
? await
|
|
200
|
+
? await callModelAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature })
|
|
195
201
|
: await callOpenRouterAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature });
|
|
196
202
|
} catch (e) {
|
|
197
203
|
debugCatch(e, `callHaiku:${mode}`);
|
|
@@ -683,63 +689,6 @@ export async function callModelCLIAsync(prompt, model, { timeout }) {
|
|
|
683
689
|
return second.result;
|
|
684
690
|
}
|
|
685
691
|
|
|
686
|
-
// ─── API Mode ────────────────────────────────────────────────────────────────
|
|
687
|
-
|
|
688
|
-
async function callHaikuAPI(prompt, { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE }) {
|
|
689
|
-
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
690
|
-
if (!apiKey) return null;
|
|
691
|
-
|
|
692
|
-
const { api: modelId } = resolveModel();
|
|
693
|
-
const controller = new AbortController();
|
|
694
|
-
const timer = setTimeout(() => controller.abort(), timeout);
|
|
695
|
-
|
|
696
|
-
try {
|
|
697
|
-
const { system, user } = splitPrompt(prompt);
|
|
698
|
-
const body = {
|
|
699
|
-
model: modelId,
|
|
700
|
-
max_tokens: maxTokens,
|
|
701
|
-
temperature,
|
|
702
|
-
messages: [{ role: 'user', content: user }],
|
|
703
|
-
};
|
|
704
|
-
// See callModelAPI: cache_control on the constant system slot.
|
|
705
|
-
if (system) {
|
|
706
|
-
body.system = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }];
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// Proxy-aware, same as the OpenRouter site below. Missing it here meant the
|
|
710
|
-
// ANTHROPIC_API_KEY paths were the one keyed provider still doing a bare
|
|
711
|
-
// fetch — a silent outage behind a proxy, and one the new doctor check would
|
|
712
|
-
// have certified as healthy because it probes the hop this code was ASSUMED
|
|
713
|
-
// to use. (pre-tag review SHOULD-FIX 3)
|
|
714
|
-
const apiUrl = 'https://api.anthropic.com/v1/messages';
|
|
715
|
-
const apiHeaders = {
|
|
716
|
-
'Content-Type': 'application/json',
|
|
717
|
-
'x-api-key': apiKey,
|
|
718
|
-
'anthropic-version': '2023-06-01',
|
|
719
|
-
};
|
|
720
|
-
const apiProxy = httpConnectProxyFor(apiUrl);
|
|
721
|
-
const res = apiProxy
|
|
722
|
-
? await postViaConnectProxy(apiProxy, apiUrl, { headers: apiHeaders, body: JSON.stringify(body), timeout })
|
|
723
|
-
: await fetch(apiUrl, {
|
|
724
|
-
method: 'POST',
|
|
725
|
-
headers: apiHeaders,
|
|
726
|
-
body: JSON.stringify(body),
|
|
727
|
-
signal: controller.signal,
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
if (!res.ok) {
|
|
731
|
-
debugLog('WARN', 'haiku-api', `HTTP ${res.status}`);
|
|
732
|
-
return null;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
const data = await res.json();
|
|
736
|
-
const text = data.content?.[0]?.text;
|
|
737
|
-
return text ? { text } : null;
|
|
738
|
-
} finally {
|
|
739
|
-
clearTimeout(timer);
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
692
|
// ─── OpenRouter Mode ─────────────────────────────────────────────────────────
|
|
744
693
|
|
|
745
694
|
// OpenRouter exposes an OpenAI-compatible chat-completions API (NOT the
|
package/hook-episode.mjs
CHANGED
|
@@ -246,37 +246,61 @@ export function mergePendingEntries(episode) {
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/** Rule 4's threshold — 8+ Read/Grep entries read as investigation. */
|
|
250
|
+
const RESEARCH_ENTRY_THRESHOLD = 8;
|
|
251
|
+
|
|
249
252
|
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
253
|
+
* The significance decision WITH its reasoning, for instrumentation.
|
|
254
|
+
* `episodeHasSignificantContent` is the boolean face of this same body, so the meter
|
|
255
|
+
* and the decision cannot drift (audit 2026-08-22 P2-14).
|
|
256
|
+
*
|
|
257
|
+
* `grepDecisive` answers the one question the "move Grep into the bash skip list"
|
|
258
|
+
* decision is blocked on: would this episode still have been kept without its Grep
|
|
259
|
+
* entries? It is true ONLY when rule 4 decided AND the non-Grep entries alone fall
|
|
260
|
+
* short — an edit-driven episode that happens to contain Greps is not evidence that
|
|
261
|
+
* Grep carries research episodes.
|
|
262
|
+
*
|
|
263
|
+
* @param {object} episode
|
|
264
|
+
* @returns {{significant: boolean, rule: 1|2|3|4|null, readCount: number,
|
|
265
|
+
* grepCount: number, grepDecisive: boolean}}
|
|
255
266
|
*/
|
|
256
|
-
export function
|
|
267
|
+
export function explainSignificance(episode) {
|
|
268
|
+
const entries = episode?.entries || [];
|
|
269
|
+
const grepCount = entries.filter(e => e.tool === 'Grep').length;
|
|
270
|
+
const readCount = entries.filter(e => e.tool === 'Read' || e.tool === 'Grep').length;
|
|
271
|
+
const base = { readCount, grepCount, grepDecisive: false };
|
|
272
|
+
|
|
257
273
|
// 1. File edits → always significant (code changes matter)
|
|
258
|
-
|
|
259
|
-
if (hasEdits) return true;
|
|
274
|
+
if (entries.some(e => EDIT_TOOLS.has(e.tool))) return { ...base, significant: true, rule: 1 };
|
|
260
275
|
|
|
261
276
|
// 2. Test/build errors → significant (actionable failures)
|
|
262
277
|
// Plain bash errors without edits are noise (e.g. typos, exploration errors)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (hasTestOrBuildError) return true;
|
|
278
|
+
if (entries.some(e => e.tool === 'Bash' && e.isError && (e.bashSig?.isTest || e.bashSig?.isBuild))) {
|
|
279
|
+
return { ...base, significant: true, rule: 2 };
|
|
280
|
+
}
|
|
267
281
|
|
|
268
282
|
// 3. Important files touched (config, schema, security, migration)
|
|
269
283
|
// Checks episode.files (all touched files, including reads) — catches important-file investigation
|
|
270
|
-
const allFiles = episode
|
|
271
|
-
|
|
284
|
+
const allFiles = episode?.files || [];
|
|
285
|
+
if (allFiles.some(f =>
|
|
272
286
|
/\.(env|yml|yaml|toml|lock|sql|prisma|proto)$/.test(f) ||
|
|
273
287
|
/(config|schema|migration|auth|security)/i.test(f)
|
|
274
|
-
);
|
|
275
|
-
if (hasImportantFile) return true;
|
|
288
|
+
)) return { ...base, significant: true, rule: 3 };
|
|
276
289
|
|
|
277
290
|
// 4. Research pattern: reading many files indicates investigation
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return
|
|
291
|
+
if (readCount >= RESEARCH_ENTRY_THRESHOLD) {
|
|
292
|
+
return { ...base, significant: true, rule: 4, grepDecisive: readCount - grepCount < RESEARCH_ENTRY_THRESHOLD };
|
|
293
|
+
}
|
|
294
|
+
return { ...base, significant: false, rule: null };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Check if an episode has significant content worth processing with LLM.
|
|
299
|
+
* Significant = contains file edits, Bash errors, or a review/research pattern
|
|
300
|
+
* (8+ Read/Grep entries indicate investigation worth recording).
|
|
301
|
+
* @param {object} episode The episode to check
|
|
302
|
+
* @returns {boolean} true if the episode has significant content
|
|
303
|
+
*/
|
|
304
|
+
export function episodeHasSignificantContent(episode) {
|
|
305
|
+
return explainSignificance(episode).significant;
|
|
282
306
|
}
|
package/hook-memory.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// claude-mem-lite — Semantic Memory Injection
|
|
2
2
|
// Search past observations for relevant memories to inject as context at user-prompt time.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters, basenameAnySep } from './utils.mjs';
|
|
5
|
+
import { upsFtsQuery } from './lib/ups-query.mjs';
|
|
5
6
|
import { citeFactorJs, TYPE_QUALITY, TYPE_QUALITY_DEFAULT } from './scoring-sql.mjs';
|
|
6
7
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
7
8
|
import { recordMetric } from './lib/metrics.mjs';
|
|
@@ -192,7 +193,12 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
192
193
|
};
|
|
193
194
|
|
|
194
195
|
try {
|
|
195
|
-
|
|
196
|
+
// upsFtsQuery, not bare sanitizeFtsQuery: this is the SECOND hook UserPromptSubmit
|
|
197
|
+
// fires, and v3.75.0 capped only the first. This one is the worse half — its stdin
|
|
198
|
+
// ceiling is MAX_HOOK_STDIN_BYTES (256KB) against path A's 64KB, and nothing
|
|
199
|
+
// truncates between stdin and here. The caps are shared, not copied, so the two
|
|
200
|
+
// faces of one event cannot drift apart again.
|
|
201
|
+
const ftsQuery = upsFtsQuery(userPrompt);
|
|
196
202
|
if (!ftsQuery) return [];
|
|
197
203
|
|
|
198
204
|
const cutoff = Date.now() - MEMORY_LOOKBACK_MS;
|
|
@@ -243,7 +249,15 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
243
249
|
if (rows.length === 0) {
|
|
244
250
|
const orQuery = relaxFtsQueryToOr(ftsQuery);
|
|
245
251
|
if (orQuery && (queryIsCjkDominant || queryTokenCount <= orFallbackMaxTokens)) {
|
|
246
|
-
|
|
252
|
+
// debugCatch, not a bare swallow: this is the injection chain's LAST query, and
|
|
253
|
+
// an FTS5 fault here (corrupt index, malformed relaxed query) degrades to an
|
|
254
|
+
// EMPTY injection that reads exactly like "nothing matched" — invisible to
|
|
255
|
+
// stats and doctor alike. Still non-fatal; the prompt must go through.
|
|
256
|
+
// (The two bare catches further down, around the per-row access bumps, are
|
|
257
|
+
// deliberately left bare: they are write-path and per-row, so logging them would
|
|
258
|
+
// flood the debug stream on the same corruption this one reports once.)
|
|
259
|
+
try { rows = selectStmt.all(orQuery, project, cutoff); usedOrFallback = true; }
|
|
260
|
+
catch (e) { debugCatch(e, 'injectMemory:orFallback'); }
|
|
247
261
|
}
|
|
248
262
|
}
|
|
249
263
|
|
|
@@ -274,7 +288,10 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
274
288
|
if (crossRows.length === 0) {
|
|
275
289
|
const orQuery = relaxFtsQueryToOr(ftsQuery);
|
|
276
290
|
if (orQuery && (queryIsCjkDominant || queryTokenCount <= orFallbackMaxTokens)) {
|
|
277
|
-
|
|
291
|
+
// Same reasoning as the same-project OR fallback above: a fault here silently
|
|
292
|
+
// drops the cross-project half of the injection.
|
|
293
|
+
try { crossRows = crossStmt.all(orQuery, project, cutoff); crossUsedOr = true; }
|
|
294
|
+
catch (e) { debugCatch(e, 'injectMemory:crossOrFallback'); }
|
|
278
295
|
}
|
|
279
296
|
}
|
|
280
297
|
} catch (e) { debugCatch(e, 'crossProjectSearch'); }
|
package/hook.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
readEpisodeRaw, episodeFile,
|
|
34
34
|
acquireLock, releaseLock, readEpisode, writeEpisode,
|
|
35
35
|
createEpisode, addFileToEpisode, planEpisodeFlush,
|
|
36
|
-
writePendingEntry, mergePendingEntries, episodeHasSignificantContent,
|
|
36
|
+
writePendingEntry, mergePendingEntries, episodeHasSignificantContent, explainSignificance,
|
|
37
37
|
} from './hook-episode.mjs';
|
|
38
38
|
import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
|
|
39
39
|
import { entry as preCompactEntry } from './hook-precompact.mjs';
|
|
@@ -294,7 +294,21 @@ function flushEpisodeWithDb(db, episode, hookEventName) {
|
|
|
294
294
|
// suppresses the detached enrichment spawn (test determinism; sibling of
|
|
295
295
|
// CLAUDE_MEM_SKIP_COMPRESS / _OPTIMIZE) — the synchronous immediate obs still lands.
|
|
296
296
|
function flushEpisodeGroup(ep, db) {
|
|
297
|
-
const
|
|
297
|
+
const verdict = explainSignificance(ep);
|
|
298
|
+
const isSignificant = verdict.significant;
|
|
299
|
+
// Audit P2-14 instrument: moving Grep into the bash prefilter would save 85ms per Grep
|
|
300
|
+
// (91.2ms handoff vs 6.1ms for the already-skipped Read), but nothing records how many
|
|
301
|
+
// episodes are kept ONLY because of their Greps — and demoting what the product
|
|
302
|
+
// remembers on a deduction is how work disappears silently. This is that counter.
|
|
303
|
+
// Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
|
|
304
|
+
recordMetric(join(RUNTIME_DIR, '..'), {
|
|
305
|
+
event: 'episode_significance',
|
|
306
|
+
rule: verdict.rule,
|
|
307
|
+
significant: isSignificant,
|
|
308
|
+
readCount: verdict.readCount,
|
|
309
|
+
grepCount: verdict.grepCount,
|
|
310
|
+
grepDecisive: verdict.grepDecisive,
|
|
311
|
+
});
|
|
298
312
|
|
|
299
313
|
// Immediate save: rule-based observation for instant visibility; the LLM
|
|
300
314
|
// background worker upgrades title/narrative/importance later. `db` is
|
|
@@ -809,10 +823,13 @@ async function handleStop() {
|
|
|
809
823
|
} catch (e) { debugCatch(e, 'handleStop-citation-decay'); }
|
|
810
824
|
|
|
811
825
|
// Persist cite-recall ratio for the next SessionStart to surface as
|
|
812
|
-
// feedback.
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
826
|
+
// feedback. This block re-scans the transcript rather than threading the
|
|
827
|
+
// count through `extractCitationsFromTranscript`, so the bump path stays
|
|
828
|
+
// unchanged — and since P2-8 every scanner in it shares ONE parse via
|
|
829
|
+
// lib/transcript-scan.mjs, so "scan again" costs an array iteration, not a
|
|
830
|
+
// re-read. (The old wording, "cheap; the file is already in OS cache", was
|
|
831
|
+
// arguing the pre-memo case: the OS cache saved the read, never the parse,
|
|
832
|
+
// which was ~all of the cost.)
|
|
816
833
|
try {
|
|
817
834
|
const stats = computeCiteRecall(transcriptPath);
|
|
818
835
|
// B2 (v2.83.1): also persist the bugfix-shape nudge/save delta so
|
|
@@ -939,7 +956,48 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
|
|
|
939
956
|
})();
|
|
940
957
|
}
|
|
941
958
|
|
|
959
|
+
// Per-project 24h gate for the auto-compressible marking. Separate from the global
|
|
960
|
+
// maintain gate on purpose: the marking is project-scoped work that every project needs
|
|
961
|
+
// daily, while the pass it used to ride on (VACUUM snapshot + purge + decay + dedup) is
|
|
962
|
+
// whole-DB work that should happen once a day in total.
|
|
963
|
+
function markCompressibleGateFile(project) {
|
|
964
|
+
// Project names arrive already mangled by inferProject (`projects--mem`), but this is
|
|
965
|
+
// a filename, so do not trust that — one stray separator writes outside RUNTIME_DIR.
|
|
966
|
+
return join(RUNTIME_DIR, `last-mark-compressible-${String(project).replace(/[^A-Za-z0-9._-]/g, '-')}.json`);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function markAutoCompressibleIfDue(db, project) {
|
|
970
|
+
if (!project) {
|
|
971
|
+
// Every production spawn passes it (spawnBackground('auto-maintain', project)); a
|
|
972
|
+
// hand-run `hook.mjs auto-maintain` does not. Say so rather than skipping in
|
|
973
|
+
// silence — work vanishing without a word is this codebase's recurring shape.
|
|
974
|
+
debugLog('DEBUG', 'auto-maintain', 'no project argument — skipping auto-compress marking');
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const gate = markCompressibleGateFile(project);
|
|
978
|
+
try {
|
|
979
|
+
const last = JSON.parse(readFileSync(gate, 'utf8'));
|
|
980
|
+
if (Date.now() - last.epoch < 24 * 3600000) return;
|
|
981
|
+
} catch { /* no gate file → due */ }
|
|
982
|
+
try {
|
|
983
|
+
const marked = markAutoCompressible(db, project);
|
|
984
|
+
if (marked.aged > 0) debugLog('DEBUG', 'auto-maintain', `auto-compressed ${marked.aged} old observations`);
|
|
985
|
+
if (marked.noise > 0) debugLog('DEBUG', 'auto-maintain', `auto-compressed ${marked.noise} LOW_SIGNAL noise (7d window)`);
|
|
986
|
+
writeFileSync(gate, JSON.stringify({ epoch: Date.now() }));
|
|
987
|
+
} catch (e) { debugCatch(e, 'auto-maintain-mark-compressible'); }
|
|
988
|
+
}
|
|
989
|
+
|
|
942
990
|
function runSessionStartAutoMaintain(db, project) {
|
|
991
|
+
// The auto-compressible marking runs on its OWN per-project gate, before the global
|
|
992
|
+
// one below. v3.75.0 moved this work off SessionStart onto this worker (P2-11) and,
|
|
993
|
+
// by putting it inside the global `shouldMaintain` block, cut its coverage from
|
|
994
|
+
// "every project, every boot" to "one project per 24h" — RUNTIME_DIR is a single
|
|
995
|
+
// global directory, so whichever project wins the gate is the only one marked, and
|
|
996
|
+
// with N projects in rotation N-1 never get the 7-day noise pass. The move was right;
|
|
997
|
+
// the shared gate was not. Heavy work (VACUUM snapshot, purge, decay, dedup) stays on
|
|
998
|
+
// the global gate — that genuinely should run once a day, not once per project.
|
|
999
|
+
markAutoCompressibleIfDue(db, project);
|
|
1000
|
+
|
|
943
1001
|
// Auto-maintain: cleanup + decay + boost + purge, gated to once per 24h
|
|
944
1002
|
const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
|
|
945
1003
|
let shouldMaintain = true;
|
|
@@ -969,19 +1027,10 @@ function runSessionStartAutoMaintain(db, project) {
|
|
|
969
1027
|
// children (compressed_into dangling at a deleted id). purgeStale recovers them
|
|
970
1028
|
// first and caps at opCap. Schema has no marked_at_epoch, so retention anchors on
|
|
971
1029
|
// created_at_epoch: 30d marking gate + 7d grace = 37d.
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
|
|
976
|
-
const marked = markAutoCompressible(db, project);
|
|
977
|
-
if (marked.aged > 0) debugLog('DEBUG', 'auto-maintain', `auto-compressed ${marked.aged} old observations`);
|
|
978
|
-
if (marked.noise > 0) debugLog('DEBUG', 'auto-maintain', `auto-compressed ${marked.noise} LOW_SIGNAL noise (7d window)`);
|
|
979
|
-
} else {
|
|
980
|
-
// Every production spawn passes it (spawnBackground('auto-maintain', project)); a
|
|
981
|
-
// hand-run `hook.mjs auto-maintain` does not. Say so rather than skipping in
|
|
982
|
-
// silence — work vanishing without a word is this codebase's recurring shape.
|
|
983
|
-
debugLog('DEBUG', 'auto-maintain', 'no project argument — skipping auto-compress marking');
|
|
984
|
-
}
|
|
1030
|
+
// The marking itself already ran at the top of this function, on its own
|
|
1031
|
+
// per-project gate — it must not be conditioned on the global one (see
|
|
1032
|
+
// markAutoCompressibleIfDue). It still happens BEFORE the purge/decay ops below,
|
|
1033
|
+
// preserving the order the SessionStart transaction had relative to them.
|
|
985
1034
|
|
|
986
1035
|
const purged = purgeStale(db, mctx, Date.now() - 37 * DAY_MS);
|
|
987
1036
|
if (purged > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged} stale observations`);
|
|
@@ -1112,11 +1161,19 @@ function runSessionStartAutoMaintain(db, project) {
|
|
|
1112
1161
|
// a detached `auto-maintain` worker via spawnBackground so it never blocks interactive
|
|
1113
1162
|
// session start. The worker re-checks the same gate (idempotent) before doing the work.
|
|
1114
1163
|
function scheduleSessionStartAutoMaintain(project) {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1164
|
+
// TWO gates, either of which is enough to spawn. Checking only the global one is what
|
|
1165
|
+
// made the marking single-project in v3.75.0: in a multi-project rotation the global
|
|
1166
|
+
// stamp is already fresh by the time the second project boots, so its worker never
|
|
1167
|
+
// ran and its rows were never marked.
|
|
1168
|
+
const due = (file) => {
|
|
1169
|
+
try {
|
|
1170
|
+
const last = JSON.parse(readFileSync(file, 'utf8'));
|
|
1171
|
+
return Date.now() - last.epoch >= 24 * 3600000;
|
|
1172
|
+
} catch { return true; } // no gate file → due
|
|
1173
|
+
};
|
|
1174
|
+
const maintainDue = due(join(RUNTIME_DIR, 'last-auto-maintain.json'));
|
|
1175
|
+
const markingDue = Boolean(project) && due(markCompressibleGateFile(project));
|
|
1176
|
+
if (!maintainDue && !markingDue) return;
|
|
1120
1177
|
if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain', project);
|
|
1121
1178
|
}
|
|
1122
1179
|
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -15,6 +15,9 @@ import { join } from 'path';
|
|
|
15
15
|
import { debugCatch } from '../utils.mjs';
|
|
16
16
|
import { keyContextIdsFileName } from './injected-ids.mjs';
|
|
17
17
|
import { readTranscriptEntries } from './transcript-scan.mjs';
|
|
18
|
+
// The emitter's own prefix — see SURFACE_MATCHERS.task_imperative. Importing it rather
|
|
19
|
+
// than re-typing the framing is what keeps emit and extract from becoming two lists.
|
|
20
|
+
import { TASK_IMPERATIVE_PREFIX } from './task-imperative.mjs';
|
|
18
21
|
|
|
19
22
|
import { DAY_MS } from './time-constants.mjs';
|
|
20
23
|
// `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
|
|
@@ -231,15 +234,21 @@ const FYI_LINE_ID_RE = /^#(\d{1,7})\s/;
|
|
|
231
234
|
|
|
232
235
|
/**
|
|
233
236
|
* The injection FACES memory can reach the model through, as stored in
|
|
234
|
-
* `citation_surface_log.surface` (schema v45). The first
|
|
237
|
+
* `citation_surface_log.surface` (schema v45). The first five are
|
|
235
238
|
* query-conditioned — a row appears there because it MATCHED something — and
|
|
236
239
|
* are the ones that feed the citation-decay denominator via extractAllInjected.
|
|
237
240
|
* `keyctx` is the odd one out: an unconditional SessionStart render, recorded
|
|
238
241
|
* for VISIBILITY only and promotion-only in the decay loop (see
|
|
239
242
|
* extractInjectedFromKeyContext).
|
|
240
|
-
*
|
|
243
|
+
*
|
|
244
|
+
* `task_imperative` (v3.76) rides the SAME attachment as `ups` — one
|
|
245
|
+
* `hook.mjs user-prompt` invocation writes the `<memory-context>` block and then the
|
|
246
|
+
* imperative line — which is how it stayed unmetered since v3.23 while being a live
|
|
247
|
+
* injection: the `ups` matcher gates on `<memory-context` and collects only `- [` rows.
|
|
248
|
+
* The two faces therefore OVERLAP on attachments but never on ids.
|
|
249
|
+
* @type {ReadonlyArray<'pretool'|'ups'|'error_recall'|'fyi'|'task_imperative'|'keyctx'>}
|
|
241
250
|
*/
|
|
242
|
-
export const CITATION_SURFACES = ['pretool', 'ups', 'error_recall', 'fyi', 'keyctx'];
|
|
251
|
+
export const CITATION_SURFACES = ['pretool', 'ups', 'error_recall', 'fyi', 'task_imperative', 'keyctx'];
|
|
243
252
|
|
|
244
253
|
// Single source of truth for "which attachment belongs to which face, and how
|
|
245
254
|
// its ids are read off". Both the per-face extractors below AND the one-pass
|
|
@@ -307,6 +316,24 @@ const SURFACE_MATCHERS = {
|
|
|
307
316
|
}
|
|
308
317
|
},
|
|
309
318
|
},
|
|
319
|
+
task_imperative: {
|
|
320
|
+
// Same hook entry as `ups`, different row. Gating on the emitter's own exported
|
|
321
|
+
// prefix (not a copy of the wording) keeps the meter tied to the framing; gating on
|
|
322
|
+
// the COMMAND too means a transcript that merely quotes the framing — a review of
|
|
323
|
+
// this code, say — is not counted as an injection.
|
|
324
|
+
accepts: ({ command, text }) =>
|
|
325
|
+
command.includes(UPS_COMMAND_SUFFIX) && text.includes(TASK_IMPERATIVE_PREFIX),
|
|
326
|
+
collect: (text, add) => {
|
|
327
|
+
for (const line of text.split('\n')) {
|
|
328
|
+
if (!line.startsWith(TASK_IMPERATIVE_PREFIX)) continue;
|
|
329
|
+
// Trailing `(#NN)` is this lesson's own id; earlier ones are cross-references
|
|
330
|
+
// inside the body (#8850 — an inlined lesson body must not pollute the set).
|
|
331
|
+
const matches = [...line.matchAll(UPS_ID_RE)];
|
|
332
|
+
if (matches.length === 0) continue;
|
|
333
|
+
add(matches[matches.length - 1][1]);
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
},
|
|
310
337
|
};
|
|
311
338
|
|
|
312
339
|
// The query-conditioned faces, in citation_surface_log label order. keyctx is
|
|
@@ -452,19 +479,39 @@ export function extractAllInjected(transcriptPath, opts = {}) {
|
|
|
452
479
|
return unionSurfaces(extractInjectedBySurface(transcriptPath, opts));
|
|
453
480
|
}
|
|
454
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Faces that are METERED (a citation_surface_log row) but deliberately kept OUT of the
|
|
484
|
+
* citation-decay denominator, with the reason each is here. Membership is a decision,
|
|
485
|
+
* never a default: `unionSurfaces` still walks every SURFACE_MATCHERS key, and a test
|
|
486
|
+
* requires each face to be either in the union or listed here — so a face added later
|
|
487
|
+
* cannot slip out of the denominator by being forgotten, only by being argued for.
|
|
488
|
+
*
|
|
489
|
+
* - `task_imperative` (v3.76): metering it is the point of adding it — D#137/D#150/D#151
|
|
490
|
+
* are all blocked on not knowing this face's cite-rate. Widening the denominator at the
|
|
491
|
+
* same moment would change what gets demoted in every live install on upgrade, and it
|
|
492
|
+
* would do so on the face whose framing is itself the open question: if the imperative
|
|
493
|
+
* framing under-performs, the penalty lands on the LESSONS it carried (imperativePick
|
|
494
|
+
* selects high-value ones) rather than on the framing. Read the rate first, then decide.
|
|
495
|
+
* Removing an entry from this set is the one-line change that widens the denominator.
|
|
496
|
+
*/
|
|
497
|
+
const DECAY_EXCLUDED_SURFACES = new Set(['task_imperative']);
|
|
498
|
+
|
|
499
|
+
/** @type {ReadonlyArray<string>} faces that DO feed the decay denominator. */
|
|
500
|
+
export const DECAY_DENOMINATOR_SURFACES = ATTACHMENT_SURFACES.filter((f) => !DECAY_EXCLUDED_SURFACES.has(f));
|
|
501
|
+
|
|
455
502
|
/**
|
|
456
503
|
* Flatten a per-face breakdown into the single injected set the decay loop
|
|
457
504
|
* takes. Derived — NOT a second hand-maintained face list — so adding a face to
|
|
458
505
|
* SURFACE_MATCHERS automatically widens the denominator (v45; the pre-v45 union
|
|
459
506
|
* enumerated the faces a second time and that is exactly how a face goes
|
|
460
|
-
* unmetered).
|
|
507
|
+
* unmetered), unless that face is argued into DECAY_EXCLUDED_SURFACES above.
|
|
461
508
|
*
|
|
462
509
|
* @param {Record<string, Set<number>>} bySurface
|
|
463
510
|
* @returns {Set<number>}
|
|
464
511
|
*/
|
|
465
512
|
export function unionSurfaces(bySurface) {
|
|
466
513
|
const out = new Set();
|
|
467
|
-
for (const face of
|
|
514
|
+
for (const face of DECAY_DENOMINATOR_SURFACES) {
|
|
468
515
|
for (const id of bySurface?.[face] || []) out.add(id);
|
|
469
516
|
}
|
|
470
517
|
return out;
|
package/lib/persist-reminder.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// the context). Remind-only by design: false positives cost one line, so the
|
|
12
12
|
// signal list stays conservative and never auto-writes anything.
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { readTranscriptEntries } from './transcript-scan.mjs';
|
|
15
15
|
|
|
16
16
|
// Distinctive finalization word forms (CJK + EN). Deliberately NOT included:
|
|
17
17
|
// "方案 A 定" and bare "定" (too FP-prone), bare "ok/好" (noise). The list is
|
|
@@ -62,14 +62,14 @@ const MEMDIR_WRITE_RE = /[\\/]\.claude[\\/]projects[\\/][^\\/]+[\\/]memory[\\/][
|
|
|
62
62
|
* `save` / `defer add`) in the session transcript. 0 on missing/unreadable.
|
|
63
63
|
*/
|
|
64
64
|
export function countDeliberatePersistence(transcriptPath) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// Shares the Stop-time parse (lib/transcript-scan.mjs) rather than doing its own
|
|
66
|
+
// read+split+parse. It was the NINTH scanner of the same file and was missed when the
|
|
67
|
+
// other eight were collapsed — sitting two lines away from two that were migrated, so
|
|
68
|
+
// it was quietly charging a full re-parse (~20-25ms on a 5.7MB transcript) against a
|
|
69
|
+
// pass whose whole point was to stop doing that. Missing/unreadable paths still yield
|
|
70
|
+
// 0, because readTranscriptEntries returns [] for them.
|
|
68
71
|
let count = 0;
|
|
69
|
-
for (const
|
|
70
|
-
if (!line.trim()) continue;
|
|
71
|
-
let entry;
|
|
72
|
-
try { entry = JSON.parse(line); } catch { continue; }
|
|
72
|
+
for (const entry of readTranscriptEntries(transcriptPath)) {
|
|
73
73
|
if (entry.type !== 'assistant' && entry.message?.role !== 'assistant') continue;
|
|
74
74
|
const content = entry.message?.content;
|
|
75
75
|
if (!Array.isArray(content)) continue;
|
package/lib/task-imperative.mjs
CHANGED
|
@@ -12,11 +12,18 @@ import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
|
12
12
|
// dropped.
|
|
13
13
|
// Spec: docs/superpowers/specs/2026-06-29-task-imperative-memory-injection-design.md
|
|
14
14
|
|
|
15
|
+
// The emitted line's fixed head. Exported because lib/citation-tracker.mjs matches on
|
|
16
|
+
// it to meter this face: an extractor carrying its own copy of the framing is the exact
|
|
17
|
+
// shape that silenced Go panics in v3.74.0 (trigger and filter were two lists that were
|
|
18
|
+
// never the same list). One constant, both directions — change the framing and the
|
|
19
|
+
// meter follows, or nothing does.
|
|
20
|
+
export const TASK_IMPERATIVE_PREFIX = 'Memory — a past lesson applies to THIS task.';
|
|
21
|
+
|
|
15
22
|
export function formatTaskImperative(lesson, id) {
|
|
16
23
|
const body = neutralizeContextDelimiters(String(lesson || '').trim().replace(/\.$/, ''));
|
|
17
24
|
if (!body) return '';
|
|
18
25
|
const tag = (id === undefined || id === null || id === '') ? '' : ` (#${id})`;
|
|
19
|
-
return
|
|
26
|
+
return `${TASK_IMPERATIVE_PREFIX} You must: ${body}.${tag}`;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
// Subagent-dispatch framing. Subagents are memory-blind (plugin hooks don't fire
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// lib/ups-query.mjs — the ONE query-cap definition for the UserPromptSubmit event.
|
|
2
|
+
//
|
|
3
|
+
// That event fires two hooks: scripts/user-prompt-search.js (the FYI block) and
|
|
4
|
+
// `hook.mjs user-prompt` (the <memory-context> block). v3.75.0 capped the first and left
|
|
5
|
+
// the second building an uncapped query from the raw prompt — the guard-on-one-path shape
|
|
6
|
+
// this codebase pays for more than any other. Both faces now import from here, so the cap
|
|
7
|
+
// cannot be present on one and absent on the other.
|
|
8
|
+
//
|
|
9
|
+
// The caps bound what is COMPUTED, not what is read. The stdin guards upstream
|
|
10
|
+
// (MAX_UPS_PROMPT_BYTES 64KB on path A, MAX_HOOK_STDIN_BYTES 256KB on path B) cap the
|
|
11
|
+
// input; sanitizeFtsQuery still costs 0.8ms on a normal prompt, 6.2ms on a 64KB ASCII one
|
|
12
|
+
// and 31.8ms on a 64KB CJK one (extractCjkKeywords is O(len x dict) over an unsegmented
|
|
13
|
+
// run), all of it before the model sees the turn. 2000 characters is a long prompt by any
|
|
14
|
+
// measure, and past ~64 meaningful AND-joined terms an FTS5 query matches nothing anyway
|
|
15
|
+
// and survives only through the OR fallback.
|
|
16
|
+
//
|
|
17
|
+
// An explicit `claude-mem-lite search` stays UNCAPPED — a person who types a long query
|
|
18
|
+
// meant it. Only these two automatic surfaces pass the caps.
|
|
19
|
+
import { sanitizeFtsQuery } from '../utils.mjs';
|
|
20
|
+
|
|
21
|
+
export const UPS_QUERY_CAPS = { maxChars: 2000, maxTokens: 64 };
|
|
22
|
+
|
|
23
|
+
/** The capped query builder every automatic prompt-time search path goes through. */
|
|
24
|
+
export function upsFtsQuery(text) {
|
|
25
|
+
return sanitizeFtsQuery(text, UPS_QUERY_CAPS);
|
|
26
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -70,11 +70,12 @@ import { computeCitationFunnelTrend, computeSurfaceFunnel } from './lib/citation
|
|
|
70
70
|
// the citation-stats face table lines up; the enum itself lives in
|
|
71
71
|
// lib/citation-tracker.mjs (CITATION_SURFACES).
|
|
72
72
|
const SURFACE_LABELS = {
|
|
73
|
-
pretool:
|
|
74
|
-
ups:
|
|
75
|
-
error_recall:
|
|
76
|
-
fyi:
|
|
77
|
-
|
|
73
|
+
pretool: 'PreToolUse recall ',
|
|
74
|
+
ups: 'UserPromptSubmit ',
|
|
75
|
+
error_recall: 'error-recall ',
|
|
76
|
+
fyi: 'FYI (prompt-search)',
|
|
77
|
+
task_imperative: 'task-imperative ',
|
|
78
|
+
keyctx: 'Key Context ',
|
|
78
79
|
};
|
|
79
80
|
import { aggregateMetrics, readMetrics } from './lib/metrics.mjs';
|
|
80
81
|
import {
|
|
@@ -2806,6 +2807,9 @@ Commands:
|
|
|
2806
2807
|
|
|
2807
2808
|
doctor Environment diagnostics and benchmarks
|
|
2808
2809
|
--benchmark Run perf benchmark and emit JSON
|
|
2810
|
+
--metrics Summarize the recorded metrics window (CLAUDE_MEM_METRICS=1)
|
|
2811
|
+
--session-audit Audit session/episode state for orphans and drift
|
|
2812
|
+
--json Machine-readable output (plain doctor run)
|
|
2809
2813
|
|
|
2810
2814
|
fts-check <check|rebuild> FTS5 index check or rebuild
|
|
2811
2815
|
|
|
@@ -2841,6 +2845,7 @@ Commands:
|
|
|
2841
2845
|
import Import resource --name N --resource-type T [--repo-url U] [--local-path P] [--use-cases U]
|
|
2842
2846
|
remove Remove resource --name N --resource-type T
|
|
2843
2847
|
reindex Rebuild FTS5 index
|
|
2848
|
+
recommend-stats Shadow recommendation funnel [--days N] [--sweep] [--json]
|
|
2844
2849
|
|
|
2845
2850
|
import-jsonl <file-or-dir> Import Claude Code JSONL transcripts (cold-start backfill)
|
|
2846
2851
|
--project P Project name (default: inferred from cwd)
|
package/memdir.mjs
CHANGED
|
@@ -209,7 +209,11 @@ export function writePluginSection(memdir, { slug, version, contentLine, force =
|
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
// Function form, NOT the string form: a string second argument has `$&` / `$1` /
|
|
213
|
+
// "$`" / `$'` interpreted, and freshSection carries caller-supplied contentLine — a
|
|
214
|
+
// `$&` there would expand to the whole matched sentinel block. Same fix claudemd.mjs
|
|
215
|
+
// took at its own replace (v3.40); this was the twin that kept the string form.
|
|
216
|
+
const next = raw.replace(match[0], () => freshSection);
|
|
213
217
|
const changed = next !== raw;
|
|
214
218
|
if (changed) atomicWrite(path, next);
|
|
215
219
|
writeState(memdir, slug, { version, bodyHash: freshHash, writtenAt: new Date().toISOString() });
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.75.
|
|
3
|
+
"version": "3.75.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.75.
|
|
9
|
+
"version": "3.75.1",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"eslint": "^10.0.0",
|
|
22
22
|
"fast-check": "^4.5.3",
|
|
23
23
|
"knip": "^6.12.1",
|
|
24
|
+
"picomatch": "^4.0.4",
|
|
24
25
|
"vitest": "^4.0.18"
|
|
25
26
|
},
|
|
26
27
|
"engines": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.75.
|
|
3
|
+
"version": "3.75.1",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -113,6 +113,7 @@
|
|
|
113
113
|
"lib/maintain-core.mjs",
|
|
114
114
|
"lib/fast-summary.mjs",
|
|
115
115
|
"lib/transcript-scan.mjs",
|
|
116
|
+
"lib/ups-query.mjs",
|
|
116
117
|
"lib/dedup-constants.mjs",
|
|
117
118
|
"lib/deferred-work.mjs",
|
|
118
119
|
"lib/upgrade-banner.mjs",
|
|
@@ -198,6 +199,7 @@
|
|
|
198
199
|
"eslint": "^10.0.0",
|
|
199
200
|
"fast-check": "^4.5.3",
|
|
200
201
|
"knip": "^6.12.1",
|
|
202
|
+
"picomatch": "^4.0.4",
|
|
201
203
|
"vitest": "^4.0.18"
|
|
202
204
|
}
|
|
203
205
|
}
|
package/schema.mjs
CHANGED
|
@@ -280,7 +280,9 @@ const CORE_SCHEMA = `
|
|
|
280
280
|
-- v45: per-INJECTION-FACE twin of citation_log. One row per
|
|
281
281
|
-- (project, session, surface); the surface column is one of the
|
|
282
282
|
-- CITATION_SURFACES enum in lib/citation-tracker.mjs
|
|
283
|
-
-- (pretool | ups | error_recall | fyi | keyctx).
|
|
283
|
+
-- (pretool | ups | error_recall | fyi | task_imperative | keyctx). The column is plain
|
|
284
|
+
-- TEXT with no CHECK: the JS enum is the gate (recordCitationSurfaces drops unknown
|
|
285
|
+
-- labels), which is why adding a face needs no migration.
|
|
284
286
|
--
|
|
285
287
|
-- session_id is the CLAUDE CODE session id, NOT the memory session id that
|
|
286
288
|
-- keys citation_log. The two tables therefore do NOT join, on purpose. The
|
|
File without changes
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
|
|
5
5
|
|
|
6
6
|
import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, notLowSignalTitleClause, stripPrivate, neutralizeContextDelimiters, MAX_UPS_PROMPT_BYTES } from '../utils.mjs';
|
|
8
8
|
import { liveObsFilterSql, injectionRelevanceSql } from '../lib/inject-search-core.mjs';
|
|
9
9
|
import { cjkPrecisionOk } from '../nlp.mjs';
|
|
10
|
+
import { upsFtsQuery } from '../lib/ups-query.mjs';
|
|
10
11
|
import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
|
|
11
12
|
import { join, sep } from 'path';
|
|
12
13
|
import { pathToFileURL } from 'url';
|
|
@@ -343,21 +344,11 @@ export function hasExplicitSignal(text, { errSig, files, intent } = {}) {
|
|
|
343
344
|
// ×3 runs) → VERDICT NET-POSITIVE. The only behavior delta is on-topic eagerness (naming
|
|
344
345
|
// an identifier surfaces its obs) — the highest-precision injection trigger there is. The
|
|
345
346
|
// prose stop-list (IDENTIFIER_STOPWORDS) keeps the extractor off ordinary English.
|
|
346
|
-
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
// runs on every prompt before the model sees the turn. 2000 characters is a long prompt
|
|
350
|
-
// by any measure, and past ~64 meaningful AND-joined terms an FTS5 query matches nothing
|
|
351
|
-
// anyway and survives only through the OR fallback. An explicit `search` stays uncapped
|
|
352
|
-
// — a person who types a long query meant it.
|
|
353
|
-
const UPS_QUERY_CAPS = { maxChars: 2000, maxTokens: 64 };
|
|
354
|
-
|
|
355
|
-
/** The capped query builder both search paths on this surface go through. */
|
|
356
|
-
export function upsFtsQuery(text) {
|
|
357
|
-
return sanitizeFtsQuery(text, UPS_QUERY_CAPS);
|
|
358
|
-
}
|
|
347
|
+
// Query caps live in lib/ups-query.mjs — shared with `hook.mjs user-prompt`, the OTHER
|
|
348
|
+
// hook this same event fires. v3.75.0 capped this face only; a second copy of the
|
|
349
|
+
// constants here is what would let them drift apart again.
|
|
359
350
|
|
|
360
|
-
const IDENTIFIER_BYPASS = process.env.CLAUDE_MEM_UPS_IDENTIFIER_BYPASS !== '0';
|
|
351
|
+
export const IDENTIFIER_BYPASS = process.env.CLAUDE_MEM_UPS_IDENTIFIER_BYPASS !== '0';
|
|
361
352
|
const TECH_IDENTIFIER_RE_G = new RegExp(TECH_IDENTIFIER_RE.source, 'g');
|
|
362
353
|
|
|
363
354
|
// All tech-identifier tokens in `text`, lowercased + de-duped (for case-insensitive
|
package/source-files.mjs
CHANGED
|
@@ -149,6 +149,9 @@ export const SOURCE_FILES = [
|
|
|
149
149
|
// search-engine.mjs AND the standalone hook scripts — missing it from the
|
|
150
150
|
// manifest kills every retrieval surface on auto-update.
|
|
151
151
|
'lib/inject-search-core.mjs',
|
|
152
|
+
// Shared UserPromptSubmit query caps — imported by BOTH hooks that event fires
|
|
153
|
+
// (scripts/user-prompt-search.js and hook.mjs user-prompt via hook-memory.mjs).
|
|
154
|
+
'lib/ups-query.mjs',
|
|
152
155
|
// P2-12 twin cores: get/browse shared data collection for the CLI/MCP pairs
|
|
153
156
|
// (update lives in observation-write, delete-preview in delete-core, registry
|
|
154
157
|
// stats/list in registry.mjs — all already listed).
|
package/utils.mjs
CHANGED
|
@@ -21,6 +21,9 @@ export { detectBashSignificance, extractErrorKeywords, planErrorRecall, extractF
|
|
|
21
21
|
// Internal imports for functions that remain in this module
|
|
22
22
|
import { truncate } from './format-utils.mjs';
|
|
23
23
|
import { stripTestSuffix } from './bash-utils.mjs';
|
|
24
|
+
// Static, and deliberately the dependency-free resolver (node:os + node:path only) —
|
|
25
|
+
// debugCatch's sampler must not pull in the DB layer. See its comment below.
|
|
26
|
+
import { resolveDataDir } from './lib/resolve-data-dir.mjs';
|
|
24
27
|
|
|
25
28
|
// ─── Sentinel Values ────────────────────────────────────────────────────────
|
|
26
29
|
|
|
@@ -266,14 +269,19 @@ export function debugCatch(e, context) {
|
|
|
266
269
|
// Sampled-to-disk surface for post-mortem. Lazy-loaded so fs-less paths
|
|
267
270
|
// don't pay the module cost; wrapped in try so sampler faults never crash
|
|
268
271
|
// the caller (debugCatch is the error-handler-of-last-resort path).
|
|
272
|
+
//
|
|
273
|
+
// The data dir comes from lib/resolve-data-dir.mjs (imports: node:os, node:path) and
|
|
274
|
+
// NOT from schema.mjs's DB_DIR. This is the last-resort error path, so it must not
|
|
275
|
+
// inherit the DB layer's import graph: with schema.mjs unresolvable, the sampler wrote
|
|
276
|
+
// NOTHING — the trail meant to explain a broken install disappeared with it (verified
|
|
277
|
+
// by blocking the specifier; tests/debug-catch-sampler-deps.test.mjs). Resolving at
|
|
278
|
+
// call time also honours a data dir redirected after module load, which DB_DIR (a
|
|
279
|
+
// load-time constant) does not.
|
|
269
280
|
if (process.env.CLAUDE_MEM_CATCH_SAMPLE) {
|
|
270
281
|
(async () => {
|
|
271
282
|
try {
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
import('./schema.mjs'),
|
|
275
|
-
]);
|
|
276
|
-
maybeSampleError(e, context, DB_DIR);
|
|
283
|
+
const { maybeSampleError } = await import('./lib/err-sampler.mjs');
|
|
284
|
+
maybeSampleError(e, context, resolveDataDir(process.env.CLAUDE_MEM_DIR));
|
|
277
285
|
} catch { /* sampler dynamic-import fault must not propagate */ }
|
|
278
286
|
})();
|
|
279
287
|
}
|