claude-mem-lite 3.61.0 → 3.62.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/cli/common.mjs +83 -5
- package/format-utils.mjs +65 -1
- package/hook-handoff.mjs +11 -2
- package/hook-llm.mjs +3 -3
- package/hook-shared.mjs +14 -1
- package/hook.mjs +105 -13
- package/hooks/hooks.json +10 -0
- package/install.mjs +43 -2
- package/lib/recall-core.mjs +8 -0
- package/mem-cli.mjs +25 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/user-prompt-search.js +1 -1
- package/secret-scrub.mjs +39 -10
- package/server.mjs +74 -17
- package/tool-schemas.mjs +16 -4
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.62.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.62.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/cli/common.mjs
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
// Extracted from mem-cli.mjs (v2.41) as first step in the god-module split.
|
|
3
3
|
//
|
|
4
4
|
// Scope: pure utilities only. No DB, no imports from other cli/ files; only
|
|
5
|
-
// `lib/`
|
|
6
|
-
// parseIdToken). This module is the single source
|
|
7
|
-
// framing, arg parsing, ID-token parsing, and
|
|
8
|
-
// every command imports from here so the CLI stays
|
|
5
|
+
// leaf utilities from `lib/` and the repo root may be pulled in (currently:
|
|
6
|
+
// parseIdToken, neutralizeContextDelimiters). This module is the single source
|
|
7
|
+
// of truth for stdout/stderr framing, arg parsing, ID-token parsing, and
|
|
8
|
+
// relative-time formatting — every command imports from here so the CLI stays
|
|
9
|
+
// consistent.
|
|
10
|
+
|
|
11
|
+
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
9
12
|
|
|
10
13
|
// ─── Argument Parsing ────────────────────────────────────────────────────────
|
|
11
14
|
|
|
@@ -85,8 +88,41 @@ export function parseArgs(argv) {
|
|
|
85
88
|
|
|
86
89
|
// ─── Output Helpers ──────────────────────────────────────────────────────────
|
|
87
90
|
|
|
88
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Write a line to stdout, with structural context delimiters neutralized.
|
|
93
|
+
*
|
|
94
|
+
* CLI stdout IS model context, not just a human channel: commands/mem.md routes
|
|
95
|
+
* `/mem search|get|recall|timeline` to `node cli.mjs … via Bash`, and
|
|
96
|
+
* buildServerInstructions actively tells the agent the Bash CLI is the CHEAPER path
|
|
97
|
+
* than the MCP tool. The MCP read family has been defanged since v3.61 at its own
|
|
98
|
+
* chokepoint (server.mjs safeHandler), but the CLI twins printed stored text raw —
|
|
99
|
+
* so the exact indirect-prompt-injection channel the MCP defang closes stayed open on
|
|
100
|
+
* the surface the instructions recommend (audit 2026-08-14 A1). Observations are
|
|
101
|
+
* stored raw on purpose (defense lives at the injection boundary, not at save), so it
|
|
102
|
+
* has to happen here, at the write.
|
|
103
|
+
*
|
|
104
|
+
* `out` is the single stdout writer for every command in mem-cli.mjs and cli/*.mjs,
|
|
105
|
+
* so a NEW read command is covered by construction (§9 parallel-path completeness).
|
|
106
|
+
* Payloads that must round-trip byte-exact use `outVerbatim` instead — see below.
|
|
107
|
+
* The transform is idempotent (it strips brackets, it does not re-add them), so a
|
|
108
|
+
* path that already defanged upstream — `context` → buildSessionContextLines — is
|
|
109
|
+
* unaffected.
|
|
110
|
+
*/
|
|
89
111
|
export function out(text) {
|
|
112
|
+
// String() first: neutralizeContextDelimiters coerces nullish to '', which would turn
|
|
113
|
+
// a pre-existing `out(undefined)` line from "undefined" into an empty line.
|
|
114
|
+
outVerbatim(neutralizeContextDelimiters(String(text)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Write a line to stdout with NO defang. The CLI mirror of
|
|
119
|
+
* `safeHandler(fn, { verbatim: true })` on the MCP side, and for the same single
|
|
120
|
+
* reason: `export` is the backup half of backup/restore, so neutralizing its payload
|
|
121
|
+
* would silently rewrite every backed-up row whose text legitimately contains these
|
|
122
|
+
* tags — and `restore` would write the rewritten text back. Only use this for bytes
|
|
123
|
+
* that must survive a round trip; anything a model reads goes through `out`.
|
|
124
|
+
*/
|
|
125
|
+
export function outVerbatim(text) {
|
|
90
126
|
process.stdout.write(text + '\n');
|
|
91
127
|
}
|
|
92
128
|
|
|
@@ -176,6 +212,11 @@ export const KNOWN_CLI_FLAGS = new Set([
|
|
|
176
212
|
// stay silent about (`adopt --disable/--enable`, `activity --min-importance`,
|
|
177
213
|
// `save --supersedes`). Verified by running each command and checking for a warning.
|
|
178
214
|
'disable', 'enable', 'min-importance', 'supersedes',
|
|
215
|
+
// `doctor --benchmark --prompts-limit N` — read off raw argv in cli/doctor.mjs, so
|
|
216
|
+
// it never appeared in a `flags.x` grep. Caught by independent review after the
|
|
217
|
+
// warn-on-every-unknown-flag flip turned the omission into a false warning on a
|
|
218
|
+
// documented, working command.
|
|
219
|
+
'prompts-limit',
|
|
179
220
|
]);
|
|
180
221
|
|
|
181
222
|
/** Levenshtein distance, early-exit past `max` (cheap enough for a handful of flags). */
|
|
@@ -255,6 +296,43 @@ export function fmtDateShort(iso) {
|
|
|
255
296
|
// because the formatter lived only in mem-cli.mjs.
|
|
256
297
|
export const OBS_TIME_FIELDS = ['superseded_at', 'last_accessed_at'];
|
|
257
298
|
|
|
299
|
+
// Display labels for observation columns whose NAME misdescribes their contents.
|
|
300
|
+
// `files_modified` holds whatever file list the writer attached: hook-captured rows fill
|
|
301
|
+
// it from Edit/Write, but an explicit mem_save / `save --files` puts any associated path
|
|
302
|
+
// there — including a file that was only read. Rendering the raw column name told the
|
|
303
|
+
// reader those files were modified (audit 2026-08-14 F3). The label is `files` — the name
|
|
304
|
+
// of the input parameter that fills it. The COLUMN is untouched, so `--fields
|
|
305
|
+
// files_modified` / `fields:["files_modified"]` still select it. Shared by the CLI `get`
|
|
306
|
+
// and MCP `mem_get` renderers so the two cannot drift.
|
|
307
|
+
const OBS_FIELD_LABELS = { files_modified: 'files' };
|
|
308
|
+
|
|
309
|
+
/** Reader-facing label for an observation column (identity for everything unmapped). */
|
|
310
|
+
export function obsFieldLabel(field) {
|
|
311
|
+
return OBS_FIELD_LABELS[field] || field;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The `maintain scan` pending-purge line, shared by the CLI (mem-cli.mjs cmdMaintain) and
|
|
316
|
+
* the MCP mem_maintain handler (server.mjs) so the two cannot drift — same reason
|
|
317
|
+
* obsFieldLabel lives here.
|
|
318
|
+
*
|
|
319
|
+
* The count is `compressed_into = COMPRESSED_PENDING_PURGE` (lib/maintain-core.mjs:443),
|
|
320
|
+
* and the ONLY writers of that sentinel are the two idle/decay passes — decayAndMarkIdle
|
|
321
|
+
* (maintain-core.mjs:201) and runIdleCleanup (search-scoring.mjs:303). Compression writes
|
|
322
|
+
* COMPRESSED_AUTO (-1) or a positive parent id, and those rows are NOT counted. The CLI
|
|
323
|
+
* used to render this as "compressed originals awaiting cleanup", which told an operator
|
|
324
|
+
* about to run `maintain execute --ops purge_stale --confirm` that they were deleting
|
|
325
|
+
* compression leftovers when they were deleting decay-marked live originals (audit
|
|
326
|
+
* 2026-08-14 A4). Say what the rows are and what deletes them — and say nothing about
|
|
327
|
+
* compression, which is a different sentinel with a different lifecycle.
|
|
328
|
+
*
|
|
329
|
+
* @param {number} n stats.pendingPurge
|
|
330
|
+
* @returns {string} the full indented line, identical on both surfaces.
|
|
331
|
+
*/
|
|
332
|
+
export function formatPendingPurgeLine(n) {
|
|
333
|
+
return ` Pending purge (idle-marked): ${n} (live originals marked idle by decay — purge_stale deletes them)`;
|
|
334
|
+
}
|
|
335
|
+
|
|
258
336
|
// Pure formatter — null/undefined/non-time pass through; integer time fields
|
|
259
337
|
// render as `<raw> (<relative>)` so callers get both an audit value and a
|
|
260
338
|
// human/LLM-scannable hint, mirroring `recent`/`timeline`/`recall`.
|
package/format-utils.mjs
CHANGED
|
@@ -48,15 +48,79 @@ export function truncate(str, max = 80) {
|
|
|
48
48
|
// itself, where source/observations carry the delimiter names.
|
|
49
49
|
const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff|system-reminder|task-notification|(?:antml:)?function_calls|(?:antml:)?function_results|(?:antml:)?invoke|(?:antml:)?parameter)(?:\s[^>]*)?>/gi;
|
|
50
50
|
|
|
51
|
+
// Pass cap for the fixpoint loop below. 32 nested layers of a forged delimiter is far past
|
|
52
|
+
// anything prose produces; the cap exists only to bound the ADVERSARIAL cost (an unbounded
|
|
53
|
+
// fixpoint is O(depth \u00d7 length), i.e. quadratic on a crafted 200k-char payload, on the
|
|
54
|
+
// synchronous hook/CLI write path \u2014 this repo has shipped two ReDoS findings already).
|
|
55
|
+
const DEFANG_MAX_PASSES = 32;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Strip the angle brackets of every `re` match, repeatedly, until the text stops changing.
|
|
59
|
+
*
|
|
60
|
+
* A SINGLE pass is not enough, and the gap is two characters wide: in
|
|
61
|
+
* `<<system-reminder>>` the pattern matches the INNER pair, removing it leaves the outer
|
|
62
|
+
* pair wrapped around the bare tag name, and the "defanged" output is a live
|
|
63
|
+
* `<system-reminder>` \u2014 produced by the function whose job is to make it inert (pre-tag
|
|
64
|
+
* review, 2026-08-14). Widening the pattern to also eat adjacent brackets does not fix it
|
|
65
|
+
* either: `<system-reminder <system-reminder> x>y>` re-forms `<system-reminder x>` from
|
|
66
|
+
* text the widened match keeps. Only a fixpoint closes the general case.
|
|
67
|
+
*
|
|
68
|
+
* TERMINATION: every match begins with `<` and the replacement drops it, so any pass that
|
|
69
|
+
* changes the string removes at least one `<` \u2014 the iteration is self-bounded by the number
|
|
70
|
+
* of `<` in the input, and DEFANG_MAX_PASSES bounds it again by a constant.
|
|
71
|
+
*
|
|
72
|
+
* INERT AT ANY DEPTH: if the text is still changing when the cap is reached (\u226532 nested
|
|
73
|
+
* forged layers \u2014 not reachable by accident), every remaining angle bracket is removed.
|
|
74
|
+
* That is lossier than the normal path, but the return value then provably contains no tag,
|
|
75
|
+
* which is the property callers rely on; giving up at the cap and returning still-tagged
|
|
76
|
+
* text would hand the attacker exactly the bypass the loop exists to close.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} s Input string (any type; coerced)
|
|
79
|
+
* @param {RegExp} re Global tag pattern whose matches start with `<` and end with `>`
|
|
80
|
+
* @returns {string} Text containing no match of `re`
|
|
81
|
+
*/
|
|
82
|
+
function defangToFixpoint(s, re) {
|
|
83
|
+
let text = String(s ?? '');
|
|
84
|
+
for (let pass = 0; pass < DEFANG_MAX_PASSES; pass++) {
|
|
85
|
+
const next = text.replace(re, (m) => m.slice(1, -1));
|
|
86
|
+
if (next === text) return text; // fixpoint: nothing left to defang
|
|
87
|
+
text = next;
|
|
88
|
+
}
|
|
89
|
+
return text.replace(/[<>]/g, ''); // pathological nesting \u2192 fail closed
|
|
90
|
+
}
|
|
91
|
+
|
|
51
92
|
/**
|
|
52
93
|
* Defang the literal context-block delimiter tags in user-derived text. Strips just the
|
|
53
94
|
* angle brackets, so `</claude-mem-context>` renders as `/claude-mem-context` \u2014 still
|
|
54
95
|
* readable, but no longer a structural delimiter. Complements `mdCell`'s pipe-escaping.
|
|
96
|
+
* Iterated to a fixpoint (see defangToFixpoint): a single pass let `<<system-reminder>>`
|
|
97
|
+
* come back live.
|
|
55
98
|
* @param {string} s Input string (any type; coerced)
|
|
56
99
|
* @returns {string} Text with delimiter tags defanged
|
|
57
100
|
*/
|
|
58
101
|
export function neutralizeContextDelimiters(s) {
|
|
59
|
-
return
|
|
102
|
+
return defangToFixpoint(s, CONTEXT_DELIMITER_RE);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// <skill-loaded> is deliberately NOT in CONTEXT_DELIMITER_RE above: mem_use's legitimate
|
|
106
|
+
// load path has to emit a REAL one, and that result goes through the same handler-wide
|
|
107
|
+
// defang, which would strip it. So the tag is neutralized here instead — per call site,
|
|
108
|
+
// on the untrusted text only. Attribute-bearing openers and the bare closer both match,
|
|
109
|
+
// same "strip the brackets, keep the text" treatment as the class above.
|
|
110
|
+
const SKILL_BLOCK_RE = /<\/?skill-loaded(?:\s[^>]*)?>/gi;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Defang a literal `<skill-loaded>` opener/closer in text that is about to be echoed
|
|
114
|
+
* INSIDE a mem_use response. A caller-supplied name interpolated raw could otherwise
|
|
115
|
+
* forge a whole skill block (plus its execute imperative) in a message the caller
|
|
116
|
+
* controls end to end — audit F7, 2026-08-14. Never apply this to the real load path.
|
|
117
|
+
* Same fixpoint iteration as the class above, for the same reason: one pass turned the
|
|
118
|
+
* caller-supplied `<<skill-loaded>>` back into a live `<skill-loaded>` opener.
|
|
119
|
+
* @param {string} s Input string (any type; coerced)
|
|
120
|
+
* @returns {string} Text with skill-block delimiters defanged
|
|
121
|
+
*/
|
|
122
|
+
export function neutralizeSkillDelimiters(s) {
|
|
123
|
+
return defangToFixpoint(s, SKILL_BLOCK_RE);
|
|
60
124
|
}
|
|
61
125
|
|
|
62
126
|
/**
|
package/hook-handoff.mjs
CHANGED
|
@@ -173,11 +173,20 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
173
173
|
try { JSON.parse(row.files_modified).filter(isValidFile).forEach(f => fileSet.add(f)); } catch {}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// 5. Key decisions — high importance observations (skip low-signal degraded titles)
|
|
176
|
+
// 5. Key decisions — high importance observations (skip low-signal degraded titles).
|
|
177
|
+
//
|
|
178
|
+
// superseded_at IS NULL, unlike `completed` and `files_modified` above: those two are
|
|
179
|
+
// the session's own history ("what happened here"), where an overturned decision still
|
|
180
|
+
// happened and erasing it would misreport the session. key_decisions is different — it
|
|
181
|
+
// is replayed to the NEXT session under "## Key Decisions" as standing policy, so a
|
|
182
|
+
// retracted decision rendered there is indistinguishable from live policy. The
|
|
183
|
+
// carry-forward fallback at the top of this function already filters the same column;
|
|
184
|
+
// this is the sibling that did not.
|
|
177
185
|
const decisions = db.prepare(`
|
|
178
186
|
SELECT title FROM observations
|
|
179
187
|
WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
|
|
180
|
-
AND COALESCE(compressed_into, 0) = 0
|
|
188
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
189
|
+
AND superseded_at IS NULL ${obsWindowClause}
|
|
181
190
|
ORDER BY created_at_epoch DESC LIMIT 10
|
|
182
191
|
`).all(sessionId, ...obsWindowParams).filter(d => d.title && !LOW_SIGNAL_TITLE.test(d.title)).slice(0, 5);
|
|
183
192
|
|
package/hook-llm.mjs
CHANGED
|
@@ -156,7 +156,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
156
156
|
// (no lesson, importance<2, empty facts, thin narrative) is dropped before
|
|
157
157
|
// dedup/MinHash/vector work. Opt-out: CLAUDE_MEM_KEEP_LOW_SIGNAL=1.
|
|
158
158
|
if (isNoiseObservation(obs)) {
|
|
159
|
-
debugLog('saveObservation', `dropped noise: ${truncate(obs.title || '', 60)}`);
|
|
159
|
+
debugLog('DEBUG', 'saveObservation', `dropped noise: ${truncate(obs.title || '', 60)}`);
|
|
160
160
|
return null;
|
|
161
161
|
}
|
|
162
162
|
|
|
@@ -172,7 +172,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
172
172
|
// pre-save is not lost on LLM success — that path clean-inserts a fresh row — but it loses
|
|
173
173
|
// those three.) capNoiseImportance then caps any title-noise survivor to imp=1 as before.
|
|
174
174
|
if (isLowYieldChangeObs(obs)) {
|
|
175
|
-
debugLog('saveObservation', `dropped low-yield change: ${truncate(obs.title || '', 60)}`);
|
|
175
|
+
debugLog('DEBUG', 'saveObservation', `dropped low-yield change: ${truncate(obs.title || '', 60)}`);
|
|
176
176
|
return null;
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -182,7 +182,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
182
182
|
// enter the 7-day accelerated auto-compress window in hook.mjs.
|
|
183
183
|
const capped = capNoiseImportance(obs);
|
|
184
184
|
if (capped !== (obs.importance ?? 1)) {
|
|
185
|
-
debugLog('saveObservation', `capped imp ${obs.importance}→${capped}: ${truncate(obs.title || '', 60)}`);
|
|
185
|
+
debugLog('DEBUG', 'saveObservation', `capped imp ${obs.importance}→${capped}: ${truncate(obs.title || '', 60)}`);
|
|
186
186
|
obs.importance = capped;
|
|
187
187
|
}
|
|
188
188
|
|
package/hook-shared.mjs
CHANGED
|
@@ -7,6 +7,9 @@ import { join } from 'path';
|
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync, chmodSync } from 'fs';
|
|
8
8
|
import { inferProject, debugCatch } from './utils.mjs';
|
|
9
9
|
import { ensureDbWithWalRecovery, DB_DIR } from './schema.mjs';
|
|
10
|
+
// Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
|
|
11
|
+
// neither imports this file) — no cycle.
|
|
12
|
+
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
10
13
|
import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku } from './haiku-client.mjs';
|
|
11
14
|
// Phase D: invited-memory sentinel detection. memdir.mjs/claudemd.mjs only pull in
|
|
12
15
|
// fs/path/os/crypto; adopt-content.mjs is pure strings. No circular deps —
|
|
@@ -157,7 +160,17 @@ export function openDb() {
|
|
|
157
160
|
// WAL-corruption self-heal (was server.mjs-only): without it, hooks stayed
|
|
158
161
|
// silently dead (null DB) on a corrupt WAL until the next MCP server start.
|
|
159
162
|
return ensureDbWithWalRecovery();
|
|
160
|
-
} catch {
|
|
163
|
+
} catch (e) {
|
|
164
|
+
// Still null, still no throw — a hook must never crash the host session, and all
|
|
165
|
+
// eight call sites in hook.mjs are written to no-op on null. But "returned null"
|
|
166
|
+
// used to be the ONLY trace: nothing reached runtime/hook-errors/, so `stats`
|
|
167
|
+
// reported 0 and doctor printed "no recent silent hook breakage" while every
|
|
168
|
+
// capture path was dead (audit B1, 2026-08-14 — the same blindness that hid the
|
|
169
|
+
// v3.60 binding outage for four days). recordHookError is the established sink;
|
|
170
|
+
// scripts/pre-tool-recall.js already logs its own db-open failures this way, and
|
|
171
|
+
// routing through it also flags the native-binding family for the session-start
|
|
172
|
+
// self-heal. The recorder swallows its own errors, so this cannot throw.
|
|
173
|
+
recordHookError('hook-shared:db-open', e, RUNTIME_DIR);
|
|
161
174
|
return null;
|
|
162
175
|
}
|
|
163
176
|
}
|
package/hook.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation, saveEpisodeImmediate } from './hook-llm.mjs';
|
|
48
48
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
49
49
|
import { formatHookError } from './lib/native-binding-hint.mjs';
|
|
50
|
+
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
50
51
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
51
52
|
import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
|
|
52
53
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
@@ -94,8 +95,14 @@ const event = process.argv[2];
|
|
|
94
95
|
// the dispatch below exits everything else). EVERY spawnBackground/queue* event
|
|
95
96
|
// MUST be listed here — a missing entry makes the detached worker exit(0)
|
|
96
97
|
// silently, which looks identical to "worker ran and found nothing" from the
|
|
97
|
-
// outside (live-probe catch, 2026-07-18: enrich-save no-oped on this line
|
|
98
|
-
|
|
98
|
+
// outside (live-probe catch, 2026-07-18: enrich-save no-oped on this line;
|
|
99
|
+
// audit F6, 2026-08-14: update-check had been dead the same way, so the 24h
|
|
100
|
+
// release check never ran and every SessionStart respawned a worker that
|
|
101
|
+
// exit(0)'d before its handler).
|
|
102
|
+
// `tests/audit-findings-20260814.test.mjs` scans BOTH detached spawners
|
|
103
|
+
// (spawnBackground here, spawn(node,[HOOK_PATH,…]) in lib/save-enrich.mjs) and
|
|
104
|
+
// reds when a spawned event is missing from this list.
|
|
105
|
+
const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize', 'auto-maintain', 'enrich-save', 'update-check']);
|
|
99
106
|
|
|
100
107
|
// Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
|
|
101
108
|
// install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
|
|
@@ -139,8 +146,19 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
|
139
146
|
// sessions into one garbled row. planEpisodeFlush returns [ep] by reference when
|
|
140
147
|
// there is ≤1 CC session (the common case → identical to before), else one sub
|
|
141
148
|
// per session. Pure/sync → safe inside the signal handler.
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
// Same B1 gate as flushEpisode: without an openable DB every save below is a
|
|
150
|
+
// no-op, so deleting the buffer afterwards would destroy the very episode this
|
|
151
|
+
// handler exists to salvage. openDb is sync (safe here) and records its own
|
|
152
|
+
// failure; leaving the file untouched lets the next fire retry it.
|
|
153
|
+
const db = openDb();
|
|
154
|
+
if (db) {
|
|
155
|
+
try {
|
|
156
|
+
for (const sub of planEpisodeFlush(ep)) saveEpisodeImmediate(sub, db);
|
|
157
|
+
try { unlinkSync(join(RUNTIME_DIR, `ep-${inferProject()}.json`)); } catch {}
|
|
158
|
+
} finally {
|
|
159
|
+
try { db.close(); } catch { /* already gone */ }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
144
162
|
}
|
|
145
163
|
} catch {}
|
|
146
164
|
process.exit(0);
|
|
@@ -164,6 +182,25 @@ const RECEIPT_EVENTS = new Set(['PostToolUse', 'SessionStart', 'UserPromptSubmit
|
|
|
164
182
|
function flushEpisode(episode, hookEventName = 'PostToolUse') {
|
|
165
183
|
if (!episode || episode.entries.length === 0) return;
|
|
166
184
|
|
|
185
|
+
// Acquire the DB ONCE, up front, and bail before touching anything destructive when it
|
|
186
|
+
// will not open. Every persistence step below is a no-op without it (saveObservation
|
|
187
|
+
// returns null on a null db; the detached llm-episode worker hits the same wall), yet
|
|
188
|
+
// the `unlinkSync(episodeFile())` at the tail used to run regardless — so a DB that
|
|
189
|
+
// could not be opened deleted the session's captured work while the hook exited 0 with
|
|
190
|
+
// empty stdout AND empty stderr (audit B1, 2026-08-14). Returning here leaves the
|
|
191
|
+
// buffer on disk for the next fire to retry; openDb() has already recorded the failure
|
|
192
|
+
// under `hook-shared:db-open`. Reusing the handle for the immediate saves also drops
|
|
193
|
+
// this path from one open per sub-episode to one per flush.
|
|
194
|
+
const db = openDb();
|
|
195
|
+
if (!db) return;
|
|
196
|
+
try {
|
|
197
|
+
flushEpisodeWithDb(db, episode, hookEventName);
|
|
198
|
+
} finally {
|
|
199
|
+
try { db.close(); } catch { /* already closed / gone */ }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function flushEpisodeWithDb(db, episode, hookEventName) {
|
|
167
204
|
// Collect Read file paths tracked by post-tool-use.sh
|
|
168
205
|
// Use rename to atomically collect — prevents losing concurrent appends
|
|
169
206
|
const readsFile = join(RUNTIME_DIR, `reads-${episode.project || inferProject()}.txt`);
|
|
@@ -185,7 +222,7 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
|
|
|
185
222
|
const subs = planEpisodeFlush(episode);
|
|
186
223
|
let anySignificant = false;
|
|
187
224
|
for (const sub of subs) {
|
|
188
|
-
const r = flushEpisodeGroup(sub);
|
|
225
|
+
const r = flushEpisodeGroup(sub, db);
|
|
189
226
|
if (r === 'writefail') {
|
|
190
227
|
// Single-group: preserve the original early return — buffer left un-unlinked
|
|
191
228
|
// for a later retry, no receipt. Multi-group: skip only the failed group and
|
|
@@ -250,7 +287,7 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
|
|
|
250
287
|
// 'significant' | 'insignificant' | 'writefail'. CLAUDE_MEM_SKIP_EPISODE_LLM
|
|
251
288
|
// suppresses the detached enrichment spawn (test determinism; sibling of
|
|
252
289
|
// CLAUDE_MEM_SKIP_COMPRESS / _OPTIMIZE) — the synchronous immediate obs still lands.
|
|
253
|
-
function flushEpisodeGroup(ep) {
|
|
290
|
+
function flushEpisodeGroup(ep, db) {
|
|
254
291
|
const isSignificant = episodeHasSignificantContent(ep);
|
|
255
292
|
|
|
256
293
|
// Immediate save: rule-based observation for instant visibility; the LLM
|
|
@@ -258,7 +295,9 @@ function flushEpisodeGroup(ep) {
|
|
|
258
295
|
if (isSignificant) {
|
|
259
296
|
try {
|
|
260
297
|
const obs = buildImmediateObservation(ep);
|
|
261
|
-
|
|
298
|
+
// `db` is flushEpisode's handle — passed in so the caller owns open/close and the
|
|
299
|
+
// whole flush is gated on one availability check (B1).
|
|
300
|
+
const id = saveObservation(obs, ep.project, ep.sessionId, db);
|
|
262
301
|
if (id) ep.savedId = id;
|
|
263
302
|
} catch (e) { debugCatch(e, 'flushEpisode-immediateSave'); }
|
|
264
303
|
}
|
|
@@ -300,6 +339,23 @@ async function handlePostToolUse() {
|
|
|
300
339
|
|
|
301
340
|
const { tool_name, tool_input, tool_response } = hookData;
|
|
302
341
|
if (!tool_name) return;
|
|
342
|
+
// A non-string tool_name is a host-protocol violation, not a payload we can handle:
|
|
343
|
+
// `tool_name.startsWith(p)` two lines down threw a TypeError that the top-level catch
|
|
344
|
+
// absorbed, so the observation was dropped with nothing attributable behind it. Guard the
|
|
345
|
+
// type (parity with scripts/pre-skill-bridge.js:43) and RECORD it rather than dropping
|
|
346
|
+
// quietly: PostToolUse is the plugin's whole capture path, so a host field-shape change
|
|
347
|
+
// would kill every observation, and hook-errors/ is the only window into that — the same
|
|
348
|
+
// blindness that let the v3.60 binding outage run for 4 days. Volume is bounded by the
|
|
349
|
+
// recorder's 14-day retention and one short line per fire.
|
|
350
|
+
if (typeof tool_name !== 'string') {
|
|
351
|
+
recordHookError(
|
|
352
|
+
'post-tool-use:tool_name-type',
|
|
353
|
+
new TypeError(`tool_name is ${Array.isArray(tool_name) ? 'array' : typeof tool_name}, expected string`),
|
|
354
|
+
RUNTIME_DIR,
|
|
355
|
+
{ toolNameType: Array.isArray(tool_name) ? 'array' : typeof tool_name },
|
|
356
|
+
);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
303
359
|
|
|
304
360
|
// Skip noise (source of truth: skip-tools.mjs)
|
|
305
361
|
if (SKIP_TOOLS.has(tool_name)) return;
|
|
@@ -426,6 +482,17 @@ function triggerErrorRecall(db, toolInput, response) {
|
|
|
426
482
|
FROM observations_fts
|
|
427
483
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
428
484
|
WHERE observations_fts MATCH ? AND o.project = ?
|
|
485
|
+
-- Live-row invariant, same as every other model-facing retrieval path
|
|
486
|
+
-- (hook-context obsPool/fallbackObs/keyObs, hook-memory, search-engine,
|
|
487
|
+
-- recent/search/timeline/recall-core, pre-tool-recall, user-prompt-search).
|
|
488
|
+
-- This surface INLINES rows[0].lesson_learned into the model context, so an
|
|
489
|
+
-- unfiltered SELECT handed a retracted lesson to the agent verbatim while its
|
|
490
|
+
-- correction trailed as a bare pointer. compressed_into is filtered too, not
|
|
491
|
+
-- only for symmetry: the block's own footer is a mem_get(ids=...) pointer, and a
|
|
492
|
+
-- COMPRESSED_PENDING_PURGE row is queued for deletion by maintain purge_stale,
|
|
493
|
+
-- so that pointer would resolve to nothing.
|
|
494
|
+
AND COALESCE(o.compressed_into, 0) = 0
|
|
495
|
+
AND o.superseded_at IS NULL
|
|
429
496
|
AND ${notLowSignalTitleClause('o')}
|
|
430
497
|
ORDER BY ${OBS_BM25}
|
|
431
498
|
* (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / 1209600000.0))
|
|
@@ -497,10 +564,17 @@ async function handleStop() {
|
|
|
497
564
|
// Prevents data loss from concurrent PostToolUse writes between read and delete.
|
|
498
565
|
const epFile = episodeFile();
|
|
499
566
|
const claimFile = epFile + `.claim-${process.pid}-${Date.now()}`;
|
|
567
|
+
// Third instance of the B1 gate (flushEpisode and the SIGTERM salvage are the other
|
|
568
|
+
// two): this path already MOVED the buffer out of the way, so with no openable DB the
|
|
569
|
+
// `unlinkSync(claimFile)` below would destroy it just as surely — and the 1h orphan
|
|
570
|
+
// sweep would have eaten a restored-but-unnoticed claim file anyway. Open once, and
|
|
571
|
+
// put the buffer back under its real name when the save cannot happen.
|
|
572
|
+
let claimDb;
|
|
500
573
|
try {
|
|
501
574
|
renameSync(epFile, claimFile);
|
|
575
|
+
claimDb = openDb();
|
|
502
576
|
try {
|
|
503
|
-
const episode = JSON.parse(readFileSync(claimFile, 'utf8'));
|
|
577
|
+
const episode = claimDb ? JSON.parse(readFileSync(claimFile, 'utf8')) : null;
|
|
504
578
|
if (episode && episode.entries && episode.entries.length > 0 && episodeHasSignificantContent(episode)) {
|
|
505
579
|
if (!episode.sessionId) episode.sessionId = sessionId;
|
|
506
580
|
if (!episode.project) episode.project = project;
|
|
@@ -519,7 +593,7 @@ async function handleStop() {
|
|
|
519
593
|
if (!episodeHasSignificantContent(sub)) continue;
|
|
520
594
|
try {
|
|
521
595
|
const obs = buildImmediateObservation(sub);
|
|
522
|
-
const id = saveObservation(obs, sub.project, sub.sessionId);
|
|
596
|
+
const id = saveObservation(obs, sub.project, sub.sessionId, claimDb);
|
|
523
597
|
if (id) sub.savedId = id;
|
|
524
598
|
} catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
|
|
525
599
|
const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
|
|
@@ -528,7 +602,15 @@ async function handleStop() {
|
|
|
528
602
|
}
|
|
529
603
|
}
|
|
530
604
|
} finally {
|
|
531
|
-
|
|
605
|
+
if (claimDb) {
|
|
606
|
+
try { unlinkSync(claimFile); } catch {}
|
|
607
|
+
try { claimDb.close(); } catch { /* already gone */ }
|
|
608
|
+
} else {
|
|
609
|
+
// Nothing was (or could be) persisted — restore the buffer under its real name
|
|
610
|
+
// so the next fire retries it. If even the rename fails, the claim file stays
|
|
611
|
+
// and the 1h orphan sweep collects it, which is the pre-B1 behaviour.
|
|
612
|
+
try { renameSync(claimFile, epFile); } catch { /* leave it for sweepOrphanEpisodeFiles */ }
|
|
613
|
+
}
|
|
532
614
|
}
|
|
533
615
|
} catch (e) { debugCatch(e, 'handleStop-fallback'); }
|
|
534
616
|
}
|
|
@@ -1767,9 +1849,19 @@ try {
|
|
|
1767
1849
|
case 'auto-maintain': handleAutoMaintain(); break;
|
|
1768
1850
|
case 'llm-optimize': await handleLLMOptimize(); break;
|
|
1769
1851
|
// Detached update refresh spawned by handleSessionStart (audit P3d) — does the
|
|
1770
|
-
// GitHub fetch
|
|
1771
|
-
//
|
|
1772
|
-
|
|
1852
|
+
// GitHub fetch off the SessionStart critical path, writing update-state.json so
|
|
1853
|
+
// the NEXT session's cached banner is fresh.
|
|
1854
|
+
//
|
|
1855
|
+
// F6 staging: the detached update-check worker has not run since v2.85.0
|
|
1856
|
+
// (missing from BG_EVENTS). Restore the check + banner first; re-enable the
|
|
1857
|
+
// self-replacing install in a follow-up once this path has proven itself, so a
|
|
1858
|
+
// failure in either half is attributable. Without the option, hook-update.mjs's
|
|
1859
|
+
// `allowInstall = options.allowInstall ?? !pluginMode` defaults to TRUE on a
|
|
1860
|
+
// direct / settings.json install, so fixing F6 would switch a ten-week-dormant
|
|
1861
|
+
// self-installer back on in the same release that resurrects the worker. The
|
|
1862
|
+
// module default and the installer's own guards are unchanged — install.mjs
|
|
1863
|
+
// still passes allowInstall:true for the explicit, user-invoked update.
|
|
1864
|
+
case 'update-check': await checkForUpdate({ allowInstall: false }); break;
|
|
1773
1865
|
}
|
|
1774
1866
|
} catch (err) {
|
|
1775
1867
|
// Log fatal errors (ungated) with structured format. ERR_DLOPEN_FAILED (an
|
package/hooks/hooks.json
CHANGED
|
@@ -72,6 +72,16 @@
|
|
|
72
72
|
"timeout": 5
|
|
73
73
|
}
|
|
74
74
|
]
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"matcher": "Edit|Write|NotebookEdit",
|
|
78
|
+
"hooks": [
|
|
79
|
+
{
|
|
80
|
+
"type": "command",
|
|
81
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-launcher.mjs\" scripts/post-tool-recall.js",
|
|
82
|
+
"timeout": 3
|
|
83
|
+
}
|
|
84
|
+
]
|
|
75
85
|
}
|
|
76
86
|
],
|
|
77
87
|
"Stop": [
|
package/install.mjs
CHANGED
|
@@ -679,6 +679,24 @@ const memPostToolUse = {
|
|
|
679
679
|
}]
|
|
680
680
|
};
|
|
681
681
|
|
|
682
|
+
// Component 2 of the bind-salience forcing function: after an Edit/Write, flag an
|
|
683
|
+
// identifier the file's own lesson named that the edit just removed (component 1 is the
|
|
684
|
+
// pre-edit directive from scripts/pre-tool-recall.js, which also records the identifiers
|
|
685
|
+
// this one checks). Shipped, signed and tested since it was written, but registered in
|
|
686
|
+
// NEITHER registry — so `CLAUDE_MEM_SALIENCE=bind` delivered half the mechanism and
|
|
687
|
+
// nothing said so (audit B6, 2026-08-14). Matched on the edit tools only, NOT Read: there
|
|
688
|
+
// is no post-edit state to compare after a read. Inert (returns before touching stdin)
|
|
689
|
+
// unless CLAUDE_MEM_SALIENCE=bind, so the default chain pays one short-circuit spawn per
|
|
690
|
+
// edit and emits nothing.
|
|
691
|
+
const memPostToolRecall = {
|
|
692
|
+
matcher: 'Edit|Write|NotebookEdit',
|
|
693
|
+
hooks: [{
|
|
694
|
+
type: 'command',
|
|
695
|
+
command: nodeHook('scripts/post-tool-recall.js'),
|
|
696
|
+
timeout: 3
|
|
697
|
+
}]
|
|
698
|
+
};
|
|
699
|
+
|
|
682
700
|
const memSessionStart = {
|
|
683
701
|
matcher: 'startup|clear|compact',
|
|
684
702
|
hooks: [{
|
|
@@ -697,6 +715,21 @@ const memStop = {
|
|
|
697
715
|
}]
|
|
698
716
|
};
|
|
699
717
|
|
|
718
|
+
// Fires immediately BEFORE auto-compaction, re-emitting <claude-mem-context> so the
|
|
719
|
+
// summarizer that rewrites the transcript still has memory in scope (SessionStart's
|
|
720
|
+
// compact matcher fires AFTER, when the context is already gone). Parity with
|
|
721
|
+
// hooks/hooks.json: omitting it here made every settings.json install lose exactly the
|
|
722
|
+
// block that exists to survive compaction, invisibly — doctor only ever asked "are ANY
|
|
723
|
+
// mem hooks present", never "which events" (audit B3, 2026-08-14).
|
|
724
|
+
const memPreCompact = {
|
|
725
|
+
matcher: '*',
|
|
726
|
+
hooks: [{
|
|
727
|
+
type: 'command',
|
|
728
|
+
command: nodeHook('hook.mjs', 'pre-compact'),
|
|
729
|
+
timeout: 5
|
|
730
|
+
}]
|
|
731
|
+
};
|
|
732
|
+
|
|
700
733
|
const memUserPrompt = {
|
|
701
734
|
matcher: '*',
|
|
702
735
|
hooks: [
|
|
@@ -754,9 +787,14 @@ const memPreAgentInject = {
|
|
|
754
787
|
|
|
755
788
|
// Filter out existing mem hooks, then append fresh ones
|
|
756
789
|
// PreToolUse has three separate matchers, so we register all three
|
|
790
|
+
// Event set MUST stay equal to hooks/hooks.json's (minus scripts/setup.sh, which
|
|
791
|
+
// bootstraps the plugin cache and has no settings.json counterpart) —
|
|
792
|
+
// tests/audit-silent-20260814.test.mjs diffs a real `install --dev` run's
|
|
793
|
+
// settings.json against the shipped manifest and reds on any new divergence.
|
|
757
794
|
const hookConfigs = {
|
|
758
795
|
PreToolUse: [memPreToolRecall, memPreSkillBridge, memPreAgentInject],
|
|
759
|
-
PostToolUse: [memPostToolUse],
|
|
796
|
+
PostToolUse: [memPostToolUse, memPostToolRecall],
|
|
797
|
+
PreCompact: [memPreCompact],
|
|
760
798
|
SessionStart: [memSessionStart],
|
|
761
799
|
Stop: [memStop],
|
|
762
800
|
UserPromptSubmit: [memUserPrompt],
|
|
@@ -768,7 +806,10 @@ for (const [event, configs] of Object.entries(hookConfigs)) {
|
|
|
768
806
|
}
|
|
769
807
|
|
|
770
808
|
writeSettings(settings);
|
|
771
|
-
|
|
809
|
+
// Derived from the map, not a parallel literal: the pre-B3 line said five events and
|
|
810
|
+
// kept saying five after the map changed, which is how a missing registration reads as
|
|
811
|
+
// a successful one.
|
|
812
|
+
ok(`Hooks configured (${Object.keys(hookConfigs).join(', ')})`);
|
|
772
813
|
}
|
|
773
814
|
|
|
774
815
|
function backupLegacyClaudeMemData() {
|
package/lib/recall-core.mjs
CHANGED
|
@@ -13,6 +13,13 @@ import { notLowSignalTitleClause } from '../utils.mjs';
|
|
|
13
13
|
* { filename, rows } where rows carry the column superset both surfaces render.
|
|
14
14
|
* Side effect: bumps access_count / last_accessed_at on every returned row —
|
|
15
15
|
* recall IS engagement, and the tier/decay system feeds on these counters.
|
|
16
|
+
*
|
|
17
|
+
* `superseded_at IS NULL` is load-bearing twice over (audit B2, 2026-08-14): recall was
|
|
18
|
+
* the ONE retrieval path missing it, so a lesson a later save explicitly retracted
|
|
19
|
+
* (`--supersedes N`) was still served to an agent about to edit that very file — and the
|
|
20
|
+
* access-count bump below runs over exactly these rows, so the tombstone was ALSO pushed
|
|
21
|
+
* back up the decay/tier system on every read. `includeNoise` is about LOW_SIGNAL titles
|
|
22
|
+
* and must not reach this clause: nobody asks for retracted content.
|
|
16
23
|
*/
|
|
17
24
|
export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}) {
|
|
18
25
|
const filename = basename(file);
|
|
@@ -25,6 +32,7 @@ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}
|
|
|
25
32
|
FROM observations o
|
|
26
33
|
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
27
34
|
WHERE COALESCE(o.compressed_into, 0) = 0
|
|
35
|
+
AND o.superseded_at IS NULL
|
|
28
36
|
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
29
37
|
${noiseClause}
|
|
30
38
|
ORDER BY o.created_at_epoch DESC
|
package/mem-cli.mjs
CHANGED
|
@@ -43,7 +43,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
|
43
43
|
// move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
|
|
44
44
|
import { isNativeBindingError, healAndReexec } from './lib/binding-probe.mjs';
|
|
45
45
|
import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
|
|
46
|
-
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
|
|
46
|
+
import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
|
|
47
47
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
48
48
|
import { rebuildObservationDerived, normalizeScope, insertObservationVector } from './lib/observation-write.mjs';
|
|
49
49
|
import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
|
|
@@ -533,7 +533,7 @@ function renderObsRows(db, ids, requestedFields) {
|
|
|
533
533
|
const formatted = formatObsFieldValue(f, val);
|
|
534
534
|
const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
|
|
535
535
|
const display = typeof formatted === 'string' && formatted.length > maxLen ? formatted.slice(0, maxLen) + '…' : formatted;
|
|
536
|
-
lines.push(`${f}: ${display}`);
|
|
536
|
+
lines.push(`${obsFieldLabel(f)}: ${display}`);
|
|
537
537
|
}
|
|
538
538
|
parts.push(lines.join('\n'));
|
|
539
539
|
}
|
|
@@ -1327,7 +1327,13 @@ function cmdContext(db, args) {
|
|
|
1327
1327
|
}
|
|
1328
1328
|
out(JSON.stringify(result, null, 2));
|
|
1329
1329
|
} else {
|
|
1330
|
-
|
|
1330
|
+
// outVerbatim: `context` is the one CLI command that must EMIT a real
|
|
1331
|
+
// <claude-mem-context> wrapper — it prints the same block the SessionStart hook
|
|
1332
|
+
// injects, so `out`'s defang would strip the delimiters this command exists to
|
|
1333
|
+
// produce (the CLI twin of why <skill-loaded> is excluded from CONTEXT_DELIMITER_RE).
|
|
1334
|
+
// The untrusted half is already neutralized one layer up: buildSessionContextLines
|
|
1335
|
+
// defangs every row it renders, so only the trusted wrapper is written raw here.
|
|
1336
|
+
outVerbatim(`<claude-mem-context>\n${block}\n</claude-mem-context>`);
|
|
1331
1337
|
}
|
|
1332
1338
|
}
|
|
1333
1339
|
|
|
@@ -1714,15 +1720,20 @@ function cmdExport(db, args) {
|
|
|
1714
1720
|
// jsonl → 0 lines (valid empty file)
|
|
1715
1721
|
// The friendly note goes to stderr so it doesn't poison stdout for callers
|
|
1716
1722
|
// piping to a parser.
|
|
1717
|
-
if (format === 'json')
|
|
1723
|
+
if (format === 'json') outVerbatim('[]');
|
|
1718
1724
|
process.stderr.write('[mem] No observations found matching criteria\n');
|
|
1719
1725
|
return;
|
|
1720
1726
|
}
|
|
1721
1727
|
|
|
1728
|
+
// outVerbatim, NOT out: `out` neutralizes structural context delimiters (cli/common.mjs)
|
|
1729
|
+
// because CLI stdout is model context — but this stream is a BACKUP that `restore` reads
|
|
1730
|
+
// back, so defanging it would silently rewrite any row whose text legitimately contains
|
|
1731
|
+
// `<system-reminder>`/`</claude-mem-context>` and persist the rewrite on restore. Mirrors
|
|
1732
|
+
// safeHandler(mem_export, { verbatim: true }) on the MCP side (audit 2026-08-14 A1).
|
|
1722
1733
|
if (format === 'jsonl') {
|
|
1723
|
-
for (const r of rows)
|
|
1734
|
+
for (const r of rows) outVerbatim(JSON.stringify(r));
|
|
1724
1735
|
} else {
|
|
1725
|
-
|
|
1736
|
+
outVerbatim(JSON.stringify(rows, null, 2));
|
|
1726
1737
|
}
|
|
1727
1738
|
|
|
1728
1739
|
if (limitGiven && rows.length >= limit) {
|
|
@@ -1971,7 +1982,7 @@ function cmdMaintain(db, args) {
|
|
|
1971
1982
|
out(` Broken (no title/narrative): ${stats.broken}`);
|
|
1972
1983
|
out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
|
|
1973
1984
|
out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
|
|
1974
|
-
out(
|
|
1985
|
+
out(formatPendingPurgeLine(stats.pendingPurge));
|
|
1975
1986
|
if (duplicates.length > 0) {
|
|
1976
1987
|
const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
|
|
1977
1988
|
const manualReview = duplicates.filter(d => parseFloat(d.similarity) < AUTO_MERGE_THRESHOLD);
|
|
@@ -3119,8 +3130,12 @@ async function cmdOptimize(db, args) {
|
|
|
3119
3130
|
if (project) out(` Project filter: ${project}`);
|
|
3120
3131
|
out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}${preview.reenrichAliases ? ` (aliases scope: ${preview.reenrichAliases})` : ''}`);
|
|
3121
3132
|
out(` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`);
|
|
3122
|
-
|
|
3123
|
-
|
|
3133
|
+
// "candidates" matches the MCP wording (server.mjs mem_optimize preview) AND the
|
|
3134
|
+
// Re-enrich line just above, which already read that way on both surfaces. The two
|
|
3135
|
+
// surfaces render one optimizePreview() result — tests/audit-findings-20260814.test.mjs
|
|
3136
|
+
// drives both and compares the label lists, so the drift cannot reopen.
|
|
3137
|
+
out(` Cluster-merge candidates: ${preview.clusterMerge} clusters`);
|
|
3138
|
+
out(` Smart-compress candidates: ${preview.smartCompress} clusters`);
|
|
3124
3139
|
out(` Total: ${preview.total} items`);
|
|
3125
3140
|
if (verbose) {
|
|
3126
3141
|
out('');
|
|
@@ -3194,7 +3209,7 @@ export async function run(argv) {
|
|
|
3194
3209
|
for (const { flag, suggestion } of suggestUnknownFlags(parseArgs(cmdArgs).flags)) {
|
|
3195
3210
|
process.stderr.write(suggestion
|
|
3196
3211
|
? `[mem] Unknown flag --${flag}; did you mean --${suggestion}?\n`
|
|
3197
|
-
: `[mem] Unknown flag --${flag} — ignored
|
|
3212
|
+
: `[mem] Unknown flag --${flag} — ignored, it had no effect. Run "claude-mem-lite help" for this command's flags.\n`);
|
|
3198
3213
|
}
|
|
3199
3214
|
|
|
3200
3215
|
// adopt / unadopt do pure filesystem work on ~/.claude/projects/<encoded>/memory/ —
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.62.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.62.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.62.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",
|
|
@@ -114,7 +114,7 @@ const OR_TOP_BM25_FLOOR = TOP_REL_FLOOR === 0
|
|
|
114
114
|
? 0
|
|
115
115
|
: Number(process.env.CLAUDE_MEM_UPS_OR_BM25_MIN || 30);
|
|
116
116
|
|
|
117
|
-
// ─── Corpus-size normalization of the absolute floors (v3.
|
|
117
|
+
// ─── Corpus-size normalization of the absolute floors (v3.61.0) ─────────────
|
|
118
118
|
//
|
|
119
119
|
// Both floors above are ABSOLUTE magnitudes, but the quantity they gate is not
|
|
120
120
|
// scale-free: FTS5 bm25 carries an IDF term ≈ ln(N/df), so the SAME hit scores
|
package/secret-scrub.mjs
CHANGED
|
@@ -30,16 +30,45 @@ export const SECRET_PATTERNS = [
|
|
|
30
30
|
// value is covered (the hex-only assignment pattern below misses non-hex values).
|
|
31
31
|
// 1a. `=` assignment → ALWAYS scrub (config syntax, never prose):
|
|
32
32
|
[/((?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*=\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
33
|
-
// 1b. `:` separator, PASSWORD nouns
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// "<word> password:
|
|
41
|
-
//
|
|
42
|
-
|
|
33
|
+
// 1b. `:` separator, PASSWORD nouns. Position decides how permissive the value
|
|
34
|
+
// class may be, because the two positions have opposite error costs.
|
|
35
|
+
//
|
|
36
|
+
// CONFIG position (start of line, or not preceded by an English word +
|
|
37
|
+
// space) is unambiguous assignment syntax → scrub any value, exactly as
|
|
38
|
+
// before. Pinned by the ` password: hunter2` indent cases.
|
|
39
|
+
//
|
|
40
|
+
// PROSE position ("<word> password: …") is where v3.61.0 first removed the
|
|
41
|
+
// lookbehind outright, to stop "deployed to staging, the db password:
|
|
42
|
+
// hunter2correct" from persisting a credential. That closed a leak by
|
|
43
|
+
// trading it for something worse: scrubbing runs on the WRITE path, and the
|
|
44
|
+
// value class matches ordinary English, so "Reset the password: instructions
|
|
45
|
+
// are in the onboarding doc" was stored irreversibly as "password: *** are
|
|
46
|
+
// in the onboarding doc" (caught by independent pre-tag review). The claim
|
|
47
|
+
// that "<word> password: <6+ chars>" always names a credential was simply
|
|
48
|
+
// false. So in prose position the VALUE must look like a credential: not a
|
|
49
|
+
// run of lowercase letters. A digit, any uppercase, or a symbol qualifies —
|
|
50
|
+
// `hunter2correct`, `S3cretValue`, `correct-horse-battery-staple` all scrub,
|
|
51
|
+
// while `instructions` / `rotation` / `yesterday` are left alone.
|
|
52
|
+
//
|
|
53
|
+
// "Credential-shaped" is spelled as: NOT a single run of ≤15 letters. The
|
|
54
|
+
// patterns carry `i`, so the letter class is case-insensitive by
|
|
55
|
+
// construction — deliberately, because prose capitalizes ("Reset the
|
|
56
|
+
// password: Instructions are in the doc" must survive, and a
|
|
57
|
+
// lowercase-only test would corrupt it). The length bound is what still
|
|
58
|
+
// catches a letters-only secret: English words in prose run short, secrets
|
|
59
|
+
// do not, so `aVeryLongOpaqueSecretToken` (26) scrubs while `instructions`
|
|
60
|
+
// (12) does not.
|
|
61
|
+
//
|
|
62
|
+
// Two known, accepted gaps: a short letters-only password in prose position
|
|
63
|
+
// ("the password: hunter") survives, and an English word longer than 15
|
|
64
|
+
// letters is over-scrubbed. Config position still catches the former; the
|
|
65
|
+
// latter is rare in prose and errs toward protecting a secret. A value
|
|
66
|
+
// indistinguishable from an English word cannot be told from one without
|
|
67
|
+
// corrupting prose — which is exactly the error this arm exists to undo.
|
|
68
|
+
// Both arms emit `***` (3 chars, under the {6,} floor), so they cannot
|
|
69
|
+
// double-apply.
|
|
70
|
+
[/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|passphrase)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
71
|
+
[/((?:\b|_)(?:password|passwd|passphrase)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)(?![A-Za-z]{1,15}(?=[\s,;'"}\]]|$))[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
43
72
|
// 1c. `:` separator, prose-ambiguous nouns → keep the lookbehind ("the token: alice"):
|
|
44
73
|
[/((?<![A-Za-z][ \t])(?:\b|_)(?:token|bearer|secret)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
45
74
|
// access_token / refresh_token are the canonical OAuth2 field names — they were
|
package/server.mjs
CHANGED
|
@@ -27,8 +27,11 @@ import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
|
|
|
27
27
|
import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
28
28
|
import { computeStatsFeed } from './lib/stats-core.mjs';
|
|
29
29
|
import { buildLessonNudge } from './lib/save-nudge.mjs';
|
|
30
|
-
import { formatObsFieldValue } from './cli/common.mjs';
|
|
31
|
-
|
|
30
|
+
import { formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
|
|
31
|
+
// The partial-export warning points the caller at the CLI twin, which exports the complete
|
|
32
|
+
// set by default — the invocation has to be the one that actually works on this install.
|
|
33
|
+
import { CLI_INVOKE } from './cli-path.mjs';
|
|
34
|
+
import { neutralizeContextDelimiters, neutralizeSkillDelimiters } from './format-utils.mjs';
|
|
32
35
|
import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
|
|
33
36
|
|
|
34
37
|
// Lookup helper: all user-facing tool descriptions live in tool-schemas.mjs
|
|
@@ -638,7 +641,7 @@ server.registerTool(
|
|
|
638
641
|
// gets a scannable hint instead of a bare millisecond integer.
|
|
639
642
|
const display = formatObsFieldValue(f, val);
|
|
640
643
|
const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
|
|
641
|
-
lines.push(`${f}: ${typeof display === 'string' && display.length > maxLen ? display.slice(0, maxLen) + '…' : display}`);
|
|
644
|
+
lines.push(`${obsFieldLabel(f)}: ${typeof display === 'string' && display.length > maxLen ? display.slice(0, maxLen) + '…' : display}`);
|
|
642
645
|
}
|
|
643
646
|
sections.push(lines.join('\n'));
|
|
644
647
|
}
|
|
@@ -1113,7 +1116,7 @@ server.registerTool(
|
|
|
1113
1116
|
` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`,
|
|
1114
1117
|
` Broken (no title/narrative): ${stats.broken}`,
|
|
1115
1118
|
` Boostable (accessed>3, imp<3): ${stats.boostable}`,
|
|
1116
|
-
|
|
1119
|
+
formatPendingPurgeLine(stats.pendingPurge),
|
|
1117
1120
|
];
|
|
1118
1121
|
if (duplicates.length > 0) {
|
|
1119
1122
|
const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
|
|
@@ -1567,6 +1570,11 @@ server.registerTool(
|
|
|
1567
1570
|
|
|
1568
1571
|
// ─── Tool: mem_use ──────────────────────────────────────────────────────────
|
|
1569
1572
|
|
|
1573
|
+
// Cap on the caller-supplied name echoed back in a miss message. Well past any real
|
|
1574
|
+
// skill/agent name (the longest registered one here is 23 chars), short enough that an
|
|
1575
|
+
// unbounded argument cannot pad the response — the echo appears twice.
|
|
1576
|
+
const ECHO_NAME_MAX = 80;
|
|
1577
|
+
|
|
1570
1578
|
server.registerTool(
|
|
1571
1579
|
'mem_use',
|
|
1572
1580
|
{
|
|
@@ -1582,8 +1590,8 @@ server.registerTool(
|
|
|
1582
1590
|
const name = args.name.trim();
|
|
1583
1591
|
const type = args.type || 'skill';
|
|
1584
1592
|
|
|
1585
|
-
// 1. Exact match by name or invocation_name
|
|
1586
|
-
|
|
1593
|
+
// 1. Exact match by name or invocation_name — the ONLY path that loads content.
|
|
1594
|
+
const row = rdb.prepare(`
|
|
1587
1595
|
SELECT id, name, type, local_path, invocation_name, capability_summary
|
|
1588
1596
|
FROM resources
|
|
1589
1597
|
WHERE status = 'active' AND type = ?
|
|
@@ -1591,16 +1599,36 @@ server.registerTool(
|
|
|
1591
1599
|
LIMIT 1
|
|
1592
1600
|
`).get(type, name, name);
|
|
1593
1601
|
|
|
1594
|
-
// 2.
|
|
1602
|
+
// 2. Name miss → SUGGEST, never substitute. The FTS5 search still runs (it is what
|
|
1603
|
+
// produces the candidate list), but its result is only ever rendered as names: loading
|
|
1604
|
+
// the top hit under the caller's requested name shipped a different skill's body inside
|
|
1605
|
+
// <skill-loaded> plus "Follow the instructions above to execute this <type>." — with
|
|
1606
|
+
// nothing marking the swap, so an agent that asked for A executed B (audit F1,
|
|
1607
|
+
// 2026-08-14: with only `deploy-rollback-runbook` registered, `deploy-notes` /
|
|
1608
|
+
// `rollback-checklist` / `runbook-index` each returned its full body). Loading stays an
|
|
1609
|
+
// exact-name decision the caller makes.
|
|
1595
1610
|
if (!row) {
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1611
|
+
let candidates = [];
|
|
1612
|
+
try { candidates = searchResources(rdb, name, { type, limit: 5 }).map((r) => r.name).filter(Boolean); }
|
|
1613
|
+
catch { /* a suggestion is best-effort; the miss message below still stands */ }
|
|
1614
|
+
// Every echo of the caller's own name below is bounded + delimiter-inert (audit F7):
|
|
1615
|
+
// raw interpolation let a crafted `name` forge a <skill-loaded> block and the execute
|
|
1616
|
+
// imperative inside this message, and the handler-wide defangResult cannot catch it —
|
|
1617
|
+
// <skill-loaded> is off CONTEXT_DELIMITER_RE precisely so the real load path can emit
|
|
1618
|
+
// it. `truncate` also folds newlines, so a multi-line name cannot fake block structure.
|
|
1619
|
+
// Registered names are defanged too (a crafted one can be imported), but NOT truncated:
|
|
1620
|
+
// the suggestion tells the caller to load one by its exact name, so it must stay exact.
|
|
1621
|
+
const echoed = neutralizeSkillDelimiters(truncate(name, ECHO_NAME_MAX));
|
|
1622
|
+
const echoedCandidates = candidates.map((n) => neutralizeSkillDelimiters(n));
|
|
1623
|
+
const head = `No ${type} found for "${echoed}".`;
|
|
1624
|
+
const browse = `mem_registry(action="search", query="${echoed}")`;
|
|
1625
|
+
if (candidates.length === 0) {
|
|
1626
|
+
return { content: [{ type: 'text', text: `${head} Try ${browse} to browse.` }] };
|
|
1599
1627
|
}
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1628
|
+
const list = echoedCandidates.map((n) => ` - ${n}`).join('\n');
|
|
1629
|
+
return { content: [{ type: 'text', text:
|
|
1630
|
+
`${head} Closest ${type}s by search (NOT loaded — none matched the name you asked for):\n${list}\n\n` +
|
|
1631
|
+
`Load one deliberately with its exact name, e.g. mem_use(name="${echoedCandidates[0]}"${type === 'skill' ? '' : `, type="${type}"`}), or browse with ${browse}.` }] };
|
|
1604
1632
|
}
|
|
1605
1633
|
|
|
1606
1634
|
// 3. Resolve path: directory skills → SKILL.md (agents always have full .md paths)
|
|
@@ -1717,7 +1745,13 @@ async function runExport(db, args) {
|
|
|
1717
1745
|
}
|
|
1718
1746
|
|
|
1719
1747
|
const where = wheres.length > 0 ? 'WHERE ' + wheres.join(' AND ') : '';
|
|
1720
|
-
|
|
1748
|
+
// No clamp (audit 2026-08-14 A2): `Math.min(args.limit ?? 200, 1000)` made an MCP-driven
|
|
1749
|
+
// backup of a >1000-row store impossible, on the tool whose own description says "USE
|
|
1750
|
+
// when: Backing up memory before a migration or reinstall" — while the CLI twin exported
|
|
1751
|
+
// the complete matching set (mem-cli.mjs cmdExport, fixed there for the same reason). The
|
|
1752
|
+
// DEFAULT stays 200: an MCP result is model context, so a bare exploratory call must not
|
|
1753
|
+
// dump a whole store into the transcript. An explicit limit is now honoured at any size.
|
|
1754
|
+
const exportLimit = args.limit ?? 200;
|
|
1721
1755
|
// T3-P2-B: probe limit+1 so we can tell "user hit their own limit with more waiting" from
|
|
1722
1756
|
// "user got exactly what existed". Trim to exportLimit before rendering.
|
|
1723
1757
|
// EXPORT_COLUMNS_SQL: shared with CLI cmdExport — the full round-trippable set restore
|
|
@@ -1733,7 +1767,27 @@ async function runExport(db, args) {
|
|
|
1733
1767
|
? rows.map(r => JSON.stringify(r)).join('\n')
|
|
1734
1768
|
: JSON.stringify(rows, null, 2);
|
|
1735
1769
|
|
|
1736
|
-
|
|
1770
|
+
// A truncated backup is the failure mode this tool must never produce quietly: the old
|
|
1771
|
+
// note ("Results capped at N … increase limit (max 1000)") never said how much was
|
|
1772
|
+
// missing, and its advice was a dead end on a store past the ceiling. Name the real
|
|
1773
|
+
// total, the number of rows left out, and a remedy that actually returns all of them.
|
|
1774
|
+
//
|
|
1775
|
+
// The remedy has to lead with a FILE REDIRECT (pre-tag review, 2026-08-14). Removing the
|
|
1776
|
+
// 1000-row ceiling took away the only bound on an MCP export's size, and the 200 default
|
|
1777
|
+
// is kept precisely because an MCP result IS model context — so "re-run with
|
|
1778
|
+
// limit: <total>", the previous first suggestion, told the caller to put the entire store
|
|
1779
|
+
// into one tool result (thin fixture rows measure ~612 bytes each, so a few-thousand-row
|
|
1780
|
+
// store is megabytes in a single message), and a bare `cli.mjs export` had the same
|
|
1781
|
+
// property unredirected. The limit re-run stays mentioned but demoted, with its cost
|
|
1782
|
+
// stated: it is occasionally what the caller actually wants, and silently dropping it
|
|
1783
|
+
// would send them back to guessing.
|
|
1784
|
+
let cap = '';
|
|
1785
|
+
if (moreAvailable) {
|
|
1786
|
+
const total = db.prepare(`SELECT COUNT(*) AS c FROM observations ${where}`).get(...params).c;
|
|
1787
|
+
cap = `\nWARNING — PARTIAL EXPORT, NOT A COMPLETE BACKUP: capped at ${exportLimit} of ${total} matching observations; ${total - exportLimit} rows are missing from this payload and restoring it would lose them.` +
|
|
1788
|
+
`\nFor a complete backup, write it to a FILE instead of pulling it through this conversation: \`${CLI_INVOKE} export --format jsonl > backup.jsonl\` (the CLI exports the complete set by default). Narrowing with date_from/date_to also works.` +
|
|
1789
|
+
`\nRaising \`limit\` here is the last resort, not the first: this result is model context, so all ${total} rows would be loaded into the transcript.`;
|
|
1790
|
+
}
|
|
1737
1791
|
return { content: [{ type: 'text', text: `Exported ${rows.length} observations:${cap}\n${output}` }] };
|
|
1738
1792
|
}
|
|
1739
1793
|
|
|
@@ -1745,7 +1799,10 @@ server.registerTool(
|
|
|
1745
1799
|
},
|
|
1746
1800
|
// verbatim: the export payload feeds `restore` — defanging it would silently
|
|
1747
1801
|
// rewrite backed-up rows whose text legitimately contains these tags.
|
|
1748
|
-
safeHandler(
|
|
1802
|
+
safeHandler(
|
|
1803
|
+
async (args) => runExport(db, applyArgAliases(args, { from: 'date_from', to: 'date_to' })),
|
|
1804
|
+
{ verbatim: true },
|
|
1805
|
+
)
|
|
1749
1806
|
);
|
|
1750
1807
|
|
|
1751
1808
|
// ─── Tool: mem_recall ────────────────────────────────────────────────────────
|
package/tool-schemas.mjs
CHANGED
|
@@ -100,7 +100,7 @@ export const memSearchSchema = {
|
|
|
100
100
|
or: coerceBool.optional().describe('Force OR semantics between query terms from the start (default: AND with automatic OR-fallback when AND returns 0). Aligns with CLI --or.'),
|
|
101
101
|
deep: coerceBool.optional().describe('Tri-state LLM multi-query/HyDE deep search (observations-only). true=force; false=never; omit=AUTO (default ON for mem_search): a normal search that returns weak/few results auto-escalates with ONE Haiku call (query rewritten to keyword/concept/HyDE variants, RRF-fused). Set CLAUDE_MEM_AUTO_DEEP=0 to disable AUTO. Passive recall stays single-query.'),
|
|
102
102
|
rerank: coerceBool.optional().describe('Opt-in: LLM-rerank the deep-search candidates for ranking precision (one extra Haiku call, ~1.4s). Requires deep=true (no effect on AUTO/normal). Reserve for hard, ranking-sensitive queries where the right memory is likely retrieved but mis-ranked — skip for routine search. Default off.'),
|
|
103
|
-
// ── CLI-flag aliases (v3.
|
|
103
|
+
// ── CLI-flag aliases (v3.61.0) ──────────────────────────────────────────────
|
|
104
104
|
// A property the schema doesn't declare is STRIPPED by the validator, so a caller
|
|
105
105
|
// using the CLI vocabulary (`--source` / `--from` / `--to` / `--since`) previously
|
|
106
106
|
// got the UNFILTERED answer with nothing marking the filter as dropped — a wider
|
|
@@ -206,7 +206,7 @@ export const memSaveSchema = {
|
|
|
206
206
|
type: OBS_TYPE_ENUM.optional().describe('Observation type (default: discovery)'),
|
|
207
207
|
project: z.string().optional().describe('Project name (default: inferred from CWD)'),
|
|
208
208
|
importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('Importance level: 1=routine, 2=notable, 3=critical (default: 2 for explicit saves)'),
|
|
209
|
-
files: coerceStringArray.optional().describe('File paths associated with this observation'),
|
|
209
|
+
files: coerceStringArray.optional().describe('File paths associated with this observation. Stored in the `files_modified` column and rendered as `files` — passing a path here does not assert the file was edited; a file you only read belongs here too'),
|
|
210
210
|
lesson_learned: z.string().max(500).optional().describe('Key lesson or takeaway, ≤500 chars (for bugfix: root cause & fix; for decision: rationale)'),
|
|
211
211
|
closes_deferred: coerceDeferredTokens.optional().describe('Close one or more deferred_work items in the same project. Mixed array: bare integer = ordinal-within-project, "D#<n>" string = raw id. Transactional with the obs insert — a single invalid id rolls back the whole save.'),
|
|
212
212
|
supersedes: coerceSupersedes.optional().describe('Observation ids (same project) that this save overturns. They are marked superseded — dropped from live search — and linked to the new row (superseded_by). Use ONLY when this genuinely replaces a prior conclusion; do NOT use for merely-related or updated-but-still-valid memories.'),
|
|
@@ -274,8 +274,19 @@ export const memExportSchema = {
|
|
|
274
274
|
format: z.enum(['json', 'jsonl']).optional().describe('Output format (default: json)'),
|
|
275
275
|
date_from: z.string().optional().describe('Start date (ISO 8601 or YYYY-MM-DD)'),
|
|
276
276
|
date_to: z.string().optional().describe('End date (ISO 8601 or YYYY-MM-DD)'),
|
|
277
|
+
// CLI-flag aliases — `export` reads flags.from / flags.to (mem-cli.mjs). Same
|
|
278
|
+
// silent-drop defect the search/recent aliases fix, and the widest blast radius of
|
|
279
|
+
// the three: an ignored bound exports the whole DB instead of a date slice.
|
|
280
|
+
from: z.string().optional().describe('Alias for `date_from` (CLI `export --from`)'),
|
|
281
|
+
to: z.string().optional().describe('Alias for `date_to` (CLI `export --to`)'),
|
|
277
282
|
include_compressed: coerceBool.optional().describe('Include compressed observations (default: false)'),
|
|
278
|
-
|
|
283
|
+
// No upper bound: this is the BACKUP tool ("USE when: Backing up memory before a
|
|
284
|
+
// migration or reinstall"), and a 1000-row ceiling made a complete backup of a bigger
|
|
285
|
+
// store impossible over MCP while the CLI twin exported everything (audit 2026-08-14 A2).
|
|
286
|
+
// The default stays 200 because an MCP result is model context — a bare exploratory call
|
|
287
|
+
// must not dump a whole store into the transcript — but a capped result now announces
|
|
288
|
+
// itself as PARTIAL and names the limit that would return all of it.
|
|
289
|
+
limit: coerceInt.pipe(z.number().int().min(1)).optional().describe('Max observations to export (default: 200 — a capped result is flagged PARTIAL and names the total; pass that total for a complete backup, no upper bound)'),
|
|
279
290
|
};
|
|
280
291
|
|
|
281
292
|
export const memRecallSchema = {
|
|
@@ -584,7 +595,8 @@ export const tools = [
|
|
|
584
595
|
{
|
|
585
596
|
name: 'mem_use',
|
|
586
597
|
description:
|
|
587
|
-
'Load and activate a skill or agent from the registry by name
|
|
598
|
+
'Load and activate a skill or agent from the registry by EXACT name or invocation_name.\n' +
|
|
599
|
+
'A name that matches nothing returns closest-match names to pick from — never another resource\'s content.\n' +
|
|
588
600
|
'\n' +
|
|
589
601
|
'DO NOT use when:\n' +
|
|
590
602
|
' - You have not confirmed the skill exists (run mem_registry action="list" first)\n' +
|