@nogataka/claw-memory 0.1.2 → 0.2.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.
@@ -3,14 +3,14 @@
3
3
  "owner": { "name": "nogataka" },
4
4
  "metadata": {
5
5
  "description": "claw-memory: local semantic memory + raw-log search for Claude Code",
6
- "version": "0.1.2"
6
+ "version": "0.1.3"
7
7
  },
8
8
  "plugins": [
9
9
  {
10
10
  "name": "claw-memory",
11
11
  "source": "./",
12
12
  "description": "Local semantic memory: auto-distill, recall injection, and raw Claude Code + Codex transcript search.",
13
- "version": "0.1.2"
13
+ "version": "0.1.3"
14
14
  }
15
15
  ]
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claw-memory",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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
5
  "author": { "name": "nogataka" },
6
6
  "repository": "https://github.com/nogataka/claw-memory",
package/README.ja.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [English](README.md) | **日本語**
4
4
 
5
+ ![claw-memory メモリビューア](docs/screenshot.png)
6
+
5
7
  **AI コーディングエージェント(Claude Code / Codex)のためのローカル完結型・長期記憶。**
6
8
  エージェントが過去のセッション・あなたの好み・以前の決定事項を記憶し、記録済みの全
7
9
  トランスクリプトを横断検索できます。デーモン不要、Python 不要、外部ベクトル DB 不要。
@@ -42,11 +44,16 @@ npm install -g @nogataka/claw-memory
42
44
  | ソース | 内容 | ツール |
43
45
  |--------|------|--------|
44
46
  | **蒸留 DB** | LLM がセッションを要約 → 要約・好み・構造化メタデータ付きの埋め込みチャンク。セマンティック検索可能。 | `memory_recall`, `memory_search`, `memory_get` |
45
- | **生ログ検索** | 実際の Claude Code(`~/.claude/projects`)/ Codex(`~/.codex/sessions`)ログを全文検索。蒸留していないセッションも対象。 | `memory_search_logs` |
47
+ | **生ログ検索** | 実際の Claude Code(`~/.claude/projects`)/ Codex(`~/.codex/sessions`)/ **ChatGPT web** エクスポート(`conversations.json`)ログを全文検索。蒸留していないセッションも対象。 | `memory_search_logs` |
46
48
 
47
49
  蒸留 DB は整理済みで高速に想起でき、生ログ検索は claw-memory 導入前を含め「いつか話した
48
50
  こと」を必ず見つけられるセーフティネットです。
49
51
 
52
+ **ChatGPT web** の会話は OpenAI サーバー側にありローカルに残らないため、公式エクスポートから読み込みます。
53
+ ChatGPT → Settings → **Data controls** → **Export data** でダウンロードし、解凍して `conversations.json` を
54
+ `~/.claw-memory/chatgpt/` に置く(または `CLAW_MEMORY_CHATGPT_EXPORT` でファイル/フォルダを指定)だけで、
55
+ 他ログと同様に検索でき、`claw-memory distill-chatgpt` で専用 `chatgpt` プロジェクトとして意味 DB にも取り込めます。
56
+
50
57
  ### 2. 自動取り込み(distill)
51
58
 
52
59
  セッション終了時、トランスクリプトを次の形に蒸留します。
@@ -94,7 +101,19 @@ npm install -g @nogataka/claw-memory
94
101
 
95
102
  ビルド不要・読み取り専用のビューア(`claw-memory ui`)。プロジェクト・要約・チャンク
96
103
  (メタデータ付き)・好みを閲覧でき、生ログ検索も実行できます。SSE で開いている間は自動
97
- 更新。起動した時だけ動きます。
104
+ 更新。起動した時だけ動きます。抽出されたレッスンをレビュー・承認・編集できる **Lessons**
105
+ タブも備えています。
106
+
107
+ ### 8. 再利用可能なレッスン
108
+
109
+ 過去ログをそのまま検索するだけでなく、claw-memory は AI コーディングセッションを
110
+ **再利用可能なレッスン**へと蒸留します。プロジェクト固有の制約、デバッグパターン、設計
111
+ 判断、ユーザーの開発方針などの、実行可能で抽象化された知識です。レッスンは通常の要約と
112
+ 同時に抽出され(追加の LLM 呼び出しなし)、ローカルに保存され、同じローカルモデルで埋め込
113
+ まれ、(承認後のみ)類似タスク時に想起されます。各レッスンは `scope`・`confidence`・
114
+ `applies_when` / `avoid_when` と、ライフサイクル(candidate → approved → archived /
115
+ superseded)を持ち、重複・矛盾検出と時間経過による confidence 減衰を備えます。raw ログは
116
+ 根拠として保持しつつ、通常の想起では簡潔で再利用可能なレッスンを優先します。
98
117
 
99
118
  ---
100
119
 
@@ -195,11 +214,17 @@ npm link # 任意: `claw-memory` バイナリを公開
195
214
  | `memory_remember(text, cwd?, sessionId?)` | フリーテキストのメモを永続保存。 |
196
215
  | `memory_distill(cwd, sessionId? \| transcriptPath?)` | セッションを要約して記憶化(LLM バックエンドが必要)。 |
197
216
  | `memory_get_preferences(cwd?)` | プロジェクトの保存済み好みを一覧。 |
