claude-mem-lite 2.84.1 → 2.85.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 +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +56 -2
- package/README.zh-CN.md +4 -2
- package/hook-memory.mjs +8 -4
- package/hook-update.mjs +32 -0
- package/hook.mjs +19 -14
- package/install.mjs +40 -8
- package/lib/citation-tracker.mjs +124 -49
- package/lib/cite-back-hint.mjs +38 -6
- package/lib/tmp-fixture-sweep.mjs +69 -0
- package/mem-cli.mjs +57 -4
- package/package.json +3 -2
- package/scripts/pre-tool-recall.js +11 -7
- package/server.mjs +39 -0
- package/source-files.mjs +3 -0
- package/tool-schemas.mjs +2 -2
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "2.
|
|
13
|
+
"version": "2.85.0",
|
|
14
14
|
"source": "./",
|
|
15
|
-
"description": "
|
|
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. Alternative to claude-mem with 600x lower cost."
|
|
16
16
|
}
|
|
17
17
|
]
|
|
18
18
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.85.0",
|
|
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. Alternative to claude-mem with 600x lower cost.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
|
7
7
|
},
|
package/README.md
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
# claude-mem-lite
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`claude-mem-lite` is a **persistent memory** (also called *long-term memory* or *cross-session context*) system for **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic's CLI coding agent. It runs as an **[MCP](https://modelcontextprotocol.io/) server** plus a set of Claude Code hooks, automatically capturing coding observations, decisions, and bug fixes during sessions, then providing hybrid full-text + semantic search to recall them later.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Compared to general-purpose LLM memory frameworks like [`mem0`](https://github.com/mem0ai/mem0) or the MCP reference [`memory`](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) server, claude-mem-lite is purpose-built for Claude Code's hook lifecycle: episode batching cuts LLM calls 7–10× vs the original [claude-mem](https://github.com/thedotmack/claude-mem) (600× lower total cost), and the hybrid FTS5 + TF-IDF retriever benchmarks at 0.88 Recall@10 / 0.96 Precision@10.
|
|
8
|
+
|
|
9
|
+
> 中文简介:claude-mem-lite 是 Claude Code 的轻量级**持久化记忆 / 长期记忆 / 跨会话上下文**插件,基于 MCP 协议 + 钩子机制,自动捕获编码会话中的决策、修复和上下文,并通过 FTS5 + TF-IDF 混合检索召回。详见 [中文 README](README.zh-CN.md)。
|
|
10
|
+
|
|
11
|
+
Zero external services. Single SQLite database. Minimal overhead.
|
|
8
12
|
|
|
9
13
|
## Why claude-mem-lite?
|
|
10
14
|
|
|
@@ -50,6 +54,22 @@ For a typical 50-tool-call session:
|
|
|
50
54
|
|
|
51
55
|
The original sends **everything to the LLM and hopes it filters well**. claude-mem-lite **filters first with code, then sends only what matters** to a smaller model. This is not a downgrade; it's a smarter architecture that produces equivalent search quality at a fraction of the cost.
|
|
52
56
|
|
|
57
|
+
### Comparison: memory systems for AI coding agents
|
|
58
|
+
|
|
59
|
+
How claude-mem-lite differs from the major neighbors in the LLM-memory space (verified May 2026):
|
|
60
|
+
|
|
61
|
+
| | **claude-mem-lite** | [`mem0`](https://github.com/mem0ai/mem0) | MCP reference [`memory`](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) | [claude-mem](https://github.com/thedotmack/claude-mem) (original) |
|
|
62
|
+
|---|---|---|---|---|
|
|
63
|
+
| **Target client** | Claude Code only | Any LLM app via SDK | Any MCP client | Claude Code only |
|
|
64
|
+
| **Capture model** | Auto via hooks | Manual `memory.add()` | Manual tool calls (`create_entities`, `add_observations`) | Auto via hooks |
|
|
65
|
+
| **Code-aware retrieval** | FTS5 + 100+ synonym pairs (incl. CJK↔EN) | General-purpose | Generic graph nodes | Code-aware |
|
|
66
|
+
| **Search** | Hybrid: FTS5 BM25 + TF-IDF cosine via RRF | Hybrid: semantic + BM25 + entity linking | Knowledge-graph traversal | FTS5 + Chroma vector |
|
|
67
|
+
| **Storage** | Single local SQLite | Pluggable; Qdrant or configurable vector store | Single JSONL file (knowledge graph) | SQLite + Chroma |
|
|
68
|
+
| **LLM dependency** | Haiku per episode (5–10 ops batched) | LLM per add/search op | None (graph CRUD only) | Sonnet per tool call |
|
|
69
|
+
| **Setup** | One command (`/plugin install` or `npx`) | SDK integration + vector store config | MCP install (per-client) | Bun + Python + Chroma |
|
|
70
|
+
|
|
71
|
+
**When to pick which**: pick `mem0` if you need a memory layer for a non-Claude-Code app (your own agent, multiple LLM providers). Pick the MCP reference `memory` server if you specifically want a knowledge-graph data model and don't mind invoking memory tools by hand. Pick claude-mem-lite if you want zero-touch automatic capture purpose-built for Claude Code's hook lifecycle, with code-domain retrieval and no external services.
|
|
72
|
+
|
|
53
73
|
## Features
|
|
54
74
|
|
|
55
75
|
- **Automatic capture** -- Hooks into Claude Code lifecycle (PostToolUse, SessionStart, Stop, UserPromptSubmit) to record observations without manual effort
|
|
@@ -642,6 +662,40 @@ npm run benchmark:gate # CI gate: fails if metrics regress beyond 5% toleranc
|
|
|
642
662
|
| `MEM_NO_AUTO_ADOPT` | Global opt-out for auto-adopt (v2.82.0+). `1` prevents the first-SessionStart auto-write of the invited-memory sentinel across **all** projects. For per-project opt-out use `claude-mem-lite adopt --disable` instead (writes a durable `<memdir>/.mem-no-auto-adopt` sentinel that survives marker deletion). | _(disabled)_ |
|
|
643
663
|
| `MEM_NO_ADOPT_HINT` | Silences the one-line "Invited-memory 未启用:`claude-mem-lite adopt`…" hint that SessionStart appends when the current project hasn't been adopted. Since v2.82.1 auto-adopt fires on first SessionStart for any install path, so this hint typically surfaces only when you've explicitly opted out (`MEM_NO_AUTO_ADOPT=1` or `claude-mem-lite adopt --disable`). | _(disabled)_ |
|
|
644
664
|
|
|
665
|
+
## FAQ
|
|
666
|
+
|
|
667
|
+
### What is a memory system for Claude Code?
|
|
668
|
+
|
|
669
|
+
A memory system lets Claude Code remember context — coding decisions, bug fixes, file history — across sessions. By default Claude Code's context resets each session; claude-mem-lite persists observations to a local SQLite database and re-injects them at session start and on relevant prompts.
|
|
670
|
+
|
|
671
|
+
### Does Claude Code have built-in long-term memory?
|
|
672
|
+
|
|
673
|
+
No. Claude Code's `CLAUDE.md` and `MEMORY.md` files act as static instruction memory, but there is no native dynamic recall of past sessions, bug fixes, or decisions. claude-mem-lite adds that layer via MCP and hooks, with no manual note-taking required.
|
|
674
|
+
|
|
675
|
+
### How is claude-mem-lite different from mem0 or MCP's reference memory server?
|
|
676
|
+
|
|
677
|
+
`mem0` and the MCP `memory` server are general-purpose LLM memory frameworks designed for any client. claude-mem-lite is purpose-built for Claude Code's hook lifecycle: it captures *episodes* (batched tool calls), uses domain-specific synonym expansion for code terms (`K8s`, `DB`, `数据库`, ...), and surfaces past observations proactively before file edits via the `PreToolUse:Edit` hook.
|
|
678
|
+
|
|
679
|
+
### Why "lite"? What did the original claude-mem do differently?
|
|
680
|
+
|
|
681
|
+
The original called an LLM on every tool use with raw JSON inputs. claude-mem-lite batches 5–10 operations per LLM call, uses a smaller model (Haiku), and runs a deterministic code-level filter before sending anything to the model. Net result: ~600× lower cost with equivalent search quality. See the [Architecture comparison](#architecture-comparison) above.
|
|
682
|
+
|
|
683
|
+
### Does this work cross-project? Cross-machine?
|
|
684
|
+
|
|
685
|
+
Project-scoped by default — each project has its own memory namespace. Single-machine only (SQLite, not networked). Use `mem_export` (JSON / JSONL) to back up or migrate between machines.
|
|
686
|
+
|
|
687
|
+
### What about privacy? Does it call external APIs?
|
|
688
|
+
|
|
689
|
+
Only the Haiku summarization step calls Anthropic's API (or the local `claude -p` CLI if no API key is set). All search, storage, and retrieval is local SQLite — no telemetry, no third-party services.
|
|
690
|
+
|
|
691
|
+
### 中文常见问题
|
|
692
|
+
|
|
693
|
+
**Claude Code 怎么跨会话记住内容?** 默认不能。claude-mem-lite 通过 MCP 协议和钩子自动把决策、bug 修复、文件历史持久化到本地 SQLite,下次会话开始时再注入。
|
|
694
|
+
|
|
695
|
+
**和 mem0、官方 MCP memory server 有什么区别?** 那两个是通用 LLM 记忆框架;claude-mem-lite 是为 Claude Code 钩子生命周期定制的:批量 episode 处理、代码领域同义词扩展(K8s/DB/数据库等)、文件编辑前主动召回相关历史。
|
|
696
|
+
|
|
697
|
+
**支持中文吗?** 完整支持。FTS5 + 中英文同义词扩展(100+ 对,含 CJK ↔ EN 跨语言映射),中文记忆也可用英文关键词召回,反之亦然。
|
|
698
|
+
|
|
645
699
|
## License
|
|
646
700
|
|
|
647
701
|
MIT
|
package/README.zh-CN.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
# claude-mem-lite
|
|
4
4
|
|
|
5
|
-
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
|
5
|
+
`claude-mem-lite` 是 **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)**(Anthropic 官方 CLI 编程代理)的 **持久化记忆系统**(也称 **长期记忆 / 跨会话上下文 / Claude Code 记忆插件**)。它以 **[MCP](https://modelcontextprotocol.io/) 服务器** + Claude Code 钩子(hooks)的形式运行,在编码会话中自动捕获观察记录、决策、bug 修复,并通过 FTS5 全文检索 + TF-IDF 向量的混合检索召回历史上下文。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
与 [`mem0`](https://github.com/mem0ai/mem0)、MCP 官方参考实现的 [`memory`](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) 服务器等通用 LLM 记忆框架相比,claude-mem-lite 专为 Claude Code 的钩子生命周期定制:episode 批处理把 LLM 调用量相比原版 [claude-mem](https://github.com/thedotmack/claude-mem) 减少 7-10 倍(综合成本下降 600 倍),FTS5 + TF-IDF 混合检索在 30 个查询的基准上达到 **Recall@10 = 0.88 / Precision@10 = 0.96**。
|
|
8
|
+
|
|
9
|
+
无需外部服务。单一 SQLite 数据库。开销极低。
|
|
8
10
|
|
|
9
11
|
## 为什么选择 claude-mem-lite?
|
|
10
12
|
|
package/hook-memory.mjs
CHANGED
|
@@ -34,14 +34,18 @@ function getCoverageThreshold() {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
// v2.41: cross-project boost (applied to decisions/discoveries from other
|
|
37
|
-
// projects). Default 0.
|
|
38
|
-
// project
|
|
37
|
+
// projects). Default 0.4 = 60% penalty vs same-project hits. Was 0.7 (30%), but
|
|
38
|
+
// a cross-project audit found that a 30% discount let strongly-matching but
|
|
39
|
+
// off-topic decisions still win injection slots in unrelated projects (e.g. an
|
|
40
|
+
// FTS5 SQL gotcha surfacing in a UI session). Transferable insights are the
|
|
41
|
+
// minority of cross-project matches, so the penalty should be steep; raise it
|
|
42
|
+
// back via env for installs that want more sharing.
|
|
39
43
|
// Env override `MEM_CROSS_PROJECT_BOOST` ∈ [0, 1]; clamped, invalid → default.
|
|
40
44
|
function getCrossProjectBoost() {
|
|
41
45
|
const raw = process.env.MEM_CROSS_PROJECT_BOOST;
|
|
42
|
-
if (raw === undefined || raw === '') return 0.
|
|
46
|
+
if (raw === undefined || raw === '') return 0.4;
|
|
43
47
|
const n = parseFloat(raw);
|
|
44
|
-
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.
|
|
48
|
+
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.4;
|
|
45
49
|
}
|
|
46
50
|
function extractQueryTerms(text) {
|
|
47
51
|
if (!text) return [];
|
package/hook-update.mjs
CHANGED
|
@@ -101,6 +101,38 @@ export async function checkForUpdate(options = {}) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// ── Non-blocking SessionStart helpers (audit P3d) ──────────────────────────
|
|
105
|
+
// Previously handleSessionStart `await checkForUpdate()` inline, blocking the
|
|
106
|
+
// session up to ~3-6s on a GitHub fetch once per 24h. These two helpers split
|
|
107
|
+
// that: emit the banner from CACHED state (zero network) and let the network
|
|
108
|
+
// refresh run in a detached background worker, so SessionStart never blocks.
|
|
109
|
+
|
|
110
|
+
// Banner string from cached update-state (≤24h stale), or null. No network I/O.
|
|
111
|
+
export function getCachedUpdateBanner() {
|
|
112
|
+
try {
|
|
113
|
+
if (isDevMode() || process.env.CLAUDE_MEM_SKIP_UPDATE) return null;
|
|
114
|
+
const state = readState();
|
|
115
|
+
if (state.updateAvailable && state.latestVersion) {
|
|
116
|
+
// Cached "available" state only persists for deferred installs (plugin mode
|
|
117
|
+
// / allowInstall=false); a successful auto-install clears updateAvailable.
|
|
118
|
+
const hint = isPluginMode()
|
|
119
|
+
? ' — plugin mode only checks for updates; reinstall/update the plugin to apply it'
|
|
120
|
+
: '';
|
|
121
|
+
return `\n📦 claude-mem-lite: v${state.latestVersion} available (current: v${state.installedVersion})${hint}\n`;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
} catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// True when a network refresh is due (24h throttle) and updates aren't disabled.
|
|
128
|
+
// Caller spawns the refresh in the background so this session doesn't wait.
|
|
129
|
+
export function isUpdateCheckDue() {
|
|
130
|
+
try {
|
|
131
|
+
if (isDevMode() || process.env.CLAUDE_MEM_SKIP_UPDATE) return false;
|
|
132
|
+
return shouldCheck(readState());
|
|
133
|
+
} catch { return false; }
|
|
134
|
+
}
|
|
135
|
+
|
|
104
136
|
function isPluginMode() {
|
|
105
137
|
return Boolean(process.env.CLAUDE_PLUGIN_ROOT);
|
|
106
138
|
}
|
package/hook.mjs
CHANGED
|
@@ -57,11 +57,11 @@ import { extractTailAssistantText, extractStructuredSummary } from './lib/summar
|
|
|
57
57
|
import { searchRelevantMemories, formatMemoryLine } from './hook-memory.mjs';
|
|
58
58
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
59
59
|
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
60
|
-
import { checkForUpdate } from './hook-update.mjs';
|
|
60
|
+
import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
|
|
61
61
|
import { handleLLMOptimize } from './hook-optimize.mjs';
|
|
62
62
|
import { silentAutoAdopt, hasAutoAdoptMarker } from './adopt-cli.mjs';
|
|
63
63
|
import { emitV270UpgradeBanner } from './lib/upgrade-banner.mjs';
|
|
64
|
-
import { loadCiteBackForEpisode, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge } from './lib/cite-back-hint.mjs';
|
|
64
|
+
import { loadCiteBackForEpisode, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge, nextCiteLowStreak } from './lib/cite-back-hint.mjs';
|
|
65
65
|
// plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
|
|
66
66
|
// from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
|
|
67
67
|
// crash on static import. Degrade gracefully to no-op when the module is absent.
|
|
@@ -570,8 +570,13 @@ async function handleStop() {
|
|
|
570
570
|
// alongside cite-recall. Same scan target (transcript already in OS
|
|
571
571
|
// cache); same persistence file; one extra line in buildCiteRecallNudge.
|
|
572
572
|
const bugfixStats = countUnsavedBugfixShape(transcriptPath);
|
|
573
|
-
const payload = { ...stats, ...bugfixStats, project, savedAt: Date.now() };
|
|
574
573
|
const dest = join(RUNTIME_DIR, `cite-recall-${project.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64)}.json`);
|
|
574
|
+
// Carry the consecutive-low-cite streak forward so the SessionStart
|
|
575
|
+
// nag can self-silence after the project has ignored it N times.
|
|
576
|
+
let priorStreak = 0;
|
|
577
|
+
try { priorStreak = JSON.parse(readFileSync(dest, 'utf8')).lowStreak || 0; } catch {}
|
|
578
|
+
const lowStreak = nextCiteLowStreak(priorStreak, stats);
|
|
579
|
+
const payload = { ...stats, ...bugfixStats, lowStreak, project, savedAt: Date.now() };
|
|
575
580
|
writeFileSync(dest, JSON.stringify(payload), { mode: 0o600 });
|
|
576
581
|
} catch (e) { debugCatch(e, 'handleStop-cite-recall-persist'); }
|
|
577
582
|
}
|
|
@@ -1181,18 +1186,14 @@ async function handleSessionStart() {
|
|
|
1181
1186
|
// Pre-load TF-IDF vocabulary cache for this session (from DB, ~1ms)
|
|
1182
1187
|
try { getVocabulary(db); } catch (e) { debugCatch(e, 'session-start-vocab'); }
|
|
1183
1188
|
|
|
1184
|
-
// Auto-update check (
|
|
1185
|
-
//
|
|
1189
|
+
// Auto-update check (audit P3d): NON-BLOCKING. Emit the banner from cached
|
|
1190
|
+
// state (zero network) and, if the 24h check is due, refresh in a detached
|
|
1191
|
+
// background worker so SessionStart never blocks on a GitHub fetch (was an
|
|
1192
|
+
// inline `await checkForUpdate()` that could stall the session 3-6s).
|
|
1186
1193
|
try {
|
|
1187
|
-
const
|
|
1188
|
-
if (
|
|
1189
|
-
|
|
1190
|
-
} else if (updateResult?.updateAvailable) {
|
|
1191
|
-
const hint = updateResult.installDeferred
|
|
1192
|
-
? ' — plugin mode only checks for updates; reinstall/update the plugin to apply it'
|
|
1193
|
-
: '';
|
|
1194
|
-
process.stdout.write(`\n📦 claude-mem-lite: v${updateResult.to} available (current: v${updateResult.from})${hint}\n`);
|
|
1195
|
-
}
|
|
1194
|
+
const banner = getCachedUpdateBanner();
|
|
1195
|
+
if (banner) process.stdout.write(banner);
|
|
1196
|
+
if (isUpdateCheckDue()) spawnBackground('update-check');
|
|
1196
1197
|
} catch (e) { debugCatch(e, 'session-start-update'); }
|
|
1197
1198
|
|
|
1198
1199
|
} finally {
|
|
@@ -1496,6 +1497,10 @@ try {
|
|
|
1496
1497
|
case 'llm-summary': await handleLLMSummary(); break;
|
|
1497
1498
|
case 'auto-compress': handleAutoCompress(); break;
|
|
1498
1499
|
case 'llm-optimize': await handleLLMOptimize(); break;
|
|
1500
|
+
// Detached update refresh spawned by handleSessionStart (audit P3d) — does the
|
|
1501
|
+
// GitHub fetch + (non-plugin) install off the SessionStart critical path,
|
|
1502
|
+
// writing update-state.json so the NEXT session's cached banner is fresh.
|
|
1503
|
+
case 'update-check': await checkForUpdate(); break;
|
|
1499
1504
|
}
|
|
1500
1505
|
} catch (err) {
|
|
1501
1506
|
// Always log fatal errors (ungated) with structured format
|
package/install.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { execSync, execFileSync } from 'child_process';
|
|
|
5
5
|
import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, copyFileSync, cpSync, renameSync, symlinkSync, unlinkSync, readdirSync, statSync, lstatSync } from 'fs';
|
|
6
6
|
import { join, resolve, dirname, isAbsolute } from 'path';
|
|
7
7
|
import { homedir, tmpdir } from 'os';
|
|
8
|
-
import { fileURLToPath } from 'url';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
9
10
|
|
|
10
11
|
const PROJECT_DIR = resolve(import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)));
|
|
11
12
|
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
@@ -29,6 +30,7 @@ import { RESOURCE_METADATA } from './install-metadata.mjs';
|
|
|
29
30
|
import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
|
|
30
31
|
import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
|
|
31
32
|
import { probeBetterSqlite3Binding, ensureBetterSqlite3Working } from './lib/binding-probe.mjs';
|
|
33
|
+
import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
|
|
32
34
|
|
|
33
35
|
// Re-export for backward compatibility — tests/install-hook-scripts.test.mjs
|
|
34
36
|
// and any external consumers still import HOOK_SCRIPT_FILES from install.mjs.
|
|
@@ -186,9 +188,19 @@ function registerVirtualResources(rdb) {
|
|
|
186
188
|
);
|
|
187
189
|
count += changes;
|
|
188
190
|
|
|
189
|
-
// Backfill FTS5 fields for existing resources
|
|
191
|
+
// Backfill FTS5 fields for existing resources.
|
|
192
|
+
// ?N numbered placeholders REQUIRE object-form binding in better-sqlite3 —
|
|
193
|
+
// positional .run(v1, v2, …) always throws "Too many parameter values"
|
|
194
|
+
// regardless of arg count. Pre-fix this swallow-warned on every install
|
|
195
|
+
// (masked by install.mjs:785 import failure before the v2.84.2 path fix).
|
|
190
196
|
if (changes === 0) {
|
|
191
|
-
updateFts.run(
|
|
197
|
+
updateFts.run({
|
|
198
|
+
1: meta.keywords || '',
|
|
199
|
+
2: meta.tech_stack || '',
|
|
200
|
+
3: meta.use_cases || '',
|
|
201
|
+
4: type,
|
|
202
|
+
5: name,
|
|
203
|
+
});
|
|
192
204
|
}
|
|
193
205
|
}
|
|
194
206
|
|
|
@@ -285,6 +297,16 @@ function isDevInstall() {
|
|
|
285
297
|
async function install() {
|
|
286
298
|
console.log('\nclaude-mem-lite installer\n');
|
|
287
299
|
|
|
300
|
+
// Resolve dynamic imports against the installed copy at INSTALL_DIR rather
|
|
301
|
+
// than install.mjs's own directory. Lets install.mjs run correctly from a
|
|
302
|
+
// /tmp staging dir (repair flow, `curl … | tar xz | node install.mjs install`)
|
|
303
|
+
// where PROJECT_DIR has no node_modules but INSTALL_DIR does — step 2 below
|
|
304
|
+
// ran `npm install --cwd INSTALL_DIR`. Pre-fix, steps 6/7 fired
|
|
305
|
+
// "Cannot find package 'better-sqlite3' imported from /tmp/…/registry.mjs"
|
|
306
|
+
// and silently skipped registry-DB seeding + DB health check on every repair.
|
|
307
|
+
const importFromInstall = (rel) => import(pathToFileURL(join(INSTALL_DIR, rel)).href);
|
|
308
|
+
const requireFromInstall = createRequire(pathToFileURL(join(INSTALL_DIR, 'package.json')).href);
|
|
309
|
+
|
|
288
310
|
// 1. Install source files to ~/.claude-mem-lite/
|
|
289
311
|
const IS_DEV = flags.has('--dev');
|
|
290
312
|
|
|
@@ -782,7 +804,7 @@ async function install() {
|
|
|
782
804
|
(deadRepos.size > 0 ? `, ${deadRepos.size} dead removed` : ''));
|
|
783
805
|
|
|
784
806
|
// 6b. Init registry DB and record preinstalled entries
|
|
785
|
-
const { ensureRegistryDb } = await
|
|
807
|
+
const { ensureRegistryDb } = await importFromInstall('registry.mjs');
|
|
786
808
|
const regDbPath = join(DATA_DIR, 'resource-registry.db');
|
|
787
809
|
const rdb = ensureRegistryDb(regDbPath);
|
|
788
810
|
|
|
@@ -830,7 +852,7 @@ async function install() {
|
|
|
830
852
|
|
|
831
853
|
// 6d. Scan and index resources (fallback-only, Haiku indexing deferred to first run)
|
|
832
854
|
log(' Scanning resources...');
|
|
833
|
-
const { scanAllResources, diffResources } = await
|
|
855
|
+
const { scanAllResources, diffResources } = await importFromInstall('registry-scanner.mjs');
|
|
834
856
|
const scanned = scanAllResources({ dataDir: DATA_DIR });
|
|
835
857
|
|
|
836
858
|
// Attach star counts and repo URLs
|
|
@@ -846,7 +868,7 @@ async function install() {
|
|
|
846
868
|
if (toIndex.length > 0) {
|
|
847
869
|
// Use fallback indexing at install time (no Haiku calls)
|
|
848
870
|
// Full Haiku indexing happens on first SessionStart
|
|
849
|
-
const { upsertResource } = await
|
|
871
|
+
const { upsertResource } = await importFromInstall('registry.mjs');
|
|
850
872
|
for (const res of toIndex) {
|
|
851
873
|
try {
|
|
852
874
|
const metaKey = `${res.type}:${res.name}`;
|
|
@@ -892,7 +914,7 @@ async function install() {
|
|
|
892
914
|
// 7. Verify database
|
|
893
915
|
if (existsSync(DB_PATH)) {
|
|
894
916
|
try {
|
|
895
|
-
const Database = (
|
|
917
|
+
const Database = requireFromInstall('better-sqlite3');
|
|
896
918
|
const db = new Database(DB_PATH, { readonly: true });
|
|
897
919
|
const count = db.prepare('SELECT COUNT(*) as c FROM observations').get();
|
|
898
920
|
db.close();
|
|
@@ -914,7 +936,7 @@ async function install() {
|
|
|
914
936
|
const remote = execFileSync('git', ['-C', PROJECT_DIR, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
915
937
|
const isDogfood = /github\.com[:/]sdsrss\/claude-mem-lite(\.git)?$/i.test(remote);
|
|
916
938
|
if (isDogfood) {
|
|
917
|
-
const { cmdAdopt } = await
|
|
939
|
+
const { cmdAdopt } = await importFromInstall('adopt-cli.mjs');
|
|
918
940
|
cmdAdopt([]);
|
|
919
941
|
ok('Invited-memory: auto-adopt for claude-mem-lite dogfood repo');
|
|
920
942
|
}
|
|
@@ -1732,6 +1754,16 @@ function cleanup() {
|
|
|
1732
1754
|
}
|
|
1733
1755
|
}
|
|
1734
1756
|
|
|
1757
|
+
// Reap leaked test-fixture sandboxes from temp (mem-e2e-* / mem-audit-* / cite-*
|
|
1758
|
+
// etc.) left by interrupted vitest runs — the §8.V4 disposal gap the audit found
|
|
1759
|
+
// (~795MB). 24h age here (vs 1h in the test reaper) is conservative for a manual
|
|
1760
|
+
// cleanup. Scans os.tmpdir() and the Claude Code temp root, depth-1, mem-prefixes
|
|
1761
|
+
// only — never touches other tools' temp dirs.
|
|
1762
|
+
const fixtureRoots = [tmpdir(), join(homedir(), '.claude', 'tmp')];
|
|
1763
|
+
const swept = sweepStaleTestFixtures({ dirs: fixtureRoots, ageMs: 24 * 60 * 60 * 1000, dryRun });
|
|
1764
|
+
for (const p of swept.names) ok(`${dryRun ? 'Would remove' : 'Removed'}: ${p}`);
|
|
1765
|
+
removed += swept.removed;
|
|
1766
|
+
|
|
1735
1767
|
const verb = dryRun ? 'would be removed' : 'removed';
|
|
1736
1768
|
console.log(`\n ${removed === 0 ? 'No stale files found.' : `${removed} stale file(s) ${verb}.`}\n`);
|
|
1737
1769
|
}
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -151,26 +151,42 @@ export function bumpCitationAccess(db, ids, project) {
|
|
|
151
151
|
return n;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
// Matches a pre-tool-recall lesson line: ` #NN [type] body...`.
|
|
155
|
-
// list mirrors observations.type CHECK + the events table's allowed
|
|
156
|
-
// values
|
|
154
|
+
// Matches a pre-tool-recall / error-recall lesson line: ` #NN [type] body...`.
|
|
155
|
+
// Bounded type list mirrors observations.type CHECK + the events table's allowed
|
|
156
|
+
// event_type values these surfaces can emit.
|
|
157
157
|
const INJECTED_RE = /#(\d{1,7})\s+\[(bugfix|decision|change|discovery|feature|refactor|lesson)\]/g;
|
|
158
158
|
|
|
159
|
+
// Add a numeric obs id to `set` if it parses to a sane in-range positive int.
|
|
160
|
+
function addObsId(set, raw) {
|
|
161
|
+
const id = Number(raw);
|
|
162
|
+
if (Number.isInteger(id) && id > 0 && id < 1e7) set.add(id);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Claude Code records a registered hook command (e.g. `node "${CLAUDE_PLUGIN_ROOT}/hook.mjs" user-prompt`)
|
|
166
|
+
// VERBATIM with the path quote-wrapped: `node "/abs/hook.mjs" user-prompt`. A
|
|
167
|
+
// naive `.includes('hook.mjs user-prompt')` then fails because the `"` sits
|
|
168
|
+
// between the path and the subcommand — this was the bug that made the entire
|
|
169
|
+
// UserPromptSubmit injection surface invisible to citation-decay in every real
|
|
170
|
+
// install (tests only ever used unquoted commands, so it was never caught).
|
|
171
|
+
// Strip shell quotes before substring-matching so command detection is robust to
|
|
172
|
+
// plugin-cache vs symlinked-install AND quoted vs unquoted path forms.
|
|
173
|
+
function normalizeHookCommand(command) {
|
|
174
|
+
return (command || '').replace(/["']/g, '');
|
|
175
|
+
}
|
|
176
|
+
|
|
159
177
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* references or unrelated #NN tokens in tool output.
|
|
178
|
+
* Walk every `hook_success` attachment in a transcript, invoking `fn` with the
|
|
179
|
+
* quote-normalized command and the injected text (JSON additionalContext
|
|
180
|
+
* unwrapped when present, else raw stdout). Shared by all injection extractors
|
|
181
|
+
* so command-matching + JSON-unwrap logic lives in exactly one place.
|
|
165
182
|
*
|
|
166
183
|
* @param {string|null|undefined} transcriptPath
|
|
167
|
-
* @
|
|
184
|
+
* @param {(ctx: {command: string, text: string}) => void} fn
|
|
168
185
|
*/
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (!transcriptPath || !existsSync(transcriptPath)) return ids;
|
|
186
|
+
function eachHookAttachment(transcriptPath, fn) {
|
|
187
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return;
|
|
172
188
|
let raw;
|
|
173
|
-
try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return
|
|
189
|
+
try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return; }
|
|
174
190
|
for (const line of raw.split('\n')) {
|
|
175
191
|
if (!line.trim()) continue;
|
|
176
192
|
let entry;
|
|
@@ -178,83 +194,140 @@ export function extractInjectedFromPreToolUse(transcriptPath) {
|
|
|
178
194
|
if (entry.type !== 'attachment') continue;
|
|
179
195
|
const att = entry.attachment;
|
|
180
196
|
if (!att || att.type !== 'hook_success') continue;
|
|
181
|
-
if (!(att.command || '').includes('pre-tool-recall')) continue;
|
|
182
197
|
const stdout = att.stdout || '';
|
|
183
198
|
if (!stdout) continue;
|
|
184
|
-
// stdout is JSON wrapping additionalContext OR raw text (
|
|
185
|
-
//
|
|
199
|
+
// stdout is JSON wrapping additionalContext OR raw text (triggerErrorRecall
|
|
200
|
+
// and the <memory-context> block write raw). Try JSON first, fall back to raw.
|
|
186
201
|
let text = stdout;
|
|
187
202
|
try {
|
|
188
203
|
const parsed = JSON.parse(stdout);
|
|
189
204
|
text = parsed?.hookSpecificOutput?.additionalContext || stdout;
|
|
190
205
|
} catch {}
|
|
206
|
+
fn({ command: normalizeHookCommand(att.command), text });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Extract observation IDs injected by pre-tool-recall hook in this transcript.
|
|
212
|
+
*
|
|
213
|
+
* Tighter than `computeCiteRecall`'s over-inclusive "any #NN in non-assistant
|
|
214
|
+
* text" — only counts IDs the agent actually saw from us, not user-pasted
|
|
215
|
+
* references or unrelated #NN tokens in tool output.
|
|
216
|
+
*
|
|
217
|
+
* @param {string|null|undefined} transcriptPath
|
|
218
|
+
* @returns {Set<number>} unique injected IDs (empty set on missing path/file)
|
|
219
|
+
*/
|
|
220
|
+
export function extractInjectedFromPreToolUse(transcriptPath) {
|
|
221
|
+
const ids = new Set();
|
|
222
|
+
eachHookAttachment(transcriptPath, ({ command, text }) => {
|
|
223
|
+
if (!command.includes('pre-tool-recall')) return;
|
|
191
224
|
INJECTED_RE.lastIndex = 0;
|
|
192
225
|
let m;
|
|
193
|
-
while ((m = INJECTED_RE.exec(text)))
|
|
194
|
-
|
|
195
|
-
if (Number.isInteger(id) && id > 0 && id < 1e7) ids.add(id);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
226
|
+
while ((m = INJECTED_RE.exec(text))) addObsId(ids, m[1]);
|
|
227
|
+
});
|
|
198
228
|
return ids;
|
|
199
229
|
}
|
|
200
230
|
|
|
201
231
|
// v34.x: UserPromptSubmit injection extractor. hook.mjs handleUserPrompt emits
|
|
202
232
|
// formatMemoryLine `- [type] title | Lesson: X (#NN)[ [verify-before-use]]`,
|
|
203
|
-
// which INJECTED_RE (anchored on `#NN [type]`) never matched — leaving
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// `(#NN)` at end-of-line.
|
|
233
|
+
// which INJECTED_RE (anchored on `#NN [type]`) never matched — leaving this
|
|
234
|
+
// injection surface invisible to applyCitationDecay. The extractors are disjoint
|
|
235
|
+
// by design: PTR has `[type]` AFTER `#NN`, UPS has `(#NN)` at end-of-line.
|
|
207
236
|
//
|
|
208
237
|
// Line-scan with `- [` prefix gate so a lesson body containing a back-reference
|
|
209
238
|
// like "see (#999)" doesn't pollute the injected set (would streak-uncite an
|
|
210
239
|
// obs we never actually displayed as a top-level entry).
|
|
211
240
|
const UPS_LINE_PREFIX = '- [';
|
|
212
241
|
const UPS_ID_RE = /\(#(\d{1,7})\)/g;
|
|
242
|
+
// Quote-normalized (see normalizeHookCommand): real recorded command is
|
|
243
|
+
// `node "/abs/hook.mjs" user-prompt` → normalized to `node /abs/hook.mjs user-prompt`.
|
|
213
244
|
const UPS_COMMAND_SUFFIX = 'hook.mjs user-prompt';
|
|
214
245
|
|
|
215
246
|
/**
|
|
216
247
|
* Extract observation IDs injected by the UserPromptSubmit `<memory-context>`
|
|
217
248
|
* block (hook.mjs handleUserPrompt). Disjoint from pre-tool-recall extraction —
|
|
218
|
-
* the Stop handler unions
|
|
249
|
+
* the Stop handler unions all surfaces via extractAllInjected.
|
|
219
250
|
*
|
|
220
251
|
* @param {string|null|undefined} transcriptPath
|
|
221
252
|
* @returns {Set<number>}
|
|
222
253
|
*/
|
|
223
254
|
export function extractInjectedFromUserPromptSubmit(transcriptPath) {
|
|
224
255
|
const ids = new Set();
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (!line.trim()) continue;
|
|
230
|
-
let entry;
|
|
231
|
-
try { entry = JSON.parse(line); } catch { continue; }
|
|
232
|
-
if (entry.type !== 'attachment') continue;
|
|
233
|
-
const att = entry.attachment;
|
|
234
|
-
if (!att || att.type !== 'hook_success') continue;
|
|
235
|
-
// Suffix match — survives plugin-cache vs symlinked-install path differences.
|
|
236
|
-
if (!(att.command || '').includes(UPS_COMMAND_SUFFIX)) continue;
|
|
237
|
-
const stdout = att.stdout || '';
|
|
238
|
-
if (!stdout.includes('<memory-context')) continue;
|
|
239
|
-
for (const memLine of stdout.split('\n')) {
|
|
256
|
+
eachHookAttachment(transcriptPath, ({ command, text }) => {
|
|
257
|
+
if (!command.includes(UPS_COMMAND_SUFFIX)) return;
|
|
258
|
+
if (!text.includes('<memory-context')) return;
|
|
259
|
+
for (const memLine of text.split('\n')) {
|
|
240
260
|
if (!memLine.startsWith(UPS_LINE_PREFIX)) continue;
|
|
241
261
|
// Take the LAST (#NN) on the line — formatMemoryLine puts the obs id
|
|
242
262
|
// in trailing parens, possibly followed by ` [verify-before-use]`. Any
|
|
243
|
-
// earlier (#NN) refs are inside title/lesson text
|
|
244
|
-
// pins "see (#999)" → NOT extracted).
|
|
263
|
+
// earlier (#NN) refs are inside title/lesson text.
|
|
245
264
|
const matches = [...memLine.matchAll(UPS_ID_RE)];
|
|
246
265
|
if (matches.length === 0) continue;
|
|
247
|
-
|
|
248
|
-
if (Number.isInteger(id) && id > 0 && id < 1e7) ids.add(id);
|
|
266
|
+
addObsId(ids, matches[matches.length - 1][1]);
|
|
249
267
|
}
|
|
250
|
-
}
|
|
268
|
+
});
|
|
269
|
+
return ids;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Extract observation IDs injected by the PostToolUse error-recall hint
|
|
274
|
+
* (hook.mjs triggerErrorRecall → `[claude-mem-lite] Related memories found for
|
|
275
|
+
* this error:` followed by ` #NN [type] title` lines, delivered via
|
|
276
|
+
* post-tool-use.sh). This is a high-volume surface that NO extractor matched
|
|
277
|
+
* before — error-recall'd obs accrued injection_count but never reached
|
|
278
|
+
* applyCitationDecay, so they could neither promote nor demote.
|
|
279
|
+
*
|
|
280
|
+
* @param {string|null|undefined} transcriptPath
|
|
281
|
+
* @returns {Set<number>}
|
|
282
|
+
*/
|
|
283
|
+
export function extractInjectedFromErrorRecall(transcriptPath) {
|
|
284
|
+
const ids = new Set();
|
|
285
|
+
eachHookAttachment(transcriptPath, ({ command, text }) => {
|
|
286
|
+
if (!command.includes('post-tool-use')) return;
|
|
287
|
+
if (!text.includes('Related memories found for this error')) return;
|
|
288
|
+
// INJECTED_RE requires `#NN [type]`, so the trailing
|
|
289
|
+
// `→ Use mem_get(ids=[7933,8455])` line (bare numbers) is not matched.
|
|
290
|
+
INJECTED_RE.lastIndex = 0;
|
|
291
|
+
let m;
|
|
292
|
+
while ((m = INJECTED_RE.exec(text))) addObsId(ids, m[1]);
|
|
293
|
+
});
|
|
294
|
+
return ids;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// user-prompt-search.js formatResults emits `[mem] FYI — Related memories ...`
|
|
298
|
+
// then one `#NN <icon> title` row per obs (raw stdout, line-leading id). Distinct
|
|
299
|
+
// from the `<memory-context>` block (hook.mjs) — the two UPS injectors dedup obs
|
|
300
|
+
// by id at inject time, so they carry DISJOINT obs sets; both must be extracted
|
|
301
|
+
// or the FYI-carried (highest-importance keyContext) obs never reach decay.
|
|
302
|
+
const FYI_HEADER = '[mem] FYI — Related memories';
|
|
303
|
+
// Anchored at line start so `P#NN` past-question rows (user_prompts, different id
|
|
304
|
+
// space) and any `#NN` inside lesson text are NOT matched.
|
|
305
|
+
const FYI_LINE_ID_RE = /^#(\d{1,7})\s/;
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Extract observation IDs injected by the user-prompt-search.js `[mem] FYI —
|
|
309
|
+
* Related memories` block.
|
|
310
|
+
*
|
|
311
|
+
* @param {string|null|undefined} transcriptPath
|
|
312
|
+
* @returns {Set<number>}
|
|
313
|
+
*/
|
|
314
|
+
export function extractInjectedFromFyi(transcriptPath) {
|
|
315
|
+
const ids = new Set();
|
|
316
|
+
eachHookAttachment(transcriptPath, ({ command, text }) => {
|
|
317
|
+
if (!command.includes('user-prompt-search')) return;
|
|
318
|
+
if (!text.includes(FYI_HEADER)) return;
|
|
319
|
+
for (const fyiLine of text.split('\n')) {
|
|
320
|
+
const m = FYI_LINE_ID_RE.exec(fyiLine);
|
|
321
|
+
if (m) addObsId(ids, m[1]);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
251
324
|
return ids;
|
|
252
325
|
}
|
|
253
326
|
|
|
254
327
|
/**
|
|
255
|
-
* Union of
|
|
256
|
-
*
|
|
257
|
-
*
|
|
328
|
+
* Union of every injection surface's IDs for a transcript: pre-tool-recall +
|
|
329
|
+
* UserPromptSubmit `<memory-context>` + PostToolUse error-recall + the
|
|
330
|
+
* user-prompt-search FYI block. Single integration point the Stop handler calls.
|
|
258
331
|
*
|
|
259
332
|
* @param {string|null|undefined} transcriptPath
|
|
260
333
|
* @returns {Set<number>}
|
|
@@ -263,6 +336,8 @@ export function extractAllInjected(transcriptPath) {
|
|
|
263
336
|
return new Set([
|
|
264
337
|
...extractInjectedFromPreToolUse(transcriptPath),
|
|
265
338
|
...extractInjectedFromUserPromptSubmit(transcriptPath),
|
|
339
|
+
...extractInjectedFromErrorRecall(transcriptPath),
|
|
340
|
+
...extractInjectedFromFyi(transcriptPath),
|
|
266
341
|
]);
|
|
267
342
|
}
|
|
268
343
|
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -172,10 +172,40 @@ export function countUnsavedBugfixShape(transcriptPath) {
|
|
|
172
172
|
// the bugfix-shape heuristic already requires ≥3 entries)
|
|
173
173
|
// Either gate can fire independently. Both off → empty string (no surface).
|
|
174
174
|
//
|
|
175
|
+
// Self-silence: after this many consecutive qualifying sessions where the
|
|
176
|
+
// project's cite-recall stayed below threshold, stop emitting the ratio nag — a
|
|
177
|
+
// project that has ignored the cite-#NN ask N times running is not going to
|
|
178
|
+
// start because we asked again; further nags are pure context noise (the audit's
|
|
179
|
+
// "nag-at-0%-compliance" anti-pattern). The streak resets the moment cite-recall
|
|
180
|
+
// recovers, so the nudge re-engages if behavior changes. Env override:
|
|
181
|
+
// CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER (0 = never silence).
|
|
182
|
+
export const CITE_NUDGE_SILENCE_AFTER = 3;
|
|
183
|
+
|
|
184
|
+
// True iff this session's stats satisfy the ratio-nag gate (low cite-recall with
|
|
185
|
+
// enough injection volume to judge). Shared by buildCiteRecallNudge (decide to
|
|
186
|
+
// nag) and nextCiteLowStreak (decide to keep silencing).
|
|
187
|
+
function ratioGateFires(data, env) {
|
|
188
|
+
const threshold = Number(env.CLAUDE_MEM_CITE_NUDGE_THRESHOLD) || 0.6;
|
|
189
|
+
const minInjected = Number(env.CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED) || 5;
|
|
190
|
+
return typeof data?.injected === 'number'
|
|
191
|
+
&& typeof data?.ratio === 'number'
|
|
192
|
+
&& data.injected >= minInjected
|
|
193
|
+
&& data.ratio < threshold;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Next consecutive-low-cite streak: increment when the ratio gate fires this
|
|
197
|
+
// session, reset to 0 otherwise (recovery, or too few injections to judge).
|
|
198
|
+
export function nextCiteLowStreak(priorStreak, stats, env = process.env) {
|
|
199
|
+
const prior = Number.isFinite(priorStreak) ? priorStreak : 0;
|
|
200
|
+
return ratioGateFires(stats, env) ? prior + 1 : 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
175
203
|
// Env opt-outs:
|
|
176
204
|
// • CLAUDE_MEM_NO_CITE_NUDGE=1 — disables BOTH gates (full silence)
|
|
177
205
|
// • CLAUDE_MEM_CITE_NUDGE_THRESHOLD — ratio gate threshold (default 0.6)
|
|
178
206
|
// • CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED — ratio gate min-volume (default 5)
|
|
207
|
+
// • CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER — consecutive-low streak before the
|
|
208
|
+
// ratio nag self-silences (default 3; 0 = never silence)
|
|
179
209
|
export function buildCiteRecallNudge(project, runtimeDir, env = process.env) {
|
|
180
210
|
if (env.CLAUDE_MEM_NO_CITE_NUDGE === '1') return '';
|
|
181
211
|
try {
|
|
@@ -183,13 +213,15 @@ export function buildCiteRecallNudge(project, runtimeDir, env = process.env) {
|
|
|
183
213
|
const path = join(runtimeDir, `cite-recall-${safe}.json`);
|
|
184
214
|
const raw = readFileSync(path, 'utf8');
|
|
185
215
|
const data = JSON.parse(raw);
|
|
186
|
-
const
|
|
187
|
-
|
|
216
|
+
const silenceAfter = env.CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER !== undefined
|
|
217
|
+
? Number(env.CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER)
|
|
218
|
+
: CITE_NUDGE_SILENCE_AFTER;
|
|
219
|
+
// silenceAfter > 0 AND the project has ignored the nag that many times running.
|
|
220
|
+
const silenced = silenceAfter > 0
|
|
221
|
+
&& typeof data.lowStreak === 'number'
|
|
222
|
+
&& data.lowStreak >= silenceAfter;
|
|
188
223
|
const lines = [];
|
|
189
|
-
if (
|
|
190
|
-
&& typeof data.ratio === 'number'
|
|
191
|
-
&& data.injected >= minInjected
|
|
192
|
-
&& data.ratio < threshold) {
|
|
224
|
+
if (!silenced && ratioGateFires(data, env)) {
|
|
193
225
|
const pct = Math.round(data.ratio * 100);
|
|
194
226
|
lines.push(`[mem] Last session cite-recall ${pct}% (${data.recalled}/${data.injected}) — when injected lessons (#NN lines) inform your action, cite #NN explicitly so the contract loop stays observable.`);
|
|
195
227
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Sweep stale claude-mem-lite test-fixture directories from temp dirs.
|
|
2
|
+
//
|
|
3
|
+
// Tests create sandboxes via mkdtempSync(join(tmpdir(), '<prefix>')) and clean
|
|
4
|
+
// them in afterEach — but an interrupted or SIGKILL'd vitest run never reaches
|
|
5
|
+
// afterEach, leaking the dir (and its DBs) forever. The cross-project audit
|
|
6
|
+
// found ~795MB of such residue (mem-e2e-* / mem-audit-* dominating). Per-test
|
|
7
|
+
// cleanup cannot survive SIGKILL, so we ALSO reap at the next run's start
|
|
8
|
+
// (globalSetup) and via `node install.mjs cleanup`.
|
|
9
|
+
//
|
|
10
|
+
// Safety: depth-1 only (no recursion — §8 forbids deep traversal of ~/.claude),
|
|
11
|
+
// age-gated so a concurrently-running suite isn't disturbed, and restricted to a
|
|
12
|
+
// conservative allowlist of clearly mem-namespaced prefixes so we never delete
|
|
13
|
+
// another tool's temp dirs (e.g. code-graph-mcp's `.tmp*`/`index.db`).
|
|
14
|
+
|
|
15
|
+
import { readdirSync, statSync, rmSync } from 'fs';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import { tmpdir } from 'os';
|
|
18
|
+
|
|
19
|
+
// Prefixes used by THIS repo's mkdtempSync fixtures. Deliberately only the
|
|
20
|
+
// clearly mem-namespaced ones — generic prefixes some tests use (plans-/tasks-/
|
|
21
|
+
// metrics-/drift-/projects-/git-fixture-) are EXCLUDED to avoid collateral
|
|
22
|
+
// deletion of unrelated /tmp dirs.
|
|
23
|
+
export const TEST_FIXTURE_PREFIXES = [
|
|
24
|
+
'mem-', 'cite-', 'memdir-', 'adopt-', 'citation-test-',
|
|
25
|
+
'text-floor-', 'unsaved-bugfix-', 'hook-telemetry-', 'hook-latency-',
|
|
26
|
+
'quiet-hooks-', 'silent-adopt-', 'sweep-orphan-', 'pre-recall-sandbox-',
|
|
27
|
+
'err-sampler-', 'cml-preflight-', 'cli-audit-',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_FIXTURE_AGE_MS = 60 * 60 * 1000; // 1h — wide margin over the longest test
|
|
31
|
+
|
|
32
|
+
function isFixtureName(name) {
|
|
33
|
+
return TEST_FIXTURE_PREFIXES.some(p => name.startsWith(p));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Remove (or, with dryRun, list) stale test-fixture directories.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {string[]} [opts.dirs] temp roots to scan, depth-1 (default [os.tmpdir()])
|
|
41
|
+
* @param {number} [opts.ageMs] only act on entries older than this (default 1h)
|
|
42
|
+
* @param {boolean} [opts.dryRun] when true, list but do not delete
|
|
43
|
+
* @param {number} [opts.now] injectable clock for tests
|
|
44
|
+
* @returns {{removed: number, names: string[]}} absolute paths removed (or that would be)
|
|
45
|
+
*/
|
|
46
|
+
export function sweepStaleTestFixtures({ dirs, ageMs = DEFAULT_FIXTURE_AGE_MS, dryRun = false, now = Date.now() } = {}) {
|
|
47
|
+
const roots = (dirs && dirs.length) ? dirs : [tmpdir()];
|
|
48
|
+
const cutoff = now - ageMs;
|
|
49
|
+
const names = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
for (const root of roots) {
|
|
52
|
+
if (!root || seen.has(root)) continue;
|
|
53
|
+
seen.add(root);
|
|
54
|
+
let entries;
|
|
55
|
+
try { entries = readdirSync(root); } catch { continue; }
|
|
56
|
+
for (const name of entries) {
|
|
57
|
+
if (!isFixtureName(name)) continue;
|
|
58
|
+
const full = join(root, name);
|
|
59
|
+
try {
|
|
60
|
+
const st = statSync(full);
|
|
61
|
+
if (!st.isDirectory()) continue;
|
|
62
|
+
if (st.mtimeMs >= cutoff) continue; // too fresh — may be an in-flight run
|
|
63
|
+
if (!dryRun) rmSync(full, { recursive: true, force: true });
|
|
64
|
+
names.push(full);
|
|
65
|
+
} catch { /* concurrent unlink / permission — ignore */ }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { removed: names.length, names };
|
|
69
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -1869,7 +1869,7 @@ function cmdMaintain(db, args) {
|
|
|
1869
1869
|
const { positional, flags } = parseArgs(args);
|
|
1870
1870
|
const action = positional[0];
|
|
1871
1871
|
if (!action || !['scan', 'execute'].includes(action)) {
|
|
1872
|
-
fail("[mem] Usage: claude-mem-lite maintain <scan|execute> [--ops cleanup,decay,boost,dedup,purge_stale,rebuild_vectors] [--project P] [--retain-days N] [--merge-ids keepId:removeId,...] — 'scan' previews, 'execute' applies.");
|
|
1872
|
+
fail("[mem] Usage: claude-mem-lite maintain <scan|execute> [--ops cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum] [--project P] [--retain-days N] [--merge-ids keepId:removeId,...] — 'scan' previews, 'execute' applies.");
|
|
1873
1873
|
return;
|
|
1874
1874
|
}
|
|
1875
1875
|
|
|
@@ -1879,6 +1879,10 @@ function cmdMaintain(db, args) {
|
|
|
1879
1879
|
const STALE_AGE_MS = 30 * 86400000;
|
|
1880
1880
|
const SCAN_LIMIT = 500;
|
|
1881
1881
|
const SIMILARITY_THRESHOLD = 0.7;
|
|
1882
|
+
// demote_pinned threshold: a memory injected this many times with zero
|
|
1883
|
+
// citations is "pinned noise" the regular `decay` op can't touch (decay
|
|
1884
|
+
// protects injection_count > 0). 8 aligns with the noise-penalty tier-2 cut.
|
|
1885
|
+
const PINNED_INJ_THRESHOLD = 8;
|
|
1882
1886
|
|
|
1883
1887
|
if (action === 'scan') {
|
|
1884
1888
|
const staleAge = Date.now() - STALE_AGE_MS;
|
|
@@ -1915,7 +1919,10 @@ function cmdMaintain(db, args) {
|
|
|
1915
1919
|
COALESCE(SUM(CASE WHEN (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
|
|
1916
1920
|
THEN 1 ELSE 0 END), 0) as broken,
|
|
1917
1921
|
COALESCE(SUM(CASE WHEN COALESCE(access_count, 0) > 3 AND COALESCE(importance, 1) < 3
|
|
1918
|
-
THEN 1 ELSE 0 END), 0) as boostable
|
|
1922
|
+
THEN 1 ELSE 0 END), 0) as boostable,
|
|
1923
|
+
COALESCE(SUM(CASE WHEN COALESCE(injection_count, 0) >= ${PINNED_INJ_THRESHOLD}
|
|
1924
|
+
AND COALESCE(cited_count, 0) = 0 AND COALESCE(importance, 1) > 1
|
|
1925
|
+
THEN 1 ELSE 0 END), 0) as pinned
|
|
1919
1926
|
FROM observations
|
|
1920
1927
|
WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}
|
|
1921
1928
|
`).get(staleAge, ...baseParams);
|
|
@@ -1930,6 +1937,7 @@ function cmdMaintain(db, args) {
|
|
|
1930
1937
|
out(` Stale (>30d, imp=1, no access): ${stats.stale}`);
|
|
1931
1938
|
out(` Broken (no title/narrative): ${stats.broken}`);
|
|
1932
1939
|
out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
|
|
1940
|
+
out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
|
|
1933
1941
|
out(` Pending purge: ${pendingPurge.count} (compressed originals awaiting cleanup)`);
|
|
1934
1942
|
if (duplicates.length > 0) {
|
|
1935
1943
|
const AUTO_MERGE_THRESHOLD = 0.85;
|
|
@@ -1962,7 +1970,7 @@ function cmdMaintain(db, args) {
|
|
|
1962
1970
|
}
|
|
1963
1971
|
|
|
1964
1972
|
// Execute
|
|
1965
|
-
const VALID_OPS = ['cleanup', 'decay', 'boost', 'dedup', 'purge_stale', 'rebuild_vectors'];
|
|
1973
|
+
const VALID_OPS = ['cleanup', 'decay', 'boost', 'demote_pinned', 'dedup', 'purge_stale', 'rebuild_vectors', 'vacuum'];
|
|
1966
1974
|
const opsStr = flags.ops || 'cleanup,decay,boost';
|
|
1967
1975
|
const ops = opsStr.split(',').map(s => s.trim());
|
|
1968
1976
|
const invalidOps = ops.filter(op => !VALID_OPS.includes(op));
|
|
@@ -2024,6 +2032,31 @@ function cmdMaintain(db, args) {
|
|
|
2024
2032
|
results.push(`Decayed ${decayed.changes} stale observations, marked ${idleMarked.changes} idle as pending-purge${decayCap}`);
|
|
2025
2033
|
}
|
|
2026
2034
|
|
|
2035
|
+
if (ops.includes('demote_pinned')) {
|
|
2036
|
+
// Repair the citation-decay blind spot: the `decay` op above PROTECTS
|
|
2037
|
+
// injection_count > 0 rows, so a memory injected many times but never
|
|
2038
|
+
// cited stays pinned at max importance and keeps dominating injection
|
|
2039
|
+
// forever (the entrenched-noise pool the extractor bug let accumulate).
|
|
2040
|
+
// Target the inverse signal — heavy injection, zero citations — and drop
|
|
2041
|
+
// importance to 1 in a SINGLE pass. Injection priority is binary
|
|
2042
|
+
// (importance >= 2 → full weight; hook-memory.mjs), so a gentle 3→2 step
|
|
2043
|
+
// would leave the obs dominating injection just the same; only reaching 1
|
|
2044
|
+
// actually de-ranks it. Floors at 1 (not 0/purge) so a later boost (access)
|
|
2045
|
+
// or a genuine cite can still rescue a useful entry.
|
|
2046
|
+
const demoted = db.prepare(`
|
|
2047
|
+
UPDATE observations SET importance = 1
|
|
2048
|
+
WHERE id IN (
|
|
2049
|
+
SELECT id FROM observations
|
|
2050
|
+
WHERE COALESCE(compressed_into, 0) = 0
|
|
2051
|
+
AND COALESCE(injection_count, 0) >= ${PINNED_INJ_THRESHOLD}
|
|
2052
|
+
AND COALESCE(cited_count, 0) = 0
|
|
2053
|
+
AND COALESCE(importance, 1) > 1
|
|
2054
|
+
${projectFilter} LIMIT ${OP_CAP}
|
|
2055
|
+
)
|
|
2056
|
+
`).run(...baseParams);
|
|
2057
|
+
results.push(`Demoted ${demoted.changes} pinned-but-uncited observations to importance 1 (inj>=${PINNED_INJ_THRESHOLD}, cited=0)${capHint(demoted.changes)}`);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2027
2060
|
if (ops.includes('boost')) {
|
|
2028
2061
|
const boosted = db.prepare(`
|
|
2029
2062
|
UPDATE observations SET importance = MIN(3, COALESCE(importance, 1) + 1)
|
|
@@ -2137,6 +2170,24 @@ function cmdMaintain(db, args) {
|
|
|
2137
2170
|
}
|
|
2138
2171
|
}
|
|
2139
2172
|
|
|
2173
|
+
// vacuum: reclaim freelist pages left behind by DELETEs (purge_stale / cleanup
|
|
2174
|
+
// / dedup). DELETE only grows the freelist; the file never shrinks without
|
|
2175
|
+
// VACUUM, which is absent everywhere else (auto_vacuum=0). Must run OUTSIDE any
|
|
2176
|
+
// transaction. Whole-DB regardless of --project. Reports freelist before/after
|
|
2177
|
+
// as the §7 reclaim metric.
|
|
2178
|
+
if (ops.includes('vacuum')) {
|
|
2179
|
+
try {
|
|
2180
|
+
const pageSize = db.pragma('page_size', { simple: true });
|
|
2181
|
+
const freeBefore = db.pragma('freelist_count', { simple: true });
|
|
2182
|
+
db.exec('VACUUM');
|
|
2183
|
+
const freeAfter = db.pragma('freelist_count', { simple: true });
|
|
2184
|
+
const reclaimedMB = ((Math.max(0, freeBefore - freeAfter) * pageSize) / 1048576).toFixed(1);
|
|
2185
|
+
results.push(`VACUUM: reclaimed ~${reclaimedMB}MB (freelist ${freeBefore} → ${freeAfter} pages)`);
|
|
2186
|
+
} catch (e) {
|
|
2187
|
+
results.push(`VACUUM failed — ${e.message}`);
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2140
2191
|
out(`[mem] ${results.join('\n[mem] ')}`);
|
|
2141
2192
|
}
|
|
2142
2193
|
|
|
@@ -2575,10 +2626,12 @@ Commands:
|
|
|
2575
2626
|
--project P Filter by project
|
|
2576
2627
|
|
|
2577
2628
|
maintain <scan|execute> Memory maintenance
|
|
2578
|
-
--ops O Comma-separated: cleanup,decay,boost,dedup,purge_stale,rebuild_vectors
|
|
2629
|
+
--ops O Comma-separated: cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum
|
|
2579
2630
|
--merge-ids K:R,... For dedup: keepId:removeId pairs (e.g. 10:11,20:21:22)
|
|
2580
2631
|
--project P Filter by project
|
|
2581
2632
|
--retain-days N For purge_stale: keep last N days (default 30)
|
|
2633
|
+
demote_pinned: importance→1 for inj>=8 & cited=0 (clears pinned noise)
|
|
2634
|
+
vacuum: reclaim freelist dead space (whole-DB, ignores --project)
|
|
2582
2635
|
|
|
2583
2636
|
optimize LLM-powered memory optimization (preview by default)
|
|
2584
2637
|
--run Execute (default: preview gates)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.85.0",
|
|
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. Alternative to claude-mem with 600x lower cost.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
7
7
|
"engines": {
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"lib/private-strip.mjs",
|
|
59
59
|
"lib/citation-tracker.mjs",
|
|
60
60
|
"lib/cite-back-hint.mjs",
|
|
61
|
+
"lib/tmp-fixture-sweep.mjs",
|
|
61
62
|
"lib/summary-extractor.mjs",
|
|
62
63
|
"lib/id-routing.mjs",
|
|
63
64
|
"lib/err-sampler.mjs",
|
|
@@ -329,14 +329,18 @@ try {
|
|
|
329
329
|
lines.push(` #${r.id} [${r.type}] ${title}`);
|
|
330
330
|
}
|
|
331
331
|
}
|
|
332
|
-
} else if (!isRead) {
|
|
333
|
-
// R-4: Edit/Write empty → short backfill reminder.
|
|
334
|
-
//
|
|
335
|
-
//
|
|
332
|
+
} else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
|
|
333
|
+
// R-4: Edit/Write empty → short backfill reminder. OPT-IN (default off) as
|
|
334
|
+
// of the cross-project audit: this "no prior lessons, remember to /lesson"
|
|
335
|
+
// reminder fired on ~70% of Edit/Write recalls and drove zero observed
|
|
336
|
+
// /lesson calls — pure context noise, mostly on brand-new files that by
|
|
337
|
+
// definition can't have a lesson. Save-nudging now lives at Stop time
|
|
338
|
+
// (buildCiteRecallNudge's unsaved-bugfix line + the cite-back hint), which
|
|
339
|
+
// has the full episode to judge whether a real fix happened. Set
|
|
340
|
+
// CLAUDE_MEM_PRETOOL_NUDGE=1 to restore the per-Edit reminder.
|
|
336
341
|
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
// Empty Reads exit silently, saving ~60 tokens × (every empty-file Read).
|
|
342
|
+
// Read never emitted this (passive). The cooldown write below still runs on
|
|
343
|
+
// every branch, so Read→Edit dedup + cite-back lessonId tracking are intact.
|
|
340
344
|
lines.push(`[mem] PreToolUse recall — system-injected context, continue your planned action:`);
|
|
341
345
|
lines.push(`[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: /lesson --file ${fname} "<root cause + fix>"`);
|
|
342
346
|
}
|
package/server.mjs
CHANGED
|
@@ -1484,6 +1484,29 @@ server.registerTool(
|
|
|
1484
1484
|
results.push(`Boosted ${boosted.changes} frequently-accessed observations` + (boosted.changes >= OP_ROW_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1485
1485
|
}
|
|
1486
1486
|
|
|
1487
|
+
if (ops.includes('demote_pinned')) {
|
|
1488
|
+
// CLI-parity (cmdMaintain): repair the citation-decay blind spot. The
|
|
1489
|
+
// `decay` op protects injection_count > 0, so a memory injected many
|
|
1490
|
+
// times but never cited stays pinned at max importance and keeps
|
|
1491
|
+
// dominating injection. Target heavy-injection + zero-citation and
|
|
1492
|
+
// drop importance to 1 in one pass — injection priority is binary
|
|
1493
|
+
// (importance>=2), so a 3→2 step would not de-rank it. Floor 1 (not
|
|
1494
|
+
// purge). PINNED_INJ_THRESHOLD=8.
|
|
1495
|
+
const demoted = db.prepare(`
|
|
1496
|
+
UPDATE observations SET importance = 1
|
|
1497
|
+
WHERE id IN (
|
|
1498
|
+
SELECT id FROM observations
|
|
1499
|
+
WHERE COALESCE(compressed_into, 0) = 0
|
|
1500
|
+
AND COALESCE(injection_count, 0) >= 8
|
|
1501
|
+
AND COALESCE(cited_count, 0) = 0
|
|
1502
|
+
AND COALESCE(importance, 1) > 1
|
|
1503
|
+
${projectFilter}
|
|
1504
|
+
LIMIT ${OP_ROW_CAP}
|
|
1505
|
+
)
|
|
1506
|
+
`).run(...baseParams);
|
|
1507
|
+
results.push(`Demoted ${demoted.changes} pinned-but-uncited observations to importance 1 (inj>=8, cited=0)` + (demoted.changes >= OP_ROW_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1487
1510
|
if (ops.includes('dedup') && args.merge_ids) {
|
|
1488
1511
|
let totalMerged = 0;
|
|
1489
1512
|
const mergeStmt = db.prepare('UPDATE observations SET compressed_into = ? WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
|
|
@@ -1557,6 +1580,22 @@ server.registerTool(
|
|
|
1557
1580
|
}
|
|
1558
1581
|
}
|
|
1559
1582
|
|
|
1583
|
+
// vacuum: reclaim freelist dead space left by DELETEs. CLI-parity
|
|
1584
|
+
// (cmdMaintain). Must run OUTSIDE any transaction; whole-DB.
|
|
1585
|
+
if (ops.includes('vacuum')) {
|
|
1586
|
+
try {
|
|
1587
|
+
const pageSize = db.pragma('page_size', { simple: true });
|
|
1588
|
+
const freeBefore = db.pragma('freelist_count', { simple: true });
|
|
1589
|
+
db.exec('VACUUM');
|
|
1590
|
+
const freeAfter = db.pragma('freelist_count', { simple: true });
|
|
1591
|
+
const reclaimedMB = ((Math.max(0, freeBefore - freeAfter) * pageSize) / 1048576).toFixed(1);
|
|
1592
|
+
results.push(`VACUUM: reclaimed ~${reclaimedMB}MB (freelist ${freeBefore} → ${freeAfter} pages)`);
|
|
1593
|
+
} catch (e) {
|
|
1594
|
+
debugCatch(e, 'vacuum');
|
|
1595
|
+
results.push(`VACUUM failed — ${e.message}`);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1560
1599
|
return { content: [{ type: 'text', text: results.join('\n') }] };
|
|
1561
1600
|
}
|
|
1562
1601
|
|
package/source-files.mjs
CHANGED
|
@@ -41,6 +41,9 @@ export const SOURCE_FILES = [
|
|
|
41
41
|
'lib/private-strip.mjs',
|
|
42
42
|
'lib/citation-tracker.mjs',
|
|
43
43
|
'lib/cite-back-hint.mjs',
|
|
44
|
+
// v2.85: stale test-fixture sweeper. Imported by install.mjs (cleanup) + cli.mjs.
|
|
45
|
+
// Missing from manifest → tarball ships install.mjs that ERR_MODULE_NOT_FOUND on cleanup.
|
|
46
|
+
'lib/tmp-fixture-sweep.mjs',
|
|
44
47
|
'lib/summary-extractor.mjs',
|
|
45
48
|
'lib/id-routing.mjs',
|
|
46
49
|
'lib/err-sampler.mjs',
|
package/tool-schemas.mjs
CHANGED
|
@@ -203,8 +203,8 @@ export const memOptimizeSchema = {
|
|
|
203
203
|
|
|
204
204
|
export const memMaintainSchema = {
|
|
205
205
|
action: z.enum(['scan', 'execute']).describe('scan=analyze candidates, execute=apply changes'),
|
|
206
|
-
operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost', 'purge_stale', 'rebuild_vectors'])).optional()
|
|
207
|
-
.describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors'),
|
|
206
|
+
operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost', 'demote_pinned', 'purge_stale', 'rebuild_vectors', 'vacuum'])).optional()
|
|
207
|
+
.describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, demote_pinned=importance→1 for obs injected>=8 times but never cited (clears pinned noise the decay op cannot reach), purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors, vacuum=reclaim freelist dead space (whole-DB)'),
|
|
208
208
|
merge_ids: z.preprocess(
|
|
209
209
|
(v) => Array.isArray(v) ? v.map(g => Array.isArray(g) ? g.map(x => typeof x === 'string' ? parseInt(x, 10) : x) : g) : v,
|
|
210
210
|
z.array(z.array(z.number().int()).min(2))
|