@nogataka/claw-memory 0.1.3 → 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.
@@ -2,9 +2,10 @@
2
2
  //
3
3
  // Enumerate Codex rollout transcripts newest-first, for batch distillation
4
4
  // (claw-memory distill-codex). Read-only over ~/.codex/sessions.
5
- import { readdir, stat } from "node:fs/promises";
5
+ import { readdir, stat, readFile } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
- import { codexSessionsRoot } from "./paths.js";
7
+ import { codexSessionsRoot, chatgptExportRoot, MAX_CHATGPT_FILE_SIZE } from "./paths.js";
8
+ import { parseChatgptExport } from "./parse.js";
8
9
  /** All Codex `*.jsonl` rollout files under ~/.codex/sessions, newest first. */
9
10
  export async function listCodexSessionFiles() {
10
11
  const out = [];
@@ -38,3 +39,46 @@ export function codexSessionId(filePath) {
38
39
  const m = filePath.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
39
40
  return m ? m[1] : filePath;
40
41
  }
42
+ /** List the ChatGPT export `*.json` files (export root is a file or directory). */
43
+ async function listChatgptExportFiles() {
44
+ const s = await stat(chatgptExportRoot).catch(() => null);
45
+ if (!s)
46
+ return [];
47
+ if (s.isFile())
48
+ return [chatgptExportRoot];
49
+ if (!s.isDirectory())
50
+ return [];
51
+ const entries = await readdir(chatgptExportRoot).catch(() => []);
52
+ return entries
53
+ .filter((f) => f.toLowerCase().endsWith(".json"))
54
+ .map((f) => join(chatgptExportRoot, f));
55
+ }
56
+ /**
57
+ * Load all ChatGPT conversations from the export bundle(s), newest write first.
58
+ * Used by `distill-chatgpt` to feed each conversation into the distill pipeline.
59
+ */
60
+ export async function loadChatgptConversations() {
61
+ const files = await listChatgptExportFiles();
62
+ const out = [];
63
+ for (const p of files) {
64
+ const s = await stat(p).catch(() => null);
65
+ if (!s || s.size > MAX_CHATGPT_FILE_SIZE)
66
+ continue;
67
+ let content;
68
+ try {
69
+ content = await readFile(p, "utf-8");
70
+ }
71
+ catch {
72
+ continue;
73
+ }
74
+ out.push(...parseChatgptExport(content));
75
+ }
76
+ out.sort((a, b) => tsValue(b.updateTime) - tsValue(a.updateTime));
77
+ return out;
78
+ }
79
+ function tsValue(ts) {
80
+ if (!ts)
81
+ return 0;
82
+ const n = new Date(ts).getTime();
83
+ return Number.isNaN(n) ? 0 : n;
84
+ }
@@ -8,14 +8,14 @@ import { readdir, readFile, stat } from "node:fs/promises";
8
8
  import { resolve, join, basename } from "node:path";
9
9
  import { createInterface } from "node:readline";
10
10
  import { createReadStream } from "node:fs";
11
- import { claudeProjectsRoot, codexSessionsRoot, MAX_LOG_FILE_SIZE, UUID_JSONL_RE, } from "./paths.js";
12
- import { parseClaudeCodeLine, parseCodexSession } from "./parse.js";
11
+ import { claudeProjectsRoot, codexSessionsRoot, chatgptExportRoot, MAX_LOG_FILE_SIZE, MAX_CHATGPT_FILE_SIZE, UUID_JSONL_RE, } from "./paths.js";
12
+ import { parseClaudeCodeLine, parseCodexSession, parseChatgptExport, } from "./parse.js";
13
13
  const CONTEXT = 100;
14
14
  export async function searchLogs(opts) {
15
15
  const query = opts.query.trim();
16
16
  if (!query)
17
17
  return { results: [], total: 0 };
18
- const sources = opts.sources ?? ["claude-code", "codex"];
18
+ const sources = opts.sources ?? ["claude-code", "codex", "chatgpt-web"];
19
19
  const limit = opts.limit ?? 20;
20
20
  const offset = opts.offset ?? 0;
21
21
  const all = [];
@@ -25,6 +25,9 @@ export async function searchLogs(opts) {
25
25
  if (sources.includes("codex")) {
26
26
  all.push(...(await searchCodex(query, opts)));
27
27
  }
28
+ if (sources.includes("chatgpt-web")) {
29
+ all.push(...(await searchChatgpt(query, opts)));
30
+ }
28
31
  all.sort((a, b) => tsValue(b.timestamp) - tsValue(a.timestamp));
29
32
  return { results: all.slice(offset, offset + limit), total: all.length };
30
33
  }
@@ -231,3 +234,54 @@ async function searchCodex(query, opts) {
231
234
  }
232
235
  return results;
233
236
  }
237
+ // --- ChatGPT web (official data export) -------------------------------------
238
+ /** Resolve the export root to a list of `*.json` files (file or directory). */
239
+ async function listChatgptFiles(root) {
240
+ const s = await stat(root).catch(() => null);
241
+ if (!s)
242
+ return [];
243
+ if (s.isFile())
244
+ return [root];
245
+ if (!s.isDirectory())
246
+ return [];
247
+ const entries = await readdir(root).catch(() => []);
248
+ return entries
249
+ .filter((f) => f.toLowerCase().endsWith(".json"))
250
+ .map((f) => join(root, f));
251
+ }
252
+ async function searchChatgpt(query, opts) {
253
+ const results = [];
254
+ const files = await listChatgptFiles(chatgptExportRoot);
255
+ for (const p of files) {
256
+ const fs = await stat(p).catch(() => null);
257
+ if (!fs || fs.size > MAX_CHATGPT_FILE_SIZE)
258
+ continue;
259
+ let content;
260
+ try {
261
+ content = await readFile(p, "utf-8");
262
+ }
263
+ catch {
264
+ continue;
265
+ }
266
+ for (const conv of parseChatgptExport(content)) {
267
+ // ChatGPT has no cwd/project; the conversation title stands in for it.
268
+ const projectPath = conv.title;
269
+ if (opts.projectPath && !projectPath.includes(opts.projectPath))
270
+ continue;
271
+ for (const msg of conv.messages) {
272
+ if (!inDateRange(msg.timestamp, opts.startDate, opts.endDate))
273
+ continue;
274
+ for (const hit of matchAll(msg.text, query)) {
275
+ results.push(buildResult({
276
+ source: "chatgpt-web",
277
+ projectPath,
278
+ sessionId: conv.conversationId,
279
+ timestamp: msg.timestamp,
280
+ role: msg.role,
281
+ }, msg.text, hit.index, hit.length));
282
+ }
283
+ }
284
+ }
285
+ }
286
+ return results;
287
+ }
@@ -6,7 +6,54 @@
6
6
  import { embedQuery } from "./embeddings.js";
7
7
  import { searchSimilar } from "./vector-memory.js";
8
8
  import { getPreferences, getRecentSummaries } from "./memory.js";
9
+ import { searchLessons, formatLessonBlock, } from "./lesson-search.js";
9
10
  const MEMORY_MAX_DISTANCE = Number(process.env.MEMORY_SIMILARITY_MAX_DISTANCE ?? 0.4);
11
+ // Approved lessons injected into the recall block. Kept small to avoid context
12
+ // bloat; 0 disables lesson injection entirely.
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
+ }
10
57
  export async function buildMemoryBlock(projectId, query, topK = 5) {
11
58
  const prefs = getPreferences(projectId);
12
59
  const summaries = getRecentSummaries(projectId, 5);
@@ -20,6 +67,16 @@ export async function buildMemoryBlock(projectId, query, topK = 5) {
20
67
  console.error("[claw-memory] semantic search failed:", err);
21
68
  }
22
69
  }