198
- | `memory_search_logs(query, sources?, projectPath?, startDate?, endDate?, limit?, offset?)` | Claude Code + Codex の生ログを全文検索。 |
217
+ | `memory_search_logs(query, sources?, projectPath?, startDate?, endDate?, limit?, offset?)` | Claude Code + Codex + ChatGPT-web の生ログを全文検索(`sources`: `claude-code` / `codex` / `chatgpt-web`)。 |
199
218
  | `memory_forget(ids)` | チャンクをソフト削除(検索 / recall / ビューアから除外)。 |
219
+ | `lesson_search(query, cwd?, limit?)` | 承認済みレッスンを関連度 + scope + confidence で検索。 |
220
+ | `lesson_inject(query, cwd?, limit?)` | 同上を `<relevant-lessons>` ブロックとして返す。 |
221
+ | `lesson_get(lesson_id)` | レッスン1件の詳細(全フィールド + 履歴 + リンク)。 |
222
+ | `lesson_extract(cwd, sessionId? \| transcriptPath?)` | セッションから専用のレッスン抽出(LLM 必要)。 |
223
+ | `lesson_approve / lesson_reject / lesson_archive(lesson_id, reason?)` | ステータス遷移。 |
224
+ | `lesson_supersede(old_lesson_id, new_lesson_id)` | 古いレッスンを新しいもので置き換え。 |
200
225
 
201
- `memory_distill`(LLM)と `memory_search_logs`(`~/.claude/projects`・`~/.codex/sessions`
202
- を直接読む)以外は完全ローカルで動作します。
226
+ `memory_distill` / `lesson_extract`(LLM)と `memory_search_logs`
227
+ (`~/.claude/projects`・`~/.codex/sessions` を直接読む)以外は完全ローカルで動作します。
203
228
 
204
229
  ---
205
230
 
@@ -218,6 +243,12 @@ npm link # 任意: `claw-memory` バイナリを公開
218
243
  | `CLAW_MEMORY_EXCLUDED_PROJECTS` | — | 記録・想起しないパス部分文字列(カンマ/コロン区切り)。 |
219
244
  | `MEMORY_SIMILARITY_MAX_DISTANCE` | `0.6` | セマンティックヒットの最大コサイン距離(小さいほど厳格)。 |
220
245
  | `CLAW_MEMORY_UI_PORT` | `4319` | ビューアのポート。 |
246
+ | `LESSON_RECALL_LIMIT` | `3` | recall ブロックに注入する承認済みレッスン数(`0` で無効)。 |
247
+ | `CLAW_MEMORY_LESSON_DEDICATED` | — | `1` で専用の高品質レッスン抽出パスを実行(LLM 追加呼び出し)。 |
248
+ | `CLAW_MEMORY_LESSON_CONFLICT_LLM` | — | `1` で抽出時に LLM による矛盾検出を有効化。 |
249
+ | `LESSON_DECAY_FACTOR` / `LESSON_STALE_DAYS` | `0.9` / `30` | `lessons decay` の減衰係数と陳腐化しきい値。 |
250
+ | `CLAW_MEMORY_CHATGPT_EXPORT` | `~/.claw-memory/chatgpt` | ChatGPT `conversations.json` エクスポート(ファイル or `*.json` を含むフォルダ)。 |
251
+ | `CLAW_MEMORY_CHATGPT_MAX_BYTES` | `209715200`(200 MB) | これを超える ChatGPT エクスポートはスキップ(メモリ内パースのため)。 |
221
252
 
222
253
  ### LLM バックエンド
223
254
 
@@ -241,8 +272,18 @@ claw-memory mcp # stdio MCP サーバー(エ
241
272
  claw-memory ui [--port N] [--open] # 読み取り専用 Web ビューア
242
273
  claw-memory distill --cwd P --session ID [--path FILE] [--if-stale]
243
274
  claw-memory distill-codex [--recent] [--limit N] [--all]
275
+ claw-memory distill-chatgpt [--limit N] [--all] # ChatGPT web エクスポートの会話を蒸留
244
276
  claw-memory remember --cwd P "メモ"
245
- claw-memory search-logs "クエリ" [--source claude-code,codex] [--project P]
277
+ claw-memory lessons list [--status candidate|approved|...] [--cwd P]
278
+ claw-memory lessons search "クエリ" [--cwd P] [--limit N]
279
+ claw-memory lessons inject "クエリ" [--cwd P] [--limit N]
280
+ claw-memory lessons extract --session ID [--cwd P] [--path FILE]
281
+ claw-memory lessons approve|reject|archive <lesson_id> [--reason R]
282
+ claw-memory lessons supersede <old_id> <new_id>
283
+ claw-memory lessons decay [--days N] [--factor F] [--dry]
284
+ claw-memory lessons export [--status S] [--cwd P] > bundle.json
285
+ claw-memory lessons import bundle.json [--status S] [--cwd P]
286
+ claw-memory search-logs "クエリ" [--source claude-code,codex,chatgpt-web] [--project P]
246
287
  [--start ISO] [--end ISO] [--limit N] [--offset N]
247
288
  claw-memory hook <recall|distill> # ライフサイクルフック(JSON を stdin で受領)
