@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.
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "claw-memory-marketplace",
3
- "owner": { "name": "nogataka" },
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.1.3"
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.1.3"
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.1.3",
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": { "name": "nogataka" },
5
+ "author": {
6
+ "name": "nogataka"
7
+ },
6
8
  "repository": "https://github.com/nogataka/claw-memory",
7
9
  "license": "MIT",
8
- "keywords": ["memory", "mcp", "context", "search", "codex"]
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
- - **ストレージ**: `better-sqlite3` + `sqlite-vec` — ベクトルを 1 つの SQLite ファイル内に格納
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` / `UserPromptSubmit` recall 注入、`Stop` → 自動 distill
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` / `UserPromptSubmit` → **recall 自動注入**(memory ブロックを developer context として)
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 # ネイティブの better-sqlite3 / sqlite-vec をビルド
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
- - `better-sqlite3` / `sqlite-vec` はネイティブモジュールです。Node ABI 変更後は `npm rebuild` を実行してください。
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**: `better-sqlite3` + `sqlite-vec` — vectors live *inside* one SQLite file
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` / `UserPromptSubmit` recall injection, `Stop` → auto-distill.
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` / `UserPromptSubmit` → **auto recall injection** (memory block as developer context),
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 # builds native better-sqlite3 / sqlite-vec
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
- - `better-sqlite3` / `sqlite-vec` are native modules; run `npm rebuild` after a Node ABI change.
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: better-sqlite3 + sqlite-vec (in-process vector search) +
4
- // FTS5 keyword fallback. No ORM, no daemon, no Python.
5
- import Database from "better-sqlite3";
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
- export const sqlite = new Database(dbPath);
9
- sqlite.pragma("journal_mode = WAL");
10
- sqlite.pragma("busy_timeout = 5000");
11
- sqlite.pragma("foreign_keys = ON");
12
- // Load the sqlite-vec extension (provides the vec0 virtual table).
13
- sqliteVec.load(sqlite);
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,
@@ -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: m.text.slice(0, 500),
97
- assistantText: next?.role === "assistant" ? next.text.slice(0, 500) : "",
152
+ userText: userText.slice(0, 500),
153
+ assistantText: assistantContaminated
154
+ ? ""
155
+ : stripCodeFences(next.text).slice(0, 500),
98
156
  });
99
157
  }
100
158
  }
@@ -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 { buildMemoryBlock } from "./recall.js";
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 / UserPromptSubmit: print the memory block to stdout. When the
65
- * event carries the user's prompt it also pulls semantically similar past
66
- * conversations; otherwise just preferences + recent summaries.
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 = await buildMemoryBlock(project.id, input.prompt ?? "", 5);
74
- if (block.fullText.trim())
75
- process.stdout.write(block.fullText + "\n");
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 / prompt, distill on stop.
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 = (f.hooks[event] ?? []).filter((g) => !g.hooks?.some((h) => isOurHook(h.command)));
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: auto recall (SessionStart/UserPromptSubmit) + auto distill (Stop)
180
+ // 4) lifecycle hooks: compact recall on SessionStart + auto distill on Stop
167
181
  installHooks();
168
- done.push("hooks.json: SessionStart/UserPromptSubmit→recall, Stop→distill-codex");
182
+ done.push("hooks.json: SessionStart→recall, Stop→distill-codex");
169
183
  return done;
170
184
  }
171
185
  export function uninstallCodex() {
@@ -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
- const tx = sqlite.transaction(() => {
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
- const tx = sqlite.transaction(() => {
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
- const tx = sqlite.transaction(() => {
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
@@ -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
- const tx = sqlite.transaction(() => {
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
- const tx = sqlite.transaction(() => {
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
- /** Soft-delete chunks by id (memory_forget). Returns the number tombstoned. */
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 res = sqlite
164
- .prepare(`UPDATE conversation_chunks SET deleted_at = ?
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
- .run(new Date().toISOString(), ...ids);
167
- return res.changes;
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
@@ -220,7 +220,7 @@ const TOOLS = [
220
220
  },
221
221
  },
222
222
  ];
223
- const server = new Server({ name: "claw-memory", version: "0.1.0" }, { capabilities: { tools: {} } });
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.pragma("data_version", { simple: true });
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) {
@@ -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 are swallowed, but `mcp` must exec directly (no error masking).
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
- run() {
11
- if command -v claw-memory >/dev/null 2>&1; then
12
- exec claw-memory "$@"
13
- fi
14
- exec npx -y @nogataka/claw-memory@latest "$@"
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
- run "$@"
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
- run "$@" 2>/dev/null || true
43
+ # mktemp failed; still never silent — append raw stderr directly.
44
+ "${CMD[@]}" "$@" 2>>"$HOOK_ERR_LOG" || true
21
45
  fi
@@ -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.2.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": ">=20.0.0"
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/better-sqlite3": "^7.6.11",
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
  }