@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,233 @@
|
|
|
1
|
+
// src/core/logsearch/search.ts
|
|
2
|
+
//
|
|
3
|
+
// cc-search port: full-text substring search across the RAW agent transcripts of
|
|
4
|
+
// Claude Code and Codex — a second memory source, independent of claw-memory's
|
|
5
|
+
// distilled DB. Surfaces conversations that were never distilled. Read-only,
|
|
6
|
+
// dependency-free (node stdlib only).
|
|
7
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { resolve, join, basename } from "node:path";
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
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";
|
|
13
|
+
const CONTEXT = 100;
|
|
14
|
+
export async function searchLogs(opts) {
|
|
15
|
+
const query = opts.query.trim();
|
|
16
|
+
if (!query)
|
|
17
|
+
return { results: [], total: 0 };
|
|
18
|
+
const sources = opts.sources ?? ["claude-code", "codex"];
|
|
19
|
+
const limit = opts.limit ?? 20;
|
|
20
|
+
const offset = opts.offset ?? 0;
|
|
21
|
+
const all = [];
|
|
22
|
+
if (sources.includes("claude-code")) {
|
|
23
|
+
all.push(...(await searchClaudeCode(query, opts)));
|
|
24
|
+
}
|
|
25
|
+
if (sources.includes("codex")) {
|
|
26
|
+
all.push(...(await searchCodex(query, opts)));
|
|
27
|
+
}
|
|
28
|
+
all.sort((a, b) => tsValue(b.timestamp) - tsValue(a.timestamp));
|
|
29
|
+
return { results: all.slice(offset, offset + limit), total: all.length };
|
|
30
|
+
}
|
|
31
|
+
function tsValue(ts) {
|
|
32
|
+
if (!ts)
|
|
33
|
+
return 0;
|
|
34
|
+
const n = new Date(ts).getTime();
|
|
35
|
+
return Number.isNaN(n) ? 0 : n;
|
|
36
|
+
}
|
|
37
|
+
function inDateRange(ts, start, end) {
|
|
38
|
+
if (!start && !end)
|
|
39
|
+
return true;
|
|
40
|
+
if (!ts)
|
|
41
|
+
return false;
|
|
42
|
+
const t = tsValue(ts);
|
|
43
|
+
if (start && t < new Date(start).getTime())
|
|
44
|
+
return false;
|
|
45
|
+
if (end && t > new Date(end).getTime() + 86_400_000)
|
|
46
|
+
return false; // end-of-day
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
function matchAll(text, query) {
|
|
50
|
+
const out = [];
|
|
51
|
+
const hay = text.toLowerCase();
|
|
52
|
+
const needle = query.toLowerCase();
|
|
53
|
+
let from = 0;
|
|
54
|
+
while (true) {
|
|
55
|
+
const i = hay.indexOf(needle, from);
|
|
56
|
+
if (i === -1)
|
|
57
|
+
break;
|
|
58
|
+
out.push({ index: i, length: query.length });
|
|
59
|
+
from = i + query.length;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
function buildResult(base, text, index, length) {
|
|
64
|
+
return {
|
|
65
|
+
...base,
|
|
66
|
+
matchedText: text.slice(index, index + length),
|
|
67
|
+
contextBefore: text.slice(Math.max(0, index - CONTEXT), index),
|
|
68
|
+
contextAfter: text.slice(index + length, index + length + CONTEXT),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// --- Claude Code ------------------------------------------------------------
|
|
72
|
+
async function readClaudeCwd(filePath) {
|
|
73
|
+
try {
|
|
74
|
+
const rl = createInterface({
|
|
75
|
+
input: createReadStream(filePath, { encoding: "utf-8" }),
|
|
76
|
+
});
|
|
77
|
+
for await (const line of rl) {
|
|
78
|
+
try {
|
|
79
|
+
const data = JSON.parse(line);
|
|
80
|
+
if (typeof data.cwd === "string") {
|
|
81
|
+
rl.close();
|
|
82
|
+
return data.cwd;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// skip
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// unreadable
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
async function resolveClaudeProjectPath(dir) {
|
|
96
|
+
// sessions-index.json → first jsonl cwd → lossy dir-name conversion.
|
|
97
|
+
try {
|
|
98
|
+
const idx = JSON.parse(await readFile(join(dir, "sessions-index.json"), "utf-8"));
|
|
99
|
+
if (idx.originalPath)
|
|
100
|
+
return idx.originalPath;
|
|
101
|
+
if (idx.entries?.[0]?.projectPath)
|
|
102
|
+
return idx.entries[0].projectPath;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// none
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const files = await readdir(dir);
|
|
109
|
+
const f = files.find((x) => UUID_JSONL_RE.test(x));
|
|
110
|
+
if (f) {
|
|
111
|
+
const cwd = await readClaudeCwd(join(dir, f));
|
|
112
|
+
if (cwd)
|
|
113
|
+
return cwd;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// none
|
|
118
|
+
}
|
|
119
|
+
return basename(dir).replace(/-/g, "/");
|
|
120
|
+
}
|
|
121
|
+
async function searchClaudeCode(query, opts) {
|
|
122
|
+
const results = [];
|
|
123
|
+
let dirs;
|
|
124
|
+
try {
|
|
125
|
+
dirs = await readdir(claudeProjectsRoot);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return results;
|
|
129
|
+
}
|
|
130
|
+
for (const dirName of dirs) {
|
|
131
|
+
const dir = resolve(claudeProjectsRoot, dirName);
|
|
132
|
+
const ds = await stat(dir).catch(() => null);
|
|
133
|
+
if (!ds?.isDirectory())
|
|
134
|
+
continue;
|
|
135
|
+
const projectPath = await resolveClaudeProjectPath(dir);
|
|
136
|
+
if (opts.projectPath && !projectPath.includes(opts.projectPath))
|
|
137
|
+
continue;
|
|
138
|
+
const files = (await readdir(dir).catch(() => [])).filter((f) => UUID_JSONL_RE.test(f));
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
const p = resolve(dir, file);
|
|
141
|
+
const fs = await stat(p).catch(() => null);
|
|
142
|
+
if (!fs || fs.size > MAX_LOG_FILE_SIZE)
|
|
143
|
+
continue;
|
|
144
|
+
const sessionId = file.replace(".jsonl", "");
|
|
145
|
+
let content;
|
|
146
|
+
try {
|
|
147
|
+
content = await readFile(p, "utf-8");
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (const line of content.split("\n")) {
|
|
153
|
+
if (!line.trim())
|
|
154
|
+
continue;
|
|
155
|
+
const msg = parseClaudeCodeLine(line);
|
|
156
|
+
if (!msg)
|
|
157
|
+
continue;
|
|
158
|
+
if (!inDateRange(msg.timestamp, opts.startDate, opts.endDate))
|
|
159
|
+
continue;
|
|
160
|
+
for (const m of matchAll(msg.text, query)) {
|
|
161
|
+
results.push(buildResult({
|
|
162
|
+
source: "claude-code",
|
|
163
|
+
projectPath,
|
|
164
|
+
sessionId,
|
|
165
|
+
timestamp: msg.timestamp,
|
|
166
|
+
role: msg.role,
|
|
167
|
+
}, msg.text, m.index, m.length));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return results;
|
|
173
|
+
}
|
|
174
|
+
// --- Codex ------------------------------------------------------------------
|
|
175
|
+
async function listCodexFiles(root) {
|
|
176
|
+
const out = [];
|
|
177
|
+
const stack = [root];
|
|
178
|
+
while (stack.length) {
|
|
179
|
+
const cur = stack.pop();
|
|
180
|
+
let entries;
|
|
181
|
+
try {
|
|
182
|
+
entries = await readdir(cur, { withFileTypes: true });
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
for (const e of entries) {
|
|
188
|
+
const full = join(cur, e.name);
|
|
189
|
+
if (e.isDirectory())
|
|
190
|
+
stack.push(full);
|
|
191
|
+
else if (e.isFile() && e.name.endsWith(".jsonl"))
|
|
192
|
+
out.push(full);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
async function searchCodex(query, opts) {
|
|
198
|
+
const results = [];
|
|
199
|
+
const files = await listCodexFiles(codexSessionsRoot);
|
|
200
|
+
for (const p of files) {
|
|
201
|
+
const fs = await stat(p).catch(() => null);
|
|
202
|
+
if (!fs || fs.size > MAX_LOG_FILE_SIZE)
|
|
203
|
+
continue;
|
|
204
|
+
let content;
|
|
205
|
+
try {
|
|
206
|
+
content = await readFile(p, "utf-8");
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const { cwd, messages } = parseCodexSession(content);
|
|
212
|
+
const projectPath = cwd ?? basename(p);
|
|
213
|
+
if (opts.projectPath && !projectPath.includes(opts.projectPath))
|
|
214
|
+
continue;
|
|
215
|
+
// sessionUuid is embedded in the rollout filename: rollout-<ts>-<uuid>.jsonl
|
|
216
|
+
const m = basename(p).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
|
217
|
+
const sessionId = m ? m[1] : basename(p).replace(".jsonl", "");
|
|
218
|
+
for (const msg of messages) {
|
|
219
|
+
if (!inDateRange(msg.timestamp, opts.startDate, opts.endDate))
|
|
220
|
+
continue;
|
|
221
|
+
for (const hit of matchAll(msg.text, query)) {
|
|
222
|
+
results.push(buildResult({
|
|
223
|
+
source: "codex",
|
|
224
|
+
projectPath,
|
|
225
|
+
sessionId,
|
|
226
|
+
timestamp: msg.timestamp,
|
|
227
|
+
role: msg.role,
|
|
228
|
+
}, msg.text, hit.index, hit.length));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return results;
|
|
233
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/core/memory.ts
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { sqlite } from "./db.js";
|
|
4
|
+
/**
|
|
5
|
+
* Canonical preference keys. The distill prompt is instructed to choose from
|
|
6
|
+
* this set, and setPreference() normalizes incoming keys toward it so synonyms
|
|
7
|
+
* (e.g. "preferred_language" / "言語") don't create duplicate rows.
|
|
8
|
+
*/
|
|
9
|
+
export const CANONICAL_PREFERENCE_KEYS = [
|
|
10
|
+
"language",
|
|
11
|
+
"response_style",
|
|
12
|
+
"detail_level",
|
|
13
|
+
"code_style",
|
|
14
|
+
"framework",
|
|
15
|
+
"tone",
|
|
16
|
+
"tools",
|
|
17
|
+
];
|
|
18
|
+
const KEY_ALIASES = {
|
|
19
|
+
preferred_language: "language",
|
|
20
|
+
lang: "language",
|
|
21
|
+
言語: "language",
|
|
22
|
+
言語設定: "language",
|
|
23
|
+
response_format: "response_style",
|
|
24
|
+
reply_style: "response_style",
|
|
25
|
+
回答スタイル: "response_style",
|
|
26
|
+
応答スタイル: "response_style",
|
|
27
|
+
verbosity: "detail_level",
|
|
28
|
+
detail: "detail_level",
|
|
29
|
+
詳細度: "detail_level",
|
|
30
|
+
coding_style: "code_style",
|
|
31
|
+
コードスタイル: "code_style",
|
|
32
|
+
frameworks: "framework",
|
|
33
|
+
フレームワーク: "framework",
|
|
34
|
+
口調: "tone",
|
|
35
|
+
tooling: "tools",
|
|
36
|
+
ツール: "tools",
|
|
37
|
+
};
|
|
38
|
+
/** Map known aliases to a canonical key; otherwise lower/snake-case (kept, not dropped). */
|
|
39
|
+
export function normalizePreferenceKey(key) {
|
|
40
|
+
const trimmed = key.trim();
|
|
41
|
+
const lower = trimmed.toLowerCase();
|
|
42
|
+
if (KEY_ALIASES[trimmed])
|
|
43
|
+
return KEY_ALIASES[trimmed];
|
|
44
|
+
if (KEY_ALIASES[lower])
|
|
45
|
+
return KEY_ALIASES[lower];
|
|
46
|
+
if (CANONICAL_PREFERENCE_KEYS.includes(lower)) {
|
|
47
|
+
return lower;
|
|
48
|
+
}
|
|
49
|
+
return lower.replace(/[\s-]+/g, "_");
|
|
50
|
+
}
|
|
51
|
+
export function addSessionSummary(projectId, sessionId, summary) {
|
|
52
|
+
// Upsert by session: delete existing then insert.
|
|
53
|
+
sqlite
|
|
54
|
+
.prepare("DELETE FROM session_summaries WHERE session_id = ?")
|
|
55
|
+
.run(sessionId);
|
|
56
|
+
sqlite
|
|
57
|
+
.prepare("INSERT INTO session_summaries(id, project_id, session_id, summary, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
58
|
+
.run(randomUUID(), projectId, sessionId, summary, new Date().toISOString());
|
|
59
|
+
}
|
|
60
|
+
export function getRecentSummaries(projectId, limit = 5) {
|
|
61
|
+
return sqlite
|
|
62
|
+
.prepare("SELECT * FROM session_summaries WHERE project_id = ? ORDER BY created_at DESC LIMIT ?")
|
|
63
|
+
.all(projectId, limit);
|
|
64
|
+
}
|
|
65
|
+
export function getPreferences(projectId) {
|
|
66
|
+
return sqlite
|
|
67
|
+
.prepare("SELECT * FROM user_preferences WHERE project_id = ?")
|
|
68
|
+
.all(projectId);
|
|
69
|
+
}
|
|
70
|
+
export function setPreference(projectId, key, value) {
|
|
71
|
+
key = normalizePreferenceKey(key);
|
|
72
|
+
if (projectId) {
|
|
73
|
+
sqlite
|
|
74
|
+
.prepare("DELETE FROM user_preferences WHERE project_id = ? AND key = ?")
|
|
75
|
+
.run(projectId, key);
|
|
76
|
+
}
|
|
77
|
+
sqlite
|
|
78
|
+
.prepare("INSERT INTO user_preferences(id, project_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
79
|
+
.run(randomUUID(), projectId, key, value, new Date().toISOString());
|
|
80
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/core/paths.ts
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
/**
|
|
6
|
+
* Root data directory for claw-memory. Independent of agent-claw's data/chat.db.
|
|
7
|
+
* Override with CLAW_MEMORY_DIR.
|
|
8
|
+
*/
|
|
9
|
+
export const dataDir = process.env.CLAW_MEMORY_DIR || path.join(os.homedir(), ".claw-memory");
|
|
10
|
+
if (!fs.existsSync(dataDir)) {
|
|
11
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
export const dbPath = path.join(dataDir, "memory.db");
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// src/core/private.ts
|
|
2
|
+
//
|
|
3
|
+
// Remove <private>…</private> spans from text before it is persisted or sent to
|
|
4
|
+
// an LLM. Case-insensitive, multiline, non-greedy.
|
|
5
|
+
const PRIVATE_RE = /<private>[\s\S]*?<\/private>/gi;
|
|
6
|
+
export function stripPrivate(text) {
|
|
7
|
+
return text.replace(PRIVATE_RE, "").trim();
|
|
8
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/core/projects.ts
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { sqlite } from "./db.js";
|
|
5
|
+
export function getProjectByPath(p) {
|
|
6
|
+
return sqlite
|
|
7
|
+
.prepare("SELECT * FROM projects WHERE path = ?")
|
|
8
|
+
.get(p);
|
|
9
|
+
}
|
|
10
|
+
export function getProject(id) {
|
|
11
|
+
return sqlite
|
|
12
|
+
.prepare("SELECT * FROM projects WHERE id = ?")
|
|
13
|
+
.get(id);
|
|
14
|
+
}
|
|
15
|
+
export function listProjects() {
|
|
16
|
+
return sqlite
|
|
17
|
+
.prepare("SELECT * FROM projects ORDER BY last_accessed_at DESC")
|
|
18
|
+
.all();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a working directory to a stable project record, creating it on first
|
|
22
|
+
* sight. Paths are normalized with path.resolve so symlink/trailing-slash
|
|
23
|
+
* variants don't fork into separate projects.
|
|
24
|
+
*/
|
|
25
|
+
export function getOrCreateProjectByPath(cwd) {
|
|
26
|
+
const normalized = path.resolve(cwd);
|
|
27
|
+
const existing = getProjectByPath(normalized);
|
|
28
|
+
const now = new Date().toISOString();
|
|
29
|
+
if (existing) {
|
|
30
|
+
sqlite
|
|
31
|
+
.prepare("UPDATE projects SET last_accessed_at = ? WHERE id = ?")
|
|
32
|
+
.run(now, existing.id);
|
|
33
|
+
return existing;
|
|
34
|
+
}
|
|
35
|
+
const row = {
|
|
36
|
+
id: randomUUID(),
|
|
37
|
+
name: path.basename(normalized) || normalized,
|
|
38
|
+
path: normalized,
|
|
39
|
+
created_at: now,
|
|
40
|
+
last_accessed_at: now,
|
|
41
|
+
};
|
|
42
|
+
sqlite
|
|
43
|
+
.prepare("INSERT INTO projects(id, name, path, created_at, last_accessed_at) VALUES (?, ?, ?, ?, ?)")
|
|
44
|
+
.run(row.id, row.name, row.path, row.created_at, row.last_accessed_at);
|
|
45
|
+
return row;
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// src/core/providers.ts
|
|
2
|
+
//
|
|
3
|
+
// SDK environment helper for the Claude Agent SDK backend. Model selection and
|
|
4
|
+
// multi-backend routing now live in llm.ts (see complete()/modelForTier()).
|
|
5
|
+
// Default: Claude with the CLI's stored credentials. Override model with
|
|
6
|
+
// AGENT_SDK_MODEL; for non-default endpoints set ANTHROPIC_BASE_URL /
|
|
7
|
+
// ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY in the environment.
|
|
8
|
+
/** Inherit process.env minus Claude Code's own injected vars (avoid recursion). */
|
|
9
|
+
export function buildFullSdkEnv() {
|
|
10
|
+
const env = {};
|
|
11
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
12
|
+
if (v !== undefined && !k.startsWith("CLAUDE_CODE_") && k !== "CLAUDECODE") {
|
|
13
|
+
env[k] = v;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return env;
|
|
17
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/core/recall.ts
|
|
2
|
+
//
|
|
3
|
+
// Build the formatted memory block injected into an agent's context. Mirrors
|
|
4
|
+
// agent-claw's buildSystemPrompt memory section: preferences (always-apply) +
|
|
5
|
+
// summaries & similar conversations (reference-only).
|
|
6
|
+
import { embedQuery } from "./embeddings.js";
|
|
7
|
+
import { searchSimilar } from "./vector-memory.js";
|
|
8
|
+
import { getPreferences, getRecentSummaries } from "./memory.js";
|
|
9
|
+
const MEMORY_MAX_DISTANCE = Number(process.env.MEMORY_SIMILARITY_MAX_DISTANCE ?? 0.4);
|
|
10
|
+
export async function buildMemoryBlock(projectId, query, topK = 5) {
|
|
11
|
+
const prefs = getPreferences(projectId);
|
|
12
|
+
const summaries = getRecentSummaries(projectId, 5);
|
|
13
|
+
let similar = [];
|
|
14
|
+
if (query.trim()) {
|
|
15
|
+
try {
|
|
16
|
+
const emb = await embedQuery(query);
|
|
17
|
+
similar = searchSimilar(emb, projectId, topK, MEMORY_MAX_DISTANCE);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
console.error("[claw-memory] semantic search failed:", err);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
let text = "";
|
|
24
|
+
if (prefs.length > 0) {
|
|
25
|
+
text += '<user-preferences instruction="always-apply">\n';
|
|
26
|
+
text += "以下のユーザー設定は常に従ってください。\n";
|
|
27
|
+
for (const p of prefs)
|
|
28
|
+
text += `- ${p.key}: ${p.value}\n`;
|
|
29
|
+
text += "</user-preferences>\n";
|
|
30
|
+
}
|
|
31
|
+
if (summaries.length > 0 || similar.length > 0) {
|
|
32
|
+
text += '\n<memory-context instruction="reference-only">\n';
|
|
33
|
+
text +=
|
|
34
|
+
"以下は過去の会話から得られた参考情報です。\n" +
|
|
35
|
+
"背景知識として参照する程度に留め、自発的に言及しないでください。\n" +
|
|
36
|
+
"ユーザーが明示的に過去の話題に触れた場合にのみ活用してください。\n";
|
|
37
|
+
if (summaries.length > 0) {
|
|
38
|
+
text += "\n<previous-session-summaries>\n";
|
|
39
|
+
for (const s of summaries)
|
|
40
|
+
text += `- ${s.summary}\n`;
|
|
41
|
+
text += "</previous-session-summaries>\n";
|
|
42
|
+
}
|
|
43
|
+
if (similar.length > 0) {
|
|
44
|
+
text += "\n<relevant-past-conversations>\n";
|
|
45
|
+
for (const c of similar) {
|
|
46
|
+
const date = c.createdAt.split("T")[0];
|
|
47
|
+
text += `- [${date}] User: ${c.userText}\n Assistant: ${c.assistantText}\n`;
|
|
48
|
+
}
|
|
49
|
+
text += "</relevant-past-conversations>\n";
|
|
50
|
+
}
|
|
51
|
+
text += "</memory-context>\n";
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
fullText: text.trim(),
|
|
55
|
+
preferences: prefs.map((p) => ({ key: p.key, value: p.value })),
|
|
56
|
+
summaries: summaries.map((s) => s.summary),
|
|
57
|
+
similar,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/core/search.ts
|
|
2
|
+
//
|
|
3
|
+
// Token-efficient layered search (claude-mem-style). search() returns a light
|
|
4
|
+
// index of ids+titles; callers fetch full bodies with getChunksByIds() only for
|
|
5
|
+
// the entries they care about.
|
|
6
|
+
import { embedQuery } from "./embeddings.js";
|
|
7
|
+
import { searchSimilar, searchKeyword, } from "./vector-memory.js";
|
|
8
|
+
const MAX_DISTANCE = Number(process.env.MEMORY_SIMILARITY_MAX_DISTANCE ?? 0.6);
|
|
9
|
+
/**
|
|
10
|
+
* Hybrid search: semantic (vector) first, augmented with FTS5 keyword hits so
|
|
11
|
+
* exact-token queries that embeddings miss still surface. De-duplicated by id.
|
|
12
|
+
* Optional metadata filters (type / concept / file / date) narrow both passes.
|
|
13
|
+
*/
|
|
14
|
+
export async function searchIndex(projectId, query, limit = 8, filter) {
|
|
15
|
+
const hits = new Map();
|
|
16
|
+
if (query.trim()) {
|
|
17
|
+
try {
|
|
18
|
+
const emb = await embedQuery(query);
|
|
19
|
+
for (const c of searchSimilar(emb, projectId, limit, MAX_DISTANCE, filter)) {
|
|
20
|
+
hits.set(c.id, toHit(c, "semantic", c.distance));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
console.error("[claw-memory] semantic search failed:", err);
|
|
25
|
+
}
|
|
26
|
+
for (const c of searchKeyword(query, projectId, limit, filter)) {
|
|
27
|
+
if (!hits.has(c.id))
|
|
28
|
+
hits.set(c.id, toHit(c, "keyword"));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return [...hits.values()]
|
|
32
|
+
.sort((a, b) => (a.distance ?? 1) - (b.distance ?? 1))
|
|
33
|
+
.slice(0, limit);
|
|
34
|
+
}
|
|
35
|
+
function toHit(c, source, distance) {
|
|
36
|
+
const title = c.userText.replace(/\s+/g, " ").slice(0, 80);
|
|
37
|
+
return {
|
|
38
|
+
id: c.id,
|
|
39
|
+
title,
|
|
40
|
+
date: c.createdAt.split("T")[0],
|
|
41
|
+
source,
|
|
42
|
+
obsType: c.obsType,
|
|
43
|
+
distance,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/core/transcript.ts
|
|
2
|
+
//
|
|
3
|
+
// Load a conversation transcript. Supports an explicit .jsonl path or a Claude
|
|
4
|
+
// Code session id resolved under ~/.claude/projects/<encoded-cwd>/<id>.jsonl.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { parseCodexSession } from "./logsearch/parse.js";
|
|
9
|
+
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
|
|
10
|
+
export function encodeCwdToDir(cwd) {
|
|
11
|
+
return path.resolve(cwd).replace(/\//g, "-");
|
|
12
|
+
}
|
|
13
|
+
export function resolveSessionJsonl(cwd, sessionId) {
|
|
14
|
+
return path.join(CLAUDE_PROJECTS_DIR, encodeCwdToDir(cwd), `${sessionId}.jsonl`);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parse a transcript into ordered user/assistant text messages. Auto-detects
|
|
18
|
+
* the format: Codex rollout logs (first line is `session_meta`, or path under
|
|
19
|
+
* ~/.codex) are delegated to the Codex parser; otherwise Claude Code JSONL.
|
|
20
|
+
*/
|
|
21
|
+
export function loadTranscript(filePath) {
|
|
22
|
+
if (!fs.existsSync(filePath))
|
|
23
|
+
return [];
|
|
24
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
25
|
+
if (isCodexTranscript(filePath, raw)) {
|
|
26
|
+
const { messages } = parseCodexSession(raw);
|
|
27
|
+
return messages.map((m) => ({ role: m.role, text: m.text }));
|
|
28
|
+
}
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const line of raw.split("\n")) {
|
|
31
|
+
if (!line.trim())
|
|
32
|
+
continue;
|
|
33
|
+
let entry;
|
|
34
|
+
try {
|
|
35
|
+
entry = JSON.parse(line);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (entry.parent_tool_use_id)
|
|
41
|
+
continue;
|
|
42
|
+
const role = entry?.message?.role;
|
|
43
|
+
if (entry.type !== "user" && entry.type !== "assistant")
|
|
44
|
+
continue;
|
|
45
|
+
if (role !== "user" && role !== "assistant")
|
|
46
|
+
continue;
|
|
47
|
+
const content = entry.message.content;
|
|
48
|
+
let text = "";
|
|
49
|
+
if (typeof content === "string") {
|
|
50
|
+
text = content;
|
|
51
|
+
}
|
|
52
|
+
else if (Array.isArray(content)) {
|
|
53
|
+
// Skip tool_result-only user turns.
|
|
54
|
+
if (content.some((b) => b?.type === "tool_result"))
|
|
55
|
+
continue;
|
|
56
|
+
text = content
|
|
57
|
+
.filter((b) => b?.type === "text")
|
|
58
|
+
.map((b) => b.text)
|
|
59
|
+
.join("");
|
|
60
|
+
}
|
|
61
|
+
if (text.trim())
|
|
62
|
+
out.push({ role, text });
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** Heuristic: a Codex rollout log lives under ~/.codex or starts with session_meta. */
|
|
67
|
+
function isCodexTranscript(filePath, raw) {
|
|
68
|
+
if (filePath.includes(`${path.sep}.codex${path.sep}`))
|
|
69
|
+
return true;
|
|
70
|
+
const firstLine = raw.slice(0, raw.indexOf("\n") + 1 || undefined).trim();
|
|
71
|
+
if (!firstLine)
|
|
72
|
+
return false;
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(firstLine).type === "session_meta";
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|