248
289
  claw-memory install [--codex | --claude-code] # MCP + フックを登録(既定: codex)
@@ -281,6 +322,14 @@ claw-memory uninstall [--codex | --claude-code]
281
322
  claw-memory ui --open # http://localhost:4319
282
323
  ```
283
324
 
325
+ **Claude Code プラグインだけ**で導入した場合(グローバル npm 未導入)、`claw-memory`
326
+ コマンドは `PATH` に乗りません。その場合は `npx` 経由で起動してください。
327
+
328
+ ```bash
329
+ npx @nogataka/claw-memory ui --open # http://localhost:4319
330
+ npx @nogataka/claw-memory ui --port 5000 --open
331
+ ```
332
+
284
333
  読み取り専用。プロジェクト・要約・会話チャンク(type / concepts / files 付き)・好みを
285
334
  閲覧でき、**🔎 ログ検索** で Claude Code + Codex の生ログを全文検索できます。開いている間
286
335
  は SSE で自動更新。それ以外はバックグラウンドで何も動きません — 確認したい時だけ起動して
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  **English** | [日本語](README.ja.md)
4
4
 
5
+ ![claw-memory memory viewer](docs/screenshot.png)
6
+
5
7
  **Local, in-process long-term memory for AI coding agents (Claude Code & Codex).**
6
8
  Your agent remembers past sessions, your preferences, and prior decisions — and can
7
9
  search every raw transcript you've ever recorded. No daemon, no Python, no external
@@ -44,11 +46,18 @@ npm install -g @nogataka/claw-memory
44
46
  | Source | What it is | Tooling |
45
47
  |--------|-----------|---------|
46
48
  | **Distilled DB** | LLM-summarized sessions → summaries, preferences, and embedded conversation chunks with structured metadata. Semantically searchable. | `memory_recall`, `memory_search`, `memory_get` |
47
- | **Raw transcript search** | Full-text grep over your *actual* Claude Code (`~/.claude/projects`) and Codex (`~/.codex/sessions`) logs — including sessions that were never distilled. | `memory_search_logs` |
49
+ | **Raw transcript search** | Full-text grep over your *actual* Claude Code (`~/.claude/projects`), Codex (`~/.codex/sessions`), and **ChatGPT web** export (`conversations.json`) logs — including sessions that were never distilled. | `memory_search_logs` |
48
50
 
49
51
  The distilled DB is curated and fast to recall; raw search is a safety net that finds
50
52
  anything you ever discussed, even before claw-memory was installed.
51
53
 
54
+ **ChatGPT web** conversations live on OpenAI's servers, not on disk, so claw-memory
55
+ reads them from the official export: ChatGPT → Settings → **Data controls** → **Export
56
+ data**; unzip and drop `conversations.json` into `~/.claw-memory/chatgpt/` (or point
57
+ `CLAW_MEMORY_CHATGPT_EXPORT` at the file/folder). Then it's searchable like any other
58
+ log, and `claw-memory distill-chatgpt` adds it to the semantic DB under a dedicated
59
+ `chatgpt` project.
60
+
52
61
  ### 2. Automatic capture (distill)
53
62
 
54
63
  When a session ends, claw-memory distills the transcript into:
@@ -99,7 +108,21 @@ handle the high-frequency distill work.
99
108
 
100
109
  A zero-build, read-only viewer (`claw-memory ui`) to browse projects, summaries,
101
110
  chunks (with their metadata), preferences, and to run raw-log search — with live
102
- updates via SSE. It runs only when you start it.
111
+ updates via SSE. It runs only when you start it. The viewer also has a **Lessons**
112
+ tab for reviewing, approving and editing extracted lessons.
113
+
114
+ ### 8. Reusable lessons
115
+
116
+ Beyond retrieving past logs, claw-memory distills AI coding sessions into
117
+ **reusable lessons** — actionable, abstracted knowledge such as project-specific
118
+ constraints, debugging patterns, design decisions and user preferences. Lessons
119
+ are extracted alongside the normal summary (no extra LLM call), stored locally,
120
+ embedded with the same local model, and surfaced (only after approval) when a
121
+ similar task appears later. Each lesson carries `scope`, `confidence`,
122
+ `applies_when` / `avoid_when` and a lifecycle (candidate → approved → archived /
123
+ superseded), with duplicate / conflict detection and confidence decay over time.
124
+ Raw logs remain available as evidence; day-to-day recall focuses on concise,
125
+ reusable lessons.
103
126
 
104
127
  ---
105
128
 
@@ -200,11 +223,18 @@ npm link # optional: expose the `claw-memory` binary
200
223
  | `memory_remember(text, cwd?, sessionId?)` | Store a durable free-text note. |
201
224
  | `memory_distill(cwd, sessionId? \| transcriptPath?)` | Summarize a session into memory (needs an LLM backend). |
202
225
  | `memory_get_preferences(cwd?)` | List stored preferences for the project. |
203
- | `memory_search_logs(query, sources?, projectPath?, startDate?, endDate?, limit?, offset?)` | Full-text search over RAW Claude Code + Codex transcripts. |
226
+ | `memory_search_logs(query, sources?, projectPath?, startDate?, endDate?, limit?, offset?)` | Full-text search over RAW Claude Code + Codex + ChatGPT-web transcripts (`sources`: `claude-code` / `codex` / `chatgpt-web`). |
204
227
  | `memory_forget(ids)` | Soft-delete chunks (hidden from search / recall / viewer). |
228
+ | `lesson_search(query, cwd?, limit?)` | Search approved reusable lessons, ranked by relevance + scope + confidence. |
229
+ | `lesson_inject(query, cwd?, limit?)` | Same, returned as a ready-to-read `<relevant-lessons>` block. |
230
+ | `lesson_get(lesson_id)` | Full detail of one lesson (fields + history + links). |
231
+ | `lesson_extract(cwd, sessionId? \| transcriptPath?)` | Dedicated lesson-extraction pass over a session (needs an LLM backend). |
232
+ | `lesson_approve / lesson_reject / lesson_archive(lesson_id, reason?)` | Lifecycle transitions. |
233
+ | `lesson_supersede(old_lesson_id, new_lesson_id)` | Replace an old lesson with a newer one. |
205
234
 
206
- All tools are fully local except `memory_distill` (LLM) and `memory_search_logs`
207
- (reads `~/.claude/projects` and `~/.codex/sessions` directly).
235
+ All tools are fully local except `memory_distill` / `lesson_extract` (LLM) and
236
+ `memory_search_logs` (reads `~/.claude/projects` and `~/.codex/sessions`
237
+ directly).
208
238
 
209
239
  ---
210
240
 
@@ -223,6 +253,12 @@ All tools are fully local except `memory_distill` (LLM) and `memory_search_logs`
223
253
  | `CLAW_MEMORY_EXCLUDED_PROJECTS` | — | Comma/colon-separated path substrings to never record or recall. |
224
254
  | `MEMORY_SIMILARITY_MAX_DISTANCE` | `0.6` | Max cosine distance for a semantic hit (lower = stricter). |
225
255
  | `CLAW_MEMORY_UI_PORT` | `4319` | Viewer port. |
256
+ | `LESSON_RECALL_LIMIT` | `3` | Approved lessons injected into the recall block (`0` disables). |
257
+ | `CLAW_MEMORY_LESSON_DEDICATED` | — | `1` = run a separate, higher-quality lesson-extraction pass (extra LLM call). |
258
+ | `CLAW_MEMORY_LESSON_CONFLICT_LLM` | — | `1` = use the LLM to detect conflicting lessons during extraction. |
259
+ | `LESSON_DECAY_FACTOR` / `LESSON_STALE_DAYS` | `0.9` / `30` | Confidence-decay factor and staleness threshold for `lessons decay`. |
260
+ | `CLAW_MEMORY_CHATGPT_EXPORT` | `~/.claw-memory/chatgpt` | ChatGPT `conversations.json` export — a file or a folder of `*.json`. |
261
+ | `CLAW_MEMORY_CHATGPT_MAX_BYTES` | `209715200` (200 MB) | Skip ChatGPT export files larger than this (parsed in memory). |
226
262
 
227
263
  ### LLM backends
228
264
 
@@ -246,8 +282,18 @@ claw-memory mcp # stdio MCP server (what agents
246
282
  claw-memory ui [--port N] [--open] # read-only web viewer
247
283
  claw-memory distill --cwd P --session ID [--path FILE] [--if-stale]
248
284
  claw-memory distill-codex [--recent] [--limit N] [--all]
285
+ claw-memory distill-chatgpt [--limit N] [--all] # distill ChatGPT web export conversations
249
286
  claw-memory remember --cwd P "a note"
250
- claw-memory search-logs "query" [--source claude-code,codex] [--project P]
287
+ claw-memory lessons list [--status candidate|approved|...] [--cwd P]
288
+ claw-memory lessons search "query" [--cwd P] [--limit N]
289
+ claw-memory lessons inject "query" [--cwd P] [--limit N]
290
+ claw-memory lessons extract --session ID [--cwd P] [--path FILE]
291
+ claw-memory lessons approve|reject|archive <lesson_id> [--reason R]
292
+ claw-memory lessons supersede <old_id> <new_id>
293
+ claw-memory lessons decay [--days N] [--factor F] [--dry]
294
+ claw-memory lessons export [--status S] [--cwd P] > bundle.json
295
+ claw-memory lessons import bundle.json [--status S] [--cwd P]
296
+ claw-memory search-logs "query" [--source claude-code,codex,chatgpt-web] [--project P]
251
297
  [--start ISO] [--end ISO] [--limit N] [--offset N]
252
298
  claw-memory hook <recall|distill> # lifecycle hook (reads JSON on stdin)
253
299
  claw-memory install [--codex | --claude-code] # register MCP + hooks (default: codex)
@@ -286,6 +332,14 @@ session ends (Stop hook / distill-codex) session starts (SessionStart hook / m
286
332
  claw-memory ui --open # http://localhost:4319
287
333
  ```
