@nogataka/claw-memory 0.1.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 +16 -0
- package/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +8 -0
- package/README.md +168 -0
- package/dist/cli.js +223 -0
- package/dist/core/config.js +14 -0
- package/dist/core/db.js +92 -0
- package/dist/core/distill.js +146 -0
- package/dist/core/embeddings.js +34 -0
- package/dist/core/excludes.js +16 -0
- package/dist/core/hooks.js +76 -0
- package/dist/core/installer/claude.js +87 -0
- package/dist/core/installer/codex.js +122 -0
- package/dist/core/llm.js +163 -0
- package/dist/core/logger.js +19 -0
- package/dist/core/logsearch/parse.js +0 -0
- package/dist/core/logsearch/paths.js +11 -0
- package/dist/core/logsearch/recent.js +40 -0
- package/dist/core/logsearch/search.js +233 -0
- package/dist/core/memory.js +80 -0
- package/dist/core/paths.js +13 -0
- package/dist/core/private.js +8 -0
- package/dist/core/projects.js +46 -0
- package/dist/core/providers.js +17 -0
- package/dist/core/recall.js +59 -0
- package/dist/core/search.js +45 -0
- package/dist/core/transcript.js +79 -0
- package/dist/core/vector-memory.js +203 -0
- package/dist/core/watermark.js +19 -0
- package/dist/mcp/server.js +259 -0
- package/dist/ui/page.js +182 -0
- package/dist/ui/server.js +109 -0
- package/hooks/claw-hook.sh +21 -0
- package/hooks/hooks.json +37 -0
- package/hooks/run-hook.cmd +25 -0
- package/package.json +65 -0
- package/skills/memory-recall/SKILL.md +39 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// src/core/vector-memory.ts
|
|
2
|
+
//
|
|
3
|
+
// Vector store (sqlite-vec) + keyword index (FTS5). Vectors and readable
|
|
4
|
+
// metadata are split across vec_chunks (vec0) and conversation_chunks.
|
|
5
|
+
// Chunks carry optional structured metadata (obs_type / concepts / files) and a
|
|
6
|
+
// deleted_at tombstone; all reads exclude tombstoned rows.
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { sqlite } from "./db.js";
|
|
9
|
+
function jsonArr(v) {
|
|
10
|
+
return v && v.length ? JSON.stringify(v) : null;
|
|
11
|
+
}
|
|
12
|
+
function parseArr(v) {
|
|
13
|
+
if (typeof v !== "string" || !v)
|
|
14
|
+
return [];
|
|
15
|
+
try {
|
|
16
|
+
const a = JSON.parse(v);
|
|
17
|
+
return Array.isArray(a) ? a.map(String) : [];
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** True if an identical (project, user, assistant) chunk already exists (live). */
|
|
24
|
+
export function chunkExists(projectId, userText, assistantText) {
|
|
25
|
+
const row = sqlite
|
|
26
|
+
.prepare(`SELECT 1 FROM conversation_chunks
|
|
27
|
+
WHERE project_id = ? AND user_text = ? AND assistant_text = ? AND deleted_at IS NULL
|
|
28
|
+
LIMIT 1`)
|
|
29
|
+
.get(projectId, userText, assistantText);
|
|
30
|
+
return !!row;
|
|
31
|
+
}
|
|
32
|
+
/** Save conversation chunks: vec0 embedding + metadata row + FTS keyword row, atomically. */
|
|
33
|
+
export function saveChunks(chunks) {
|
|
34
|
+
const insertVec = sqlite.prepare("INSERT INTO vec_chunks(embedding, project_id) VALUES (?, ?)");
|
|
35
|
+
const insertMeta = sqlite.prepare(`INSERT INTO conversation_chunks(
|
|
36
|
+
id, vec_rowid, project_id, session_id, user_text, assistant_text, created_at,
|
|
37
|
+
obs_type, concepts, files_read, files_modified)
|
|
38
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
39
|
+
const insertFts = sqlite.prepare("INSERT INTO chunks_fts(chunk_id, text) VALUES (?, ?)");
|
|
40
|
+
const ids = [];
|
|
41
|
+
const tx = sqlite.transaction(() => {
|
|
42
|
+
for (const chunk of chunks) {
|
|
43
|
+
const res = insertVec.run(Buffer.from(chunk.embedding.buffer), chunk.projectId);
|
|
44
|
+
const vecRowid = res.lastInsertRowid;
|
|
45
|
+
const id = randomUUID();
|
|
46
|
+
const now = new Date().toISOString();
|
|
47
|
+
insertMeta.run(id, vecRowid, chunk.projectId, chunk.sessionId, chunk.userText, chunk.assistantText, now, chunk.obsType ?? null, jsonArr(chunk.concepts), jsonArr(chunk.filesRead), jsonArr(chunk.filesModified));
|
|
48
|
+
insertFts.run(id, `${chunk.userText}\n${chunk.assistantText}`);
|
|
49
|
+
ids.push(id);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
tx();
|
|
53
|
+
return ids;
|
|
54
|
+
}
|
|
55
|
+
const CHUNK_COLS = `c.id, c.session_id, c.project_id, c.user_text, c.assistant_text,
|
|
56
|
+
c.created_at, c.obs_type, c.concepts, c.files_read, c.files_modified`;
|
|
57
|
+
/** Build the metadata WHERE fragment (always excludes tombstones). */
|
|
58
|
+
function metaWhere(filter) {
|
|
59
|
+
const clauses = ["c.deleted_at IS NULL"];
|
|
60
|
+
const params = [];
|
|
61
|
+
if (filter?.obsType) {
|
|
62
|
+
clauses.push("c.obs_type = ?");
|
|
63
|
+
params.push(filter.obsType);
|
|
64
|
+
}
|
|
65
|
+
if (filter?.concept) {
|
|
66
|
+
clauses.push("c.concepts LIKE ?");
|
|
67
|
+
params.push(`%${filter.concept}%`);
|
|
68
|
+
}
|
|
69
|
+
if (filter?.file) {
|
|
70
|
+
clauses.push("(c.files_read LIKE ? OR c.files_modified LIKE ?)");
|
|
71
|
+
params.push(`%${filter.file}%`, `%${filter.file}%`);
|
|
72
|
+
}
|
|
73
|
+
if (filter?.dateFrom) {
|
|
74
|
+
clauses.push("c.created_at >= ?");
|
|
75
|
+
params.push(filter.dateFrom);
|
|
76
|
+
}
|
|
77
|
+
if (filter?.dateTo) {
|
|
78
|
+
clauses.push("c.created_at <= ?");
|
|
79
|
+
params.push(filter.dateTo);
|
|
80
|
+
}
|
|
81
|
+
return { sql: clauses.join(" AND "), params };
|
|
82
|
+
}
|
|
83
|
+
/** KNN semantic search, filtered by project inside the MATCH query. */
|
|
84
|
+
export function searchSimilar(queryEmbedding, projectId, topK = 5, maxDistance, filter) {
|
|
85
|
+
const meta = metaWhere(filter);
|
|
86
|
+
const distClause = maxDistance != null ? " AND v.distance <= ?" : "";
|
|
87
|
+
const stmt = sqlite.prepare(`
|
|
88
|
+
SELECT ${CHUNK_COLS}, v.distance
|
|
89
|
+
FROM (
|
|
90
|
+
SELECT rowid, distance FROM vec_chunks
|
|
91
|
+
WHERE embedding MATCH ? AND k = ? AND project_id = ?
|
|
92
|
+
) v
|
|
93
|
+
INNER JOIN conversation_chunks c ON c.vec_rowid = v.rowid
|
|
94
|
+
WHERE ${meta.sql}${distClause}
|
|
95
|
+
ORDER BY v.distance
|
|
96
|
+
`);
|
|
97
|
+
const params = [
|
|
98
|
+
Buffer.from(queryEmbedding.buffer),
|
|
99
|
+
topK,
|
|
100
|
+
projectId,
|
|
101
|
+
...meta.params,
|
|
102
|
+
];
|
|
103
|
+
if (maxDistance != null)
|
|
104
|
+
params.push(maxDistance);
|
|
105
|
+
return stmt.all(...params).map(mapRow);
|
|
106
|
+
}
|
|
107
|
+
/** FTS5 keyword search (fallback / augmentation for keyword-ish queries). */
|
|
108
|
+
export function searchKeyword(query, projectId, limit = 5, filter) {
|
|
109
|
+
const match = toFtsQuery(query);
|
|
110
|
+
if (!match)
|
|
111
|
+
return [];
|
|
112
|
+
const meta = metaWhere(filter);
|
|
113
|
+
const rows = sqlite
|
|
114
|
+
.prepare(`SELECT ${CHUNK_COLS}
|
|
115
|
+
FROM chunks_fts f
|
|
116
|
+
INNER JOIN conversation_chunks c ON c.id = f.chunk_id
|
|
117
|
+
WHERE f.text MATCH ? AND c.project_id = ? AND ${meta.sql}
|
|
118
|
+
LIMIT ?`)
|
|
119
|
+
.all(match, projectId, ...meta.params, limit);
|
|
120
|
+
return rows.map(mapRow);
|
|
121
|
+
}
|
|
122
|
+
export function getChunksByIds(ids) {
|
|
123
|
+
if (ids.length === 0)
|
|
124
|
+
return [];
|
|
125
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
126
|
+
const rows = sqlite
|
|
127
|
+
.prepare(`SELECT ${CHUNK_COLS} FROM conversation_chunks c
|
|
128
|
+
WHERE c.id IN (${placeholders}) AND c.deleted_at IS NULL`)
|
|
129
|
+
.all(...ids);
|
|
130
|
+
return rows.map(mapRow);
|
|
131
|
+
}
|
|
132
|
+
export function listChunks(projectId, limit = 200) {
|
|
133
|
+
const rows = sqlite
|
|
134
|
+
.prepare(`SELECT ${CHUNK_COLS} FROM conversation_chunks c
|
|
135
|
+
WHERE c.project_id = ? AND c.deleted_at IS NULL
|
|
136
|
+
ORDER BY c.created_at DESC LIMIT ?`)
|
|
137
|
+
.all(projectId, limit);
|
|
138
|
+
return rows.map(mapRow);
|
|
139
|
+
}
|
|
140
|
+
export function deleteChunksBySession(sessionId) {
|
|
141
|
+
const rows = sqlite
|
|
142
|
+
.prepare("SELECT id, vec_rowid FROM conversation_chunks WHERE session_id = ?")
|
|
143
|
+
.all(sessionId);
|
|
144
|
+
if (rows.length === 0)
|
|
145
|
+
return;
|
|
146
|
+
const deleteVec = sqlite.prepare("DELETE FROM vec_chunks WHERE rowid = ?");
|
|
147
|
+
const deleteFts = sqlite.prepare("DELETE FROM chunks_fts WHERE chunk_id = ?");
|
|
148
|
+
const deleteMeta = sqlite.prepare("DELETE FROM conversation_chunks WHERE session_id = ?");
|
|
149
|
+
const tx = sqlite.transaction(() => {
|
|
150
|
+
for (const row of rows) {
|
|
151
|
+
deleteVec.run(row.vec_rowid);
|
|
152
|
+
deleteFts.run(row.id);
|
|
153
|
+
}
|
|
154
|
+
deleteMeta.run(sessionId);
|
|
155
|
+
});
|
|
156
|
+
tx();
|
|
157
|
+
}
|
|
158
|
+
/** Soft-delete chunks by id (memory_forget). Returns the number tombstoned. */
|
|
159
|
+
export function forgetChunks(ids) {
|
|
160
|
+
if (ids.length === 0)
|
|
161
|
+
return 0;
|
|
162
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
163
|
+
const res = sqlite
|
|
164
|
+
.prepare(`UPDATE conversation_chunks SET deleted_at = ?
|
|
165
|
+
WHERE id IN (${placeholders}) AND deleted_at IS NULL`)
|
|
166
|
+
.run(new Date().toISOString(), ...ids);
|
|
167
|
+
return res.changes;
|
|
168
|
+
}
|
|
169
|
+
export function getChunkCount(projectId) {
|
|
170
|
+
const row = sqlite
|
|
171
|
+
.prepare("SELECT COUNT(*) as count FROM conversation_chunks WHERE project_id = ? AND deleted_at IS NULL")
|
|
172
|
+
.get(projectId);
|
|
173
|
+
return row.count;
|
|
174
|
+
}
|
|
175
|
+
function mapRow(r) {
|
|
176
|
+
return {
|
|
177
|
+
id: r.id,
|
|
178
|
+
sessionId: r.session_id,
|
|
179
|
+
projectId: r.project_id,
|
|
180
|
+
userText: r.user_text,
|
|
181
|
+
assistantText: r.assistant_text,
|
|
182
|
+
createdAt: r.created_at,
|
|
183
|
+
obsType: r.obs_type ?? null,
|
|
184
|
+
concepts: parseArr(r.concepts),
|
|
185
|
+
filesRead: parseArr(r.files_read),
|
|
186
|
+
filesModified: parseArr(r.files_modified),
|
|
187
|
+
distance: r.distance ?? 0,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Turn a free-text query into a safe FTS5 OR-query of quoted terms. The trigram
|
|
192
|
+
* tokenizer needs terms of >=3 chars; shorter tokens are dropped.
|
|
193
|
+
*/
|
|
194
|
+
function toFtsQuery(query) {
|
|
195
|
+
const terms = query
|
|
196
|
+
.replace(/["()*:^]/g, " ")
|
|
197
|
+
.split(/\s+/)
|
|
198
|
+
.filter((t) => t.length >= 3)
|
|
199
|
+
.map((t) => `"${t.replace(/"/g, "")}"`);
|
|
200
|
+
if (terms.length === 0)
|
|
201
|
+
return null;
|
|
202
|
+
return terms.join(" OR ");
|
|
203
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/core/watermark.ts
|
|
2
|
+
//
|
|
3
|
+
// Incremental distill bookkeeping: remember the mtime of each transcript we've
|
|
4
|
+
// already distilled so hooks can skip sessions with no new content.
|
|
5
|
+
import { sqlite } from "./db.js";
|
|
6
|
+
/** True if the transcript at `path` is newer than the last time we distilled it. */
|
|
7
|
+
export function shouldDistill(path, mtimeMs) {
|
|
8
|
+
const row = sqlite
|
|
9
|
+
.prepare("SELECT mtime_ms FROM distill_watermarks WHERE path = ?")
|
|
10
|
+
.get(path);
|
|
11
|
+
return !row || mtimeMs > row.mtime_ms;
|
|
12
|
+
}
|
|
13
|
+
export function markDistilled(path, mtimeMs) {
|
|
14
|
+
sqlite
|
|
15
|
+
.prepare(`INSERT INTO distill_watermarks(path, mtime_ms, distilled_at)
|
|
16
|
+
VALUES (?, ?, ?)
|
|
17
|
+
ON CONFLICT(path) DO UPDATE SET mtime_ms = excluded.mtime_ms, distilled_at = excluded.distilled_at`)
|
|
18
|
+
.run(path, mtimeMs, new Date().toISOString());
|
|
19
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// src/mcp/server.ts
|
|
2
|
+
//
|
|
3
|
+
// stdio MCP server. The engine is imported in-process: the Xenova model loads
|
|
4
|
+
// once at startup and is reused for every tool call within the agent session.
|
|
5
|
+
// No daemon, no external services.
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { getOrCreateProjectByPath } from "../core/projects.js";
|
|
11
|
+
import { getPreferences } from "../core/memory.js";
|
|
12
|
+
import { buildMemoryBlock } from "../core/recall.js";
|
|
13
|
+
import { searchIndex } from "../core/search.js";
|
|
14
|
+
import { getChunksByIds, forgetChunks } from "../core/vector-memory.js";
|
|
15
|
+
import { distill, rememberText } from "../core/distill.js";
|
|
16
|
+
import { resolveSessionJsonl } from "../core/transcript.js";
|
|
17
|
+
import { searchLogs } from "../core/logsearch/search.js";
|
|
18
|
+
import { isExcludedPath } from "../core/excludes.js";
|
|
19
|
+
function projectFor(cwd) {
|
|
20
|
+
return getOrCreateProjectByPath(cwd && cwd.trim() ? cwd : process.cwd());
|
|
21
|
+
}
|
|
22
|
+
const TOOLS = [
|
|
23
|
+
{
|
|
24
|
+
name: "memory_recall",
|
|
25
|
+
description: "Retrieve relevant long-term memory for the current project as a ready-to-read context block: user preferences (always-apply) plus recent session summaries and semantically similar past conversations (reference-only). Call this at the start of a conversation, passing the user's request as `query`.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
query: { type: "string", description: "The user's latest request / topic to find related memory for." },
|
|
30
|
+
cwd: { type: "string", description: "Project working directory. Defaults to the server's cwd." },
|
|
31
|
+
topK: { type: "number", description: "Max similar conversations (default 5)." },
|
|
32
|
+
},
|
|
33
|
+
required: ["query"],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "memory_search",
|
|
38
|
+
description: "Token-efficient memory search. Returns a light index (id + title + date + type) of matching past conversation chunks (hybrid semantic + keyword). Optional filters: type/concept/file/date. Use the returned ids with memory_get to fetch full bodies only for what you need.",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
query: { type: "string" },
|
|
43
|
+
cwd: { type: "string" },
|
|
44
|
+
limit: { type: "number", description: "Max hits (default 8)." },
|
|
45
|
+
type: { type: "string", description: "Filter by obs_type: discovery|bugfix|feature|decision|change|other." },
|
|
46
|
+
concept: { type: "string", description: "Filter to chunks tagged with this concept (substring)." },
|
|
47
|
+
file: { type: "string", description: "Filter to chunks touching this file path (substring)." },
|
|
48
|
+
dateFrom: { type: "string", description: "Only chunks created on/after this ISO date." },
|
|
49
|
+
dateTo: { type: "string", description: "Only chunks created on/before this ISO date." },
|
|
50
|
+
},
|
|
51
|
+
required: ["query"],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "memory_get",
|
|
56
|
+
description: "Fetch the full text of conversation chunks by their ids (from memory_search).",
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: { ids: { type: "array", items: { type: "string" } } },
|
|
60
|
+
required: ["ids"],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "memory_remember",
|
|
65
|
+
description: "Store a single free-text note into long-term memory for later semantic recall.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
text: { type: "string" },
|
|
70
|
+
cwd: { type: "string" },
|
|
71
|
+
sessionId: { type: "string", description: "Optional grouping id (default 'manual')." },
|
|
72
|
+
},
|
|
73
|
+
required: ["text"],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "memory_distill",
|
|
78
|
+
description: "Distill a finished session transcript into a summary + user preferences + embedded chunks. Provide a sessionId (resolved from the Claude Code transcript under the given cwd) or an explicit transcriptPath. Requires LLM credentials in the environment.",
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: {
|
|
82
|
+
cwd: { type: "string" },
|
|
83
|
+
sessionId: { type: "string" },
|
|
84
|
+
transcriptPath: { type: "string" },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "memory_get_preferences",
|
|
90
|
+
description: "List the stored user preferences for the current project.",
|
|
91
|
+
inputSchema: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: { cwd: { type: "string" } },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "memory_forget",
|
|
98
|
+
description: "Soft-delete conversation chunks by id (from memory_search). Tombstoned chunks are excluded from search, recall and the viewer. Irreversible via this tool.",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: { ids: { type: "array", items: { type: "string" } } },
|
|
102
|
+
required: ["ids"],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
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.",
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
query: { type: "string", description: "Substring to search for (case-insensitive)." },
|
|
112
|
+
sources: {
|
|
113
|
+
type: "array",
|
|
114
|
+
items: { type: "string", enum: ["claude-code", "codex"] },
|
|
115
|
+
description: "Which log sources to scan (default both).",
|
|
116
|
+
},
|
|
117
|
+
projectPath: { type: "string", description: "Restrict to a project by working-dir path (substring)." },
|
|
118
|
+
startDate: { type: "string", description: "ISO date lower bound (inclusive)." },
|
|
119
|
+
endDate: { type: "string", description: "ISO date upper bound (inclusive)." },
|
|
120
|
+
limit: { type: "number", description: "Max hits (default 20)." },
|
|
121
|
+
offset: { type: "number", description: "Skip N hits (default 0)." },
|
|
122
|
+
},
|
|
123
|
+
required: ["query"],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
const server = new Server({ name: "claw-memory", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
128
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
129
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
130
|
+
const name = req.params.name;
|
|
131
|
+
const args = (req.params.arguments ?? {});
|
|
132
|
+
try {
|
|
133
|
+
const text = await dispatch(name, args);
|
|
134
|
+
return { content: [{ type: "text", text }] };
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
return {
|
|
138
|
+
isError: true,
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: "text",
|
|
142
|
+
text: `claw-memory error in ${name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
async function dispatch(name, args) {
|
|
149
|
+
switch (name) {
|
|
150
|
+
case "memory_recall": {
|
|
151
|
+
const project = projectFor(args.cwd);
|
|
152
|
+
const block = await buildMemoryBlock(project.id, String(args.query ?? ""), args.topK ?? 5);
|
|
153
|
+
return block.fullText || "(記憶なし: このプロジェクトにはまだ保存された記憶がありません)";
|
|
154
|
+
}
|
|
155
|
+
case "memory_search": {
|
|
156
|
+
const project = projectFor(args.cwd);
|
|
157
|
+
const hits = await searchIndex(project.id, String(args.query ?? ""), args.limit ?? 8, {
|
|
158
|
+
obsType: args.type,
|
|
159
|
+
concept: args.concept,
|
|
160
|
+
file: args.file,
|
|
161
|
+
dateFrom: args.dateFrom,
|
|
162
|
+
dateTo: args.dateTo,
|
|
163
|
+
});
|
|
164
|
+
if (hits.length === 0)
|
|
165
|
+
return "(該当なし)";
|
|
166
|
+
return hits
|
|
167
|
+
.map((h) => `- id=${h.id} [${h.date}] (${h.source}${h.obsType ? `/${h.obsType}` : ""}${h.distance != null ? ` d=${h.distance.toFixed(3)}` : ""}) ${h.title}`)
|
|
168
|
+
.join("\n");
|
|
169
|
+
}
|
|
170
|
+
case "memory_get": {
|
|
171
|
+
const ids = args.ids ?? [];
|
|
172
|
+
const chunks = getChunksByIds(ids);
|
|
173
|
+
if (chunks.length === 0)
|
|
174
|
+
return "(該当なし)";
|
|
175
|
+
return chunks
|
|
176
|
+
.map((c) => {
|
|
177
|
+
const tags = [
|
|
178
|
+
c.obsType ? `type=${c.obsType}` : "",
|
|
179
|
+
c.concepts.length ? `concepts=${c.concepts.join(", ")}` : "",
|
|
180
|
+
c.filesModified.length ? `modified=${c.filesModified.join(", ")}` : "",
|
|
181
|
+
c.filesRead.length ? `read=${c.filesRead.join(", ")}` : "",
|
|
182
|
+
].filter(Boolean);
|
|
183
|
+
const meta = tags.length ? `\n[${tags.join(" | ")}]` : "";
|
|
184
|
+
return `### ${c.id} [${c.createdAt.split("T")[0]}]${meta}\nUser: ${c.userText}\nAssistant: ${c.assistantText}`;
|
|
185
|
+
})
|
|
186
|
+
.join("\n\n");
|
|
187
|
+
}
|
|
188
|
+
case "memory_forget": {
|
|
189
|
+
const ids = args.ids ?? [];
|
|
190
|
+
const n = forgetChunks(ids);
|
|
191
|
+
return `忘却しました (${n}件を削除済みにしました)`;
|
|
192
|
+
}
|
|
193
|
+
case "memory_remember": {
|
|
194
|
+
const cwdArg = args.cwd ?? process.cwd();
|
|
195
|
+
if (isExcludedPath(cwdArg))
|
|
196
|
+
return "(除外プロジェクトのため保存しませんでした)";
|
|
197
|
+
const project = projectFor(args.cwd);
|
|
198
|
+
const id = await rememberText({
|
|
199
|
+
projectId: project.id,
|
|
200
|
+
sessionId: args.sessionId ?? "manual",
|
|
201
|
+
text: String(args.text ?? ""),
|
|
202
|
+
});
|
|
203
|
+
return `保存しました (id=${id})`;
|
|
204
|
+
}
|
|
205
|
+
case "memory_distill": {
|
|
206
|
+
const cwd = args.cwd ?? process.cwd();
|
|
207
|
+
if (isExcludedPath(cwd))
|
|
208
|
+
return JSON.stringify({ skipped: "excluded project" });
|
|
209
|
+
const project = getOrCreateProjectByPath(cwd);
|
|
210
|
+
const sessionId = args.sessionId ?? "";
|
|
211
|
+
const transcriptPath = args.transcriptPath ??
|
|
212
|
+
(sessionId ? resolveSessionJsonl(cwd, sessionId) : undefined);
|
|
213
|
+
if (!transcriptPath) {
|
|
214
|
+
throw new Error("memory_distill requires sessionId or transcriptPath");
|
|
215
|
+
}
|
|
216
|
+
const res = await distill({
|
|
217
|
+
projectId: project.id,
|
|
218
|
+
sessionId: sessionId || transcriptPath,
|
|
219
|
+
transcriptPath,
|
|
220
|
+
});
|
|
221
|
+
return JSON.stringify(res);
|
|
222
|
+
}
|
|
223
|
+
case "memory_get_preferences": {
|
|
224
|
+
const project = projectFor(args.cwd);
|
|
225
|
+
const prefs = getPreferences(project.id);
|
|
226
|
+
if (prefs.length === 0)
|
|
227
|
+
return "(好みは未保存)";
|
|
228
|
+
return prefs.map((p) => `- ${p.key}: ${p.value}`).join("\n");
|
|
229
|
+
}
|
|
230
|
+
case "memory_search_logs": {
|
|
231
|
+
const { results, total } = await searchLogs({
|
|
232
|
+
query: String(args.query ?? ""),
|
|
233
|
+
sources: args.sources,
|
|
234
|
+
projectPath: args.projectPath,
|
|
235
|
+
startDate: args.startDate,
|
|
236
|
+
endDate: args.endDate,
|
|
237
|
+
limit: args.limit ?? 20,
|
|
238
|
+
offset: args.offset ?? 0,
|
|
239
|
+
});
|
|
240
|
+
if (results.length === 0)
|
|
241
|
+
return "(該当なし)";
|
|
242
|
+
const lines = results.map((r) => {
|
|
243
|
+
const date = r.timestamp ? r.timestamp.split("T")[0] : "????-??-??";
|
|
244
|
+
const ctx = `${r.contextBefore}「${r.matchedText}」${r.contextAfter}`
|
|
245
|
+
.replace(/\s+/g, " ")
|
|
246
|
+
.trim();
|
|
247
|
+
return `- [${date}] (${r.source}/${r.role}) ${basename(r.projectPath)} #${r.sessionId.slice(0, 8)}\n …${ctx}…`;
|
|
248
|
+
});
|
|
249
|
+
return `${total}件ヒット (上位${results.length}件表示):\n${lines.join("\n")}`;
|
|
250
|
+
}
|
|
251
|
+
default:
|
|
252
|
+
throw new Error(`unknown tool: ${name}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
export async function runMcpServer() {
|
|
256
|
+
const transport = new StdioServerTransport();
|
|
257
|
+
await server.connect(transport);
|
|
258
|
+
console.error("[claw-memory] MCP server ready (stdio)");
|
|
259
|
+
}
|
package/dist/ui/page.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// src/ui/page.ts
|
|
2
|
+
// Single-file viewer (vanilla JS, no build step). Served as-is by the UI server.
|
|
3
|
+
export const PAGE = /* html */ `<!doctype html>
|
|
4
|
+
<html lang="ja">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8" />
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
+
<title>claw-memory viewer</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root { --bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#e6edf3; --muted:#8b949e;
|
|
11
|
+
--accent:#58a6ff; --chip:#21262d; }
|
|
12
|
+
* { box-sizing:border-box; }
|
|
13
|
+
body { margin:0; font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
|
14
|
+
background:var(--bg); color:var(--fg);
|
|
15
|
+
height:100vh; overflow:hidden; display:flex; flex-direction:column; }
|
|
16
|
+
header { display:flex; align-items:center; gap:12px; padding:12px 18px;
|
|
17
|
+
border-bottom:1px solid var(--border); background:var(--panel); flex:0 0 auto; }
|
|
18
|
+
header h1 { font-size:15px; margin:0; font-weight:600; }
|
|
19
|
+
header .muted { color:var(--muted); font-size:12px; }
|
|
20
|
+
#search { margin-left:auto; background:var(--bg); border:1px solid var(--border); color:var(--fg);
|
|
21
|
+
border-radius:6px; padding:6px 10px; width:280px; }
|
|
22
|
+
.layout { display:flex; flex:1; min-height:0; }
|
|
23
|
+
nav { width:240px; border-right:1px solid var(--border); padding:10px; overflow-y:auto; min-height:0; }
|
|
24
|
+
nav button { display:block; width:100%; text-align:left; background:transparent; color:var(--fg);
|
|
25
|
+
border:0; border-radius:6px; padding:8px 10px; cursor:pointer; font-size:13px; }
|
|
26
|
+
nav button:hover { background:var(--chip); }
|
|
27
|
+
nav button.active { background:var(--chip); border:1px solid var(--border); }
|
|
28
|
+
nav .nm { font-weight:600; }
|
|
29
|
+
nav .pth { color:var(--muted); font-size:11px; word-break:break-all; }
|
|
30
|
+
main { flex:1; padding:18px; overflow-y:auto; min-height:0; }
|
|
31
|
+
.sect { margin-bottom:26px; }
|
|
32
|
+
.sect h2 { font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:var(--muted);
|
|
33
|
+
margin:0 0 10px; }
|
|
34
|
+
.card { background:var(--panel); border:1px solid var(--border); border-radius:8px;
|
|
35
|
+
padding:12px 14px; margin-bottom:10px; }
|
|
36
|
+
.card .meta { color:var(--muted); font-size:11px; margin-bottom:6px; display:flex; gap:8px; }
|
|
37
|
+
.tag { background:var(--chip); border-radius:4px; padding:1px 6px; font-size:10px; }
|
|
38
|
+
.card .u { color:var(--accent); }
|
|
39
|
+
.card pre { white-space:pre-wrap; word-break:break-word; margin:4px 0 0; font:inherit; }
|
|
40
|
+
.pref { display:inline-flex; gap:6px; background:var(--panel); border:1px solid var(--border);
|
|
41
|
+
border-radius:6px; padding:6px 10px; margin:0 8px 8px 0; }
|
|
42
|
+
.pref b { color:var(--accent); }
|
|
43
|
+
.empty { color:var(--muted); padding:30px; text-align:center; }
|
|
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); }
|
|
48
|
+
.src { color:var(--accent); font-size:10px; }
|
|
49
|
+
.hl { background:#9e6a03; color:#fff; border-radius:2px; padding:0 1px; }
|
|
50
|
+
</style>
|
|
51
|
+
</head>
|
|
52
|
+
<body>
|
|
53
|
+
<header>
|
|
54
|
+
<h1>🧠 claw-memory</h1>
|
|
55
|
+
<span class="muted" id="stats"></span>
|
|
56
|
+
<button id="logsBtn" title="生ログ全文検索 (Claude Code + Codex)">🔎 ログ検索</button>
|
|
57
|
+
<input id="search" placeholder="フィルタ (本文を絞り込み)" />
|
|
58
|
+
</header>
|
|
59
|
+
<div class="layout">
|
|
60
|
+
<nav id="nav"></nav>
|
|
61
|
+
<main id="main"><div class="empty">プロジェクトを選択してください</div></main>
|
|
62
|
+
</div>
|
|
63
|
+
<script>
|
|
64
|
+
let projects = [], current = null, filter = "";
|
|
65
|
+
const el = (id) => document.getElementById(id);
|
|
66
|
+
|
|
67
|
+
async function boot() {
|
|
68
|
+
const s = await (await fetch("/api/stats")).json();
|
|
69
|
+
el("stats").textContent = s.projects + " projects · " + s.chunks + " chunks · " + s.summaries + " summaries";
|
|
70
|
+
projects = await (await fetch("/api/projects")).json();
|
|
71
|
+
renderNav();
|
|
72
|
+
if (projects.length && !current) select(projects[0].id);
|
|
73
|
+
else if (current) select(current);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Live updates: the server pushes a "change" event whenever the DB is written
|
|
77
|
+
// (e.g. the MCP server stores a new memory). Refresh in place — keep the
|
|
78
|
+
// selected project and scroll position so the view doesn't jump.
|
|
79
|
+
function connectEvents() {
|
|
80
|
+
const es = new EventSource("/api/events");
|
|
81
|
+
es.addEventListener("change", () => {
|
|
82
|
+
const main = el("main");
|
|
83
|
+
const y = main ? main.scrollTop : 0;
|
|
84
|
+
boot().then(() => { const m = el("main"); if (m) m.scrollTop = y; });
|
|
85
|
+
});
|
|
86
|
+
es.onerror = () => {}; // EventSource auto-reconnects
|
|
87
|
+
}
|
|
88
|
+
function renderNav() {
|
|
89
|
+
el("nav").innerHTML = projects.map(p =>
|
|
90
|
+
'<button data-id="'+p.id+'" class="'+(p.id===current?'active':'')+'">'
|
|
91
|
+
+ '<div class="nm">'+esc(p.name)+'</div>'
|
|
92
|
+
+ '<div class="pth">'+esc(p.path)+'</div>'
|
|
93
|
+
+ '<div class="pth">'+p.counts.summaries+' sum · '+p.counts.chunks+' chunk · '+p.counts.preferences+' pref</div>'
|
|
94
|
+
+ '</button>').join("");
|
|
95
|
+
el("nav").querySelectorAll("button").forEach(b =>
|
|
96
|
+
b.onclick = () => select(b.dataset.id));
|
|
97
|
+
}
|
|
98
|
+
async function select(id) {
|
|
99
|
+
current = id; renderNav();
|
|
100
|
+
const d = await (await fetch("/api/memory?project="+encodeURIComponent(id))).json();
|
|
101
|
+
render(d);
|
|
102
|
+
}
|
|
103
|
+
function render(d) {
|
|
104
|
+
const f = filter.toLowerCase();
|
|
105
|
+
const match = (t) => !f || (t||"").toLowerCase().includes(f);
|
|
106
|
+
const prefs = d.preferences.filter(p => match(p.key+" "+p.value));
|
|
107
|
+
const sums = d.summaries.filter(s => match(s.summary));
|
|
108
|
+
const chunks = d.chunks.filter(c => match(c.userText+" "+c.assistantText));
|
|
109
|
+
let h = "";
|
|
110
|
+
if (prefs.length) h += '<div class="sect"><h2>User Preferences (always-apply)</h2>'
|
|
111
|
+
+ prefs.map(p => '<span class="pref"><b>'+esc(p.key)+'</b> '+esc(p.value)+'</span>').join("") + '</div>';
|
|
112
|
+
if (sums.length) h += '<div class="sect"><h2>Session Summaries</h2>'
|
|
113
|
+
+ sums.map(s => '<div class="card"><div class="meta"><span class="tag">summary</span>'
|
|
114
|
+
+ (s.created_at||"").split("T")[0]+'</div><pre>'+esc(s.summary)+'</pre></div>').join("") + '</div>';
|
|
115
|
+
if (chunks.length) h += '<div class="sect"><h2>Conversation Chunks</h2>'
|
|
116
|
+
+ chunks.map(c => '<div class="card"><div class="meta"><span class="tag">chunk</span>'
|
|
117
|
+
+ (c.obsType ? '<span class="tag">'+esc(c.obsType)+'</span>' : "")
|
|
118
|
+
+ (c.createdAt||"").split("T")[0]+'</div>'
|
|
119
|
+
+ chunkMeta(c)
|
|
120
|
+
+ '<pre><span class="u">User:</span> '+esc(c.userText)
|
|
121
|
+
+ (c.assistantText ? '\\n<span class="u">Assistant:</span> '+esc(c.assistantText) : "")
|
|
122
|
+
+ '</pre></div>').join("") + '</div>';
|
|
123
|
+
el("main").innerHTML = h || '<div class="empty">記憶がありません</div>';
|
|
124
|
+
}
|
|
125
|
+
function chunkMeta(c) {
|
|
126
|
+
const parts = [];
|
|
127
|
+
if (c.concepts && c.concepts.length) parts.push('<div class="cmeta">🏷 '+c.concepts.map(esc).join(", ")+'</div>');
|
|
128
|
+
const files = [].concat(c.filesModified||[], c.filesRead||[]);
|
|
129
|
+
if (files.length) parts.push('<div class="cmeta">📄 '+files.map(esc).join(", ")+'</div>');
|
|
130
|
+
return parts.join("");
|
|
131
|
+
}
|
|
132
|
+
function esc(s){ return (s==null?"":String(s)).replace(/[&<>]/g, m => ({'&':'&','<':'<','>':'>'}[m])); }
|
|
133
|
+
let logMode = false, logTimer = null;
|
|
134
|
+
function currentPath() { const p = projects.find(x => x.id === current); return p ? p.path : ""; }
|
|
135
|
+
|
|
136
|
+
el("logsBtn").addEventListener("click", () => {
|
|
137
|
+
logMode = !logMode;
|
|
138
|
+
el("logsBtn").classList.toggle("on", logMode);
|
|
139
|
+
el("search").value = "";
|
|
140
|
+
el("search").placeholder = logMode
|
|
141
|
+
? "生ログ全文検索 (このプロジェクトのCC+Codexログ)"
|
|
142
|
+
: "フィルタ (本文を絞り込み)";
|
|
143
|
+
if (logMode) el("main").innerHTML = '<div class="empty">検索語を入力してください (Claude Code + Codex の生ログを全文検索)</div>';
|
|
144
|
+
else if (current) select(current);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
el("search").addEventListener("input", e => {
|
|
148
|
+
const v = e.target.value;
|
|
149
|
+
if (logMode) {
|
|
150
|
+
clearTimeout(logTimer);
|
|
151
|
+
logTimer = setTimeout(() => runLogSearch(v), 300);
|
|
152
|
+
} else {
|
|
153
|
+
filter = v;
|
|
154
|
+
if (current) select(current);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
async function runLogSearch(q) {
|
|
159
|
+
if (!q.trim()) { el("main").innerHTML = '<div class="empty">検索語を入力してください</div>'; return; }
|
|
160
|
+
el("main").innerHTML = '<div class="empty">検索中…</div>';
|
|
161
|
+
const url = "/api/logs?q=" + encodeURIComponent(q) + "&project=" + encodeURIComponent(currentPath()) + "&limit=50";
|
|
162
|
+
let d;
|
|
163
|
+
try { d = await (await fetch(url)).json(); }
|
|
164
|
+
catch { el("main").innerHTML = '<div class="empty">検索に失敗しました</div>'; return; }
|
|
165
|
+
if (!d.results || d.results.length === 0) { el("main").innerHTML = '<div class="empty">該当なし</div>'; return; }
|
|
166
|
+
const cards = d.results.map(r => {
|
|
167
|
+
const date = r.timestamp ? r.timestamp.split("T")[0] : "????-??-??";
|
|
168
|
+
const ctx = esc(r.contextBefore) + '<span class="hl">' + esc(r.matchedText) + '</span>' + esc(r.contextAfter);
|
|
169
|
+
return '<div class="card"><div class="meta">'
|
|
170
|
+
+ '<span class="tag">' + esc(r.source) + '</span>'
|
|
171
|
+
+ '<span class="tag">' + esc(r.role) + '</span>' + date + '</div>'
|
|
172
|
+
+ '<div class="cmeta">' + esc(r.projectPath) + ' · #' + esc((r.sessionId||"").slice(0,8)) + '</div>'
|
|
173
|
+
+ '<pre>…' + ctx + '…</pre></div>';
|
|
174
|
+
}).join("");
|
|
175
|
+
el("main").innerHTML = '<div class="sect"><h2>Raw Log Search — ' + d.total + ' hits (showing ' + d.results.length + ')</h2>' + cards + '</div>';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
boot();
|
|
179
|
+
connectEvents();
|
|
180
|
+
</script>
|
|
181
|
+
</body>
|
|
182
|
+
</html>`;
|