70
+ // Approved, reusable lessons relevant to this request (best-effort).
71
+ let lessons = [];
72
+ if (query.trim() && RECALL_LESSON_LIMIT > 0) {
73
+ try {
74
+ lessons = await searchLessons(query, { projectId }, { limit: RECALL_LESSON_LIMIT });
75
+ }
76
+ catch (err) {
77
+ console.error("[claw-memory] lesson recall failed:", err);
78
+ }
79
+ }
23
80
  let text = "";
24
81
  if (prefs.length > 0) {
25
82
  text += '<user-preferences instruction="always-apply">\n';
@@ -50,10 +107,14 @@ export async function buildMemoryBlock(projectId, query, topK = 5) {
50
107
  }
51
108
  text += "</memory-context>\n";
52
109
  }
110
+ const lessonBlock = formatLessonBlock(lessons);
111
+ if (lessonBlock)
112
+ text += `\n${lessonBlock}\n`;
53
113
  return {
54
114
  fullText: text.trim(),
55
115
  preferences: prefs.map((p) => ({ key: p.key, value: p.value })),
56
116
  summaries: summaries.map((s) => s.summary),
57
117
  similar,
118
+ lessons,
58
119
  };
59
120
  }
@@ -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
@@ -13,9 +13,13 @@ import { buildMemoryBlock } from "../core/recall.js";
13
13
  import { searchIndex } from "../core/search.js";