288
334
 
335
+ If you installed via the **Claude Code plugin only** (no global npm install), the
336
+ `claw-memory` binary isn't on your `PATH`. Run the viewer through `npx` instead:
337
+
338
+ ```bash
339
+ npx @nogataka/claw-memory ui --open # http://localhost:4319
340
+ npx @nogataka/claw-memory ui --port 5000 --open
341
+ ```
342
+
289
343
  Read-only. Browse projects, session summaries, conversation chunks (with type /
290
344
  concepts / files), and preferences; toggle **🔎 ログ検索** to full-text search raw
291
345
  Claude Code + Codex transcripts. Live-updates via SSE while open. Nothing runs in the
package/dist/cli.js CHANGED
@@ -162,6 +162,47 @@ async function main() {
162
162
  console.log(JSON.stringify({ scanned: files.length, distilled, skipped, failed }, null, 2));
163
163
  process.exit(0);
164
164
  }
165
+ case "distill-chatgpt": {
166
+ const { loadChatgptConversations } = await import("./core/logsearch/recent.js");
167
+ const { chatgptProjectPath } = await import("./core/logsearch/paths.js");
168
+ const { distill } = await import("./core/distill.js");
169
+ const { getOrCreateProjectByPath } = await import("./core/projects.js");
170
+ const { shouldDistill, markDistilled } = await import("./core/watermark.js");
171
+ const all = hasFlag(rest, "all");
172
+ const limit = Number(getFlag(rest, "limit") ?? (all ? 100000 : 50));
173
+ const convos = (await loadChatgptConversations()).slice(0, limit);
174
+ // ChatGPT has no cwd; all conversations live under one stable synthetic
175
+ // project so they never pollute a real repository's recall.
176
+ const project = getOrCreateProjectByPath(chatgptProjectPath);
177
+ let distilled = 0, skipped = 0, failed = 0;
178
+ for (const conv of convos) {
179
+ // Watermark per conversation, keyed by id + last write time.
180
+ const wmKey = `chatgpt:${conv.conversationId}`;
181
+ const mtimeMs = conv.updateTime ? new Date(conv.updateTime).getTime() : 0;
182
+ if (!shouldDistill(wmKey, mtimeMs)) {
183
+ skipped++;
184
+ continue;
185
+ }
186
+ try {
187
+ const res = await distill({
188
+ projectId: project.id,
189
+ sessionId: conv.conversationId,
190
+ messages: conv.messages.map((m) => ({ role: m.role, text: m.text })),
191
+ });
192
+ markDistilled(wmKey, mtimeMs);
193
+ if (res.skipped)
194
+ skipped++;
195
+ else
196
+ distilled++;
197
+ }
198
+ catch (e) {
199
+ failed++;
200
+ console.error(`distill failed ${conv.conversationId}: ${String(e)}`);
201
+ }
202
+ }
203
+ console.log(JSON.stringify({ scanned: convos.length, distilled, skipped, failed }, null, 2));
204
+ process.exit(0);
205
+ }
165
206
  case "remember": {
166
207
  const { getOrCreateProjectByPath } = await import("./core/projects.js");
167
208
  const { rememberText } = await import("./core/distill.js");
@@ -201,14 +242,154 @@ async function main() {
201
242
  console.log(JSON.stringify(out, null, 2));
202
243
  process.exit(0);
203
244
  }
245
+ case "lessons": {
246
+ const sub = rest[0];
247
+ const subRest = rest.slice(1);
248
+ const positionals = (args) => {
249
+ const out = [];
250
+ for (let i = 0; i < args.length; i++) {
251
+ if (args[i].startsWith("--")) {
252
+ i++;
253
+ continue;
254
+ }
255
+ out.push(args[i]);
256
+ }
257
+ return out;
258
+ };
259
+ const projectId = async () => {
260
+ const { getOrCreateProjectByPath } = await import("./core/projects.js");
261
+ return getOrCreateProjectByPath(getFlag(subRest, "cwd") ?? process.cwd()).id;
262
+ };
263
+ switch (sub) {
264
+ case "list": {
265
+ const { listLessons } = await import("./core/lessons.js");
266
+ const status = getFlag(subRest, "status");
267
+ const rows = listLessons({ status, projectId: getFlag(subRest, "cwd") ? await projectId() : undefined });
268
+ for (const l of rows) {
269
+ console.log(`${l.id} [${l.status}/${l.scope}] conf=${l.confidence.toFixed(2)} ${l.title}`);
270
+ }
271
+ console.log(`(${rows.length} lessons)`);
272
+ process.exit(0);
273
+ }
274
+ case "search": {
275
+ const { searchLessons } = await import("./core/lesson-search.js");
276
+ const query = positionals(subRest).join(" ").trim();
277
+ if (!query)
278
+ throw new Error("lessons search requires a query");
279
+ const hits = await searchLessons(query, { projectId: await projectId() }, { limit: Number(getFlag(subRest, "limit") ?? 5) });
280
+ for (const l of hits) {
281
+ console.log(`${l.id} score=${l.score.toFixed(3)} [${l.scope}] ${l.title}`);
282
+ }
283
+ process.exit(0);
284
+ }
285
+ case "inject": {
286
+ const { injectLessons } = await import("./core/lesson-search.js");
287
+ const query = positionals(subRest).join(" ").trim();
288
+ if (!query)
289
+ throw new Error("lessons inject requires a query");
290
+ console.log(await injectLessons(query, { projectId: await projectId() }, { limit: Number(getFlag(subRest, "limit") ?? 5) }));
291
+ process.exit(0);
292
+ }
293
+ case "approve":
294
+ case "reject":
295
+ case "archive": {
296
+ const { setStatus } = await import("./core/lessons.js");
297
+ const id = positionals(subRest)[0];
298
+ if (!id)
299
+ throw new Error(`lessons ${sub} requires a lesson_id`);
300
+ const newStatus = sub === "approve" ? "approved" : sub === "reject" ? "rejected" : "archived";
301
+ const ok = setStatus(id, newStatus, getFlag(subRest, "reason"));
302
+ console.log(ok ? `${id} -> ${newStatus}` : "(該当なし)");
303
+ process.exit(ok ? 0 : 1);
304
+ }
305
+ case "supersede": {
306
+ const { supersede } = await import("./core/lessons.js");
307
+ const [oldId, newId] = positionals(subRest);
308
+ if (!oldId || !newId)
309
+ throw new Error("lessons supersede requires <old_id> <new_id>");
310
+ const ok = supersede(oldId, newId);
311
+ console.log(ok ? `${oldId} superseded by ${newId}` : "(該当なし)");
312
+ process.exit(ok ? 0 : 1);
313
+ }
314
+ case "decay": {
315
+ const { decayConfidence, listStale } = await import("./core/lesson-quality.js");
316
+ const dryRun = hasFlag(subRest, "dry");
317
+ const res = decayConfidence({
318
+ factor: getFlag(subRest, "factor") ? Number(getFlag(subRest, "factor")) : undefined,
319
+ staleDays: getFlag(subRest, "days") ? Number(getFlag(subRest, "days")) : undefined,
320
+ dryRun,
321
+ });
322
+ console.log(JSON.stringify({ ...res, dryRun, stale: listStale().length }, null, 2));
323
+ process.exit(0);
324
+ }
325
+ case "export": {
326
+ const { exportLessons } = await import("./core/lesson-share.js");
327
+ const bundle = exportLessons({
328
+ projectId: getFlag(subRest, "cwd") ? await projectId() : undefined,
329
+ status: getFlag(subRest, "status"),
330
+ });
331
+ console.log(JSON.stringify(bundle, null, 2));
332
+ process.exit(0);
333
+ }
334
+ case "import": {
335
+ const { importLessons } = await import("./core/lesson-share.js");
336
+ const { readFileSync } = await import("node:fs");
337
+ const file = positionals(subRest)[0];
338
+ if (!file)
339
+ throw new Error("lessons import requires a bundle file path");
340
+ const bundle = JSON.parse(readFileSync(file, "utf-8"));
341
+ const res = await importLessons(bundle, {
342
+ projectId: await projectId(),
343
+ status: getFlag(subRest, "status"),
344
+ });
345
+ console.log(JSON.stringify(res, null, 2));
346
+ process.exit(0);
347
+ }
348
+ case "extract": {
349
+ const { getOrCreateProjectByPath } = await import("./core/projects.js");
350
+ const { resolveSessionJsonl, loadTranscript } = await import("./core/transcript.js");
351
+ const { stripPrivate } = await import("./core/private.js");
352
+ const { extractDedicated, saveCandidates } = await import("./core/lesson-extract.js");
353
+ const cwd = getFlag(subRest, "cwd") ?? process.cwd();
354
+ const session = getFlag(subRest, "session") ?? "";
355
+ const path = getFlag(subRest, "path") ?? (session ? resolveSessionJsonl(cwd, session) : undefined);
356
+ if (!path)
357
+ throw new Error("lessons extract requires --session or --path");
358
+ const project = getOrCreateProjectByPath(cwd);
359
+ const msgs = loadTranscript(path).map((m) => ({ ...m, text: stripPrivate(m.text) })).filter((m) => m.text.trim());
360
+ const transcript = msgs.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.text.slice(0, 500)}`).join("\n");
361
+ const candidates = await extractDedicated(transcript);
362
+ const ids = await saveCandidates({ projectId: project.id, sessionId: session || path, candidates });
363
+ console.log(JSON.stringify({ extracted: candidates.length, saved: ids.length }, null, 2));
364
+ process.exit(0);
365
+ }
366
+ default:
367
+ console.error("Usage: claw-memory lessons <subcommand>\n" +
368
+ " list [--status candidate|approved|...] [--cwd P]\n" +
369
+ " search \"query\" [--cwd P] [--limit N]\n" +
370
+ " inject \"query\" [--cwd P] [--limit N]\n" +
371
+ " extract --session ID [--cwd P] [--path FILE]\n" +
372
+ " approve <lesson_id>\n" +
373
+ " reject <lesson_id> [--reason R]\n" +
374
+ " archive <lesson_id> [--reason R]\n" +
375
+ " supersede <old_id> <new_id>\n" +
376
+ " decay [--days N] [--factor F] [--dry]\n" +
377
+ " export [--status S] [--cwd P] print a portable JSON bundle\n" +
378
+ " import <file> [--status S] [--cwd P] import a bundle (default status: candidate)");
379
+ process.exit(sub ? 1 : 0);
380
+ }
381
+ return;
382
+ }
204
383
  default:
205
384
  console.error("Usage: claw-memory <command>\n" +
206
385
  " mcp start stdio MCP server\n" +
207
386
  " ui [--port N] [--open] start memory viewer\n" +
208
387
  " distill --cwd P --session ID [--path FILE] [--if-stale]\n" +
209
388
  " distill-codex [--recent] [--limit N] [--all] distill recent Codex sessions\n" +
389
+ " distill-chatgpt [--limit N] [--all] distill ChatGPT web export conversations\n" +
210
390
  " remember --cwd P \"text\"\n" +
211
- " search-logs \"query\" [--source claude-code,codex] [--project P] [--start ISO] [--end ISO] [--limit N] [--offset N]\n" +
391
+ " lessons <list|search|inject|extract|approve|reject|archive|supersede>\n" +
392
+ " search-logs \"query\" [--source claude-code,codex,chatgpt-web] [--project P] [--start ISO] [--end ISO] [--limit N] [--offset N]\n" +
212
393
  " hook <distill|recall> run a Claude Code lifecycle hook (reads JSON on stdin)\n" +
213
394
  " inject-recall alias for `hook recall`\n" +
214
395
  " install [--codex|--claude-code] register MCP + hooks (default: codex)\n" +
package/dist/core/db.js CHANGED
@@ -90,3 +90,98 @@ sqlite.exec(`
90
90
  tokenize = 'trigram'
91
91
  );
92
92
  `);
93
+ // --- Lesson layer ----------------------------------------------------------
94
+ // A "lesson" is reusable, abstracted knowledge distilled from sessions (bug-fix
95
+ // patterns, project constraints, design decisions, user preferences). Unlike
96
+ // conversation_chunks (raw history), lessons are explicit, structured, scoped,
97
+ // and lifecycle-managed (candidate → approved/rejected/archived/superseded).
98
+ // JSON-array columns (applies_when / avoid_when / concepts / files /
99
+ // source_chunk_ids) are stored as TEXT to keep the schema flat and ORM-free,
100
+ // mirroring conversation_chunks.concepts.
101
+ sqlite.exec(`
102
+ CREATE TABLE IF NOT EXISTS lessons (
103
+ id TEXT PRIMARY KEY,
104
+ project_id TEXT,
105
+ repo_id TEXT,
106
+ session_id TEXT,
107
+ vec_rowid INTEGER,
108
+ title TEXT NOT NULL,
109
+ lesson TEXT NOT NULL,
110
+ applies_when TEXT,
111
+ avoid_when TEXT,
112
+ evidence TEXT,
113
+ scope TEXT NOT NULL DEFAULT 'repo',
114
+ obs_type TEXT,
115
+ concepts TEXT,
116
+ files TEXT,
117
+ confidence REAL NOT NULL DEFAULT 0.5,
118
+ source_chunk_ids TEXT,
119
+ status TEXT NOT NULL DEFAULT 'candidate',
120
+ superseded_by TEXT,
121
+ valid_until TEXT,
122
+ last_used_at TEXT,
123
+ created_at TEXT NOT NULL,
124
+ updated_at TEXT NOT NULL
125
+ );
126
+
127
+ -- Lifecycle audit trail: every status transition / decay / scope change.
128
+ CREATE TABLE IF NOT EXISTS lesson_events (
129
+ id TEXT PRIMARY KEY,
130
+ lesson_id TEXT NOT NULL,
131
+ event_type TEXT NOT NULL,
132
+ old_status TEXT,
133
+ new_status TEXT,
134
+ note TEXT,
135
+ created_at TEXT NOT NULL
136
+ );
137
+
138
+ -- Relations between lessons: duplicate / conflicts_with / supersedes /
139
+ -- related_to / derived_from (populated by dedup + conflict detection).
140
+ CREATE TABLE IF NOT EXISTS lesson_links (
141
+ id TEXT PRIMARY KEY,
142
+ lesson_id TEXT NOT NULL,
143
+ linked_lesson_id TEXT NOT NULL,
144
+ relation TEXT NOT NULL,
145
+ created_at TEXT NOT NULL
146
+ );
147
+
148
+ CREATE INDEX IF NOT EXISTS idx_lessons_status ON lessons(status);
149
+ CREATE INDEX IF NOT EXISTS idx_lessons_project ON lessons(project_id);
150
+ CREATE INDEX IF NOT EXISTS idx_lesson_events_lesson ON lesson_events(lesson_id);
151
+ CREATE INDEX IF NOT EXISTS idx_lesson_links_lesson ON lesson_links(lesson_id);
152
+ `);
153
+ // Additive, migration-less column evolution for lessons (same approach as
154
+ // conversation_chunks above): newly-introduced columns are ALTER-added in a
155
+ // try/catch so older ~/.claw-memory/memory.db files keep working.
156
+ for (const col of [
157
+ "repo_id TEXT",
158
+ "valid_until TEXT",
159
+ "last_used_at TEXT",
160
+ "superseded_by TEXT",
161
+ ]) {
162
+ try {
163
+ sqlite.exec(`ALTER TABLE lessons ADD COLUMN ${col}`);
164
+ }
165
+ catch {
166
+ // column already exists
167
+ }
168
+ }
169
+ // vec0 virtual table for lesson embeddings (same 384-dim e5 space as
170
+ // vec_chunks). scope + project_id metadata columns let KNN filter inside the
171
+ // MATCH query without post-filter loss.
172
+ sqlite.exec(`
173
+ CREATE VIRTUAL TABLE IF NOT EXISTS lesson_vec USING vec0(
174
+ embedding float[384] distance_metric=cosine,
175
+ project_id text,
176
+ scope text
177
+ );
178
+ `);
179
+ // FTS5 keyword index over lesson title + body (trigram, same rationale as
180
+ // chunks_fts). lesson_id links back to lessons.id.
181
+ sqlite.exec(`
182
+ CREATE VIRTUAL TABLE IF NOT EXISTS lessons_fts USING fts5(
183
+ lesson_id UNINDEXED,
184
+ text,
185
+ tokenize = 'trigram'
186
+ );
187
+ `);
@@ -10,6 +10,7 @@ import { saveChunks, deleteChunksBySession, chunkExists, } from "./vector-memory
10
10
  import { loadTranscript } from "./transcript.js";
11
11
  import { stripPrivate } from "./private.js";
12
12
  import { log } from "./logger.js";
13
+ import { saveCandidates, extractDedicated, dedicatedEnabled, } from "./lesson-extract.js";
13
14
  const MIN_MESSAGES = 2;
14
15
  const MIN_TEXT_LENGTH = 100;
15
16
  const MAX_CHARS_PER_MESSAGE = 500;
@@ -26,9 +27,15 @@ const PROMPT = (transcript) => `以下の会話を分析して JSON で回答し
26
27
  key は必ず次のいずれか(該当しなければ出さない):
27
28
  language | response_style | detail_level | code_style | framework | tone | tools
28
29
  変更がなければ空配列。
30
+ 7. lessons: 次回以降の作業で再利用できる「教訓」だけを抽出(無ければ空配列)。
31
+ 一時的な会話・未検証の推測・秘密情報・一回限りのコマンド・重複は抽出しない。
32
+ 各 lesson は実行可能・具体的・再利用可能であること。
33
+ 各要素: {"title","lesson","applies_when":[],"avoid_when":[],
34
+ "scope":"global|project|repo|file|task|user_preference","confidence":0.0-1.0,
35
+ "evidence","concepts":[],"files":[]}
29
36
 
30
37
  JSON のみ回答:
31
- {"summary": "...", "obs_type": "...", "concepts": ["..."], "files_read": ["..."], "files_modified": ["..."], "preferences": [{"key": "...", "value": "..."}]}
38
+ {"summary": "...", "obs_type": "...", "concepts": ["..."], "files_read": ["..."], "files_modified": ["..."], "preferences": [{"key": "...", "value": "..."}], "lessons": []}
32
39
 
33
40
  <conversation>
34
41
  ${transcript}
@@ -77,6 +84,7 @@ export async function distill(input) {
77
84
  const filesModified = (result.files_modified ?? []).map(String).filter(Boolean);
78
85
  // --- Vector memory: re-chunk this session (idempotent) ---
79
86
  let chunkCount = 0;
87
+ let savedChunkIds = [];
80
88
  try {
81
89
  deleteChunksBySession(input.sessionId);
82
90
  const pairs = [];
@@ -110,24 +118,50 @@ export async function distill(input) {
110
118
  });
111
119
  }
112
120
  if (toSave.length > 0)
113
- saveChunks(toSave);
121
+ savedChunkIds = saveChunks(toSave);
114
122
  chunkCount = toSave.length;
115
123
  }
116
124
  }
