@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.
- package/.claude-plugin/marketplace.json +5 -3
- package/.claude-plugin/plugin.json +11 -3
- package/README.ja.md +55 -11
- package/README.md +60 -11
- package/dist/cli.js +215 -1
- package/dist/core/db.js +132 -9
- package/dist/core/distill.js +97 -5
- package/dist/core/hooks.js +8 -7
- package/dist/core/installer/codex.js +19 -5
- 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 +332 -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 +61 -0
- package/dist/core/vector-memory.js +27 -10
- package/dist/mcp/server.js +177 -5
- package/dist/ui/page.js +125 -4
- package/dist/ui/server.js +65 -1
- package/hooks/claw-hook.sh +33 -9
- package/hooks/hooks.codex.json +0 -11
- package/hooks/hooks.json +0 -11
- package/package.json +4 -6
|
@@ -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
|
+
}
|
package/dist/core/recall.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
164
|
-
.prepare(`
|
|
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
|
-
.
|
|
167
|
-
|
|
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
|
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,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.
|
|
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
|
}
|