14
14
  import { getChunksByIds, forgetChunks } from "../core/vector-memory.js";
15
15
  import { distill, rememberText } from "../core/distill.js";
16
- import { resolveSessionJsonl } from "../core/transcript.js";
16
+ import { resolveSessionJsonl, loadTranscript } from "../core/transcript.js";
17
17
  import { searchLogs } from "../core/logsearch/search.js";
18
18
  import { isExcludedPath } from "../core/excludes.js";
19
+ import { stripPrivate } from "../core/private.js";
20
+ import { searchLessons, injectLessons } from "../core/lesson-search.js";
21
+ import { getLesson, setStatus, supersede, getEvents, getLinks } from "../core/lessons.js";
22
+ import { saveCandidates, extractDedicated } from "../core/lesson-extract.js";
19
23
  function projectFor(cwd) {
20
24
  return getOrCreateProjectByPath(cwd && cwd.trim() ? cwd : process.cwd());
21
25
  }
@@ -104,15 +108,15 @@ const TOOLS = [
104
108
  },
105
109
  {
106
110
  name: "memory_search_logs",
107
- description: "Full-text search across RAW agent transcripts (Claude Code and Codex) under ~/.claude/projects and ~/.codex/sessions. A second memory source independent of the distilled DB: finds past conversations even if they were never distilled. Returns matches with surrounding context, source, project path, session id, role and timestamp.",
111
+ description: "Full-text search across RAW agent transcripts: Claude Code (~/.claude/projects), Codex (~/.codex/sessions), and ChatGPT web exports (conversations.json under ~/.claw-memory/chatgpt or CLAW_MEMORY_CHATGPT_EXPORT). A second memory source independent of the distilled DB: finds past conversations even if they were never distilled. Returns matches with surrounding context, source, project path (conversation title for ChatGPT), session id, role and timestamp.",
108
112
  inputSchema: {
109
113
  type: "object",
110
114
  properties: {
111
115
  query: { type: "string", description: "Substring to search for (case-insensitive)." },
112
116
  sources: {
113
117
  type: "array",
114
- items: { type: "string", enum: ["claude-code", "codex"] },
115
- description: "Which log sources to scan (default both).",
118
+ items: { type: "string", enum: ["claude-code", "codex", "chatgpt-web"] },
119
+ description: "Which log sources to scan (default all three).",
116
120
  },
117
121
  projectPath: { type: "string", description: "Restrict to a project by working-dir path (substring)." },
118
122
  startDate: { type: "string", description: "ISO date lower bound (inclusive)." },
@@ -123,8 +127,100 @@ const TOOLS = [
123
127
  required: ["query"],
124
128
  },
125
129
  },
130
+ {
131
+ name: "lesson_search",
132
+ description: "Search reusable LESSONS (distilled, abstracted knowledge: bug-fix patterns, project constraints, design decisions) relevant to a task. Only approved lessons are returned, ranked by semantic + scope + confidence + recency. Use when starting a task to recall how similar problems were solved before.",
133
+ inputSchema: {
134
+ type: "object",
135
+ properties: {
136
+ query: { type: "string", description: "Task / problem to find lessons for." },
137
+ cwd: { type: "string" },
138
+ limit: { type: "number", description: "Max lessons (default 5)." },
139
+ },
140
+ required: ["query"],
141
+ },
142
+ },
143
+ {
144
+ name: "lesson_inject",
145
+ description: "Like lesson_search, but returns a ready-to-read <relevant-lessons> context block (hints, not absolute facts) to drop into an agent's context.",
146
+ inputSchema: {
147
+ type: "object",
148
+ properties: {
149
+ query: { type: "string" },
150
+ cwd: { type: "string" },
151
+ limit: { type: "number", description: "Max lessons (default 5)." },
152
+ },
153
+ required: ["query"],
154
+ },
155
+ },
156
+ {
157
+ name: "lesson_get",
158
+ description: "Fetch one lesson's full detail (all fields + status history + linked lessons) by id.",
159
+ inputSchema: {
160
+ type: "object",
161
+ properties: { lesson_id: { type: "string" } },
162
+ required: ["lesson_id"],
163
+ },
164
+ },
165
+ {
166
+ name: "lesson_extract",
167
+ description: "Run a dedicated lesson-extraction pass over a finished session transcript and store the candidates. Provide a sessionId (resolved under cwd) or an explicit transcriptPath. Requires LLM credentials.",
168
+ inputSchema: {
169
+ type: "object",
170
+ properties: {
171
+ cwd: { type: "string" },
172
+ sessionId: { type: "string" },
173
+ transcriptPath: { type: "string" },
174
+ },
175
+ },
176
+ },
177
+ {
178
+ name: "lesson_approve",
179
+ description: "Promote a candidate lesson to 'approved' (then it surfaces in lesson_search / recall).",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: { lesson_id: { type: "string" } },
183
+ required: ["lesson_id"],
184
+ },
185
+ },
186
+ {
187
+ name: "lesson_reject",
188
+ description: "Reject a candidate lesson (wrong / too specific / temporary).",
189
+ inputSchema: {
190
+ type: "object",
191
+ properties: {
192
+ lesson_id: { type: "string" },
193
+ reason: { type: "string" },
194
+ },
195
+ required: ["lesson_id"],
196
+ },
197
+ },
198
+ {
199
+ name: "lesson_archive",
200
+ description: "Archive a lesson that is outdated but worth keeping as history.",
201
+ inputSchema: {
202
+ type: "object",
203
+ properties: {
204
+ lesson_id: { type: "string" },
205
+ reason: { type: "string" },
206
+ },
207
+ required: ["lesson_id"],
208
+ },
209
+ },
210
+ {
211
+ name: "lesson_supersede",
212
+ description: "Replace an old lesson with a newer one (old becomes 'superseded' and is linked).",
213
+ inputSchema: {
214
+ type: "object",
215
+ properties: {
216
+ old_lesson_id: { type: "string" },
217
+ new_lesson_id: { type: "string" },
218
+ },
219
+ required: ["old_lesson_id", "new_lesson_id"],
220
+ },
221
+ },
126
222
  ];
