claude-mem-lite 2.84.2 → 2.86.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 +60 -3
- package/README.zh-CN.md +8 -3
- package/haiku-client.mjs +127 -22
- package/hook-llm.mjs +3 -3
- package/hook-memory.mjs +8 -4
- package/hook-shared.mjs +18 -5
- package/hook-update.mjs +32 -0
- package/hook.mjs +19 -14
- package/install.mjs +11 -0
- 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.86.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.86.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
|
|
@@ -85,7 +105,7 @@ The original sends **everything to the LLM and hopes it filters well**. claude-m
|
|
|
85
105
|
- **Resource registry** -- Indexes installed skills and agents with FTS5 search, composite scoring, and invocation tracking; searchable via `mem_registry` MCP tool
|
|
86
106
|
- **Unified resource discovery** -- Shared filesystem traversal layer (`resource-discovery.mjs`) used by both runtime scanner and offline indexer, supporting flat directories, plugin nesting, and loose `.md` files
|
|
87
107
|
- **Domain synonym expansion** -- Registry search queries expand to domain synonyms (e.g., "fix" → debug, bugfix, troubleshoot, diagnose, repair)
|
|
88
|
-
- **
|
|
108
|
+
- **Multi-provider LLM mode** -- Provider priority `ANTHROPIC_API_KEY` (direct Anthropic API) → `OPENROUTER_API_KEY` (OpenRouter, OpenAI-compatible — point it at any model via `OPENROUTER_MODEL`) → `claude -p` CLI fallback when no key is set
|
|
89
109
|
- **Lesson-learned indexing** -- `lesson_learned` field indexed in FTS5 with weight 8, making past debugging insights directly searchable
|
|
90
110
|
- **Cross-source normalization** -- `mem_search` normalizes scores across observations, sessions, and prompts before merging, preventing any source from dominating results
|
|
91
111
|
- **Exponential recency decay** -- Type-differentiated half-lives (decisions: 90d, discoveries: 60d, bugfixes: 14d, changes: 7d) consistently applied in all ranking paths
|
|
@@ -637,11 +657,48 @@ npm run benchmark:gate # CI gate: fails if metrics regress beyond 5% toleranc
|
|
|
637
657
|
|----------|-------------|---------|
|
|
638
658
|
| `CLAUDE_MEM_DIR` | Custom data directory. All databases, runtime files, and managed resources are stored here. | `~/.claude-mem-lite/` |
|
|
639
659
|
| `CLAUDE_MEM_MODEL` | LLM model for background calls (episode extraction, session summaries). Accepts `haiku` or `sonnet`. | `haiku` |
|
|
660
|
+
| `ANTHROPIC_API_KEY` | Anthropic API key. When set, all background LLM calls go directly to the Anthropic Messages API (with prompt caching). Highest priority. | _(unset → CLI)_ |
|
|
661
|
+
| `OPENROUTER_API_KEY` | OpenRouter API key (OpenAI-compatible). Used for background LLM calls when `ANTHROPIC_API_KEY` is **not** set. If neither key is set, calls fall back to the `claude -p` CLI. | _(unset)_ |
|
|
662
|
+
| `OPENROUTER_MODEL` | Overrides the OpenRouter model slug for **all** background calls (e.g. `openai/gpt-4o-mini`, `qwen/qwen-2.5-72b-instruct`). When unset, the `CLAUDE_MEM_MODEL` tier maps to `anthropic/claude-haiku-4.5` (haiku) or `anthropic/claude-sonnet-4.5` (sonnet). | _(tier default)_ |
|
|
640
663
|
| `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
|
|
641
664
|
| `MEM_QUIET_HOOKS` | Low-noise hooks. `1` drops the `File Lessons` / `Key Context` sections from SessionStart injection, the lesson suffix from `[mem] Related memories`, and the `WHEN TO USE` / `Decision rules` blocks from MCP server instructions. IDs and the `Recent` table still surface so `mem_get(ids=[…])` remains reachable. Intended for users running the invited-memory adopt path or who otherwise want minimal auto-injection. **Since v2.82.0 this env no longer gates auto-adopt — use `MEM_NO_AUTO_ADOPT=1` for that.** | _(disabled)_ |
|
|
642
665
|
| `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
666
|
| `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
667
|
|
|
668
|
+
## FAQ
|
|
669
|
+
|
|
670
|
+
### What is a memory system for Claude Code?
|
|
671
|
+
|
|
672
|
+
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.
|
|
673
|
+
|
|
674
|
+
### Does Claude Code have built-in long-term memory?
|
|
675
|
+
|
|
676
|
+
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.
|
|
677
|
+
|
|
678
|
+
### How is claude-mem-lite different from mem0 or MCP's reference memory server?
|
|
679
|
+
|
|
680
|
+
`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.
|
|
681
|
+
|
|
682
|
+
### Why "lite"? What did the original claude-mem do differently?
|
|
683
|
+
|
|
684
|
+
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.
|
|
685
|
+
|
|
686
|
+
### Does this work cross-project? Cross-machine?
|
|
687
|
+
|
|
688
|
+
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.
|
|
689
|
+
|
|
690
|
+
### What about privacy? Does it call external APIs?
|
|
691
|
+
|
|
692
|
+
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.
|
|
693
|
+
|
|
694
|
+
### 中文常见问题
|
|
695
|
+
|
|
696
|
+
**Claude Code 怎么跨会话记住内容?** 默认不能。claude-mem-lite 通过 MCP 协议和钩子自动把决策、bug 修复、文件历史持久化到本地 SQLite,下次会话开始时再注入。
|
|
697
|
+
|
|
698
|
+
**和 mem0、官方 MCP memory server 有什么区别?** 那两个是通用 LLM 记忆框架;claude-mem-lite 是为 Claude Code 钩子生命周期定制的:批量 episode 处理、代码领域同义词扩展(K8s/DB/数据库等)、文件编辑前主动召回相关历史。
|
|
699
|
+
|
|
700
|
+
**支持中文吗?** 完整支持。FTS5 + 中英文同义词扩展(100+ 对,含 CJK ↔ EN 跨语言映射),中文记忆也可用英文关键词召回,反之亦然。
|
|
701
|
+
|
|
645
702
|
## License
|
|
646
703
|
|
|
647
704
|
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
|
|
|
@@ -84,7 +86,7 @@
|
|
|
84
86
|
- **统一资源发现** -- 共享文件系统遍历层(`resource-discovery.mjs`),运行时扫描器和离线索引器共用,支持扁平目录、插件嵌套和松散 `.md` 文件
|
|
85
87
|
- **领域同义词扩展** -- 注册表搜索查询自动扩展领域同义词(如 "修复" → fix, debug, bugfix, repair, error)
|
|
86
88
|
- **持久化冷却机制** -- 5 分钟跨会话冷却 + 同会话去重,避免重复推荐 skill 自动加载
|
|
87
|
-
-
|
|
89
|
+
- **多 provider LLM 调用** -- provider 优先级 `ANTHROPIC_API_KEY`(直连 Anthropic API)→ `OPENROUTER_API_KEY`(OpenRouter,OpenAI 兼容,可用 `OPENROUTER_MODEL` 指向任意模型)→ 无 key 时回退 `claude -p` CLI
|
|
88
90
|
- **Haiku 熔断器** -- 连续 3 次 LLM 失败后,禁用 Haiku 调度 5 分钟,防止级联延迟
|
|
89
91
|
- **否定意图感知** -- 正确处理 "不要测试了,先修 bug" 等复杂提示,排除被否定的意图,支持中英文混合输入
|
|
90
92
|
- **可配置 LLM 模型** -- 通过 `CLAUDE_MEM_MODEL` 环境变量在 Haiku(快速/低成本)和 Sonnet(深度分析)之间切换
|
|
@@ -597,6 +599,9 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
|
|
|
597
599
|
|------|------|--------|
|
|
598
600
|
| `CLAUDE_MEM_DIR` | 自定义数据目录。所有数据库、运行时文件和托管资源均存储在此。 | `~/.claude-mem-lite/` |
|
|
599
601
|
| `CLAUDE_MEM_MODEL` | 后台 LLM 调用模型(Episode 提取、会话总结、调度)。可选 `haiku` 或 `sonnet`。 | `haiku` |
|
|
602
|
+
| `ANTHROPIC_API_KEY` | Anthropic API key。设置后所有后台 LLM 调用直连 Anthropic Messages API(带 prompt caching),优先级最高。 | _(未设 → CLI)_ |
|
|
603
|
+
| `OPENROUTER_API_KEY` | OpenRouter API key(OpenAI 兼容)。当**未设** `ANTHROPIC_API_KEY` 时用于后台 LLM 调用;两者都未设则回退到 `claude -p` CLI。 | _(未设)_ |
|
|
604
|
+
| `OPENROUTER_MODEL` | 覆盖**所有**后台调用的 OpenRouter 模型 slug(如 `openai/gpt-4o-mini`、`qwen/qwen-2.5-72b-instruct`)。未设时按 `CLAUDE_MEM_MODEL` 分层映射到 `anthropic/claude-haiku-4.5`(haiku)或 `anthropic/claude-sonnet-4.5`(sonnet)。 | _(分层默认)_ |
|
|
600
605
|
| `CLAUDE_MEM_DEBUG` | 启用调试日志(设为 `1` 启用)。 | _(禁用)_ |
|
|
601
606
|
| `MEM_QUIET_HOOKS` | 低噪声 hook。设为 `1` 时,SessionStart 注入去掉 `File Lessons` / `Key Context` 两节,`[mem] Related memories` 去掉 lesson 后缀,MCP server instructions 去掉 `WHEN TO USE` / `Decision rules` 两段。ID 与 `Recent` 表仍保留,`mem_get(ids=[…])` 可继续展开细节。适用于启用了 invited-memory adopt 流程或偏好最小化自动注入的用户。**v2.82.0 起此 env 不再阻挡 auto-adopt——如需关闭 auto-adopt 用 `MEM_NO_AUTO_ADOPT=1`。** | _(禁用)_ |
|
|
602
607
|
| `MEM_NO_AUTO_ADOPT` | auto-adopt 全局关闭开关(v2.82.0+)。设为 `1` 阻止首次 SessionStart 在**所有**项目自动写入邀请式 memory 哨兵。项目级关闭走 `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,存活于 marker 删除)。 | _(禁用)_ |
|
package/haiku-client.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// claude-mem-lite: Unified LLM call wrapper
|
|
2
2
|
// Shared by memory (hook.mjs) and dispatch modules
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Provider priority: ANTHROPIC_API_KEY (direct Anthropic API) →
|
|
4
|
+
// OPENROUTER_API_KEY (OpenRouter, OpenAI-compatible) → claude CLI fallback
|
|
5
|
+
// Model configurable via CLAUDE_MEM_MODEL (haiku|sonnet); OpenRouter slug
|
|
6
|
+
// overridable via OPENROUTER_MODEL
|
|
5
7
|
|
|
6
8
|
import { execFileSync } from 'child_process';
|
|
7
9
|
import { readFileSync } from 'fs';
|
|
@@ -30,18 +32,47 @@ export function resolveModel() {
|
|
|
30
32
|
return { cli, api };
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
// OpenRouter uses its own slug namespace (OpenAI-compatible API). Map the
|
|
36
|
+
// project's haiku/sonnet tiers to the matching anthropic/* slugs so the quality
|
|
37
|
+
// tiering is preserved when routing through OpenRouter. Slugs verified against
|
|
38
|
+
// openrouter.ai (2026-06): claude-haiku-4.5 / claude-sonnet-4.5 mirror the
|
|
39
|
+
// native MODEL_MAP IDs above.
|
|
40
|
+
const OPENROUTER_MODEL_MAP = {
|
|
41
|
+
haiku: 'anthropic/claude-haiku-4.5',
|
|
42
|
+
sonnet: 'anthropic/claude-sonnet-4.5',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the OpenRouter model slug for a given tier.
|
|
47
|
+
* OPENROUTER_MODEL (if set, non-blank) overrides every tier with an explicit
|
|
48
|
+
* slug — this is how users point claude-mem-lite at any OpenRouter model
|
|
49
|
+
* (e.g. openai/gpt-4o-mini, qwen/...). Otherwise the tier maps to its default
|
|
50
|
+
* anthropic/* slug, falling back to the haiku slug for unknown tiers.
|
|
51
|
+
* @param {string} tier 'haiku' | 'sonnet'
|
|
52
|
+
* @returns {string} OpenRouter model slug
|
|
53
|
+
*/
|
|
54
|
+
export function resolveOpenRouterModel(tier) {
|
|
55
|
+
const override = (process.env.OPENROUTER_MODEL || '').trim();
|
|
56
|
+
if (override) return override;
|
|
57
|
+
return OPENROUTER_MODEL_MAP[tier] || OPENROUTER_MODEL_MAP.haiku;
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
// ─── Mode Detection ──────────────────────────────────────────────────────────
|
|
34
61
|
|
|
35
62
|
let _mode = null;
|
|
36
63
|
|
|
37
64
|
/**
|
|
38
|
-
* Detect
|
|
39
|
-
*
|
|
40
|
-
*
|
|
65
|
+
* Detect which provider to use for LLM calls. Priority (per user contract):
|
|
66
|
+
* ANTHROPIC_API_KEY → direct Anthropic API ('api', native, supports prompt
|
|
67
|
+
* caching), else OPENROUTER_API_KEY → OpenRouter ('openrouter', OpenAI-compat),
|
|
68
|
+
* else fall back to the `claude` CLI ('cli'). Cached after first call.
|
|
69
|
+
* @returns {'api'|'openrouter'|'cli'} The detected mode
|
|
41
70
|
*/
|
|
42
71
|
export function detectMode() {
|
|
43
72
|
if (_mode) return _mode;
|
|
44
|
-
|
|
73
|
+
if (process.env.ANTHROPIC_API_KEY) _mode = 'api';
|
|
74
|
+
else if (process.env.OPENROUTER_API_KEY) _mode = 'openrouter';
|
|
75
|
+
else _mode = 'cli';
|
|
45
76
|
const { cli } = resolveModel();
|
|
46
77
|
debugLog('DEBUG', 'haiku-client', `mode: ${_mode}, model: ${cli}`);
|
|
47
78
|
return _mode;
|
|
@@ -102,8 +133,9 @@ export function flattenForCLI(input) {
|
|
|
102
133
|
|
|
103
134
|
/**
|
|
104
135
|
* Call Haiku model with a prompt. Returns parsed text or null on failure.
|
|
105
|
-
*
|
|
106
|
-
*
|
|
136
|
+
* Provider priority ANTHROPIC_API_KEY → OPENROUTER_API_KEY → CLI; if the keyed
|
|
137
|
+
* provider call fails (HTTP error / network throw / empty), degrades to the
|
|
138
|
+
* `claude -p` CLI. Never throws — returns null only when every path fails.
|
|
107
139
|
*
|
|
108
140
|
* @param {string|{system?: string, user: string}} prompt Prompt text, or split form
|
|
109
141
|
* @param {object} [opts] Options
|
|
@@ -116,15 +148,28 @@ export async function callHaiku(prompt, { timeout = 10000, maxTokens = 500 } = {
|
|
|
116
148
|
|
|
117
149
|
const mode = detectMode();
|
|
118
150
|
|
|
151
|
+
// CLI is terminal — no provider to fall back to.
|
|
152
|
+
if (mode === 'cli') {
|
|
153
|
+
try { return callHaikuCLI(prompt, { timeout }); }
|
|
154
|
+
catch (e) { debugCatch(e, 'callHaiku'); return null; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Keyed provider (api/openrouter): attempt it, then degrade to the CLI on any
|
|
158
|
+
// failure (HTTP error → null, or network/timeout throw). A region-blocked or
|
|
159
|
+
// out-of-credit key must not silently drop background summaries.
|
|
160
|
+
let primary = null;
|
|
119
161
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return callHaikuCLI(prompt, { timeout });
|
|
162
|
+
primary = mode === 'api'
|
|
163
|
+
? await callHaikuAPI(prompt, { timeout, maxTokens })
|
|
164
|
+
: await callOpenRouterAPI(prompt, resolveModel().cli, { timeout, maxTokens });
|
|
124
165
|
} catch (e) {
|
|
125
|
-
debugCatch(e,
|
|
126
|
-
return null;
|
|
166
|
+
debugCatch(e, `callHaiku:${mode}`);
|
|
127
167
|
}
|
|
168
|
+
if (primary) return primary;
|
|
169
|
+
|
|
170
|
+
debugLog('WARN', 'haiku-client', `${mode} call failed, falling back to claude CLI`);
|
|
171
|
+
try { return callHaikuCLI(prompt, { timeout }); }
|
|
172
|
+
catch (e) { debugCatch(e, 'callHaiku:cli-fallback'); return null; }
|
|
128
173
|
}
|
|
129
174
|
|
|
130
175
|
/**
|
|
@@ -143,8 +188,8 @@ export async function callHaikuJSON(prompt, opts) {
|
|
|
143
188
|
|
|
144
189
|
/**
|
|
145
190
|
* Call LLM with explicit model selection. Supports 'haiku' and 'sonnet'.
|
|
146
|
-
*
|
|
147
|
-
* Never throws — returns null
|
|
191
|
+
* Same provider priority + failure fallback to CLI as callHaiku.
|
|
192
|
+
* Never throws — returns null only when every path fails.
|
|
148
193
|
*
|
|
149
194
|
* @param {string} prompt The prompt text
|
|
150
195
|
* @param {'haiku'|'sonnet'} model Model to use (default: 'haiku')
|
|
@@ -158,15 +203,27 @@ export async function callLLMWithModel(prompt, model = 'haiku', { timeout = 1500
|
|
|
158
203
|
const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
|
|
159
204
|
const mode = detectMode();
|
|
160
205
|
|
|
206
|
+
// CLI is terminal — no provider to fall back to.
|
|
207
|
+
if (mode === 'cli') {
|
|
208
|
+
try { return callModelCLI(prompt, resolvedModel, { timeout }); }
|
|
209
|
+
catch (e) { debugCatch(e, `callLLMWithModel:${resolvedModel}`); return null; }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Keyed provider (api/openrouter): attempt it, then degrade to the CLI on any
|
|
213
|
+
// failure so a region-blocked / out-of-credit key still produces output.
|
|
214
|
+
let primary = null;
|
|
161
215
|
try {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return callModelCLI(prompt, resolvedModel, { timeout });
|
|
216
|
+
primary = mode === 'api'
|
|
217
|
+
? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens })
|
|
218
|
+
: await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens });
|
|
166
219
|
} catch (e) {
|
|
167
|
-
debugCatch(e, `callLLMWithModel:${resolvedModel}`);
|
|
168
|
-
return null;
|
|
220
|
+
debugCatch(e, `callLLMWithModel:${mode}:${resolvedModel}`);
|
|
169
221
|
}
|
|
222
|
+
if (primary) return primary;
|
|
223
|
+
|
|
224
|
+
debugLog('WARN', 'haiku-client', `${mode} call failed, falling back to claude CLI (${resolvedModel})`);
|
|
225
|
+
try { return callModelCLI(prompt, resolvedModel, { timeout }); }
|
|
226
|
+
catch (e) { debugCatch(e, `callLLMWithModel:cli-fallback:${resolvedModel}`); return null; }
|
|
170
227
|
}
|
|
171
228
|
|
|
172
229
|
/**
|
|
@@ -299,6 +356,54 @@ async function callHaikuAPI(prompt, { timeout, maxTokens }) {
|
|
|
299
356
|
}
|
|
300
357
|
}
|
|
301
358
|
|
|
359
|
+
// ─── OpenRouter Mode ─────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
// OpenRouter exposes an OpenAI-compatible chat-completions API (NOT the
|
|
362
|
+
// Anthropic Messages format), so the request/response shapes differ from
|
|
363
|
+
// callHaikuAPI/callModelAPI: Bearer auth, `messages` with a system-role entry,
|
|
364
|
+
// and the reply lives at choices[0].message.content. Anthropic's prompt-cache
|
|
365
|
+
// `cache_control` field has no OpenAI-format equivalent and is omitted.
|
|
366
|
+
// `tier` is the resolved model tier ('haiku'|'sonnet'); OPENROUTER_MODEL can
|
|
367
|
+
// override the resulting slug entirely (see resolveOpenRouterModel).
|
|
368
|
+
async function callOpenRouterAPI(prompt, tier, { timeout, maxTokens }) {
|
|
369
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
370
|
+
if (!apiKey) return null;
|
|
371
|
+
|
|
372
|
+
const model = resolveOpenRouterModel(tier);
|
|
373
|
+
const controller = new AbortController();
|
|
374
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
const { system, user } = splitPrompt(prompt);
|
|
378
|
+
const messages = [];
|
|
379
|
+
if (system) messages.push({ role: 'system', content: system });
|
|
380
|
+
messages.push({ role: 'user', content: user });
|
|
381
|
+
|
|
382
|
+
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
383
|
+
method: 'POST',
|
|
384
|
+
headers: {
|
|
385
|
+
'Content-Type': 'application/json',
|
|
386
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
387
|
+
// Optional OpenRouter attribution headers (ignored by the API if absent).
|
|
388
|
+
'X-Title': 'claude-mem-lite',
|
|
389
|
+
},
|
|
390
|
+
body: JSON.stringify({ model, max_tokens: maxTokens, messages }),
|
|
391
|
+
signal: controller.signal,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
if (!res.ok) {
|
|
395
|
+
debugLog('WARN', `${tier}-openrouter`, `HTTP ${res.status}`);
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const data = await res.json();
|
|
400
|
+
const text = data.choices?.[0]?.message?.content;
|
|
401
|
+
return text ? { text } : null;
|
|
402
|
+
} finally {
|
|
403
|
+
clearTimeout(timer);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
302
407
|
// ─── CLI Mode ────────────────────────────────────────────────────────────────
|
|
303
408
|
|
|
304
409
|
function callHaikuCLI(prompt, { timeout }) {
|
package/hook-llm.mjs
CHANGED
|
@@ -674,7 +674,7 @@ ${actionList}`;
|
|
|
674
674
|
if (gotSlot) {
|
|
675
675
|
let raw, parsed;
|
|
676
676
|
try {
|
|
677
|
-
raw = callLLM(prompt);
|
|
677
|
+
raw = await callLLM(prompt);
|
|
678
678
|
parsed = parseJsonFromLLM(raw);
|
|
679
679
|
} finally {
|
|
680
680
|
releaseLLMSlot();
|
|
@@ -721,7 +721,7 @@ ${actionList}`;
|
|
|
721
721
|
retryAttempted = true;
|
|
722
722
|
try {
|
|
723
723
|
const retryPrompt = buildLessonRetryPrompt(episode, parsed);
|
|
724
|
-
const retryRaw = callLLM(retryPrompt, 10000);
|
|
724
|
+
const retryRaw = await callLLM(retryPrompt, 10000);
|
|
725
725
|
if (retryRaw) {
|
|
726
726
|
const retry = parseJsonFromLLM(retryRaw);
|
|
727
727
|
const retryLesson = typeof retry?.lesson === 'string' ? retry.lesson.trim() : '';
|
|
@@ -974,7 +974,7 @@ ${obsList}`;
|
|
|
974
974
|
|
|
975
975
|
let raw, llmParsed;
|
|
976
976
|
try {
|
|
977
|
-
raw = callLLM(prompt, 20000);
|
|
977
|
+
raw = await callLLM(prompt, 20000);
|
|
978
978
|
llmParsed = parseJsonFromLLM(raw);
|
|
979
979
|
} finally {
|
|
980
980
|
releaseLLMSlot();
|
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-shared.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { join } from 'path';
|
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync } from 'fs';
|
|
8
8
|
import { inferProject, debugCatch } from './utils.mjs';
|
|
9
9
|
import { ensureDb, DB_DIR } from './schema.mjs';
|
|
10
|
-
import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI } from './haiku-client.mjs';
|
|
10
|
+
import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku } from './haiku-client.mjs';
|
|
11
11
|
// Phase D: invited-memory sentinel detection. memdir.mjs only pulls in fs/path/os/crypto;
|
|
12
12
|
// adopt-content.mjs is pure strings. No circular deps — memdir doesn't import hook-shared.
|
|
13
13
|
import { memdirPath as _memdirPath, isAdopted as _isAdopted } from './memdir.mjs';
|
|
@@ -130,13 +130,26 @@ export function openDb() {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
// ─── LLM
|
|
133
|
+
// ─── LLM (provider-routed: Anthropic API → OpenRouter → claude CLI) ─────────
|
|
134
134
|
|
|
135
135
|
// Accepts either a plain string (legacy) or {system, user} (defense-in-depth
|
|
136
136
|
// against prompt injection from poisoned user_prompts content — cso F#4 fix).
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
137
|
+
// Provider priority mirrors haiku-client (ANTHROPIC_API_KEY > OPENROUTER_API_KEY
|
|
138
|
+
// > CLI): when a key is present, delegate to callHaiku — it owns the Anthropic
|
|
139
|
+
// Messages / OpenRouter chat-completions request shapes, uses the system role
|
|
140
|
+
// natively, AND degrades to the `claude -p` CLI internally if the keyed provider
|
|
141
|
+
// fails (so a region-blocked / out-of-credit key still yields a summary). The
|
|
142
|
+
// keyless case shells out to `claude -p` directly here, where flattenForCLI
|
|
143
|
+
// renders {system, user} with an explicit data-boundary marker. Returns the raw
|
|
144
|
+
// response string (callers run parseJsonFromLLM themselves) or null.
|
|
145
|
+
// maxTokens is sized for session-summary / episode JSON (larger than the
|
|
146
|
+
// registry/optimize callers' budgets).
|
|
147
|
+
export async function callLLM(prompt, timeoutMs = 15000) {
|
|
148
|
+
if (detectLLMMode() !== 'cli') {
|
|
149
|
+
const result = await callHaiku(prompt, { timeout: timeoutMs, maxTokens: 2000 });
|
|
150
|
+
return result?.text ?? null;
|
|
151
|
+
}
|
|
152
|
+
|
|
140
153
|
const { cli: modelName } = resolveModelShared();
|
|
141
154
|
try {
|
|
142
155
|
const result = execFileSync(getClaudePathShared(), ['-p', '--model', modelName], {
|
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
|
@@ -30,6 +30,7 @@ import { RESOURCE_METADATA } from './install-metadata.mjs';
|
|
|
30
30
|
import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
|
|
31
31
|
import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
|
|
32
32
|
import { probeBetterSqlite3Binding, ensureBetterSqlite3Working } from './lib/binding-probe.mjs';
|
|
33
|
+
import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
|
|
33
34
|
|
|
34
35
|
// Re-export for backward compatibility — tests/install-hook-scripts.test.mjs
|
|
35
36
|
// and any external consumers still import HOOK_SCRIPT_FILES from install.mjs.
|
|
@@ -1753,6 +1754,16 @@ function cleanup() {
|
|
|
1753
1754
|
}
|
|
1754
1755
|
}
|
|
1755
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
|
+
|
|
1756
1767
|
const verb = dryRun ? 'would be removed' : 'removed';
|
|
1757
1768
|
console.log(`\n ${removed === 0 ? 'No stale files found.' : `${removed} stale file(s) ${verb}.`}\n`);
|
|
1758
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.86.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))
|