@nogataka/claw-memory 0.1.3 → 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.
@@ -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,6 +127,98 @@ 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
223
  const server = new Server({ name: "claw-memory", version: "0.1.0" }, { capabilities: { tools: {} } });
128
224
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
@@ -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
  }
package/dist/ui/page.js CHANGED
@@ -42,17 +42,37 @@ export const PAGE = /* html */ `<!doctype html>
42
42
  .pref b { color:var(--accent); }
43
43
  .empty { color:var(--muted); padding:30px; text-align:center; }
44
44
  .cmeta { color:var(--muted); font-size:11px; margin:2px 0; word-break:break-all; }
45
- header button#logsBtn { background:var(--chip); color:var(--fg); border:1px solid var(--border);
46
- border-radius:6px; padding:6px 10px; cursor:pointer; font-size:12px; }
47
- header button#logsBtn.on { background:var(--accent); color:#0d1117; border-color:var(--accent); }
45
+ header button#logsBtn, header button#lessonsBtn { background:var(--chip); color:var(--fg);
46
+ border:1px solid var(--border); border-radius:6px; padding:6px 10px; cursor:pointer; font-size:12px; }
47
+ header button#logsBtn.on, header button#lessonsBtn.on { background:var(--accent); color:#0d1117; border-color:var(--accent); }
48
48
  .src { color:var(--accent); font-size:10px; }
49
49
  .hl { background:#9e6a03; color:#fff; border-radius:2px; padding:0 1px; }
50
+ .lstatus { border-radius:4px; padding:1px 6px; font-size:10px; font-weight:600; }
51
+ .lstatus.candidate { background:#3a2d00; color:#e3b341; }
52
+ .lstatus.approved { background:#03361a; color:#3fb950; }
53
+ .lstatus.rejected { background:#3a0d0d; color:#f85149; }
54
+ .lstatus.archived { background:#21262d; color:#8b949e; }
55
+ .lstatus.superseded{ background:#26203a; color:#a371f7; }
56
+ .lact { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; }
57
+ .lact button, .lact select { background:var(--chip); color:var(--fg); border:1px solid var(--border);
58
+ border-radius:6px; padding:4px 9px; cursor:pointer; font-size:11px; }
59
+ .lact button:hover { background:var(--accent); color:#0d1117; }
60
+ .lact button.danger:hover { background:#f85149; color:#fff; }
61
+ .lbody { white-space:pre-wrap; word-break:break-word; margin:6px 0; }
62
+ .lcond { font-size:12px; color:var(--muted); margin:3px 0; }
63
+ .lcond b { color:var(--fg); font-weight:600; }
64
+ .lhist { font-size:11px; color:var(--muted); margin-top:8px; border-top:1px solid var(--border); padding-top:6px; }
65
+ .lfilter { display:flex; gap:6px; margin-bottom:14px; flex-wrap:wrap; }
66
+ .lfilter button { background:var(--chip); color:var(--fg); border:1px solid var(--border);
67
+ border-radius:14px; padding:4px 12px; cursor:pointer; font-size:12px; }
68
+ .lfilter button.on { background:var(--accent); color:#0d1117; border-color:var(--accent); }
50
69
  </style>
51
70
  </head>
52
71
  <body>
53
72
  <header>
54
73
  <h1>🧠 claw-memory</h1>
55
74
  <span class="muted" id="stats"></span>
75
+ <button id="lessonsBtn" title="レッスン (再利用可能な知識のレビュー)">📚 Lessons</button>
56
76
  <button id="logsBtn" title="生ログ全文検索 (Claude Code + Codex)">🔎 ログ検索</button>
57
77
  <input id="search" placeholder="フィルタ (本文を絞り込み)" />
58
78
  </header>
@@ -90,13 +110,14 @@ function renderNav() {
90
110
  '<button data-id="'+p.id+'" class="'+(p.id===current?'active':'')+'">'
91
111
  + '<div class="nm">'+esc(p.name)+'</div>'
92
112
  + '<div class="pth">'+esc(p.path)+'</div>'
93
- + '<div class="pth">'+p.counts.summaries+' sum · '+p.counts.chunks+' chunk · '+p.counts.preferences+' pref</div>'
113
+ + '<div class="pth">'+p.counts.summaries+' sum · '+p.counts.chunks+' chunk · '+p.counts.preferences+' pref · '+(p.counts.lessons||0)+' lesson</div>'
94
114
  + '</button>').join("");
95
115
  el("nav").querySelectorAll("button").forEach(b =>
96
116
  b.onclick = () => select(b.dataset.id));
97
117
  }
98
118
  async function select(id) {
99
119
  current = id; renderNav();
120
+ if (lessonMode) { loadLessons(); return; }
100
121
  const d = await (await fetch("/api/memory?project="+encodeURIComponent(id))).json();
101
122
  render(d);
102
123
  }
@@ -136,6 +157,7 @@ function currentPath() { const p = projects.find(x => x.id === current); return
136
157
  el("logsBtn").addEventListener("click", () => {
137
158
  logMode = !logMode;
138
159
  el("logsBtn").classList.toggle("on", logMode);
160
+ if (logMode && lessonMode) { lessonMode = false; el("lessonsBtn").classList.remove("on"); }
139
161
  el("search").value = "";
140
162
  el("search").placeholder = logMode
141
163
  ? "生ログ全文検索 (このプロジェクトのCC+Codexログ)"
@@ -175,6 +197,105 @@ async function runLogSearch(q) {
175
197
  el("main").innerHTML = '<div class="sect"><h2>Raw Log Search — ' + d.total + ' hits (showing ' + d.results.length + ')</h2>' + cards + '</div>';
176
198
  }
177
199
 
200
+ // --- Lessons view ---------------------------------------------------------
201
+ let lessonMode = false, lessonStatus = "candidate";
202
+ const LSTATUSES = ["candidate","approved","conflicts","rejected","archived","superseded"];
203
+
204
+ el("lessonsBtn").addEventListener("click", () => {
205
+ lessonMode = !lessonMode;
206
+ el("lessonsBtn").classList.toggle("on", lessonMode);
207
+ if (lessonMode && logMode) { logMode = false; el("logsBtn").classList.remove("on"); }
208
+ el("search").value = ""; filter = "";
209
+ el("search").placeholder = lessonMode ? "レッスンを絞り込み (本文)" : "フィルタ (本文を絞り込み)";
210
+ if (current) select(current);
211
+ else el("main").innerHTML = '<div class="empty">プロジェクトを選択してください</div>';
212
+ });
213
+
214
+ async function loadLessons() {
215
+ if (!current) return;
216
+ el("main").innerHTML = '<div class="empty">読み込み中…</div>';
217
+ const url = "/api/lessons?project=" + encodeURIComponent(current) + "&status=" + encodeURIComponent(lessonStatus);
218
+ let d;
219
+ try { d = await (await fetch(url)).json(); }
220
+ catch { el("main").innerHTML = '<div class="empty">読み込みに失敗しました</div>'; return; }
221
+ renderLessons(d);
222
+ }
223
+
224
+ function lfilterBar(counts) {
225
+ return '<div class="lfilter">' + LSTATUSES.map(s =>
226
+ '<button class="' + (s===lessonStatus?'on':'') + '" data-st="' + s + '">'
227
+ + s + ' (' + (counts[s]||0) + ')</button>').join("") + '</div>';
228
+ }
229
+
230
+ function renderLessons(d) {
231
+ const f = filter.toLowerCase();
232
+ const match = (t) => !f || (t||"").toLowerCase().includes(f);
233
+ const lessons = (d.lessons||[]).filter(l => match(l.title + " " + l.lesson));
234
+ const heading = lessonStatus === "candidate" ? "Candidate Review" : (lessonStatus + " lessons");
235
+ let h = lfilterBar(d.counts||{});
236
+ h += '<div class="sect"><h2>' + esc(heading) + '</h2>';
237
+ h += lessons.length ? lessons.map(lessonCard).join("") : '<div class="empty">該当するレッスンがありません</div>';
238
+ h += '</div>';
239
+ el("main").innerHTML = h;
240
+ el("main").querySelectorAll(".lfilter button").forEach(b =>
241
+ b.onclick = () => { lessonStatus = b.dataset.st; loadLessons(); });
242
+ bindLessonActions();
243
+ }
244
+
245
+ function lessonCard(l) {
246
+ const cond = (label, arr) => (arr && arr.length)
247
+ ? '<div class="lcond"><b>' + label + ':</b> ' + arr.map(esc).join("; ") + '</div>' : "";
248
+ const tags = [];
249
+ if (l.concepts && l.concepts.length) tags.push('🏷 ' + l.concepts.map(esc).join(", "));
250
+ if (l.files && l.files.length) tags.push('📄 ' + l.files.map(esc).join(", "));
251
+ return '<div class="card" data-id="' + l.id + '">'
252
+ + '<div class="meta">'
253
+ + '<span class="lstatus ' + esc(l.status) + '">' + esc(l.status) + '</span>'
254
+ + '<span class="tag">' + esc(l.scope) + '</span>'
255
+ + '<span class="tag">conf ' + Number(l.confidence).toFixed(2) + '</span>'
256
+ + (l.createdAt||"").split("T")[0] + '</div>'
257
+ + '<div class="u" style="font-weight:600">' + esc(l.title) + '</div>'
258
+ + '<div class="lbody">' + esc(l.lesson) + '</div>'
259
+ + cond("Applies when", l.appliesWhen)
260
+ + cond("Avoid when", l.avoidWhen)
261
+ + (l.evidence ? '<div class="lcond"><b>Evidence:</b> ' + esc(l.evidence) + '</div>' : "")
262
+ + (tags.length ? '<div class="cmeta">' + tags.join(" · ") + '</div>' : "")
263
+ + lessonActions(l)
264
+ + '</div>';
265
+ }
266
+
267
+ function lessonActions(l) {
268
+ const scopeOpts = ["global","project","repo","file","task","user_preference","team"]
269
+ .map(s => '<option ' + (s===l.scope?'selected':'') + '>' + s + '</option>').join("");
270
+ let btns = "";
271
+ if (l.status !== "approved") btns += '<button data-act="approve">✓ Approve</button>';
272
+ if (l.status !== "rejected") btns += '<button class="danger" data-act="reject">✕ Reject</button>';
273
+ if (l.status !== "archived") btns += '<button data-act="archive">🗄 Archive</button>';
274
+ btns += '<select data-act="scope" title="scope変更">' + scopeOpts + '</select>';
275
+ return '<div class="lact">' + btns + '</div>';
276
+ }
277
+
278
+ function bindLessonActions() {
279
+ el("main").querySelectorAll(".card[data-id]").forEach(card => {
280
+ const id = card.dataset.id;
281
+ card.querySelectorAll(".lact button").forEach(b =>
282
+ b.onclick = () => lessonAction(id, b.dataset.act));
283
+ const sel = card.querySelector('select[data-act="scope"]');
284
+ if (sel) sel.onchange = () => lessonAction(id, "scope", { scope: sel.value });
285
+ });
286
+ }
287
+
288
+ async function lessonAction(id, action, body) {
289
+ try {
290
+ await fetch("/api/lessons/" + encodeURIComponent(id) + "/" + action, {
291
+ method: "POST",
292
+ headers: { "content-type": "application/json" },
293
+ body: JSON.stringify(body||{}),
294
+ });
295
+ } catch { /* ignore */ }
296
+ loadLessons();
297
+ }
298
+
178
299
  boot();
179
300
  connectEvents();
180
301
  </script>
package/dist/ui/server.js CHANGED
@@ -10,6 +10,7 @@ import { listProjects } from "../core/projects.js";
10
10
  import { getRecentSummaries, getPreferences } from "../core/memory.js";
11
11
  import { listChunks, getChunkCount } from "../core/vector-memory.js";
12
12
  import { searchLogs } from "../core/logsearch/search.js";
13
+ import { listLessons, listConflicts, getConflictCount, getLesson, getLessonCount, getEvents, getLinks, setStatus, supersede, updateLesson, } from "../core/lessons.js";
13
14
  import { PAGE } from "./page.js";
14
15
  function countSummaries(projectId) {
15
16
  return sqlite
@@ -62,6 +63,7 @@ export function buildUiApp() {
62
63
  summaries: countSummaries(p.id),
63
64
  chunks: getChunkCount(p.id),
64
65
  preferences: countPreferences(p.id),
66
+ lessons: getLessonCount({ projectId: p.id }),
65
67
  },
66
68
  }));
67
69
  return c.json(out);
@@ -76,6 +78,68 @@ export function buildUiApp() {
76
78
  chunks: listChunks(projectId, 300),
77
79
  });
78
80
  });
81
+ // --- Lesson layer -------------------------------------------------------
82
+ // The viewer is read-only for chunks/summaries, but lessons are reviewable:
83
+ // these POST routes are the only writes the UI performs, and only ever touch
84
+ // the lessons tables.
85
+ app.get("/api/lessons", (c) => {
86
+ const projectId = c.req.query("project") || undefined;
87
+ const status = c.req.query("status") || undefined;
88
+ // "conflicts" is a virtual view (lessons in a conflicts_with link), not a
89
+ // real status column value.
90
+ const lessons = status === "conflicts"
91
+ ? listConflicts(projectId, 500)
92
+ : listLessons({ projectId, status }, 500);
93
+ return c.json({
94
+ lessons,
95
+ counts: {
96
+ candidate: getLessonCount({ projectId, status: "candidate" }),
97
+ approved: getLessonCount({ projectId, status: "approved" }),
98
+ rejected: getLessonCount({ projectId, status: "rejected" }),
99
+ archived: getLessonCount({ projectId, status: "archived" }),
100
+ superseded: getLessonCount({ projectId, status: "superseded" }),
101
+ conflicts: getConflictCount(projectId),
102
+ },
103
+ });
104
+ });
105
+ app.get("/api/lessons/:id", (c) => {
106
+ const lesson = getLesson(c.req.param("id"));
107
+ if (!lesson)
108
+ return c.json({ error: "not found" }, 404);
109
+ return c.json({ lesson, events: getEvents(lesson.id), links: getLinks(lesson.id) });
110
+ });
111
+ app.post("/api/lessons/:id/:action", async (c) => {
112
+ const id = c.req.param("id");
113
+ const action = c.req.param("action");
114
+ const body = await c.req.json().catch(() => ({}));
115
+ let ok = false;
116
+ switch (action) {
117
+ case "approve":
118
+ ok = setStatus(id, "approved");
119
+ break;
120
+ case "reject":
121
+ ok = setStatus(id, "rejected", body.reason);
122
+ break;
123
+ case "archive":
124
+ ok = setStatus(id, "archived", body.reason);
125
+ break;
126
+ case "status":
127
+ ok = setStatus(id, body.status, body.note);
128
+ break;
129
+ case "scope":
130
+ ok = updateLesson(id, { scope: String(body.scope) });
131
+ break;
132
+ case "confidence":
133
+ ok = updateLesson(id, { confidence: Number(body.confidence) });
134
+ break;
135
+ case "supersede":
136
+ ok = supersede(id, String(body.newId));
137
+ break;
138
+ default:
139
+ return c.json({ error: "unknown action" }, 400);
140
+ }
141
+ return c.json({ ok }, ok ? 200 : 404);
142
+ });
79
143
  // Raw transcript search (cc-search port) — Claude Code + Codex logs.
80
144
  app.get("/api/logs", async (c) => {
81
145
  const query = c.req.query("q") ?? "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nogataka/claw-memory",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
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.",
6
6
  "license": "MIT",