117
125
  catch (err) {
118
126
  console.error("[claw-memory] chunk embed failed:", err);
119
127
  }
128
+ // --- Lesson layer: extract reusable lessons (best-effort) ---
129
+ // Candidates ride along in the summary JSON above (no extra LLM call). When
130
+ // CLAW_MEMORY_LESSON_DEDICATED=1, run a separate higher-quality extraction
131
+ // pass instead. Failures here must never break the summary path.
132
+ let lessonCount = 0;
133
+ try {
134
+ const candidates = dedicatedEnabled()
135
+ ? await extractDedicated(transcript)
136
+ : Array.isArray(result.lessons)
137
+ ? result.lessons
138
+ : [];
139
+ if (candidates.length > 0) {
140
+ const ids = await saveCandidates({
141
+ projectId: input.projectId,
142
+ sessionId: input.sessionId,
143
+ candidates,
144
+ sourceChunkIds: savedChunkIds,
145
+ });
146
+ lessonCount = ids.length;
147
+ }
148
+ }
149
+ catch (err) {
150
+ console.error("[claw-memory] lesson extract failed:", err);
151
+ }
120
152
  log("distill", {
121
153
  projectId: input.projectId,
122
154
  sessionId: input.sessionId,
123
155
  obsType,
124
156
  chunks: chunkCount,
125
157
  preferences: result.preferences?.length ?? 0,
158
+ lessons: lessonCount,
126
159
  });
127
160
  return {
128
161
  summary: result.summary,
129
162
  preferencesCount: result.preferences?.length ?? 0,
130
163
  chunks: chunkCount,
164
+ lessons: lessonCount,
131
165
  };
132
166
  }
133
167
  /** Store a single free-text note as an embedded chunk (memory_remember). */