@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.ja.md +55 -6
- package/README.md +60 -6
- package/dist/cli.js +182 -1
- package/dist/core/db.js +95 -0
- package/dist/core/distill.js +36 -2
- package/dist/core/lesson-extract.js +233 -0
- package/dist/core/lesson-quality.js +79 -0
- package/dist/core/lesson-search.js +145 -0
- package/dist/core/lesson-share.js +80 -0
- package/dist/core/lessons.js +335 -0
- package/dist/core/llm.js +5 -0
- package/dist/core/logsearch/parse.js +0 -0
- package/dist/core/logsearch/paths.js +22 -0
- package/dist/core/logsearch/recent.js +46 -2
- package/dist/core/logsearch/search.js +57 -3
- package/dist/core/recall.js +18 -0
- package/dist/mcp/server.js +176 -4
- package/dist/ui/page.js +125 -4
- package/dist/ui/server.js +64 -0
- package/hooks/hooks.codex.json +3 -3
- package/hooks/hooks.json +3 -3
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
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
|
|
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);
|
|
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/hooks/hooks.codex.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"hooks": [
|
|
6
6
|
{
|
|
7
7
|
"type": "command",
|
|
8
|
-
"command": "\"$
|
|
8
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/run-hook.cmd\" claw-hook.sh hook recall",
|
|
9
9
|
"async": false
|
|
10
10
|
}
|
|
11
11
|
]
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"hooks": [
|
|
17
17
|
{
|
|
18
18
|
"type": "command",
|
|
19
|
-
"command": "\"$
|
|
19
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/run-hook.cmd\" claw-hook.sh hook recall",
|
|
20
20
|
"async": false
|
|
21
21
|
}
|
|
22
22
|
]
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"hooks": [
|
|
28
28
|
{
|
|
29
29
|
"type": "command",
|
|
30
|
-
"command": "\"$
|
|
30
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/run-hook.cmd\" claw-hook.sh distill-codex --limit 5",
|
|
31
31
|
"async": true
|
|
32
32
|
}
|
|
33
33
|
]
|
package/hooks/hooks.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"hooks": [
|
|
6
6
|
{
|
|
7
7
|
"type": "command",
|
|
8
|
-
"command": "\"$
|
|
8
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/run-hook.cmd\" claw-hook.sh hook recall",
|
|
9
9
|
"async": false
|
|
10
10
|
}
|
|
11
11
|
]
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"hooks": [
|
|
17
17
|
{
|
|
18
18
|
"type": "command",
|
|
19
|
-
"command": "\"$
|
|
19
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/run-hook.cmd\" claw-hook.sh hook recall",
|
|
20
20
|
"async": false
|
|
21
21
|
}
|
|
22
22
|
]
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"hooks": [
|
|
28
28
|
{
|
|
29
29
|
"type": "command",
|
|
30
|
-
"command": "\"$
|
|
30
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/run-hook.cmd\" claw-hook.sh hook distill",
|
|
31
31
|
"async": true
|
|
32
32
|
}
|
|
33
33
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nogataka/claw-memory",
|
|
3
|
-
"version": "0.
|
|
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",
|