claude-mem-lite 3.75.0 → 3.76.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/adopt-content.mjs +1 -1
- package/haiku-client.mjs +7 -58
- package/hook-episode.mjs +44 -20
- package/hook-memory.mjs +21 -4
- package/hook.mjs +112 -25
- package/lib/citation-tracker.mjs +52 -5
- package/lib/maintain-core.mjs +80 -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 +35 -17
- package/memdir.mjs +5 -1
- package/npm-shrinkwrap.json +3 -2
- package/package.json +3 -1
- package/schema.mjs +3 -1
- package/scripts/launch.mjs +8 -1
- package/scripts/pre-agent-inject.sh +0 -0
- package/scripts/user-prompt-search.js +6 -15
- package/server.mjs +3 -3
- package/source-files.mjs +3 -0
- package/tool-schemas.mjs +1 -1
- package/utils.mjs +13 -5
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.76.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.76.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/adopt-content.mjs
CHANGED
|
@@ -159,7 +159,7 @@ PreToolUse hook 在你 Read / Edit / Write 文件前已自动 \`mem_recall\` 该
|
|
|
159
159
|
| 改某条 | \`${CLI_INVOKE} update <id> [--lesson "<≤500>"] [--title T] [--type T] [--importance 1-3] [--narrative T] [--concepts "a b c"]\` |
|
|
160
160
|
| 事件日志 | \`${CLI_INVOKE} activity save --type <bugfix\\|lesson\\|bug\\|discovery\\|refactor\\|feature\\|observation\\|decision> "<title>" [--body T] [--files f1,f2]\` |
|
|
161
161
|
|
|
162
|
-
\`maintain\` / \`optimize\` / \`compress\` 见上方「维护 / 管理类工具」;\`maintain --ops\` 取值 \`cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum
|
|
162
|
+
\`maintain\` / \`optimize\` / \`compress\` 见上方「维护 / 管理类工具」;\`maintain --ops\` 取值 \`cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum\`,省略时默认 \`cleanup,decay,boost,demote_pinned\`(顺序有意义:demote_pinned 必须在 boost 之后);\`--retain-days\` ∈ [7,365]。
|
|
163
163
|
|
|
164
164
|
## 卸载 / 关闭
|
|
165
165
|
|
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';
|
|
@@ -50,7 +50,7 @@ import { formatHookError } from './lib/native-binding-hint.mjs';
|
|
|
50
50
|
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
51
51
|
import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib/hook-stdout.mjs';
|
|
52
52
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
53
|
-
import { cleanupBroken, decayAndMarkIdle, boostAccessed, markAutoCompressible, selectFuzzyDedupeIds, stampDedupSuperseded, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
|
|
53
|
+
import { cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, resolveDefaultMaintainOps, markAutoCompressible, selectFuzzyDedupeIds, stampDedupSuperseded, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
|
|
54
54
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
55
55
|
import {
|
|
56
56
|
extractCitationsFromTranscript,
|
|
@@ -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`);
|
|
@@ -1073,6 +1122,36 @@ function runSessionStartAutoMaintain(db, project) {
|
|
|
1073
1122
|
}
|
|
1074
1123
|
}
|
|
1075
1124
|
|
|
1125
|
+
// v3.76.0: the automatic path used to promote and never demote. boostAccessed ran
|
|
1126
|
+
// above; demotePinned was not even imported here, and it sat outside the default op
|
|
1127
|
+
// set of the CLI and MCP faces too — so the only op that can reach a
|
|
1128
|
+
// heavily-injected-but-uncited row (regular decay deliberately protects
|
|
1129
|
+
// injection_count>0) ran solely when a human typed `--ops demote_pinned`. Measured
|
|
1130
|
+
// on the maintainer's live DB before the fix: 148 rows demoted by citation decay,
|
|
1131
|
+
// never cited, and back at importance>=3, 148/148 of them boost-eligible.
|
|
1132
|
+
//
|
|
1133
|
+
// Placed AFTER boost, for the obvious reason: boostAccessed lifts any
|
|
1134
|
+
// access_count>3 row with importance<3, so demoting first hands the row straight
|
|
1135
|
+
// back at 2 (mem-cli had exactly that order and silently undid its own demotion
|
|
1136
|
+
// inside a single run).
|
|
1137
|
+
//
|
|
1138
|
+
// Placed AFTER fuzzy dedup, for a less obvious one, found by pre-tag review: the
|
|
1139
|
+
// dedup block above re-SELECTs `importance` and selectFuzzyDedupeIds keeps the
|
|
1140
|
+
// higher-importance member of a near-duplicate pair. Demoting first inverted that
|
|
1141
|
+
// rule using a value rewritten 40 lines earlier in the same pass — the pinned row
|
|
1142
|
+
// lost and was tombstoned, keeping the copy WITHOUT the injection history. Dedup
|
|
1143
|
+
// now decides on pre-demotion importance. Nothing between boost and here reads
|
|
1144
|
+
// importance, so the move is free.
|
|
1145
|
+
//
|
|
1146
|
+
// Whole-DB mctx (projectFilter ''), so unlike markAutoCompressibleIfDue this needs
|
|
1147
|
+
// NO per-project gate: one run under the global 24h gate covers every project at
|
|
1148
|
+
// once. Do not "fix" it into a per-project gate — that is the v3.75.0 regression in
|
|
1149
|
+
// reverse.
|
|
1150
|
+
const demotedPinned = resolveDefaultMaintainOps().includes('demote_pinned')
|
|
1151
|
+
? demotePinned(db, mctx)
|
|
1152
|
+
: 0;
|
|
1153
|
+
if (demotedPinned > 0) debugLog('DEBUG', 'auto-maintain', `demoted ${demotedPinned} pinned-but-uncited observations (no lesson → 1, lesson → 2)`);
|
|
1154
|
+
|
|
1076
1155
|
// Orphan sweep: remove `ep-flush-*` / `pending-*` runtime files older
|
|
1077
1156
|
// than 1h. handleLLMEpisode normally unlinks its own tmpFile on every
|
|
1078
1157
|
// exit path, but a crashed worker (OOM, host reboot, kill -9) leaves
|
|
@@ -1112,11 +1191,19 @@ function runSessionStartAutoMaintain(db, project) {
|
|
|
1112
1191
|
// a detached `auto-maintain` worker via spawnBackground so it never blocks interactive
|
|
1113
1192
|
// session start. The worker re-checks the same gate (idempotent) before doing the work.
|
|
1114
1193
|
function scheduleSessionStartAutoMaintain(project) {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1194
|
+
// TWO gates, either of which is enough to spawn. Checking only the global one is what
|
|
1195
|
+
// made the marking single-project in v3.75.0: in a multi-project rotation the global
|
|
1196
|
+
// stamp is already fresh by the time the second project boots, so its worker never
|
|
1197
|
+
// ran and its rows were never marked.
|
|
1198
|
+
const due = (file) => {
|
|
1199
|
+
try {
|
|
1200
|
+
const last = JSON.parse(readFileSync(file, 'utf8'));
|
|
1201
|
+
return Date.now() - last.epoch >= 24 * 3600000;
|
|
1202
|
+
} catch { return true; } // no gate file → due
|
|
1203
|
+
};
|
|
1204
|
+
const maintainDue = due(join(RUNTIME_DIR, 'last-auto-maintain.json'));
|
|
1205
|
+
const markingDue = Boolean(project) && due(markCompressibleGateFile(project));
|
|
1206
|
+
if (!maintainDue && !markingDue) return;
|
|
1120
1207
|
if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain', project);
|
|
1121
1208
|
}
|
|
1122
1209
|
|
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/maintain-core.mjs
CHANGED
|
@@ -30,6 +30,45 @@ export const MINHASH_PRE_THRESHOLD = MINHASH_PRE_THRESHOLD_SRC;
|
|
|
30
30
|
// the regular decay op can't touch (decay protects injection_count>0).
|
|
31
31
|
export const PINNED_INJ_THRESHOLD = 8;
|
|
32
32
|
|
|
33
|
+
// Single home for the default maintenance op set AND its order.
|
|
34
|
+
//
|
|
35
|
+
// Three faces run maintenance — hook.mjs auto-maintain, CLI `maintain execute`,
|
|
36
|
+
// MCP `mem_maintain` — and each used to hand-list its own default set. They had
|
|
37
|
+
// drifted in both ways a hand-copied list can:
|
|
38
|
+
// - `demote_pinned` was in NOBODY's default set, and hook.mjs did not even
|
|
39
|
+
// import demotePinned. Its opponent `boostAccessed` was in all three. So the
|
|
40
|
+
// automatic path promoted and never demoted: measured on the maintainer's live
|
|
41
|
+
// DB, 148 rows sat demoted-by-citation-decay, never cited, and back at
|
|
42
|
+
// importance>=3 — 148/148 of them boostAccessed-eligible (access_count>3).
|
|
43
|
+
// - the two faces that DID wire the op ran it in opposite orders: mem-cli did
|
|
44
|
+
// demote-then-boost, which hands the row straight back (importance 1 → 2);
|
|
45
|
+
// server.mjs did boost-then-demote, which lands it at 1.
|
|
46
|
+
// Hence: order matters, and `demote_pinned` MUST come after `boost`.
|
|
47
|
+
export const DEFAULT_MAINTAIN_OPS = Object.freeze(['cleanup', 'decay', 'boost', 'demote_pinned']);
|
|
48
|
+
|
|
49
|
+
// Opt-out for the v3.76.0 default change. Scoped to the DEFAULT set ONLY — an
|
|
50
|
+
// explicit `--ops demote_pinned` / `operations:["demote_pinned"]` still runs. An
|
|
51
|
+
// accepted value that silently means something else is worse than an unsupported
|
|
52
|
+
// one (cf. CLAUDE_MEM_RECOMMEND_MODE=live, which parses and then does not do what
|
|
53
|
+
// it says).
|
|
54
|
+
// The first cut of this compared `=== '1'`, which silently ignored `=true` / `=yes` /
|
|
55
|
+
// `= 1` — precisely the failure mode the comment above warns about, committed three
|
|
56
|
+
// lines under it. Sibling skip-flags in this repo are bare truthiness checks
|
|
57
|
+
// (`if (!process.env.CLAUDE_MEM_SKIP_COMPRESS)`), so any non-empty value opts out;
|
|
58
|
+
// the falsey WORDS are honoured too, because `=0` or `=false` reading as "skip" is the
|
|
59
|
+
// same class of silent surprise in the other direction.
|
|
60
|
+
function envFlagEnabled(raw) {
|
|
61
|
+
if (raw === undefined || raw === null) return false;
|
|
62
|
+
const v = String(raw).trim().toLowerCase();
|
|
63
|
+
return v !== '' && v !== '0' && v !== 'false' && v !== 'no' && v !== 'off';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveDefaultMaintainOps(env = process.env) {
|
|
67
|
+
return envFlagEnabled(env?.CLAUDE_MEM_SKIP_DEMOTE_PINNED)
|
|
68
|
+
? DEFAULT_MAINTAIN_OPS.filter((op) => op !== 'demote_pinned')
|
|
69
|
+
: [...DEFAULT_MAINTAIN_OPS];
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
// Two trimmed bodies count as "the same body" when both are empty (a genuine
|
|
34
73
|
// no-body re-save) or their word-set Jaccard clears the floor. One-empty-one-not
|
|
35
74
|
// is treated as DISTINCT so a body-bearing observation is never hidden by a
|
|
@@ -158,10 +197,23 @@ export function markAutoCompressible(db, project, {
|
|
|
158
197
|
// The write-side capNoiseImportance already forces imp=1 on these; this only shrinks GC
|
|
159
198
|
// latency so the corpus reduction materializes within a week instead of bleeding into
|
|
160
199
|
// the 30-day tier.
|
|
200
|
+
//
|
|
201
|
+
// v3.76.0: `injection_count = 0` added here to match the aged pass above, which has
|
|
202
|
+
// carried it since v2.56.0. Until this release nothing could reach BOTH `importance<=1`
|
|
203
|
+
// and `injection_count>=1`, so the omission was unobservable; `demote_pinned` joining
|
|
204
|
+
// the default op set creates exactly that population. Without this clause a demoted row
|
|
205
|
+
// could be marked COMPRESSED_AUTO on the NEXT maintain run — hidden from every
|
|
206
|
+
// `COALESCE(compressed_into,0)=0` read path, therefore never injected, therefore never
|
|
207
|
+
// cited, therefore with no path back. Pre-tag review reproduced that chain end to end.
|
|
208
|
+
// Today the two passes are also protected by an interlock — the title patterns below
|
|
209
|
+
// are the same set `notLowSignalTitleClause` (lib/low-signal-patterns.mjs) keeps off the
|
|
210
|
+
// only surfaces that bump `injection_count` — but that is two hand-listed sets in two
|
|
211
|
+
// files agreeing by maintenance, which is not a guarantee. This clause is.
|
|
161
212
|
const noise = db.prepare(`
|
|
162
213
|
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
163
214
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
164
215
|
AND COALESCE(importance, 1) <= 1
|
|
216
|
+
AND COALESCE(injection_count, 0) = 0
|
|
165
217
|
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
166
218
|
AND (facts IS NULL OR facts = '' OR facts = '[]')
|
|
167
219
|
AND (
|
|
@@ -340,21 +392,44 @@ export function boostAccessed(db, { projectFilter, baseParams, opCap = OP_CAP })
|
|
|
340
392
|
`).run(...baseParams).changes;
|
|
341
393
|
}
|
|
342
394
|
|
|
395
|
+
// Every other automatic pass in this file carries this clause verbatim (lines 179, 192,
|
|
396
|
+
// 299, 334) — a lesson is the distilled value a lessons store exists to hold, and
|
|
397
|
+
// background machinery must not quietly dispose of one.
|
|
398
|
+
const NO_LESSON_SQL = "(lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')";
|
|
399
|
+
|
|
343
400
|
/**
|
|
344
401
|
* Repair the citation-decay blind spot: heavy-injection + zero-citation rows that
|
|
345
|
-
* decay protects (injection_count>0) stay pinned at max importance forever.
|
|
346
|
-
* them
|
|
347
|
-
*
|
|
402
|
+
* decay protects (injection_count>0) stay pinned at max importance forever. Floor
|
|
403
|
+
* them; never purge.
|
|
404
|
+
*
|
|
405
|
+
* TWO floors, and the asymmetry is the point. `importance >= 2` is a hard WHERE on the
|
|
406
|
+
* injection faces that actually earn citations — pre-tool-recall
|
|
407
|
+
* (scripts/pre-tool-recall.js:428,470), SessionStart Key Context (hook-context.mjs:95,315)
|
|
408
|
+
* and cross-project (hook-memory.mjs:280) — whereas `injection_count`, the signal that
|
|
409
|
+
* triggers this op at all, is incremented ONLY on the two UserPromptSubmit faces
|
|
410
|
+
* (hook-memory.mjs:367, scripts/user-prompt-search.js:979). Those are the weakest faces
|
|
411
|
+
* by measured cite-rate. Dropping straight to 1 therefore convicts a row on its weakest
|
|
412
|
+
* surface and evicts it from its strongest — which pre-tag review caught: on the
|
|
413
|
+
* maintainer's live DB, 16 of the 17 rows this op would have moved were lesson-bearing.
|
|
414
|
+
*
|
|
415
|
+
* no lesson -> 1 (fully de-ranked; the original behaviour, unchanged)
|
|
416
|
+
* lesson -> 2 (loses the top tier and its ranking weight, keeps eligibility on
|
|
417
|
+
* every importance>=2 face)
|
|
418
|
+
*
|
|
419
|
+
* The floor doubles as the WHERE bound so a row already sitting at its floor is not
|
|
420
|
+
* re-touched: SQLite counts a same-value UPDATE in `changes`, which would otherwise
|
|
421
|
+
* report phantom demotions on every run forever.
|
|
348
422
|
*/
|
|
349
423
|
export function demotePinned(db, { projectFilter, baseParams, opCap = OP_CAP }) {
|
|
424
|
+
const floor = `(CASE WHEN ${NO_LESSON_SQL} THEN 1 ELSE 2 END)`;
|
|
350
425
|
return db.prepare(`
|
|
351
|
-
UPDATE observations SET importance =
|
|
426
|
+
UPDATE observations SET importance = ${floor}
|
|
352
427
|
WHERE id IN (
|
|
353
428
|
SELECT id FROM observations
|
|
354
429
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
355
430
|
AND COALESCE(injection_count, 0) >= ${PINNED_INJ_THRESHOLD}
|
|
356
431
|
AND COALESCE(cited_count, 0) = 0
|
|
357
|
-
AND COALESCE(importance, 1) >
|
|
432
|
+
AND COALESCE(importance, 1) > ${floor}
|
|
358
433
|
${projectFilter} LIMIT ${opCap}
|
|
359
434
|
)
|
|
360
435
|
`).run(...baseParams).changes;
|
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
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
|
|
31
31
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
32
32
|
hardDeleteCandidateCount,
|
|
33
|
-
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
|
|
33
|
+
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD, resolveDefaultMaintainOps,
|
|
34
34
|
} from './lib/maintain-core.mjs';
|
|
35
35
|
import { snapshotDb, listSnapshots, backupBudgetBytes } from './lib/db-backup.mjs';
|
|
36
36
|
import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
|
|
@@ -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 {
|
|
@@ -2011,7 +2012,7 @@ function cmdMaintain(db, args) {
|
|
|
2011
2012
|
out(` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`);
|
|
2012
2013
|
out(` Broken (no title/narrative): ${stats.broken}`);
|
|
2013
2014
|
out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
|
|
2014
|
-
out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} —
|
|
2015
|
+
out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — cleared by the default maintain set since v3.76.0 (opt out: CLAUDE_MEM_SKIP_DEMOTE_PINNED=1)`);
|
|
2015
2016
|
out(formatPendingPurgeLine(stats.pendingPurge));
|
|
2016
2017
|
if (duplicates.length > 0) {
|
|
2017
2018
|
const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
|
|
@@ -2046,9 +2047,11 @@ function cmdMaintain(db, args) {
|
|
|
2046
2047
|
const VALID_OPS = ['cleanup', 'decay', 'boost', 'demote_pinned', 'dedup', 'purge_stale', 'rebuild_vectors', 'vacuum'];
|
|
2047
2048
|
// Distinguish flag-absent (use default op set) from flag-present-but-empty
|
|
2048
2049
|
// (`--ops ""`, e.g. an unset shell var). The latter previously coerced via `||`
|
|
2049
|
-
// to the destructive default
|
|
2050
|
-
//
|
|
2051
|
-
|
|
2050
|
+
// to the destructive default set and EXECUTED it; route it to the VALID_OPS check
|
|
2051
|
+
// below instead so it's rejected like `--ops " "` / `--ops "decay,"`. (That default
|
|
2052
|
+
// was the literal `cleanup,decay,boost` when this was written; it now comes from
|
|
2053
|
+
// DEFAULT_MAINTAIN_OPS, which is why the list is no longer spelled out here.)
|
|
2054
|
+
const opsStr = flags.ops === undefined ? resolveDefaultMaintainOps().join(',') : String(flags.ops);
|
|
2052
2055
|
const ops = opsStr.split(',').map(s => s.trim());
|
|
2053
2056
|
const invalidOps = ops.filter(op => !VALID_OPS.includes(op));
|
|
2054
2057
|
if (invalidOps.length > 0) {
|
|
@@ -2137,17 +2140,23 @@ function cmdMaintain(db, args) {
|
|
|
2137
2140
|
results.push(`Decayed ${decayed} stale observations, marked ${idleMarked} idle as pending-purge${decayCap}`);
|
|
2138
2141
|
}
|
|
2139
2142
|
|
|
2143
|
+
if (ops.includes('boost')) {
|
|
2144
|
+
const boosted = boostAccessed(db, mctx);
|
|
2145
|
+
results.push(`Boosted ${boosted} frequently-accessed observations${capHint(boosted)}`);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// AFTER boost, matching server.mjs and hook.mjs. This block used to sit BEFORE
|
|
2149
|
+
// it, and the order was load-bearing in the wrong direction: boostAccessed lifts
|
|
2150
|
+
// any access_count>3 row with importance<3, so demoting a pinned row to 1 and
|
|
2151
|
+
// then boosting handed it straight back at 2 — the demotion silently undone
|
|
2152
|
+
// inside a single maintain run. DEFAULT_MAINTAIN_OPS pins the order; this block
|
|
2153
|
+
// has to physically follow the boost block for that order to be real.
|
|
2140
2154
|
if (ops.includes('demote_pinned')) {
|
|
2141
2155
|
// Repair the citation-decay blind spot: decay protects injection_count>0, so a
|
|
2142
2156
|
// heavily-injected-but-uncited memory stays pinned at max importance forever.
|
|
2143
2157
|
// demotePinned (maintain-core) drops it to 1 in one pass. Floor 1, not purge.
|
|
2144
2158
|
const demoted = demotePinned(db, mctx);
|
|
2145
|
-
results.push(`Demoted ${demoted} pinned-but-uncited observations
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
if (ops.includes('boost')) {
|
|
2149
|
-
const boosted = boostAccessed(db, mctx);
|
|
2150
|
-
results.push(`Boosted ${boosted} frequently-accessed observations${capHint(boosted)}`);
|
|
2159
|
+
results.push(`Demoted ${demoted} pinned-but-uncited observations (inj>=${PINNED_INJ_THRESHOLD}, cited=0; no lesson → importance 1, lesson → 2)${capHint(demoted)}`);
|
|
2151
2160
|
}
|
|
2152
2161
|
|
|
2153
2162
|
if (ops.includes('dedup') && flags['merge-ids']) {
|
|
@@ -2783,10 +2792,15 @@ Commands:
|
|
|
2783
2792
|
|
|
2784
2793
|
maintain <scan|execute> Memory maintenance
|
|
2785
2794
|
--ops O Comma-separated: cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum
|
|
2795
|
+
Default when omitted: cleanup,decay,boost,demote_pinned (in that order)
|
|
2786
2796
|
--merge-ids K:R,... For dedup: keepId:removeId pairs (e.g. 10:11,20:21:22)
|
|
2787
2797
|
--project P Filter by project
|
|
2788
2798
|
--retain-days N For purge_stale: keep last N days (default 30)
|
|
2789
|
-
demote_pinned: importance→1 for inj>=8 & cited=0 (clears pinned noise)
|
|
2799
|
+
demote_pinned: importance→1 for inj>=8 & cited=0 (clears pinned noise).
|
|
2800
|
+
In the default set since v3.76.0; runs AFTER boost, which would
|
|
2801
|
+
otherwise hand the row straight back. Opt out of the DEFAULT with
|
|
2802
|
+
CLAUDE_MEM_SKIP_DEMOTE_PINNED=1 — an explicit --ops demote_pinned
|
|
2803
|
+
still runs.
|
|
2790
2804
|
vacuum: reclaim freelist dead space (whole-DB, ignores --project)
|
|
2791
2805
|
|
|
2792
2806
|
optimize LLM-powered memory optimization (preview by default)
|
|
@@ -2806,6 +2820,9 @@ Commands:
|
|
|
2806
2820
|
|
|
2807
2821
|
doctor Environment diagnostics and benchmarks
|
|
2808
2822
|
--benchmark Run perf benchmark and emit JSON
|
|
2823
|
+
--metrics Summarize the recorded metrics window (CLAUDE_MEM_METRICS=1)
|
|
2824
|
+
--session-audit Audit session/episode state for orphans and drift
|
|
2825
|
+
--json Machine-readable output (plain doctor run)
|
|
2809
2826
|
|
|
2810
2827
|
fts-check <check|rebuild> FTS5 index check or rebuild
|
|
2811
2828
|
|
|
@@ -2841,6 +2858,7 @@ Commands:
|
|
|
2841
2858
|
import Import resource --name N --resource-type T [--repo-url U] [--local-path P] [--use-cases U]
|
|
2842
2859
|
remove Remove resource --name N --resource-type T
|
|
2843
2860
|
reindex Rebuild FTS5 index
|
|
2861
|
+
recommend-stats Shadow recommendation funnel [--days N] [--sweep] [--json]
|
|
2844
2862
|
|
|
2845
2863
|
import-jsonl <file-or-dir> Import Claude Code JSONL transcripts (cold-start backfill)
|
|
2846
2864
|
--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.
|
|
3
|
+
"version": "3.76.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.76.0",
|
|
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.
|
|
3
|
+
"version": "3.76.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -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
|
package/scripts/launch.mjs
CHANGED
|
@@ -42,8 +42,15 @@ if (!existsSync(join(ROOT, 'node_modules', 'better-sqlite3'))) {
|
|
|
42
42
|
//
|
|
43
43
|
// `e.status` not `e.code`: execSync failures carry the exit status on `status`,
|
|
44
44
|
// so the old `|| e.code` rung was dead.
|
|
45
|
+
// `?? null` not `!= null`: the loose form is the idiom, but this file is
|
|
46
|
+
// linted under `eqeqeq: always`, and rewriting it as `!== undefined` would
|
|
47
|
+
// be a BEHAVIOUR change — execSync reports a signal kill with `status: null`,
|
|
48
|
+
// which `!== undefined` accepts and would render as "npm exited null".
|
|
49
|
+
// Coalescing first keeps the original both-nullish semantics exactly, `0`
|
|
50
|
+
// included.
|
|
51
|
+
const status = e?.status ?? null;
|
|
45
52
|
const detail = e?.message?.split('\n')[0]
|
|
46
|
-
|| (
|
|
53
|
+
|| (status !== null ? `npm exited ${status}` : '')
|
|
47
54
|
|| (e?.signal ? `npm killed by ${e.signal}` : '')
|
|
48
55
|
|| 'unknown error';
|
|
49
56
|
process.stderr.write(`[claude-mem-lite] npm install failed in ${ROOT} — ${detail}\n`);
|
|
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/server.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
|
|
20
20
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
21
21
|
hardDeleteCandidateCount,
|
|
22
|
-
OP_CAP, STALE_AGE_MS,
|
|
22
|
+
OP_CAP, STALE_AGE_MS, resolveDefaultMaintainOps,
|
|
23
23
|
} from './lib/maintain-core.mjs';
|
|
24
24
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
25
25
|
import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
|
|
@@ -1148,7 +1148,7 @@ server.registerTool(
|
|
|
1148
1148
|
if (action === 'execute') {
|
|
1149
1149
|
const ops = args.operations && args.operations.length > 0
|
|
1150
1150
|
? args.operations
|
|
1151
|
-
:
|
|
1151
|
+
: resolveDefaultMaintainOps();
|
|
1152
1152
|
// T2-P1-A: reject explicit empty array (vs. omitted → defaults above). Empty-array
|
|
1153
1153
|
// callers are almost always mistakes; silently running only FTS5 optimize hides the error.
|
|
1154
1154
|
if (args.operations && args.operations.length === 0) {
|
|
@@ -1239,7 +1239,7 @@ server.registerTool(
|
|
|
1239
1239
|
|
|
1240
1240
|
if (ops.includes('demote_pinned')) {
|
|
1241
1241
|
const demoted = demotePinned(db, mctx);
|
|
1242
|
-
results.push(`Demoted ${demoted} pinned-but-uncited observations
|
|
1242
|
+
results.push(`Demoted ${demoted} pinned-but-uncited observations (inj>=8, cited=0; no lesson → importance 1, lesson → 2)` + (demoted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1243
1243
|
}
|
|
1244
1244
|
|
|
1245
1245
|
if (ops.includes('dedup') && args.merge_ids) {
|
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/tool-schemas.mjs
CHANGED
|
@@ -247,7 +247,7 @@ export const memOptimizeSchema = {
|
|
|
247
247
|
export const memMaintainSchema = {
|
|
248
248
|
action: z.enum(['scan', 'execute']).describe('scan=analyze candidates, execute=apply changes'),
|
|
249
249
|
operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost', 'demote_pinned', 'purge_stale', 'rebuild_vectors', 'vacuum'])).optional()
|
|
250
|
-
.describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, demote_pinned=importance→1 for obs injected>=8 times but never cited (clears pinned noise the decay op cannot reach), purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors, vacuum=reclaim freelist dead space (whole-DB)'),
|
|
250
|
+
.describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, demote_pinned=importance→1 for obs injected>=8 times but never cited (clears pinned noise the decay op cannot reach; in the default set since v3.76.0 and ordered after boost, since boost would otherwise raise the row straight back — set CLAUDE_MEM_SKIP_DEMOTE_PINNED=1 to drop it from the DEFAULT set only), purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors, vacuum=reclaim freelist dead space (whole-DB)'),
|
|
251
251
|
merge_ids: z.preprocess(
|
|
252
252
|
(v) => Array.isArray(v) ? v.map(g => Array.isArray(g) ? g.map(x => typeof x === 'string' ? parseInt(x, 10) : x) : g) : v,
|
|
253
253
|
z.array(z.array(z.number().int()).min(2))
|
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
|
}
|