@nogataka/claw-memory 0.2.0 → 0.3.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 +5 -3
- package/.claude-plugin/plugin.json +11 -3
- package/README.ja.md +8 -5
- package/README.md +8 -5
- package/dist/cli.js +33 -0
- package/dist/core/db.js +37 -9
- package/dist/core/distill.js +61 -3
- package/dist/core/hooks.js +8 -7
- package/dist/core/installer/codex.js +19 -5
- package/dist/core/lessons.js +4 -7
- package/dist/core/recall.js +43 -0
- package/dist/core/vector-memory.js +27 -10
- package/dist/mcp/server.js +1 -1
- package/dist/ui/server.js +1 -1
- package/hooks/claw-hook.sh +33 -9
- package/hooks/hooks.codex.json +0 -11
- package/hooks/hooks.json +0 -11
- package/package.json +4 -6
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claw-memory-marketplace",
|
|
3
|
-
"owner": {
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "nogataka"
|
|
5
|
+
},
|
|
4
6
|
"metadata": {
|
|
5
7
|
"description": "claw-memory: local semantic memory + raw-log search for Claude Code",
|
|
6
|
-
"version": "0.
|
|
8
|
+
"version": "0.3.0"
|
|
7
9
|
},
|
|
8
10
|
"plugins": [
|
|
9
11
|
{
|
|
10
12
|
"name": "claw-memory",
|
|
11
13
|
"source": "./",
|
|
12
14
|
"description": "Local semantic memory: auto-distill, recall injection, and raw Claude Code + Codex transcript search.",
|
|
13
|
-
"version": "0.
|
|
15
|
+
"version": "0.3.0"
|
|
14
16
|
}
|
|
15
17
|
]
|
|
16
18
|
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claw-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Local semantic memory for Claude Code: auto-distills sessions, injects relevant past context, and searches raw Claude Code + Codex transcripts. sqlite-vec + local embeddings, no daemon, no Python.",
|
|
5
|
-
"author": {
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "nogataka"
|
|
7
|
+
},
|
|
6
8
|
"repository": "https://github.com/nogataka/claw-memory",
|
|
7
9
|
"license": "MIT",
|
|
8
|
-
"keywords": [
|
|
10
|
+
"keywords": [
|
|
11
|
+
"memory",
|
|
12
|
+
"mcp",
|
|
13
|
+
"context",
|
|
14
|
+
"search",
|
|
15
|
+
"codex"
|
|
16
|
+
]
|
|
9
17
|
}
|
package/README.ja.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
npm install -g @nogataka/claw-memory
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
- **ストレージ**: `
|
|
17
|
+
- **ストレージ**: `node:sqlite`(Node >= 24 組み込み・ネイティブ ABI なし)+ `sqlite-vec` — ベクトルを 1 つの SQLite ファイル内に格納
|
|
18
18
|
- **埋め込み**: ローカルの `Xenova/multilingual-e5-small`(384次元・多言語・オフライン)
|
|
19
19
|
- **2 つの記憶ソース**: 蒸留済みのセマンティック DB と、Claude Code / Codex の生ログ全文検索
|
|
20
20
|
- **自動取り込み**: フックで終了済みセッションを蒸留し、新しいセッションへ関連記憶を注入
|
|
@@ -144,7 +144,8 @@ npm install -g @nogataka/claw-memory
|
|
|
144
144
|
Claude Code を再起動してください。次が自動登録されます。
|
|
145
145
|
|
|
146
146
|
- **MCP サーバー**(8 つの記憶ツール)
|
|
147
|
-
- **フック**: `SessionStart`
|
|
147
|
+
- **フック**: `SessionStart` → コンパクトな recall 注入(Pull 型)、`Stop` → 自動 distill
|
|
148
|
+
フックのエラーは握り潰さず `~/.claw-memory/logs/hook-error.log` に記録されます
|
|
148
149
|
|
|
149
150
|
手動設定は不要です。確認は `/mcp` で `claw-memory` が見えるかどうかで行えます。
|
|
150
151
|
|
|
@@ -165,7 +166,7 @@ codex
|
|
|
165
166
|
プラグインは Codex が互換提供する `${CLAUDE_PLUGIN_ROOT}` を介して次を登録します。
|
|
166
167
|
|
|
167
168
|
- `claw-memory` MCP サーバー(`.mcp.json`)
|
|
168
|
-
- `SessionStart`
|
|
169
|
+
- `SessionStart` → **コンパクトな recall 自動注入**(設定+1行要約。詳細は `memory_recall` でオンデマンド取得)
|
|
169
170
|
- `Stop` → 最近の Codex セッションの**自動 distill**(watermark 重複回避・非同期)
|
|
170
171
|
- `memory-recall` skill
|
|
171
172
|
|
|
@@ -197,7 +198,7 @@ claw-memory distill-codex --all # 全件バックフィル
|
|
|
197
198
|
```bash
|
|
198
199
|
git clone https://github.com/nogataka/claw-memory
|
|
199
200
|
cd claw-memory
|
|
200
|
-
npm install #
|
|
201
|
+
npm install # ネイティブビルドなし(ストレージは Node 組み込みの node:sqlite)
|
|
201
202
|
npm run build # tsc -> dist/
|
|
202
203
|
npm link # 任意: `claw-memory` バイナリを公開
|
|
203
204
|
```
|
|
@@ -352,7 +353,9 @@ npm uninstall -g @nogataka/claw-memory
|
|
|
352
353
|
|
|
353
354
|
## 補足
|
|
354
355
|
|
|
355
|
-
-
|
|
356
|
+
- ストレージは Node 組み込みの `node:sqlite` を使用します(Node >= 24 必須)。Node を更新してもネイティブ ABI 不一致で壊れることはありません。`sqlite-vec` 拡張はプリビルト配布で ABI 非依存です。
|
|
357
|
+
- v0.3.0 で毎プロンプト注入を廃止しました(コンテキスト圧迫対策)。フックは `SessionStart` にコンパクトなブロックを1回注入するのみで、詳細は `memory_recall` / `memory_search`(MCP)または `memory-recall` スキルで明示的に取得します。
|
|
358
|
+
- `claw-memory cleanse` で、生 JSON が混入した過去チャンクを検出(`--apply` でソフトデリート)できます。
|
|
356
359
|
- MCP サーバーはエージェントセッションごとに常駐するため、埋め込みモデルは 1 回だけロードされます。
|
|
357
360
|
- ビューアと MCP は同時実行できます(SQLite WAL が並行読み書きを処理)。
|
|
358
361
|
- インストール時、依存は `legacy-peer-deps=true` で解決されます(同梱 SDK 間の zod の
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ session, which you control).
|
|
|
14
14
|
npm install -g @nogataka/claw-memory
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
- **Storage**: `
|
|
17
|
+
- **Storage**: `node:sqlite` (built into Node >= 24, no native ABI) + `sqlite-vec` — vectors live *inside* one SQLite file
|
|
18
18
|
- **Embeddings**: local `Xenova/multilingual-e5-small` (384-dim, multilingual, offline)
|
|
19
19
|
- **Two memory sources**: a distilled semantic DB **and** full-text search over raw
|
|
20
20
|
Claude Code + Codex transcripts
|
|
@@ -153,7 +153,8 @@ plugin falls back to `npx -y @nogataka/claw-memory@latest`, which is slower on f
|
|
|
153
153
|
Restart Claude Code. This auto-registers:
|
|
154
154
|
|
|
155
155
|
- the **MCP server** (8 memory tools), and
|
|
156
|
-
- the **hooks**: `SessionStart`
|
|
156
|
+
- the **hooks**: `SessionStart` → compact recall injection (pull model), `Stop` → auto-distill.
|
|
157
|
+
Hook errors are logged to `~/.claw-memory/logs/hook-error.log` instead of being swallowed.
|
|
157
158
|
|
|
158
159
|
No manual config. To verify, run `/mcp` and look for `claw-memory`.
|
|
159
160
|
|
|
@@ -174,7 +175,7 @@ codex
|
|
|
174
175
|
The plugin registers, via Codex's `${CLAUDE_PLUGIN_ROOT}` (provided for compatibility):
|
|
175
176
|
|
|
176
177
|
- the `claw-memory` MCP server (`.mcp.json`),
|
|
177
|
-
- `SessionStart`
|
|
178
|
+
- `SessionStart` → **compact recall injection** (preferences + one-line summaries; details are pulled on demand via `memory_recall`),
|
|
178
179
|
- `Stop` → **auto distill** of recent Codex sessions (watermark-deduped, async), and
|
|
179
180
|
- the `memory-recall` skill.
|
|
180
181
|
|
|
@@ -206,7 +207,7 @@ claw-memory distill-codex --all # backfill everything
|
|
|
206
207
|
```bash
|
|
207
208
|
git clone https://github.com/nogataka/claw-memory
|
|
208
209
|
cd claw-memory
|
|
209
|
-
npm install #
|
|
210
|
+
npm install # no native builds — storage is Node's built-in node:sqlite
|
|
210
211
|
npm run build # tsc -> dist/
|
|
211
212
|
npm link # optional: expose the `claw-memory` binary
|
|
212
213
|
```
|
|
@@ -362,7 +363,9 @@ Your memory database is left untouched; delete `~/.claw-memory` to wipe it.
|
|
|
362
363
|
|
|
363
364
|
## Notes
|
|
364
365
|
|
|
365
|
-
-
|
|
366
|
+
- Storage uses Node's built-in `node:sqlite` (Node >= 24 required), so Node upgrades can't break a native ABI. The `sqlite-vec` extension ships prebuilt and is version-independent.
|
|
367
|
+
- Per-prompt injection was removed in v0.3.0 (context-bloat fix): hooks inject a compact block at `SessionStart` only; pull details on demand with `memory_recall` / `memory_search` or the `memory-recall` skill.
|
|
368
|
+
- `claw-memory cleanse` finds (and with `--apply` tombstones) legacy chunks polluted by raw JSON payloads.
|
|
366
369
|
- The MCP server is long-lived per agent session, so the embedding model loads once.
|
|
367
370
|
- Viewer + MCP can run simultaneously — SQLite WAL handles concurrent read/write.
|
|
368
371
|
- On install, dependencies resolve with `legacy-peer-deps=true` (a zod peer-range overlap
|
package/dist/cli.js
CHANGED
|
@@ -203,6 +203,38 @@ async function main() {
|
|
|
203
203
|
console.log(JSON.stringify({ scanned: convos.length, distilled, skipped, failed }, null, 2));
|
|
204
204
|
process.exit(0);
|
|
205
205
|
}
|
|
206
|
+
case "cleanse": {
|
|
207
|
+
// Hygiene pass over stored chunks: find rows whose text is a raw JSON
|
|
208
|
+
// payload (e.g. old distill responses stored verbatim) and tombstone
|
|
209
|
+
// them via the existing soft-delete. Dry-run by default; --apply acts.
|
|
210
|
+
const { sqlite } = await import("./core/db.js");
|
|
211
|
+
const { isContaminatedChunkText } = await import("./core/distill.js");
|
|
212
|
+
const { forgetChunks } = await import("./core/vector-memory.js");
|
|
213
|
+
const rows = sqlite
|
|
214
|
+
.prepare("SELECT id, user_text, assistant_text FROM conversation_chunks WHERE deleted_at IS NULL")
|
|
215
|
+
.all();
|
|
216
|
+
const bad = rows.filter((r) => isContaminatedChunkText(r.user_text) ||
|
|
217
|
+
isContaminatedChunkText(r.assistant_text));
|
|
218
|
+
if (hasFlag(rest, "apply")) {
|
|
219
|
+
const n = forgetChunks(bad.map((b) => b.id));
|
|
220
|
+
console.log(JSON.stringify({ scanned: rows.length, contaminated: bad.length, tombstoned: n }, null, 2));
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
for (const b of bad.slice(0, Number(getFlag(rest, "show") ?? 5))) {
|
|
224
|
+
const t = isContaminatedChunkText(b.user_text)
|
|
225
|
+
? b.user_text
|
|
226
|
+
: b.assistant_text;
|
|
227
|
+
console.log(`${b.id} ${t.slice(0, 120).replace(/\s+/g, " ")}`);
|
|
228
|
+
}
|
|
229
|
+
console.log(JSON.stringify({
|
|
230
|
+
scanned: rows.length,
|
|
231
|
+
contaminated: bad.length,
|
|
232
|
+
dryRun: true,
|
|
233
|
+
hint: "run with --apply to tombstone (soft-delete)",
|
|
234
|
+
}, null, 2));
|
|
235
|
+
}
|
|
236
|
+
process.exit(0);
|
|
237
|
+
}
|
|
206
238
|
case "remember": {
|
|
207
239
|
const { getOrCreateProjectByPath } = await import("./core/projects.js");
|
|
208
240
|
const { rememberText } = await import("./core/distill.js");
|
|
@@ -388,6 +420,7 @@ async function main() {
|
|
|
388
420
|
" distill-codex [--recent] [--limit N] [--all] distill recent Codex sessions\n" +
|
|
389
421
|
" distill-chatgpt [--limit N] [--all] distill ChatGPT web export conversations\n" +
|
|
390
422
|
" remember --cwd P \"text\"\n" +
|
|
423
|
+
" cleanse [--apply] [--show N] find (and with --apply tombstone) JSON-contaminated chunks\n" +
|
|
391
424
|
" lessons <list|search|inject|extract|approve|reject|archive|supersede>\n" +
|
|
392
425
|
" search-logs \"query\" [--source claude-code,codex,chatgpt-web] [--project P] [--start ISO] [--end ISO] [--limit N] [--offset N]\n" +
|
|
393
426
|
" hook <distill|recall> run a Claude Code lifecycle hook (reads JSON on stdin)\n" +
|
package/dist/core/db.js
CHANGED
|
@@ -1,16 +1,44 @@
|
|
|
1
1
|
// src/core/db.ts
|
|
2
2
|
//
|
|
3
|
-
// Storage foundation:
|
|
4
|
-
//
|
|
5
|
-
|
|
3
|
+
// Storage foundation: node:sqlite (built into Node >= 24, no native ABI to
|
|
4
|
+
// break on Node upgrades) + sqlite-vec (in-process vector search) + FTS5
|
|
5
|
+
// keyword fallback. No ORM, no daemon, no Python.
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
7
|
import * as sqliteVec from "sqlite-vec";
|
|
7
8
|
import { dbPath } from "./paths.js";
|
|
8
|
-
|
|
9
|
-
sqlite.
|
|
10
|
-
sqlite
|
|
11
|
-
sqlite.
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
// allowExtension can only be set at construction time (node:sqlite security
|
|
10
|
+
// design) and is required to load sqlite-vec below.
|
|
11
|
+
export const sqlite = new DatabaseSync(dbPath, { allowExtension: true });
|
|
12
|
+
sqlite.exec("PRAGMA journal_mode = WAL");
|
|
13
|
+
sqlite.exec("PRAGMA busy_timeout = 5000");
|
|
14
|
+
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
15
|
+
// Load the sqlite-vec extension (provides the vec0 virtual table). sqlite-vec's
|
|
16
|
+
// load() helper targets better-sqlite3; node:sqlite takes the raw path instead.
|
|
17
|
+
sqlite.loadExtension(sqliteVec.getLoadablePath());
|
|
18
|
+
/**
|
|
19
|
+
* Run fn inside BEGIN/COMMIT with ROLLBACK on throw — node:sqlite has no
|
|
20
|
+
* equivalent of better-sqlite3's db.transaction() helper. Callers never nest;
|
|
21
|
+
* if one ever does, the inner call joins the outer transaction.
|
|
22
|
+
*/
|
|
23
|
+
export function transaction(fn) {
|
|
24
|
+
if (sqlite.isTransaction)
|
|
25
|
+
return fn();
|
|
26
|
+
sqlite.exec("BEGIN");
|
|
27
|
+
try {
|
|
28
|
+
const result = fn();
|
|
29
|
+
sqlite.exec("COMMIT");
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
try {
|
|
34
|
+
sqlite.exec("ROLLBACK");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// rollback failure is secondary; surface the original error
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
14
42
|
sqlite.exec(`
|
|
15
43
|
CREATE TABLE IF NOT EXISTS projects (
|
|
16
44
|
id TEXT PRIMARY KEY,
|
package/dist/core/distill.js
CHANGED
|
@@ -14,6 +14,53 @@ import { saveCandidates, extractDedicated, dedicatedEnabled, } from "./lesson-ex
|
|
|
14
14
|
const MIN_MESSAGES = 2;
|
|
15
15
|
const MIN_TEXT_LENGTH = 100;
|
|
16
16
|
const MAX_CHARS_PER_MESSAGE = 500;
|
|
17
|
+
// --- Chunk hygiene -----------------------------------------------------------
|
|
18
|
+
// Raw JSON payloads and code dumps stored as chunks poison recall: they get
|
|
19
|
+
// embedded, matched, and re-injected as "past conversation" forever (a past
|
|
20
|
+
// distill JSON response was once re-stored verbatim this way). Strip fenced
|
|
21
|
+
// code and drop messages that are effectively raw JSON before chunking.
|
|
22
|
+
/** Replace fenced code blocks (incl. an unterminated trailing fence) with a placeholder. */
|
|
23
|
+
export function stripCodeFences(text) {
|
|
24
|
+
return text
|
|
25
|
+
.replace(/```[\s\S]*?```/g, "[code]")
|
|
26
|
+
.replace(/```[\s\S]*$/, "[code]")
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* True when the text is a raw JSON payload rather than prose. Handles
|
|
31
|
+
* truncated JSON (sliced mid-document) via the `{"key":`-shape heuristic,
|
|
32
|
+
* which complete-parse alone would miss.
|
|
33
|
+
*/
|
|
34
|
+
export function isJsonPayload(text) {
|
|
35
|
+
const t = text.trim();
|
|
36
|
+
if (!/^[[{]/.test(t))
|
|
37
|
+
return false;
|
|
38
|
+
if (/^[[{]\s*"/.test(t))
|
|
39
|
+
return true;
|
|
40
|
+
try {
|
|
41
|
+
JSON.parse(t);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* True when stored chunk text is garbage for recall: a raw JSON payload, a
|
|
50
|
+
* fence-wrapped JSON payload (```json …), or nothing but code once fences are
|
|
51
|
+
* stripped. Used by the new distill filter and by `cleanse` on legacy rows.
|
|
52
|
+
*/
|
|
53
|
+
export function isContaminatedChunkText(text) {
|
|
54
|
+
const t = text.trim();
|
|
55
|
+
if (!t)
|
|
56
|
+
return false;
|
|
57
|
+
if (isJsonPayload(t))
|
|
58
|
+
return true;
|
|
59
|
+
const fenced = t.match(/^```[a-zA-Z]*\s*([\s\S]*?)(?:```|$)/);
|
|
60
|
+
if (fenced && isJsonPayload(fenced[1] ?? ""))
|
|
61
|
+
return true;
|
|
62
|
+
return stripCodeFences(t).replace(/\[code\]/g, "").trim().length === 0;
|
|
63
|
+
}
|
|
17
64
|
const OBS_TYPES = ["discovery", "bugfix", "feature", "decision", "change", "other"];
|
|
18
65
|
const PROMPT = (transcript) => `以下の会話を分析して JSON で回答してください。
|
|
19
66
|
|
|
@@ -51,7 +98,7 @@ export async function distill(input) {
|
|
|
51
98
|
return { skipped: true, reason: "too few messages" };
|
|
52
99
|
}
|
|
53
100
|
const transcript = messages
|
|
54
|
-
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.text.slice(0, MAX_CHARS_PER_MESSAGE)}`)
|
|
101
|
+
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${stripCodeFences(m.text).slice(0, MAX_CHARS_PER_MESSAGE)}`)
|
|
55
102
|
.join("\n");
|
|
56
103
|
if (transcript.length < MIN_TEXT_LENGTH) {
|
|
57
104
|
return { skipped: true, reason: "too short" };
|
|
@@ -92,9 +139,20 @@ export async function distill(input) {
|
|
|
92
139
|
const m = messages[i];
|
|
93
140
|
if (m.role === "user" && m.text.trim()) {
|
|
94
141
|
const next = messages[i + 1];
|
|
142
|
+
// Hygiene: skip pairs whose user side is contaminated (raw/fenced JSON
|
|
143
|
+
// or code-only); blank a contaminated assistant side. The full text
|
|
144
|
+
// stays searchable in raw logs.
|
|
145
|
+
if (isContaminatedChunkText(m.text))
|
|
146
|
+
continue;
|
|
147
|
+
const userText = stripCodeFences(m.text);
|
|
148
|
+
if (!userText)
|
|
149
|
+
continue;
|
|
150
|
+
const assistantContaminated = !next || next.role !== "assistant" || isContaminatedChunkText(next.text);
|
|
95
151
|
pairs.push({
|
|
96
|
-
userText:
|
|
97
|
-
assistantText:
|
|
152
|
+
userText: userText.slice(0, 500),
|
|
153
|
+
assistantText: assistantContaminated
|
|
154
|
+
? ""
|
|
155
|
+
: stripCodeFences(next.text).slice(0, 500),
|
|
98
156
|
});
|
|
99
157
|
}
|
|
100
158
|
}
|
package/dist/core/hooks.js
CHANGED
|
@@ -8,7 +8,7 @@ import { statSync } from "node:fs";
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { getOrCreateProjectByPath } from "./projects.js";
|
|
11
|
-
import {
|
|
11
|
+
import { buildStartupBlock } from "./recall.js";
|
|
12
12
|
import { shouldDistill } from "./watermark.js";
|
|
13
13
|
import { isExcludedPath } from "./excludes.js";
|
|
14
14
|
import { log } from "./logger.js";
|
|
@@ -61,16 +61,17 @@ export function runDistillHook(input) {
|
|
|
61
61
|
child.unref();
|
|
62
62
|
}
|
|
63
63
|
/**
|
|
64
|
-
* SessionStart
|
|
65
|
-
*
|
|
66
|
-
*
|
|
64
|
+
* SessionStart: print the compact startup block (preferences + one-line
|
|
65
|
+
* summaries + pull pointer) to stdout. Prompt-driven semantic recall is pull
|
|
66
|
+
* only — the memory_recall MCP tool / memory-recall skill — so hooks no longer
|
|
67
|
+
* re-inject a large memory block into every turn.
|
|
67
68
|
*/
|
|
68
69
|
export async function runRecallHook(input) {
|
|
69
70
|
const cwd = input.cwd ?? process.cwd();
|
|
70
71
|
if (isExcludedPath(cwd))
|
|
71
72
|
return;
|
|
72
73
|
const project = getOrCreateProjectByPath(cwd);
|
|
73
|
-
const block =
|
|
74
|
-
if (block
|
|
75
|
-
process.stdout.write(block
|
|
74
|
+
const block = buildStartupBlock(project.id);
|
|
75
|
+
if (block)
|
|
76
|
+
process.stdout.write(block + "\n");
|
|
76
77
|
}
|
|
@@ -16,12 +16,12 @@ const AGENTS = join(CODEX_DIR, "AGENTS.md");
|
|
|
16
16
|
const HOOKS = join(CODEX_DIR, "hooks.json");
|
|
17
17
|
const SKILL_DIR = join(CODEX_DIR, "skills", "memory-recall");
|
|
18
18
|
// Codex lifecycle hooks for the manual (non-plugin) install path. Mirrors the
|
|
19
|
-
// Claude Code hook wiring: recall on session start
|
|
19
|
+
// Claude Code hook wiring: compact recall on session start only (pull model —
|
|
20
|
+
// per-prompt injection was removed to stop context bloat), distill on stop.
|
|
20
21
|
// Codex Stop carries no Claude-format transcript, so distill scans recent Codex
|
|
21
22
|
// session files (watermark-guarded) instead of a single transcript_path.
|
|
22
23
|
const HOOK_EVENTS = [
|
|
23
24
|
["SessionStart", "hook recall", false],
|
|
24
|
-
["UserPromptSubmit", "hook recall", false],
|
|
25
25
|
["Stop", "distill-codex --limit 5", true],
|
|
26
26
|
];
|
|
27
27
|
const BEGIN = "# >>> claw-memory >>>";
|
|
@@ -75,8 +75,22 @@ function installHooks() {
|
|
|
75
75
|
const cli = cliInvoker();
|
|
76
76
|
const f = readHooks();
|
|
77
77
|
f.hooks = f.hooks ?? {};
|
|
78
|
+
// Sweep our hooks from every event first so events we no longer wire (e.g.
|
|
79
|
+
// the removed per-prompt UserPromptSubmit recall) don't linger on upgrade.
|
|
80
|
+
// Filter at hook granularity (like removeHooks) so a user hook sharing a
|
|
81
|
+
// group with ours is preserved, not swept along.
|
|
82
|
+
for (const event of Object.keys(f.hooks)) {
|
|
83
|
+
f.hooks[event] = f.hooks[event]
|
|
84
|
+
.map((g) => ({
|
|
85
|
+
...g,
|
|
86
|
+
hooks: (g.hooks ?? []).filter((h) => !isOurHook(h.command)),
|
|
87
|
+
}))
|
|
88
|
+
.filter((g) => g.hooks.length > 0);
|
|
89
|
+
if (f.hooks[event].length === 0)
|
|
90
|
+
delete f.hooks[event];
|
|
91
|
+
}
|
|
78
92
|
for (const [event, sub, isAsync] of HOOK_EVENTS) {
|
|
79
|
-
const list =
|
|
93
|
+
const list = f.hooks[event] ?? [];
|
|
80
94
|
list.push({ hooks: [{ type: "command", command: `${cli} ${sub}`, async: isAsync }] });
|
|
81
95
|
f.hooks[event] = list;
|
|
82
96
|
}
|
|
@@ -163,9 +177,9 @@ export function installCodex() {
|
|
|
163
177
|
const agentsPrev = existsSync(AGENTS) ? readFileSync(AGENTS, "utf-8") : "";
|
|
164
178
|
writeFileSync(AGENTS, upsertBlock(agentsPrev, A_BEGIN, A_END, agentsBlock));
|
|
165
179
|
done.push("AGENTS.md: recall instruction");
|
|
166
|
-
// 4) lifecycle hooks:
|
|
180
|
+
// 4) lifecycle hooks: compact recall on SessionStart + auto distill on Stop
|
|
167
181
|
installHooks();
|
|
168
|
-
done.push("hooks.json: SessionStart
|
|
182
|
+
done.push("hooks.json: SessionStart→recall, Stop→distill-codex");
|
|
169
183
|
return done;
|
|
170
184
|
}
|
|
171
185
|
export function uninstallCodex() {
|
package/dist/core/lessons.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// source_chunk_ids) are stored as TEXT, parsed on read — same convention as
|
|
10
10
|
// conversation_chunks.concepts.
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
|
-
import { sqlite } from "./db.js";
|
|
12
|
+
import { sqlite, transaction } from "./db.js";
|
|
13
13
|
function jsonArr(v) {
|
|
14
14
|
return v && v.length ? JSON.stringify(v) : null;
|
|
15
15
|
}
|
|
@@ -75,14 +75,13 @@ export function saveLesson(input) {
|
|
|
75
75
|
const scope = input.scope ?? "repo";
|
|
76
76
|
const status = input.status ?? "candidate";
|
|
77
77
|
const confidence = input.confidence ?? 0.5;
|
|
78
|
-
|
|
78
|
+
transaction(() => {
|
|
79
79
|
const res = insertVec.run(Buffer.from(input.embedding.buffer), input.projectId ?? null, scope);
|
|
80
80
|
const vecRowid = res.lastInsertRowid;
|
|
81
81
|
insertMeta.run(id, vecRowid, input.projectId ?? null, input.repoId ?? null, input.sessionId ?? null, input.title, input.lesson, jsonArr(input.appliesWhen), jsonArr(input.avoidWhen), input.evidence ?? null, scope, input.obsType ?? null, jsonArr(input.concepts), jsonArr(input.files), confidence, jsonArr(input.sourceChunkIds), status, now, now);
|
|
82
82
|
insertFts.run(id, `${input.title}\n${input.lesson}`);
|
|
83
83
|
recordEvent(id, "created", { newStatus: status });
|
|
84
84
|
});
|
|
85
|
-
tx();
|
|
86
85
|
return id;
|
|
87
86
|
}
|
|
88
87
|
export function getLesson(id) {
|
|
@@ -230,7 +229,7 @@ export function supersede(oldId, newId) {
|
|
|
230
229
|
if (!current)
|
|
231
230
|
return false;
|
|
232
231
|
const now = new Date().toISOString();
|
|
233
|
-
|
|
232
|
+
transaction(() => {
|
|
234
233
|
sqlite
|
|
235
234
|
.prepare("UPDATE lessons SET status = 'superseded', superseded_by = ?, updated_at = ? WHERE id = ?")
|
|
236
235
|
.run(newId, now, oldId);
|
|
@@ -241,7 +240,6 @@ export function supersede(oldId, newId) {
|
|
|
241
240
|
});
|
|
242
241
|
linkLessons(newId, oldId, "supersedes");
|
|
243
242
|
});
|
|
244
|
-
tx();
|
|
245
243
|
return true;
|
|
246
244
|
}
|
|
247
245
|
/** Update one or more editable fields (scope / confidence / valid_until). */
|
|
@@ -278,11 +276,10 @@ export function markUsed(ids) {
|
|
|
278
276
|
return;
|
|
279
277
|
const now = new Date().toISOString();
|
|
280
278
|
const stmt = sqlite.prepare("UPDATE lessons SET last_used_at = ? WHERE id = ?");
|
|
281
|
-
|
|
279
|
+
transaction(() => {
|
|
282
280
|
for (const id of ids)
|
|
283
281
|
stmt.run(now, id);
|
|
284
282
|
});
|
|
285
|
-
tx();
|
|
286
283
|
}
|
|
287
284
|
export function linkLessons(lessonId, linkedLessonId, relation) {
|
|
288
285
|
sqlite
|
package/dist/core/recall.js
CHANGED
|
@@ -11,6 +11,49 @@ const MEMORY_MAX_DISTANCE = Number(process.env.MEMORY_SIMILARITY_MAX_DISTANCE ??
|
|
|
11
11
|
// Approved lessons injected into the recall block. Kept small to avoid context
|
|
12
12
|
// bloat; 0 disables lesson injection entirely.
|
|
13
13
|
const RECALL_LESSON_LIMIT = Number(process.env.LESSON_RECALL_LIMIT ?? 3);
|
|
14
|
+
// SessionStart-only injection (pull model). Hooks used to push the full memory
|
|
15
|
+
// block (~14KB measured) into every prompt; now hooks inject only this compact
|
|
16
|
+
// block and detailed recall happens on demand via the memory_recall MCP tool /
|
|
17
|
+
// memory-recall skill.
|
|
18
|
+
const STARTUP_SUMMARY_LIMIT = Number(process.env.MEMORY_STARTUP_SUMMARIES ?? 3);
|
|
19
|
+
const STARTUP_SUMMARY_CHARS = 160;
|
|
20
|
+
/** Flatten a structured markdown summary to a single truncated line. */
|
|
21
|
+
function oneLine(text, max = STARTUP_SUMMARY_CHARS) {
|
|
22
|
+
const flat = text
|
|
23
|
+
.replace(/^#{1,6}\s*/gm, "")
|
|
24
|
+
.replace(/\s+/g, " ")
|
|
25
|
+
.trim();
|
|
26
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Compact memory block for SessionStart: always-apply preferences + one-line
|
|
30
|
+
* recent summaries + a pointer to the pull tools. Target size: <1.5KB.
|
|
31
|
+
*/
|
|
32
|
+
export function buildStartupBlock(projectId) {
|
|
33
|
+
const prefs = getPreferences(projectId);
|
|
34
|
+
const summaries = getRecentSummaries(projectId, STARTUP_SUMMARY_LIMIT);
|
|
35
|
+
let text = "";
|
|
36
|
+
if (prefs.length > 0) {
|
|
37
|
+
text += '<user-preferences instruction="always-apply">\n';
|
|
38
|
+
text += "以下のユーザー設定は常に従ってください。\n";
|
|
39
|
+
for (const p of prefs)
|
|
40
|
+
text += `- ${p.key}: ${p.value}\n`;
|
|
41
|
+
text += "</user-preferences>\n";
|
|
42
|
+
}
|
|
43
|
+
if (summaries.length > 0) {
|
|
44
|
+
text += '\n<memory-context instruction="reference-only">\n';
|
|
45
|
+
text += "直近セッションの1行要約です。背景知識として参照する程度に留めてください。\n";
|
|
46
|
+
for (const s of summaries) {
|
|
47
|
+
text += `- [${s.created_at.split("T")[0]}] ${oneLine(s.summary)}\n`;
|
|
48
|
+
}
|
|
49
|
+
text += "</memory-context>\n";
|
|
50
|
+
}
|
|
51
|
+
if (text) {
|
|
52
|
+
text +=
|
|
53
|
+
"\n<memory-pull>\n過去の会話・決定・教訓の詳細が必要になったときは、memory_recall / memory_search(MCPツール)または memory-recall スキルで明示的に取得してください。\n</memory-pull>";
|
|
54
|
+
}
|
|
55
|
+
return text.trim();
|
|
56
|
+
}
|
|
14
57
|
export async function buildMemoryBlock(projectId, query, topK = 5) {
|
|
15
58
|
const prefs = getPreferences(projectId);
|
|
16
59
|
const summaries = getRecentSummaries(projectId, 5);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Chunks carry optional structured metadata (obs_type / concepts / files) and a
|
|
6
6
|
// deleted_at tombstone; all reads exclude tombstoned rows.
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
import { sqlite } from "./db.js";
|
|
8
|
+
import { sqlite, transaction } from "./db.js";
|
|
9
9
|
function jsonArr(v) {
|
|
10
10
|
return v && v.length ? JSON.stringify(v) : null;
|
|
11
11
|
}
|
|
@@ -38,7 +38,7 @@ export function saveChunks(chunks) {
|
|
|
38
38
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
39
39
|
const insertFts = sqlite.prepare("INSERT INTO chunks_fts(chunk_id, text) VALUES (?, ?)");
|
|
40
40
|
const ids = [];
|
|
41
|
-
|
|
41
|
+
transaction(() => {
|
|
42
42
|
for (const chunk of chunks) {
|
|
43
43
|
const res = insertVec.run(Buffer.from(chunk.embedding.buffer), chunk.projectId);
|
|
44
44
|
const vecRowid = res.lastInsertRowid;
|
|
@@ -49,7 +49,6 @@ export function saveChunks(chunks) {
|
|
|
49
49
|
ids.push(id);
|
|
50
50
|
}
|
|
51
51
|
});
|
|
52
|
-
tx();
|
|
53
52
|
return ids;
|
|
54
53
|
}
|
|
55
54
|
const CHUNK_COLS = `c.id, c.session_id, c.project_id, c.user_text, c.assistant_text,
|
|
@@ -146,25 +145,43 @@ export function deleteChunksBySession(sessionId) {
|
|
|
146
145
|
const deleteVec = sqlite.prepare("DELETE FROM vec_chunks WHERE rowid = ?");
|
|
147
146
|
const deleteFts = sqlite.prepare("DELETE FROM chunks_fts WHERE chunk_id = ?");
|
|
148
147
|
const deleteMeta = sqlite.prepare("DELETE FROM conversation_chunks WHERE session_id = ?");
|
|
149
|
-
|
|
148
|
+
transaction(() => {
|
|
150
149
|
for (const row of rows) {
|
|
151
150
|
deleteVec.run(row.vec_rowid);
|
|
152
151
|
deleteFts.run(row.id);
|
|
153
152
|
}
|
|
154
153
|
deleteMeta.run(sessionId);
|
|
155
154
|
});
|
|
156
|
-
tx();
|
|
157
155
|
}
|
|
158
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* Soft-delete chunks by id (memory_forget / cleanse). Returns the number
|
|
158
|
+
* tombstoned. The metadata row keeps deleted_at (text stays recoverable), but
|
|
159
|
+
* the vec0 and FTS rows are removed outright: searchSimilar picks its top-k
|
|
160
|
+
* inside vec_chunks before the tombstone JOIN, so leftover vectors would eat
|
|
161
|
+
* k-slots and push live chunks out of the results.
|
|
162
|
+
*/
|
|
159
163
|
export function forgetChunks(ids) {
|
|
160
164
|
if (ids.length === 0)
|
|
161
165
|
return 0;
|
|
162
166
|
const placeholders = ids.map(() => "?").join(",");
|
|
163
|
-
const
|
|
164
|
-
.prepare(`
|
|
167
|
+
const rows = sqlite
|
|
168
|
+
.prepare(`SELECT id, vec_rowid FROM conversation_chunks
|
|
165
169
|
WHERE id IN (${placeholders}) AND deleted_at IS NULL`)
|
|
166
|
-
.
|
|
167
|
-
|
|
170
|
+
.all(...ids);
|
|
171
|
+
if (rows.length === 0)
|
|
172
|
+
return 0;
|
|
173
|
+
const deleteVec = sqlite.prepare("DELETE FROM vec_chunks WHERE rowid = ?");
|
|
174
|
+
const deleteFts = sqlite.prepare("DELETE FROM chunks_fts WHERE chunk_id = ?");
|
|
175
|
+
const tombstone = sqlite.prepare("UPDATE conversation_chunks SET deleted_at = ? WHERE id = ?");
|
|
176
|
+
const now = new Date().toISOString();
|
|
177
|
+
transaction(() => {
|
|
178
|
+
for (const row of rows) {
|
|
179
|
+
deleteVec.run(row.vec_rowid);
|
|
180
|
+
deleteFts.run(row.id);
|
|
181
|
+
tombstone.run(now, row.id);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
return rows.length;
|
|
168
185
|
}
|
|
169
186
|
export function getChunkCount(projectId) {
|
|
170
187
|
const row = sqlite
|
package/dist/mcp/server.js
CHANGED
|
@@ -220,7 +220,7 @@ const TOOLS = [
|
|
|
220
220
|
},
|
|
221
221
|
},
|
|
222
222
|
];
|
|
223
|
-
const server = new Server({ name: "claw-memory", version: "0.
|
|
223
|
+
const server = new Server({ name: "claw-memory", version: "0.3.0" }, { capabilities: { tools: {} } });
|
|
224
224
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
225
225
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
226
226
|
const name = req.params.name;
|
package/dist/ui/server.js
CHANGED
|
@@ -31,7 +31,7 @@ export function buildUiApp() {
|
|
|
31
31
|
// cheap, I/O-free change signal — no client-side polling required.
|
|
32
32
|
app.get("/api/events", (c) => {
|
|
33
33
|
return streamSSE(c, async (stream) => {
|
|
34
|
-
const dataVersion = () => sqlite.
|
|
34
|
+
const dataVersion = () => sqlite.prepare("PRAGMA data_version").get().data_version;
|
|
35
35
|
let last = dataVersion();
|
|
36
36
|
await stream.writeSSE({ event: "ready", data: String(last) });
|
|
37
37
|
while (!stream.closed && !stream.aborted) {
|
package/hooks/claw-hook.sh
CHANGED
|
@@ -4,18 +4,42 @@
|
|
|
4
4
|
# mcp: claw-hook.sh mcp (stdio MCP server)
|
|
5
5
|
# Prefers a globally installed `claw-memory`; falls back to npx. stdin/stdout are
|
|
6
6
|
# inherited so hook input and MCP stdio pass through. Never blocks a session:
|
|
7
|
-
# hook failures
|
|
7
|
+
# hook failures don't propagate, but they are LOGGED (not swallowed) — a silent
|
|
8
|
+
# 2>/dev/null here once hid a 3-week total outage. `mcp` execs directly.
|
|
8
9
|
set -euo pipefail
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
HOOK_ERR_LOG="${CLAW_MEMORY_DIR:-$HOME/.claw-memory}/logs/hook-error.log"
|
|
12
|
+
|
|
13
|
+
# node:sqlite still carries an ExperimentalWarning on stderr; silence it so the
|
|
14
|
+
# error log only contains real failures.
|
|
15
|
+
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--disable-warning=ExperimentalWarning"
|
|
16
|
+
|
|
17
|
+
# Resolve the binary into $CMD (array, npx form needs multiple words).
|
|
18
|
+
if command -v claw-memory >/dev/null 2>&1; then
|
|
19
|
+
CMD=(claw-memory)
|
|
20
|
+
else
|
|
21
|
+
CMD=(npx -y @nogataka/claw-memory@latest)
|
|
22
|
+
fi
|
|
16
23
|
|
|
17
24
|
if [ "${1:-}" = "mcp" ]; then
|
|
18
|
-
|
|
25
|
+
exec "${CMD[@]}" "$@"
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
mkdir -p "$(dirname "$HOOK_ERR_LOG")" 2>/dev/null || true
|
|
29
|
+
# Capture stderr synchronously to a temp file, then append it timestamped.
|
|
30
|
+
# (An async process-substitution logger here raced script exit and dropped
|
|
31
|
+
# lines — exactly the silent-failure mode this log exists to prevent.)
|
|
32
|
+
ERR_TMP="$(mktemp "${TMPDIR:-/tmp}/claw-hook-err.XXXXXX" 2>/dev/null)" || ERR_TMP=""
|
|
33
|
+
if [ -n "$ERR_TMP" ]; then
|
|
34
|
+
"${CMD[@]}" "$@" 2>"$ERR_TMP" || true
|
|
35
|
+
if [ -s "$ERR_TMP" ]; then
|
|
36
|
+
TS="$(date '+%Y-%m-%dT%H:%M:%S%z')"
|
|
37
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
38
|
+
printf '%s %s\n' "$TS" "$line"
|
|
39
|
+
done < "$ERR_TMP" >> "$HOOK_ERR_LOG" || true
|
|
40
|
+
fi
|
|
41
|
+
rm -f "$ERR_TMP"
|
|
19
42
|
else
|
|
20
|
-
|
|
43
|
+
# mktemp failed; still never silent — append raw stderr directly.
|
|
44
|
+
"${CMD[@]}" "$@" 2>>"$HOOK_ERR_LOG" || true
|
|
21
45
|
fi
|
package/hooks/hooks.codex.json
CHANGED
|
@@ -11,17 +11,6 @@
|
|
|
11
11
|
]
|
|
12
12
|
}
|
|
13
13
|
],
|
|
14
|
-
"UserPromptSubmit": [
|
|
15
|
-
{
|
|
16
|
-
"hooks": [
|
|
17
|
-
{
|
|
18
|
-
"type": "command",
|
|
19
|
-
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/run-hook.cmd\" claw-hook.sh hook recall",
|
|
20
|
-
"async": false
|
|
21
|
-
}
|
|
22
|
-
]
|
|
23
|
-
}
|
|
24
|
-
],
|
|
25
14
|
"Stop": [
|
|
26
15
|
{
|
|
27
16
|
"hooks": [
|
package/hooks/hooks.json
CHANGED
|
@@ -11,17 +11,6 @@
|
|
|
11
11
|
]
|
|
12
12
|
}
|
|
13
13
|
],
|
|
14
|
-
"UserPromptSubmit": [
|
|
15
|
-
{
|
|
16
|
-
"hooks": [
|
|
17
|
-
{
|
|
18
|
-
"type": "command",
|
|
19
|
-
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/run-hook.cmd\" claw-hook.sh hook recall",
|
|
20
|
-
"async": false
|
|
21
|
-
}
|
|
22
|
-
]
|
|
23
|
-
}
|
|
24
|
-
],
|
|
25
14
|
"Stop": [
|
|
26
15
|
{
|
|
27
16
|
"hooks": [
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nogataka/claw-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Independent, in-process semantic memory MCP server (sqlite-vec + local Xenova e5) with a lightweight web viewer. No daemon, no Python. Installable as a Claude Code plugin and a Codex MCP server.",
|
|
5
|
+
"description": "Independent, in-process semantic memory MCP server (node:sqlite + sqlite-vec + local Xenova e5) with a lightweight web viewer. No daemon, no Python, no native ABI. Installable as a Claude Code plugin and a Codex MCP server.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "nogataka",
|
|
8
8
|
"repository": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"README.ja.md"
|
|
33
33
|
],
|
|
34
34
|
"engines": {
|
|
35
|
-
"node": ">=
|
|
35
|
+
"node": ">=24.0.0"
|
|
36
36
|
},
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public"
|
|
@@ -53,14 +53,12 @@
|
|
|
53
53
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
54
54
|
"@openai/codex-sdk": "^0.135.0",
|
|
55
55
|
"@xenova/transformers": "^2.17.2",
|
|
56
|
-
"better-sqlite3": "12.8.0",
|
|
57
56
|
"hono": "^4.6.0",
|
|
58
57
|
"sqlite-vec": "^0.1.7-alpha.2",
|
|
59
58
|
"zod": "^3.24.0"
|
|
60
59
|
},
|
|
61
60
|
"devDependencies": {
|
|
62
|
-
"@types/
|
|
63
|
-
"@types/node": "^22.0.0",
|
|
61
|
+
"@types/node": "^24.0.0",
|
|
64
62
|
"tsx": "^4.19.0",
|
|
65
63
|
"typescript": "^5.6.0"
|
|
66
64
|
}
|