claude-mem-lite 3.68.0 → 3.69.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 +11 -2
- package/hook-semaphore.mjs +11 -0
- package/hook.mjs +17 -3
- package/install.mjs +51 -12
- package/lib/doctor-drift.mjs +27 -0
- package/lib/get-core.mjs +15 -0
- package/lib/import-jsonl.mjs +17 -3
- package/lib/observation-write.mjs +68 -4
- package/mem-cli.mjs +31 -8
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/project-utils.mjs +14 -0
- package/scripts/pre-tool-recall.js +7 -15
- package/scripts/user-prompt-search.js +73 -8
- package/server.mjs +9 -1
- package/tool-schemas.mjs +12 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.69.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.69.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
|
@@ -200,10 +200,10 @@ export const KNOWN_CLI_FLAGS = new Set([
|
|
|
200
200
|
'after', 'age-days', 'all', 'anchor', 'batch', 'before', 'benchmark', 'body', 'branch',
|
|
201
201
|
'capability-summary', 'category', 'closes-deferred', 'concepts', 'confirm', 'days', 'deep',
|
|
202
202
|
'detail', 'domain-tags', 'dry-run', 'enrich', 'execute', 'fields', 'file', 'files', 'floors',
|
|
203
|
-
'force', 'format', 'from', '
|
|
203
|
+
'force', 'format', 'from', 'help', 'id', 'ids', 'content', 'importance', 'include-compressed', 'include-noise',
|
|
204
204
|
'intent-tags', 'invocation-name', 'json', 'key', 'keywords', 'lesson', 'lesson-learned', 'limit',
|
|
205
205
|
'local-path', 'margins', 'max', 'memdir', 'merge-ids', 'metrics', 'name', 'narrative', 'no-deep',
|
|
206
|
-
'offset', 'ops', 'or', '
|
|
206
|
+
'offset', 'ops', 'or', 'priority', 'project', 'quality', 'query', 'reason', 'repo-url',
|
|
207
207
|
'rerank', 'resource-type', 'retain-days', 'retry', 'run', 'run-all', 'scope', 'session-audit',
|
|
208
208
|
'sidechain', 'since', 'sort', 'source', 'status', 'sweep', 'task', 'tech-stack', 'text', 'tier', 'title',
|
|
209
209
|
'to', 'trigger-patterns', 'type', 'use-cases', 'verbose',
|
|
@@ -217,6 +217,15 @@ export const KNOWN_CLI_FLAGS = new Set([
|
|
|
217
217
|
// warn-on-every-unknown-flag flip turned the omission into a false warning on a
|
|
218
218
|
// documented, working command.
|
|
219
219
|
'prompts-limit',
|
|
220
|
+
// Entries here MUST be read by a `claude-mem-lite` subcommand. A flag that no
|
|
221
|
+
// command reads is worse than an absent one: it converts the "ignored, it had no
|
|
222
|
+
// effect" warning into silence, so the user's dropped flag reads as accepted.
|
|
223
|
+
// `out` sat here until the 2026-08-17 e2e round for that exact reason: `--out` is a
|
|
224
|
+
// benchmark-script flag (benchmark/longmemeval-rerank.mjs), never a CLI one, so
|
|
225
|
+
// `export --out backup.json` printed the whole export to stdout and said nothing
|
|
226
|
+
// about the file it did not write. `has` went the same round: no reader, no help
|
|
227
|
+
// entry, and (per the v3.34.0 notes) the source of the misleading "did you mean
|
|
228
|
+
// --has?" suggestion. Locked by tests/cli-flag-allowlist.test.mjs.
|
|
220
229
|
]);
|
|
221
230
|
|
|
222
231
|
/** Levenshtein distance, early-exit past `max` (cheap enough for a handful of flags). */
|
package/hook-semaphore.mjs
CHANGED
|
@@ -56,6 +56,17 @@ export const sleepMs = (ms) => new Promise(r => setTimeout(r, ms));
|
|
|
56
56
|
// same age escape hatch the reaper applies to other processes' slots. 0 = not held.
|
|
57
57
|
let localHeldAt = 0;
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Test-only: force the local hold record to an arbitrary age. The staleness
|
|
61
|
+
* escape hatch above is otherwise unreachable in a test — it needs a record
|
|
62
|
+
* older than LLM_SEM_STALE_MS (120s), and a suite cannot wait that long. It was
|
|
63
|
+
* shipped untested in v3.68.0 and a post-tag review showed the gate still passed
|
|
64
|
+
* 16/16 when the escape hatch was deleted outright. Mirrors the `_resetMode` /
|
|
65
|
+
* `_resetHeadlessFlag` hooks in haiku-client.mjs.
|
|
66
|
+
* @param {number} ts epoch ms, or 0 for "not held"
|
|
67
|
+
*/
|
|
68
|
+
export function _setLocalHeldAt(ts) { localHeldAt = ts; }
|
|
69
|
+
|
|
59
70
|
/**
|
|
60
71
|
* Acquire a file-based semaphore slot for LLM calls.
|
|
61
72
|
* Uses acquire-then-verify: atomically creates a slot file, then checks total count.
|
package/hook.mjs
CHANGED
|
@@ -878,8 +878,15 @@ async function handleStop() {
|
|
|
878
878
|
}
|
|
879
879
|
}
|
|
880
880
|
|
|
881
|
-
// Spawn background for session summary (pass sessionId and project)
|
|
882
|
-
|
|
881
|
+
// Spawn background for session summary (pass sessionId and project).
|
|
882
|
+
// CLAUDE_MEM_SKIP_SUMMARY brings this in line with every other background
|
|
883
|
+
// worker (auto-compress / llm-optimize / auto-maintain all have one). It was
|
|
884
|
+
// the only ungated spawnBackground, which made Stop untestable end-to-end
|
|
885
|
+
// without residue: the detached child outlives the parent process an e2e test
|
|
886
|
+
// waits on, then recreates the sandbox tree behind the test's cleanup. Any
|
|
887
|
+
// grace period for that is a race, not a barrier — the post-tag review timed a
|
|
888
|
+
// recreate at 432ms and watched a 300ms grace lose.
|
|
889
|
+
if (!process.env.CLAUDE_MEM_SKIP_SUMMARY) spawnBackground('llm-summary', sessionId, project);
|
|
883
890
|
|
|
884
891
|
// Clean session file AFTER spawning background
|
|
885
892
|
try { unlinkSync(sessionFile()); } catch {}
|
|
@@ -1523,7 +1530,14 @@ async function handleSessionStart() {
|
|
|
1523
1530
|
// Stdout is the sole context-delivery channel. The SessionStart hook output
|
|
1524
1531
|
// is injected as a <system-reminder> at session start, giving Claude the
|
|
1525
1532
|
// full summary + handoff state + observations table fresh from the DB.
|
|
1526
|
-
|
|
1533
|
+
// Skip the wrapper entirely when there is no body. On a brand-new install every
|
|
1534
|
+
// section is empty, and the hook still emitted `<claude-mem-context>\n\n</...>` —
|
|
1535
|
+
// a framing block that asserts a memory surface and then shows nothing, which is
|
|
1536
|
+
// both wasted context and an active misread ("memory exists and is empty" is a
|
|
1537
|
+
// reason NOT to call mem_*). Non-empty output is byte-identical.
|
|
1538
|
+
if (fullContext.trim()) {
|
|
1539
|
+
process.stdout.write(`<claude-mem-context>\n${fullContext}\n</claude-mem-context>\n`);
|
|
1540
|
+
}
|
|
1527
1541
|
|
|
1528
1542
|
// D#123 (review C-1): persist the Key Context ids ACTUALLY rendered above so
|
|
1529
1543
|
// handleUserPrompt can exclude exactly those from <memory-context> — and
|
package/install.mjs
CHANGED
|
@@ -1721,24 +1721,63 @@ async function doctor() {
|
|
|
1721
1721
|
try {
|
|
1722
1722
|
const { checkDevDrift } = await import('./lib/doctor-drift.mjs');
|
|
1723
1723
|
const r = checkDevDrift(INSTALL_DIR, SOURCE_FILES);
|
|
1724
|
-
|
|
1724
|
+
const devRemedy = `re-run: node ${join(PROJECT_DIR, 'install.mjs')} install --dev`;
|
|
1725
|
+
const nameList = (files, count) => {
|
|
1726
|
+
const suffix = count > files.length ? ` +${count - files.length} more` : '';
|
|
1727
|
+
return `${files.join(', ')}${suffix}`;
|
|
1728
|
+
};
|
|
1729
|
+
if (r.devMode) {
|
|
1725
1730
|
const parts = [];
|
|
1726
1731
|
if (r.plainCount > 0) {
|
|
1727
|
-
|
|
1728
|
-
const suffix = r.plainCount > 5 ? ` +${r.plainCount - 5} more` : '';
|
|
1729
|
-
parts.push(`${r.plainCount} non-symlink: ${names}${suffix}`);
|
|
1732
|
+
parts.push(`${r.plainCount} non-symlink: ${nameList(r.plainFiles.slice(0, 5), r.plainCount)}`);
|
|
1730
1733
|
}
|
|
1731
|
-
if (r.
|
|
1732
|
-
|
|
1733
|
-
const suffix = r.missingCount > r.missingFiles.length ? ` +${r.missingCount - r.missingFiles.length} more` : '';
|
|
1734
|
-
parts.push(`${r.missingCount} missing: ${names}${suffix}`);
|
|
1734
|
+
if (r.missingEntryCount > 0) {
|
|
1735
|
+
parts.push(`${r.missingEntryCount} missing ENTRY POINT: ${nameList(r.missingEntryFiles, r.missingEntryCount)}`);
|
|
1735
1736
|
}
|
|
1736
|
-
|
|
1737
|
+
if (parts.length > 0) {
|
|
1738
|
+
// Hard: a non-symlink means repo edits stop propagating, and a missing entry point
|
|
1739
|
+
// means the hook/CLI command that names that path cannot start at all. A hybrid
|
|
1740
|
+
// install also loses the realpath argument below — a COPIED entry point resolves
|
|
1741
|
+
// its imports against the install dir, so absent modules can throw there.
|
|
1742
|
+
if (r.missingModuleCount > 0) {
|
|
1743
|
+
parts.push(`${r.missingModuleCount} missing module: ${nameList(r.missingModuleFiles, r.missingModuleCount)}`);
|
|
1744
|
+
}
|
|
1745
|
+
warn(`Dev drift: ${parts.join('; ')} (${devRemedy})`);
|
|
1746
|
+
issues++;
|
|
1747
|
+
} else if (r.missingModuleCount > 0) {
|
|
1748
|
+
// Informational, NOT an issue: in a pure-symlink install every entry point resolves
|
|
1749
|
+
// to the repo, and Node resolves each module's imports against that REALPATH — so an
|
|
1750
|
+
// import-only file absent from the install dir is unreachable, not broken. Reporting
|
|
1751
|
+
// it as drift prescribed `install --dev` for a demonstrably healthy install (the
|
|
1752
|
+
// maintainer's own machine ran every one of those modules fine while doctor called
|
|
1753
|
+
// them missing).
|
|
1754
|
+
dwarn(`Dev drift: ${r.symlinkCount} symlinks, 0 plain, all entry points present — `
|
|
1755
|
+
+ `${r.missingModuleCount} import-only file(s) not linked into the install dir `
|
|
1756
|
+
+ `(${nameList(r.missingModuleFiles, r.missingModuleCount)}). Harmless: Node resolves `
|
|
1757
|
+
+ `imports against each entry point's realpath, i.e. the repo. ${devRemedy} to link them.`);
|
|
1758
|
+
} else {
|
|
1759
|
+
ok(`Dev drift: clean (${r.symlinkCount} symlinks, 0 plain, 0 missing)`);
|
|
1760
|
+
}
|
|
1761
|
+
} else if (r.missingCount > 0) {
|
|
1762
|
+
// COPY install (npm / plugin / `install` without --dev). Here the realpath argument
|
|
1763
|
+
// does NOT apply: entry points are real files, so `../lib/x.mjs` resolves against the
|
|
1764
|
+
// install dir and a missing module is an ERR_MODULE_NOT_FOUND on every hook fire.
|
|
1765
|
+
// This case used to print NOTHING — checkDevDrift returns devMode=false and both the
|
|
1766
|
+
// warning and the all-clear were gated on devMode, so the shape where missing files
|
|
1767
|
+
// are FATAL was the silent one (#8268's rule failing in the other direction).
|
|
1768
|
+
const parts = [];
|
|
1769
|
+
if (r.missingEntryCount > 0) {
|
|
1770
|
+
parts.push(`${r.missingEntryCount} entry point: ${nameList(r.missingEntryFiles, r.missingEntryCount)}`);
|
|
1771
|
+
}
|
|
1772
|
+
if (r.missingModuleCount > 0) {
|
|
1773
|
+
parts.push(`${r.missingModuleCount} module: ${nameList(r.missingModuleFiles, r.missingModuleCount)}`);
|
|
1774
|
+
}
|
|
1775
|
+
warn(`Managed files: ${r.missingCount} missing (${parts.join('; ')}) — a copy install resolves `
|
|
1776
|
+
+ `imports against the install dir, so these throw at hook time. Fix: claude-mem-lite update `
|
|
1777
|
+
+ `(or: node ${join(INSTALL_DIR, 'install.mjs')} repair)`);
|
|
1737
1778
|
issues++;
|
|
1738
|
-
} else if (r.devMode) {
|
|
1739
|
-
ok(`Dev drift: clean (${r.symlinkCount} symlinks, 0 plain, 0 missing)`);
|
|
1740
1779
|
}
|
|
1741
|
-
//
|
|
1780
|
+
// Complete copy install: no message — drift is a dev-install concern.
|
|
1742
1781
|
} catch (e) {
|
|
1743
1782
|
dwarn('Dev drift: check failed — ' + e.message);
|
|
1744
1783
|
}
|
package/lib/doctor-drift.mjs
CHANGED
|
@@ -11,6 +11,25 @@
|
|
|
11
11
|
import { existsSync, lstatSync } from 'fs';
|
|
12
12
|
import { join } from 'path';
|
|
13
13
|
|
|
14
|
+
// Files something EXECUTES by path (the CLI, the MCP server, a hook entry) as opposed to
|
|
15
|
+
// files that are only ever `import`ed. The distinction decides whether an absent file
|
|
16
|
+
// matters in a symlink install: Node resolves an ESM specifier against the importing
|
|
17
|
+
// module's REALPATH, so a symlinked entry point resolves `../lib/x.mjs` inside the REPO and
|
|
18
|
+
// never looks at the install dir. An absent import-only module is therefore unreachable
|
|
19
|
+
// dead weight there — while an absent ENTRY POINT is fatal in every shape, because the
|
|
20
|
+
// command names that path directly.
|
|
21
|
+
//
|
|
22
|
+
// Scope note (pre-tag review): this classifies only what the CALLER passes in, and
|
|
23
|
+
// install.mjs passes SOURCE_FILES, which holds zero `scripts/` entries — hook scripts are
|
|
24
|
+
// installed from the separate HOOK_SCRIPT_FILES manifest. An earlier draft mapped those
|
|
25
|
+
// into this set; it could never match a single path, so it is gone rather than left as
|
|
26
|
+
// inert code implying coverage it does not have. Extending doctor to check the hook-script
|
|
27
|
+
// manifest is real work with its own fixture, and is tracked as deferred rather than
|
|
28
|
+
// implied here.
|
|
29
|
+
const ENTRY_POINTS = new Set([
|
|
30
|
+
'cli.mjs', 'mem-cli.mjs', 'server.mjs', 'hook.mjs', 'install.mjs',
|
|
31
|
+
]);
|
|
32
|
+
|
|
14
33
|
export function checkDevDrift(installDir, sourceFiles) {
|
|
15
34
|
if (!existsSync(installDir)) {
|
|
16
35
|
return { devMode: false, drift: false, symlinkCount: 0, plainCount: 0, plainFiles: [], missingCount: 0, details: [] };
|
|
@@ -34,6 +53,8 @@ export function checkDevDrift(installDir, sourceFiles) {
|
|
|
34
53
|
// because there's nothing to drift from.)
|
|
35
54
|
const devMode = symlinkFiles.length > 0;
|
|
36
55
|
const drift = devMode && plainFiles.length > 0;
|
|
56
|
+
const missingEntry = missing.filter((rel) => ENTRY_POINTS.has(rel));
|
|
57
|
+
const missingModules = missing.filter((rel) => !ENTRY_POINTS.has(rel));
|
|
37
58
|
return {
|
|
38
59
|
devMode,
|
|
39
60
|
drift,
|
|
@@ -42,6 +63,12 @@ export function checkDevDrift(installDir, sourceFiles) {
|
|
|
42
63
|
plainFiles,
|
|
43
64
|
missingCount: missing.length,
|
|
44
65
|
missingFiles: missing.slice(0, 5),
|
|
66
|
+
// Split so the caller can grade by consequence rather than by count: an entry point is
|
|
67
|
+
// fatal in every install shape, an import-only module only in a copy install.
|
|
68
|
+
missingEntryCount: missingEntry.length,
|
|
69
|
+
missingEntryFiles: missingEntry.slice(0, 5),
|
|
70
|
+
missingModuleCount: missingModules.length,
|
|
71
|
+
missingModuleFiles: missingModules.slice(0, 5),
|
|
45
72
|
details: plainFiles.slice(0, 5),
|
|
46
73
|
};
|
|
47
74
|
}
|
package/lib/get-core.mjs
CHANGED
|
@@ -23,6 +23,21 @@ export const SESSION_DETAIL_FIELDS = ['id', 'request', 'investigated', 'learned'
|
|
|
23
23
|
* @param {number[]} ids
|
|
24
24
|
* @returns {object[]} full observation rows (SELECT *), created order
|
|
25
25
|
*/
|
|
26
|
+
export function supersededNotice(row) {
|
|
27
|
+
if (!row || !row.superseded_at) return null;
|
|
28
|
+
// Every LIST surface (search / recent / timeline / browse / injection) filters
|
|
29
|
+
// superseded rows out, so the only way to reach one is to name its id — which is
|
|
30
|
+
// exactly what a stale citation in a transcript, a note, or an old handoff does.
|
|
31
|
+
// Both detail faces render fields in OBS_FIELDS order, putting `lesson_learned`
|
|
32
|
+
// near the top and `superseded_at` ~15 lines below it: a reader taking the first
|
|
33
|
+
// actionable line away from `mem_get(1)` takes the RETRACTED advice and never
|
|
34
|
+
// reaches the marker. Hoist it to the header so the retraction is read first.
|
|
35
|
+
const by = typeof row.superseded_by === 'number' ? `#${row.superseded_by}` : null;
|
|
36
|
+
return by
|
|
37
|
+
? `⚠ RETRACTED — superseded by ${by}. Read ${by} instead; the fields below are the withdrawn version.`
|
|
38
|
+
: '⚠ RETRACTED — superseded (auto-dedup or merge). The fields below are the withdrawn version.';
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
export function fetchObsDetail(db, ids) {
|
|
27
42
|
const ph = ids.map(() => '?').join(',');
|
|
28
43
|
try {
|
package/lib/import-jsonl.mjs
CHANGED
|
@@ -102,11 +102,18 @@ function importToolPair(db, toolUse, toolResult, project) {
|
|
|
102
102
|
const filesRead = toolName === 'Read' && toolUse.input?.file_path
|
|
103
103
|
? [toolUse.input.file_path] : [];
|
|
104
104
|
|
|
105
|
+
// `narrative` carries the body and `text` is the derived search blob
|
|
106
|
+
// (lib/observation-write.mjs rebuildObservationDerived). Writing the payload to `text`
|
|
107
|
+
// ONLY left every imported row outside that invariant, so a later `update` rebuilt
|
|
108
|
+
// `text` from a narrative that was empty and dropped the payload. Store the body in
|
|
109
|
+
// both: `narrative` as the durable home, `text` as the index copy the ingest paths
|
|
110
|
+
// write directly.
|
|
111
|
+
const body = `${inputJson}\n---\n${resultText}`;
|
|
105
112
|
const safe = scrubRecord('observations', {
|
|
106
113
|
title: `${toolName}: ${(toolUse.input?.command || toolUse.input?.file_path || '').slice(0, 80)}`,
|
|
107
114
|
subtitle: '',
|
|
108
|
-
text:
|
|
109
|
-
narrative:
|
|
115
|
+
text: body,
|
|
116
|
+
narrative: body,
|
|
110
117
|
concepts: '',
|
|
111
118
|
facts: '',
|
|
112
119
|
lesson_learned: null,
|
|
@@ -265,6 +272,13 @@ export async function importJsonl(db, path, { project }) {
|
|
|
265
272
|
|
|
266
273
|
// Orphan tool_use fallback: persist tool_use events that never paired with
|
|
267
274
|
// a tool_result (truncated transcript / killed Claude Code session).
|
|
275
|
+
//
|
|
276
|
+
// `orphans` is a SUBSET counter, not a sibling of `observations`: each one writes a
|
|
277
|
+
// real observation row via the same importToolPair path. Counting it only under
|
|
278
|
+
// `orphans` made the summary say "+0 observations" for an import that had just
|
|
279
|
+
// written rows — a user backfilling a truncated transcript (the common shape, since
|
|
280
|
+
// the newest session is usually still open) read that as "nothing imported" and had
|
|
281
|
+
// no reason to run `recent`. The caller renders the subset relation explicitly.
|
|
268
282
|
let orphans = 0;
|
|
269
283
|
if (pendingToolUse.size > 0) {
|
|
270
284
|
const tx2 = db.transaction(() => {
|
|
@@ -273,7 +287,7 @@ export async function importJsonl(db, path, { project }) {
|
|
|
273
287
|
content: '[tool_use without result — transcript truncated]',
|
|
274
288
|
timestamp: useEv.timestamp,
|
|
275
289
|
};
|
|
276
|
-
if (tryImportToolPair(useEv, fauxResult)) orphans++;
|
|
290
|
+
if (tryImportToolPair(useEv, fauxResult)) { orphans++; observations++; }
|
|
277
291
|
}
|
|
278
292
|
});
|
|
279
293
|
tx2();
|
|
@@ -86,12 +86,76 @@ export function insertObservationVector(db, obsId, vecText) {
|
|
|
86
86
|
*/
|
|
87
87
|
// P2-12: internal-only since applyObsUpdate became the single update choke point
|
|
88
88
|
// (both faces previously imported this directly; un-exported per knip discipline).
|
|
89
|
+
/**
|
|
90
|
+
* The `text` search blob a row's columns imply, for a given narrative. Factored out so the
|
|
91
|
+
* rebuild can ask "is `text` already what I would derive?" with the exact same expression
|
|
92
|
+
* it writes — a second, drifting copy of the concatenation would defeat the check.
|
|
93
|
+
* @param {object} row observations row (title/subtitle/concepts/facts/lesson_learned/search_aliases)
|
|
94
|
+
* @param {string} narrative narrative to derive with ('' probes the already-derived shape)
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function derivedText(row, narrative) {
|
|
98
|
+
const base = [row.title, row.subtitle, narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases]
|
|
99
|
+
.filter(Boolean).join(' ');
|
|
100
|
+
const bigrams = cjkBigrams((row.title || '') + ' ' + (narrative || ''));
|
|
101
|
+
return bigrams ? base + ' ' + bigrams : base;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Does `text` look like an already-derived search blob rather than an orphaned body?
|
|
106
|
+
*
|
|
107
|
+
* Byte-equality against derivedText() cannot answer this: the OTHER producer of these rows
|
|
108
|
+
* (hook-llm.mjs buildFtsTextField) joins concepts + facts + aliases + bigrams and omits
|
|
109
|
+
* title and narrative entirely, so its output never equals this module's concatenation.
|
|
110
|
+
* What both derived shapes DO share is that every token comes from the row's own
|
|
111
|
+
* enrichment fields. A real body — an import-jsonl tool payload, a user's prose — carries
|
|
112
|
+
* tokens found nowhere else on the row. So: all-known ⇒ derived ⇒ do not promote.
|
|
113
|
+
* The title-only case falls out of the same test.
|
|
114
|
+
* @param {object} row observations row
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
function looksAlreadyDerived(row) {
|
|
118
|
+
const text = String(row.text || '').trim();
|
|
119
|
+
if (!text) return true;
|
|
120
|
+
const known = new Set(
|
|
121
|
+
([row.title, row.subtitle, row.concepts, row.facts, row.lesson_learned, row.search_aliases]
|
|
122
|
+
.filter(Boolean).join(' ') + ' ' + cjkBigrams(String(row.title || '')))
|
|
123
|
+
.split(/\s+/).filter(Boolean)
|
|
124
|
+
);
|
|
125
|
+
const tokens = text.split(/\s+/).filter(Boolean);
|
|
126
|
+
return tokens.length > 0 && tokens.every((t) => known.has(t));
|
|
127
|
+
}
|
|
128
|
+
|
|
89
129
|
function rebuildObservationDerived(db, obsId) {
|
|
90
|
-
const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(obsId);
|
|
130
|
+
const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases, text FROM observations WHERE id = ?').get(obsId);
|
|
91
131
|
if (!row) return;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
132
|
+
// Deriving `text` from these columns is sound ONLY while `narrative` holds the body.
|
|
133
|
+
// Two ingest shapes break that: import-jsonl writes `narrative: ''` with the whole
|
|
134
|
+
// payload in `text`, and OBS_DEFAULTS defaults `narrative` to '' for any caller that
|
|
135
|
+
// omits it. On such a row the rebuild used to derive from a base with no body in it, so
|
|
136
|
+
// `update <id> --importance 3` — a field unrelated to content — replaced the payload
|
|
137
|
+
// with nothing but the row's own title. Unrecoverable: update takes no snapshot (only
|
|
138
|
+
// `delete` does), and the row also stopped matching searches for its own contents.
|
|
139
|
+
//
|
|
140
|
+
// Repair in place instead of guessing: promote the orphaned body into `narrative`, then
|
|
141
|
+
// derive. Content-preserving, and idempotent because the next rebuild sees a non-empty
|
|
142
|
+
// narrative.
|
|
143
|
+
//
|
|
144
|
+
// "Empty narrative" alone is NOT enough to conclude that `text` holds a body. A second
|
|
145
|
+
// production shape has an empty narrative legitimately: hook-llm.mjs (`narrative:
|
|
146
|
+
// obs.narrative || ''`), persistHaikuSummary and hook-optimize.mjs all write rows whose
|
|
147
|
+
// `text` is ALREADY the derived FTS blob — concepts + facts + aliases + CJK bigrams,
|
|
148
|
+
// which never contains a narrative. Promoting that blob would write bigram fragments
|
|
149
|
+
// ("构认", "证模") into a user-visible field rendered by both get faces, injected into
|
|
150
|
+
// context and fed to compress — irreversibly, since update takes no snapshot. Caught
|
|
151
|
+
// pre-tag by review; reproduced, then closed with looksAlreadyDerived() — see there for
|
|
152
|
+
// why byte-equality against derivedText() is the wrong test.
|
|
153
|
+
let narrative = row.narrative;
|
|
154
|
+
if ((!narrative || !narrative.trim()) && !looksAlreadyDerived(row)) {
|
|
155
|
+
narrative = row.text;
|
|
156
|
+
db.prepare('UPDATE observations SET narrative = ? WHERE id = ?').run(narrative, obsId);
|
|
157
|
+
}
|
|
158
|
+
const textField = derivedText(row, narrative);
|
|
95
159
|
db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, obsId);
|
|
96
160
|
insertObservationVector(db, obsId, textField);
|
|
97
161
|
}
|
package/mem-cli.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { resolveProject } from './project-utils.mjs';
|
|
|
9
9
|
import { _resetVocabCache, vecTextForRow, vectorsEnabled } from './tfidf.mjs';
|
|
10
10
|
import { reRankWithContext } from './search-scoring.mjs';
|
|
11
11
|
import { searchObservationsHybrid } from './search-engine.mjs';
|
|
12
|
-
import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS } from './lib/get-core.mjs';
|
|
12
|
+
import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
|
|
13
13
|
import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
|
|
14
14
|
import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
|
|
15
15
|
import { ensureRegistryDb, upsertResource, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
|
|
@@ -532,6 +532,9 @@ function renderObsRows(db, ids, requestedFields) {
|
|
|
532
532
|
const parts = [];
|
|
533
533
|
for (const r of rows) {
|
|
534
534
|
const lines = [`#${r.id} [${r.type}] ${fmtDateShort(r.created_at)}`];
|
|
535
|
+
// Retraction first (shared with mem_get via get-core) — see supersededNotice.
|
|
536
|
+
const retracted = supersededNotice(r);
|
|
537
|
+
if (retracted) lines.push(retracted);
|
|
535
538
|
for (const f of fields) {
|
|
536
539
|
if (f === 'id' || f === 'type' || f === 'created_at') continue;
|
|
537
540
|
const val = r[f];
|
|
@@ -1853,7 +1856,21 @@ function cmdRestore(db, argv) {
|
|
|
1853
1856
|
const totalMalformed = malformed + parseFailures;
|
|
1854
1857
|
const totalLines = rows.length + parseFailures;
|
|
1855
1858
|
const tombstoneNote = tombstoned > 0 ? `, ${tombstoned} compressed member(s) rejected (keeper-absorbed or auto-retired tombstones)` : '';
|
|
1856
|
-
|
|
1859
|
+
// Past tense only when rows were actually written. `Restore (dry-run): 6 restored` reads
|
|
1860
|
+
// as done to anyone skimming past the parenthetical, and this command's whole job is to
|
|
1861
|
+
// let a user check a backup BEFORE trusting it.
|
|
1862
|
+
out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} ${dryRun ? 'would be restored (at most)' : 'restored'}`
|
|
1863
|
+
+ `, ${skipped} duplicate(s) ${dryRun ? 'would be skipped' : 'skipped'}${tombstoneNote}`
|
|
1864
|
+
+ `, ${totalMalformed} malformed/failed from ${totalLines} row(s).`);
|
|
1865
|
+
if (dryRun) {
|
|
1866
|
+
// The preview applies the durable exact-dup guard (project+title+created_at) but NOT
|
|
1867
|
+
// saveObservation's Jaccard near-duplicate collapse, which only exists once rows are
|
|
1868
|
+
// being written. Measured: a backup holding two same-titled weekly summaries previewed
|
|
1869
|
+
// 10 and restored 9. Simulating Jaccard here would mean a second copy of the dedup rule
|
|
1870
|
+
// — the drift class this codebase keeps paying for — so the number is labelled an upper
|
|
1871
|
+
// bound instead. Run without --dry-run for the exact count.
|
|
1872
|
+
out('[mem] Note: the preview does not simulate near-duplicate collapse, so the real run may restore fewer.');
|
|
1873
|
+
}
|
|
1857
1874
|
// Name the lossiness where the user meets it. Export omits related_ids and drops
|
|
1858
1875
|
// superseded rows, and restore re-inserts under fresh AUTOINCREMENT ids — so no
|
|
1859
1876
|
// cross-link can survive the round-trip. That is a deliberate format tradeoff (stored
|
|
@@ -2823,6 +2840,8 @@ Commands:
|
|
|
2823
2840
|
recent [N] Most recent events [--type T] [--project P]
|
|
2824
2841
|
show <id> Show full event row by id
|
|
2825
2842
|
delete <id1,id2,…> Delete events by ID (preview by default; use --confirm to execute)
|
|
2843
|
+
promote Promote insight-bearing events (body + importance>=2) to searchable
|
|
2844
|
+
observations (preview by default; use --execute to apply)
|
|
2826
2845
|
|
|
2827
2846
|
Valid types: bugfix, lesson, bug, discovery, refactor, feature, observation, decision
|
|
2828
2847
|
--files (plural, comma-split) preferred; --file (singular) kept for back-compat.
|
|
@@ -2964,14 +2983,18 @@ async function cmdImportJsonl(db, argv) {
|
|
|
2964
2983
|
totalSkip += r.skipped;
|
|
2965
2984
|
totalOrphans += r.orphans || 0;
|
|
2966
2985
|
totalRecognized += r.recognized || 0;
|
|
2967
|
-
out(`[mem] ${f}: +${r.prompts} prompts, +${r.observations} observations
|
|
2986
|
+
out(`[mem] ${f}: +${r.prompts} prompts, +${r.observations} observations`
|
|
2987
|
+
+ `${r.orphans ? ` (${r.orphans} from unpaired tool_use)` : ''}, ${r.skipped} skipped`);
|
|
2968
2988
|
}
|
|
2969
2989
|
const errorTail = errorCount > 0 ? `, ${errorCount} file(s) errored` : '';
|
|
2970
|
-
out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
//
|
|
2990
|
+
out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations`
|
|
2991
|
+
+ `${totalOrphans ? ` (${totalOrphans} from unpaired tool_use)` : ''}`
|
|
2992
|
+
+ `, ${totalSkip} skipped from ${files.length} file(s)${errorTail}.`);
|
|
2993
|
+
if (totalPrompts > 0 || totalObs > 0) {
|
|
2994
|
+
// Orphan tool_use events persist as (truncated) observations and are counted INSIDE
|
|
2995
|
+
// totalObs (lib/import-jsonl.mjs), so they already count as "something was imported"
|
|
2996
|
+
// — an orphan-only first import must not fall through to the "already imported"
|
|
2997
|
+
// no-op branch below.
|
|
2975
2998
|
out(`[mem] Try: claude-mem-lite recent 5 --project ${project}`);
|
|
2976
2999
|
} else if (totalRecognized > 0) {
|
|
2977
3000
|
// Lines WERE Claude Code transcript events but produced no new rows — the file
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.69.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.69.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.69.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",
|
package/project-utils.mjs
CHANGED
|
@@ -12,6 +12,20 @@ const _cache = new Map();
|
|
|
12
12
|
/**
|
|
13
13
|
* Infer a sanitized project name from CLAUDE_PROJECT_DIR, PWD, or cwd.
|
|
14
14
|
* Format: "parent--basename" with non-alphanumeric chars replaced by hyphens.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately does NOT anchor on the git work-tree root. That was tried and reverted
|
|
17
|
+
* before it shipped: it fixes `cd src/auth && claude-mem-lite recent` (session rooted at
|
|
18
|
+
* the repo root, CLI run deeper) but BREAKS the mirror case, which is more common —
|
|
19
|
+
* CLAUDE_PROJECT_DIR is the directory Claude Code was started in, not the repo root, so
|
|
20
|
+
* `cd packages/api && claude` makes hooks write `packages--api` while a plain terminal in
|
|
21
|
+
* the same directory would walk to the work-tree root and read `mono--monorepo`.
|
|
22
|
+
* Reproduced pre-tag: hooks saved to `packages--api`, `recent` answered
|
|
23
|
+
* `No recent observations (h1--mono)`. Splitting a monorepo user's namespace silently is
|
|
24
|
+
* worse than the subdirectory case, and unlike it, cwd-derivation at least keeps the hook
|
|
25
|
+
* and CLI faces agreeing whenever the session root and cwd match. A correct fix has to
|
|
26
|
+
* consult the DB for which candidate actually holds rows; this module is DB-free and on
|
|
27
|
+
* the hook hot path, so it is not the place. Tracked as deferred work.
|
|
28
|
+
*
|
|
15
29
|
* @returns {string} Sanitized project identifier safe for use in filenames
|
|
16
30
|
*/
|
|
17
31
|
export function inferProject() {
|
|
@@ -19,6 +19,13 @@ import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/rerea
|
|
|
19
19
|
import { recordMetric } from '../lib/metrics.mjs';
|
|
20
20
|
import { presentIdents } from '../lib/lesson-idents.mjs';
|
|
21
21
|
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
22
|
+
// Recall queries the SAVE-path project, so this MUST produce the same string as the
|
|
23
|
+
// save path. It used to be a hand-kept copy of the same 6 lines; that copy had already
|
|
24
|
+
// drifted once (missing the process.env.PWD fallback, so a symlinked project dir
|
|
25
|
+
// recalled nothing) and would have drifted again when the 2026-08-17 e2e round taught inferProject to
|
|
26
|
+
// anchor on the git work-tree root. project-utils.mjs is a leaf module over path/fs/os
|
|
27
|
+
// only — cheaper than several imports this script already carries.
|
|
28
|
+
import { inferProject } from '../project-utils.mjs';
|
|
22
29
|
|
|
23
30
|
import { DAY_MS } from '../lib/time-constants.mjs';
|
|
24
31
|
// CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
|
|
@@ -127,21 +134,6 @@ async function bridgeTopLesson(rows, changeText) {
|
|
|
127
134
|
|
|
128
135
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
129
136
|
|
|
130
|
-
// SYNC: must produce the SAME string as utils.mjs::inferProject (the path obs are SAVED
|
|
131
|
-
// under) and the bash post-tool-use.sh fast-path — recall queries the SAVE-path project.
|
|
132
|
-
// This previously used process.cwd() WITHOUT the process.env.PWD fallback the other two
|
|
133
|
-
// have, so under a symlinked project dir (PWD = logical/symlinked path, cwd = resolved)
|
|
134
|
-
// with CLAUDE_PROJECT_DIR unset it computed a DIFFERENT project than the save path and
|
|
135
|
-
// silently recalled nothing. Resolution order + sanitize + 100-char cap now match utils.
|
|
136
|
-
function inferProject() {
|
|
137
|
-
const dir = process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
|
|
138
|
-
const base = basename(dir);
|
|
139
|
-
const parent = basename(join(dir, '..'));
|
|
140
|
-
const raw = (parent && parent !== '.' && parent !== '/')
|
|
141
|
-
? `${parent}--${base}` : base;
|
|
142
|
-
return raw.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 100);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
137
|
function readCooldown(cooldownPath) {
|
|
146
138
|
try { return JSON.parse(readFileSync(cooldownPath, 'utf8')); } catch { return {}; }
|
|
147
139
|
}
|
|
@@ -144,12 +144,35 @@ const OR_TOP_BM25_FLOOR = TOP_REL_FLOOR === 0
|
|
|
144
144
|
// one was dropped by the OR floor at |bm25| 3.8–15.2 < 30. The plugin is inert
|
|
145
145
|
// during exactly the window where a new user decides whether it earns its keep.
|
|
146
146
|
//
|
|
147
|
-
// Fix: scale both floors by
|
|
148
|
-
//
|
|
147
|
+
// Fix: scale both floors by the corpus's MAX ATTAINABLE IDF over the reference
|
|
148
|
+
// corpus's, capped at 1.0, so the SIGNAL↔NOISE separation the maintainer measured
|
|
149
149
|
// (signal ≥41, noise ≤22 at N_REF) is preserved proportionally at any N. At
|
|
150
150
|
// N ≥ N_REF the factor is exactly 1.0, so every established install keeps
|
|
151
151
|
// byte-identical behavior; only genuinely-new installs relax.
|
|
152
152
|
//
|
|
153
|
+
// 2026-08-17 e2e round — the ramp shape. The first cut used ln(N+1)/ln(N_REF+1), which has the
|
|
154
|
+
// right asymptotics but the wrong small-N behavior: FTS5's IDF term is
|
|
155
|
+
// `log((N - df + 0.5) / (df + 0.5))`, which is EXACTLY 0 at N=2/df=1 and stays far
|
|
156
|
+
// below ln(N+1) for the whole first-week window. Re-measured end-to-end through the
|
|
157
|
+
// production write path (lib/save-observation.mjs — a raw INSERT skips CJK bigram
|
|
158
|
+
// expansion and understates the corpus, which is how the first cut's ramp table was
|
|
159
|
+
// misread), 1 planted target + topically clustered filler, CJK prose prompt carrying
|
|
160
|
+
// no identifier for the bypass to rescue:
|
|
161
|
+
//
|
|
162
|
+
// N 2 3 4 5 6 10 25 80
|
|
163
|
+
// top|bm25| 0.0 5.1 7.0 9.5 11.4 15.5 22.2 30.3
|
|
164
|
+
// ln ramp floor 5.2 6.5 7.6 8.4 9.2 11.3 15.3 20.7 ← DROP at N≤4
|
|
165
|
+
// idf ramp floor 0.0 2.6 4.3 5.5 6.5 9.3 14.1 20.0 ← admits all
|
|
166
|
+
//
|
|
167
|
+
// The two ramps agree within 8% at N≥30 and within 2% at N≥200, so this re-shape is
|
|
168
|
+
// confined to the window it is meant to fix. Accepted tradeoff: on a ≤2-row corpus the
|
|
169
|
+
// scale is EXACTLY 0 (FTS5's max IDF is 0 there), which disables both set-level floors
|
|
170
|
+
// rather than lowering them, and just above that they are small; so the best lexical match
|
|
171
|
+
// is injected even when it is weak.
|
|
172
|
+
// That is the intended trade — the alternative measured behavior is total silence, and
|
|
173
|
+
// a 4-row corpus has no room to bury signal under noise. The upstream
|
|
174
|
+
// hasExplicitSignal gate, not these floors, is what suppresses noise prompts.
|
|
175
|
+
//
|
|
153
176
|
// N counts the WHOLE observations table, not the project: FTS5 computes IDF over
|
|
154
177
|
// the entire index and `o.project = ?` is a post-MATCH filter. Verified — a
|
|
155
178
|
// 2-row project on a 302-row install scores 31.5, matching the 300-row global
|
|
@@ -158,7 +181,25 @@ const OR_TOP_BM25_FLOOR = TOP_REL_FLOOR === 0
|
|
|
158
181
|
const FLOOR_REF_CORPUS = Number(process.env.CLAUDE_MEM_UPS_FLOOR_REF_CORPUS || 584);
|
|
159
182
|
|
|
160
183
|
/**
|
|
161
|
-
*
|
|
184
|
+
* FTS5's IDF term for the best case a query can hit: a term appearing in exactly
|
|
185
|
+
* one row (df=1) of an n-row index. SQLite computes
|
|
186
|
+
* `log((n - df + 0.5) / (df + 0.5))`, so this is the ceiling any single-term bm25
|
|
187
|
+
* contribution can reach at corpus size n — the quantity the absolute floors are
|
|
188
|
+
* implicitly denominated in. Clamped at 0: below n=2 the formula goes negative,
|
|
189
|
+
* which as a scale would flip the comparison rather than relax it.
|
|
190
|
+
* @param {number} n Row count.
|
|
191
|
+
* @returns {number} Max attainable IDF at this corpus size, ≥ 0.
|
|
192
|
+
*/
|
|
193
|
+
function maxIdf(n) {
|
|
194
|
+
// n <= 1 makes the numerator non-positive → Math.log returns NaN or -Infinity, and
|
|
195
|
+
// Math.max(0, NaN) is NaN, not 0. Short-circuit instead: a 0- or 1-row index has no
|
|
196
|
+
// term that can discriminate, so the max attainable IDF is 0.
|
|
197
|
+
if (!(n > 1)) return 0;
|
|
198
|
+
return Math.max(0, Math.log((n - 1 + 0.5) / 1.5));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Scale factor in [0, 1] for the absolute score floors, by total corpus size.
|
|
162
203
|
*
|
|
163
204
|
* Short-circuits with a bounded probe: if a row exists at offset N_REF-1 the
|
|
164
205
|
* corpus is at or above the reference and the factor is 1.0 — no COUNT scan on
|
|
@@ -173,7 +214,12 @@ export function corpusFloorScale(db) {
|
|
|
173
214
|
const atRef = db.prepare('SELECT 1 FROM observations LIMIT 1 OFFSET ?').get(FLOOR_REF_CORPUS - 1);
|
|
174
215
|
if (atRef) return 1;
|
|
175
216
|
const { c = 0 } = db.prepare('SELECT count(*) AS c FROM observations').get() || {};
|
|
176
|
-
|
|
217
|
+
const refIdf = maxIdf(FLOOR_REF_CORPUS);
|
|
218
|
+
// Degenerate reference (CLAUDE_MEM_UPS_FLOOR_REF_CORPUS set to 2 or 3, where
|
|
219
|
+
// maxIdf is 0 or near it): division would blow up or divide by zero. Treat the
|
|
220
|
+
// floors as fully calibrated, matching the FLOOR_REF_CORPUS <= 1 guard above.
|
|
221
|
+
if (!(refIdf > 0)) return 1;
|
|
222
|
+
return Math.min(1, maxIdf(c) / refIdf);
|
|
177
223
|
} catch {
|
|
178
224
|
// Any probe failure → behave exactly as before the ramp existed.
|
|
179
225
|
return 1;
|
|
@@ -761,10 +807,22 @@ async function main() {
|
|
|
761
807
|
// when CLAUDE_MEM_UPS_IDENTIFIER_BYPASS=0 (bypass is default-on), then it is a no-op.
|
|
762
808
|
const promptIdentifiers = IDENTIFIER_BYPASS ? extractTechIdentifiers(promptText) : [];
|
|
763
809
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
810
|
+
// Recall intent ("之前 / previously / 记得 …") used to short-circuit straight to
|
|
811
|
+
// searchRecent, discarding the prompt text — so the most explicit memory request a
|
|
812
|
+
// user can make was the one answered without reading what they asked about. Measured
|
|
813
|
+
// on a 600-row corpus, two prompts one word apart: "分页接口又报 500 了,边界问题怎么处理"
|
|
814
|
+
// put the right row at rank 1, while "…之前那个边界问题是怎么处理的" surfaced NEITHER it
|
|
815
|
+
// nor anything related — 5 unrelated recency rows instead. Adding the recall keyword
|
|
816
|
+
// removed the answer and spent the injection budget on noise.
|
|
817
|
+
//
|
|
818
|
+
// Recency is still the right answer for a CONTENTLESS recall prompt ("之前我们在做什么"),
|
|
819
|
+
// which has no topic to match — so it becomes a FALLBACK (below) rather than a
|
|
820
|
+
// short-circuit. The explicit-signal gate needs no recall-intent carve-out:
|
|
821
|
+
// hasExplicitSignal already returns true whenever `intent` is truthy, and recall intent
|
|
822
|
+
// implies that, so a recall prompt cannot reach the no-signal branch. (An earlier draft
|
|
823
|
+
// added `!recentFallback &&` here; review showed the condition was dead.)
|
|
824
|
+
const recentFallback = Boolean(intent?.useRecent);
|
|
825
|
+
if (REQUIRE_EXPLICIT_SIGNAL && !signalPresent) {
|
|
768
826
|
// No explicit signal — skip FTS pipeline + prompt-fallback. sigRows
|
|
769
827
|
// is already empty (errSig was null else signalPresent would be true).
|
|
770
828
|
// Registry skill pointer below remains unaffected (its own name match).
|
|
@@ -848,6 +906,13 @@ async function main() {
|
|
|
848
906
|
rows = rows.slice(0, MAX_RESULTS);
|
|
849
907
|
}
|
|
850
908
|
|
|
909
|
+
// Recall-intent fallback (see the `recentFallback` rationale above): only when the
|
|
910
|
+
// prompt named nothing the corpus matches. A contentless "之前我们在做什么" lands here
|
|
911
|
+
// and behaves exactly as it did before; a topical recall prompt no longer does.
|
|
912
|
+
if (rows.length === 0 && recentFallback) {
|
|
913
|
+
rows = searchRecent(db, project, intent.limit);
|
|
914
|
+
}
|
|
915
|
+
|
|
851
916
|
// A (v2.32.8): prepend error-signature hits (higher precision), dedup, cap.
|
|
852
917
|
if (sigRows.length > 0) {
|
|
853
918
|
const sigIds = new Set(sigRows.map(r => r.id));
|
package/server.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
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';
|
|
26
|
-
import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS } from './lib/get-core.mjs';
|
|
26
|
+
import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
|
|
27
27
|
import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
|
|
28
28
|
import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
|
|
29
29
|
import { computeStatsFeed } from './lib/stats-core.mjs';
|
|
@@ -628,6 +628,9 @@ server.registerTool(
|
|
|
628
628
|
for (const row of rows) {
|
|
629
629
|
foundBySource.obs.add(row.id);
|
|
630
630
|
const lines = [`── #${row.id} ──`];
|
|
631
|
+
// Retraction first (shared with the CLI `get` via get-core) — see supersededNotice.
|
|
632
|
+
const retracted = supersededNotice(row);
|
|
633
|
+
if (retracted) lines.push(retracted);
|
|
631
634
|
for (const f of renderFields) {
|
|
632
635
|
const val = row[f];
|
|
633
636
|
if (val === null || val === undefined || val === '') continue;
|
|
@@ -826,6 +829,9 @@ server.registerTool(
|
|
|
826
829
|
inputSchema: memSaveSchema,
|
|
827
830
|
},
|
|
828
831
|
safeHandler(async (args) => {
|
|
832
|
+
// `obs_type` → `type`: the sibling read tools all name it obs_type (see the schema
|
|
833
|
+
// comment). Without this, an unknown key was dropped and the row saved as `discovery`.
|
|
834
|
+
args = applyArgAliases(args, { obs_type: 'type' });
|
|
829
835
|
if (args.project) args = { ...args, project: resolveProject(args.project) };
|
|
830
836
|
const project = args.project || inferProject();
|
|
831
837
|
|
|
@@ -1661,6 +1667,8 @@ server.registerTool(
|
|
|
1661
1667
|
inputSchema: memUpdateSchema,
|
|
1662
1668
|
},
|
|
1663
1669
|
safeHandler(async (args) => {
|
|
1670
|
+
// `obs_type` → `type`, same alias mem_save takes — see the schema comment.
|
|
1671
|
+
args = applyArgAliases(args, { obs_type: 'type' });
|
|
1664
1672
|
const obs = db.prepare('SELECT id, title FROM observations WHERE id = ?').get(args.id);
|
|
1665
1673
|
if (!obs) return { content: [{ type: 'text', text: `Observation #${args.id} not found` }], isError: true };
|
|
1666
1674
|
|
package/tool-schemas.mjs
CHANGED
|
@@ -204,6 +204,13 @@ export const memSaveSchema = {
|
|
|
204
204
|
content: z.string().min(1).max(50000).describe('Memory content to save'),
|
|
205
205
|
title: z.string().optional().describe('Short title'),
|
|
206
206
|
type: OBS_TYPE_ENUM.optional().describe('Observation type (default: discovery)'),
|
|
207
|
+
// Alias, same treatment memRecentSchema gives `type`. mem_search/mem_recall/mem_recent
|
|
208
|
+
// all name this field `obs_type`, mem_save named it only `type`, and the schema is
|
|
209
|
+
// non-strict — so `mem_save(obs_type: "bugfix")` (the shape a caller reaches for right
|
|
210
|
+
// after a search) dropped the unknown key and saved a `discovery` row with no error.
|
|
211
|
+
// A silently-wrong type changes type_quality ranking AND makes the row invisible to
|
|
212
|
+
// every `--type bugfix` filter, so it must not be a silent coercion.
|
|
213
|
+
obs_type: OBS_TYPE_ENUM.optional().describe('Alias for `type` (parity with mem_search/mem_recent)'),
|
|
207
214
|
project: z.string().optional().describe('Project name (default: inferred from CWD)'),
|
|
208
215
|
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
216
|
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'),
|
|
@@ -261,6 +268,11 @@ export const memUpdateSchema = {
|
|
|
261
268
|
// '' would blank narrative/lesson/concepts irrecoverably (mem_update takes no snapshot).
|
|
262
269
|
narrative: z.string().refine(s => s.trim() !== '', 'narrative cannot be empty').optional().describe('New narrative/content'),
|
|
263
270
|
type: OBS_TYPE_ENUM.optional().describe('New observation type'),
|
|
271
|
+
// Same alias as memSaveSchema, for the same reason and found by the same review: without
|
|
272
|
+
// it `mem_update({id, importance: 3, obs_type: 'bugfix'})` reported
|
|
273
|
+
// "Updated observation #N: importance" and dropped the type silently. (obs_type alone
|
|
274
|
+
// errored loudly with "No fields to update", so only the mixed call was dangerous.)
|
|
275
|
+
obs_type: OBS_TYPE_ENUM.optional().describe('Alias for `type` (parity with mem_search/mem_recent)'),
|
|
264
276
|
importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('New importance (1-3)'),
|
|
265
277
|
// 500-char cap mirrors memSaveSchema + cmdUpdate — update was the one path
|
|
266
278
|
// that let overlong lessons leak into the DB via MCP.
|