127
- 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: {} } });
128
224
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
129
225
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
130
226
  const name = req.params.name;
@@ -248,6 +344,82 @@ async function dispatch(name, args) {
248
344
  });
249
345
  return `${total}件ヒット (上位${results.length}件表示):\n${lines.join("\n")}`;
250
346
  }
347
+ case "lesson_search": {
348
+ const project = projectFor(args.cwd);
349
+ const hits = await searchLessons(String(args.query ?? ""), { projectId: project.id }, { limit: args.limit ?? 5 });
350
+ if (hits.length === 0)
351
+ return "(該当なし)";
352
+ return hits
353
+ .map((l) => `- id=${l.id} [${l.scope}] (conf=${l.confidence.toFixed(2)} score=${l.score.toFixed(2)}) ${l.title}`)
354
+ .join("\n");
355
+ }
356
+ case "lesson_inject": {
357
+ const project = projectFor(args.cwd);
358
+ const block = await injectLessons(String(args.query ?? ""), { projectId: project.id }, { limit: args.limit ?? 5 });
359
+ return block || "(該当する approved lesson なし)";
360
+ }
361
+ case "lesson_get": {
362
+ const lesson = getLesson(String(args.lesson_id ?? ""));
363
+ if (!lesson)
364
+ return "(該当なし)";
365
+ const events = getEvents(lesson.id);
366
+ const links = getLinks(lesson.id);
367
+ const parts = [
368
+ `# ${lesson.title}`,
369
+ `id=${lesson.id} | scope=${lesson.scope} | status=${lesson.status} | confidence=${lesson.confidence.toFixed(2)}`,
370
+ `\n${lesson.lesson}`,
371
+ lesson.appliesWhen.length ? `\nApplies when:\n${lesson.appliesWhen.map((s) => `- ${s}`).join("\n")}` : "",
372
+ lesson.avoidWhen.length ? `\nAvoid when:\n${lesson.avoidWhen.map((s) => `- ${s}`).join("\n")}` : "",
373
+ lesson.evidence ? `\nEvidence: ${lesson.evidence}` : "",
374
+ lesson.concepts.length ? `\nConcepts: ${lesson.concepts.join(", ")}` : "",
375
+ lesson.files.length ? `\nFiles: ${lesson.files.join(", ")}` : "",
376
+ lesson.sessionId ? `\nSource session: ${lesson.sessionId}` : "",
377
+ events.length ? `\nHistory: ${events.map((e) => `${e.eventType}(${e.oldStatus ?? "-"}→${e.newStatus ?? "-"})`).join(", ")}` : "",
378
+ links.length ? `\nLinks: ${links.map((k) => `${k.relation}→${k.linkedLessonId === lesson.id ? k.lessonId : k.linkedLessonId}`).join(", ")}` : "",
379
+ ];
380
+ return parts.filter(Boolean).join("\n");
381
+ }
382
+ case "lesson_extract": {
383
+ const cwd = args.cwd ?? process.cwd();
384
+ if (isExcludedPath(cwd))
385
+ return JSON.stringify({ skipped: "excluded project" });
386
+ const project = getOrCreateProjectByPath(cwd);
387
+ const sessionId = args.sessionId ?? "";
388
+ const transcriptPath = args.transcriptPath ??
389
+ (sessionId ? resolveSessionJsonl(cwd, sessionId) : undefined);
390
+ if (!transcriptPath) {
391
+ throw new Error("lesson_extract requires sessionId or transcriptPath");
392
+ }
393
+ const messages = loadTranscript(transcriptPath)
394
+ .map((m) => ({ ...m, text: stripPrivate(m.text) }))
395
+ .filter((m) => m.text.trim());
396
+ const transcript = messages
397
+ .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.text.slice(0, 500)}`)
398
+ .join("\n");
399
+ const candidates = await extractDedicated(transcript);
400
+ const ids = await saveCandidates({
401
+ projectId: project.id,
402
+ sessionId: sessionId || transcriptPath,
403
+ candidates,
404
+ });
405
+ return JSON.stringify({ extracted: candidates.length, saved: ids.length });
406
+ }
407
+ case "lesson_approve": {
408
+ const ok = setStatus(String(args.lesson_id ?? ""), "approved");
409
+ return ok ? "approved" : "(該当なし)";
410
+ }
411
+ case "lesson_reject": {
412
+ const ok = setStatus(String(args.lesson_id ?? ""), "rejected", args.reason);
413
+ return ok ? "rejected" : "(該当なし)";
414
+ }
415
+ case "lesson_archive": {
416
+ const ok = setStatus(String(args.lesson_id ?? ""), "archived", args.reason);
417
+ return ok ? "archived" : "(該当なし)";
418
+ }
419
+ case "lesson_supersede": {
420
+ const ok = supersede(String(args.old_lesson_id ?? ""), String(args.new_lesson_id ?? ""));
421
+ return ok ? "superseded" : "(該当なし)";
422
+ }
251
423
  default:
252
424
  throw new Error(`unknown tool: ${name}`);
253
425
  }