@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
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// src/core/lessons.ts
|
|
2
|
+
//
|
|
3
|
+
// Lesson store: reusable, abstracted knowledge distilled from sessions. Mirrors
|
|
4
|
+
// vector-memory.ts (vec0 embedding + readable metadata row + FTS5 keyword row),
|
|
5
|
+
// but adds lifecycle (candidate → approved/rejected/archived/superseded), an
|
|
6
|
+
// event audit trail, and inter-lesson links (duplicate / conflict / supersede).
|
|
7
|
+
//
|
|
8
|
+
// JSON-array fields (applies_when / avoid_when / concepts / files /
|
|
9
|
+
// source_chunk_ids) are stored as TEXT, parsed on read — same convention as
|
|
10
|
+
// conversation_chunks.concepts.
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { sqlite } from "./db.js";
|
|
13
|
+
function jsonArr(v) {
|
|
14
|
+
return v && v.length ? JSON.stringify(v) : null;
|
|
15
|
+
}
|
|
16
|
+
function parseArr(v) {
|
|
17
|
+
if (typeof v !== "string" || !v)
|
|
18
|
+
return [];
|
|
19
|
+
try {
|
|
20
|
+
const a = JSON.parse(v);
|
|
21
|
+
return Array.isArray(a) ? a.map(String) : [];
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const LESSON_COLS = `l.id, l.project_id, l.repo_id, l.session_id, l.title, l.lesson,
|
|
28
|
+
l.applies_when, l.avoid_when, l.evidence, l.scope, l.obs_type, l.concepts, l.files,
|
|
29
|
+
l.confidence, l.source_chunk_ids, l.status, l.superseded_by, l.valid_until,
|
|
30
|
+
l.last_used_at, l.created_at, l.updated_at`;
|
|
31
|
+
function mapRow(r) {
|
|
32
|
+
return {
|
|
33
|
+
id: r.id,
|
|
34
|
+
projectId: r.project_id,
|
|
35
|
+
repoId: r.repo_id,
|
|
36
|
+
sessionId: r.session_id,
|
|
37
|
+
title: r.title,
|
|
38
|
+
lesson: r.lesson,
|
|
39
|
+
appliesWhen: parseArr(r.applies_when),
|
|
40
|
+
avoidWhen: parseArr(r.avoid_when),
|
|
41
|
+
evidence: r.evidence,
|
|
42
|
+
scope: r.scope,
|
|
43
|
+
obsType: r.obs_type,
|
|
44
|
+
concepts: parseArr(r.concepts),
|
|
45
|
+
files: parseArr(r.files),
|
|
46
|
+
confidence: r.confidence,
|
|
47
|
+
sourceChunkIds: parseArr(r.source_chunk_ids),
|
|
48
|
+
status: r.status,
|
|
49
|
+
supersededBy: r.superseded_by,
|
|
50
|
+
validUntil: r.valid_until,
|
|
51
|
+
lastUsedAt: r.last_used_at,
|
|
52
|
+
createdAt: r.created_at,
|
|
53
|
+
updatedAt: r.updated_at,
|
|
54
|
+
distance: r.distance ?? 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Record a lifecycle event (status change, decay, scope edit, …). */
|
|
58
|
+
export function recordEvent(lessonId, eventType, opts = {}) {
|
|
59
|
+
sqlite
|
|
60
|
+
.prepare(`INSERT INTO lesson_events(id, lesson_id, event_type, old_status, new_status, note, created_at)
|
|
61
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
62
|
+
.run(randomUUID(), lessonId, eventType, opts.oldStatus ?? null, opts.newStatus ?? null, opts.note ?? null, new Date().toISOString());
|
|
63
|
+
}
|
|
64
|
+
/** Save one lesson: vec0 embedding + metadata row + FTS keyword row, atomically. */
|
|
65
|
+
export function saveLesson(input) {
|
|
66
|
+
const insertVec = sqlite.prepare("INSERT INTO lesson_vec(embedding, project_id, scope) VALUES (?, ?, ?)");
|
|
67
|
+
const insertMeta = sqlite.prepare(`INSERT INTO lessons(
|
|
68
|
+
id, vec_rowid, project_id, repo_id, session_id, title, lesson,
|
|
69
|
+
applies_when, avoid_when, evidence, scope, obs_type, concepts, files,
|
|
70
|
+
confidence, source_chunk_ids, status, created_at, updated_at)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
72
|
+
const insertFts = sqlite.prepare("INSERT INTO lessons_fts(lesson_id, text) VALUES (?, ?)");
|
|
73
|
+
const id = randomUUID();
|
|
74
|
+
const now = new Date().toISOString();
|
|
75
|
+
const scope = input.scope ?? "repo";
|
|
76
|
+
const status = input.status ?? "candidate";
|
|
77
|
+
const confidence = input.confidence ?? 0.5;
|
|
78
|
+
const tx = sqlite.transaction(() => {
|
|
79
|
+
const res = insertVec.run(Buffer.from(input.embedding.buffer), input.projectId ?? null, scope);
|
|
80
|
+
const vecRowid = res.lastInsertRowid;
|
|
81
|
+
insertMeta.run(id, vecRowid, input.projectId ?? null, input.repoId ?? null, input.sessionId ?? null, input.title, input.lesson, jsonArr(input.appliesWhen), jsonArr(input.avoidWhen), input.evidence ?? null, scope, input.obsType ?? null, jsonArr(input.concepts), jsonArr(input.files), confidence, jsonArr(input.sourceChunkIds), status, now, now);
|
|
82
|
+
insertFts.run(id, `${input.title}\n${input.lesson}`);
|
|
83
|
+
recordEvent(id, "created", { newStatus: status });
|
|
84
|
+
});
|
|
85
|
+
tx();
|
|
86
|
+
return id;
|
|
87
|
+
}
|
|
88
|
+
export function getLesson(id) {
|
|
89
|
+
const row = sqlite
|
|
90
|
+
.prepare(`SELECT ${LESSON_COLS} FROM lessons l WHERE l.id = ?`)
|
|
91
|
+
.get(id);
|
|
92
|
+
return row ? mapRow(row) : null;
|
|
93
|
+
}
|
|
94
|
+
export function getLessonsByIds(ids) {
|
|
95
|
+
if (ids.length === 0)
|
|
96
|
+
return [];
|
|
97
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
98
|
+
const rows = sqlite
|
|
99
|
+
.prepare(`SELECT ${LESSON_COLS} FROM lessons l WHERE l.id IN (${placeholders})`)
|
|
100
|
+
.all(...ids);
|
|
101
|
+
return rows.map(mapRow);
|
|
102
|
+
}
|
|
103
|
+
/** Build a WHERE fragment from a LessonFilter. */
|
|
104
|
+
function lessonWhere(filter) {
|
|
105
|
+
const clauses = [];
|
|
106
|
+
const params = [];
|
|
107
|
+
if (filter?.status) {
|
|
108
|
+
clauses.push("l.status = ?");
|
|
109
|
+
params.push(filter.status);
|
|
110
|
+
}
|
|
111
|
+
if (filter?.scope) {
|
|
112
|
+
clauses.push("l.scope = ?");
|
|
113
|
+
params.push(filter.scope);
|
|
114
|
+
}
|
|
115
|
+
if (filter?.projectId) {
|
|
116
|
+
clauses.push("l.project_id = ?");
|
|
117
|
+
params.push(filter.projectId);
|
|
118
|
+
}
|
|
119
|
+
if (filter?.concept) {
|
|
120
|
+
clauses.push("l.concepts LIKE ?");
|
|
121
|
+
params.push(`%${filter.concept}%`);
|
|
122
|
+
}
|
|
123
|
+
if (filter?.file) {
|
|
124
|
+
clauses.push("l.files LIKE ?");
|
|
125
|
+
params.push(`%${filter.file}%`);
|
|
126
|
+
}
|
|
127
|
+
return { sql: clauses.length ? clauses.join(" AND ") : "1=1", params };
|
|
128
|
+
}
|
|
129
|
+
export function listLessons(filter, limit = 200) {
|
|
130
|
+
const w = lessonWhere(filter);
|
|
131
|
+
const rows = sqlite
|
|
132
|
+
.prepare(`SELECT ${LESSON_COLS} FROM lessons l
|
|
133
|
+
WHERE ${w.sql}
|
|
134
|
+
ORDER BY l.updated_at DESC LIMIT ?`)
|
|
135
|
+
.all(...w.params, limit);
|
|
136
|
+
return rows.map(mapRow);
|
|
137
|
+
}
|
|
138
|
+
export function getLessonCount(filter) {
|
|
139
|
+
const w = lessonWhere(filter);
|
|
140
|
+
const row = sqlite
|
|
141
|
+
.prepare(`SELECT COUNT(*) c FROM lessons l WHERE ${w.sql}`)
|
|
142
|
+
.get(...w.params);
|
|
143
|
+
return row.c;
|
|
144
|
+
}
|
|
145
|
+
/** Lessons involved in a conflicts_with link (for Conflict Review). */
|
|
146
|
+
export function listConflicts(projectId, limit = 200) {
|
|
147
|
+
const proj = projectId ? "AND l.project_id = ?" : "";
|
|
148
|
+
const rows = sqlite
|
|
149
|
+
.prepare(`SELECT DISTINCT ${LESSON_COLS} FROM lessons l
|
|
150
|
+
INNER JOIN lesson_links k
|
|
151
|
+
ON (k.lesson_id = l.id OR k.linked_lesson_id = l.id)
|
|
152
|
+
WHERE k.relation = 'conflicts_with' AND l.status != 'rejected' ${proj}
|
|
153
|
+
ORDER BY l.updated_at DESC LIMIT ?`)
|
|
154
|
+
.all(...(projectId ? [projectId] : []), limit);
|
|
155
|
+
return rows.map(mapRow);
|
|
156
|
+
}
|
|
157
|
+
export function getConflictCount(projectId) {
|
|
158
|
+
const proj = projectId ? "AND l.project_id = ?" : "";
|
|
159
|
+
const row = sqlite
|
|
160
|
+
.prepare(`SELECT COUNT(DISTINCT l.id) c FROM lessons l
|
|
161
|
+
INNER JOIN lesson_links k
|
|
162
|
+
ON (k.lesson_id = l.id OR k.linked_lesson_id = l.id)
|
|
163
|
+
WHERE k.relation = 'conflicts_with' AND l.status != 'rejected' ${proj}`)
|
|
164
|
+
.get(...(projectId ? [projectId] : []));
|
|
165
|
+
return row.c;
|
|
166
|
+
}
|
|
167
|
+
/** KNN semantic search over lessons, filtered by project/scope inside MATCH. */
|
|
168
|
+
export function searchSimilarLessons(queryEmbedding, opts = {}) {
|
|
169
|
+
const topK = opts.topK ?? 8;
|
|
170
|
+
// vec0 MATCH metadata filters: project must match OR be a cross-project scope.
|
|
171
|
+
const stmt = sqlite.prepare(`
|
|
172
|
+
SELECT ${LESSON_COLS}, v.distance
|
|
173
|
+
FROM (
|
|
174
|
+
SELECT rowid, distance FROM lesson_vec
|
|
175
|
+
WHERE embedding MATCH ? AND k = ?
|
|
176
|
+
) v
|
|
177
|
+
INNER JOIN lessons l ON l.vec_rowid = v.rowid
|
|
178
|
+
WHERE ${opts.status ? "l.status = ?" : "1=1"}
|
|
179
|
+
${opts.maxDistance != null ? "AND v.distance <= ?" : ""}
|
|
180
|
+
ORDER BY v.distance
|
|
181
|
+
`);
|
|
182
|
+
const params = [
|
|
183
|
+
Buffer.from(queryEmbedding.buffer),
|
|
184
|
+
topK,
|
|
185
|
+
];
|
|
186
|
+
if (opts.status)
|
|
187
|
+
params.push(opts.status);
|
|
188
|
+
if (opts.maxDistance != null)
|
|
189
|
+
params.push(opts.maxDistance);
|
|
190
|
+
const rows = stmt.all(...params);
|
|
191
|
+
return rows.map(mapRow);
|
|
192
|
+
}
|
|
193
|
+
/** FTS5 keyword search over lesson title + body. */
|
|
194
|
+
export function searchKeywordLessons(query, opts = {}) {
|
|
195
|
+
const match = toFtsQuery(query);
|
|
196
|
+
if (!match)
|
|
197
|
+
return [];
|
|
198
|
+
const rows = sqlite
|
|
199
|
+
.prepare(`SELECT ${LESSON_COLS}
|
|
200
|
+
FROM lessons_fts f
|
|
201
|
+
INNER JOIN lessons l ON l.id = f.lesson_id
|
|
202
|
+
WHERE f.text MATCH ? ${opts.status ? "AND l.status = ?" : ""}
|
|
203
|
+
LIMIT ?`)
|
|
204
|
+
.all(match, ...(opts.status ? [opts.status] : []), opts.topK ?? 8);
|
|
205
|
+
return rows.map(mapRow);
|
|
206
|
+
}
|
|
207
|
+
/** Transition a lesson's status, stamping updated_at and recording the event. */
|
|
208
|
+
export function setStatus(id, newStatus, note) {
|
|
209
|
+
const current = sqlite
|
|
210
|
+
.prepare("SELECT status FROM lessons WHERE id = ?")
|
|
211
|
+
.get(id);
|
|
212
|
+
if (!current)
|
|
213
|
+
return false;
|
|
214
|
+
const now = new Date().toISOString();
|
|
215
|
+
sqlite
|
|
216
|
+
.prepare("UPDATE lessons SET status = ?, updated_at = ? WHERE id = ?")
|
|
217
|
+
.run(newStatus, now, id);
|
|
218
|
+
recordEvent(id, "status_change", {
|
|
219
|
+
oldStatus: current.status,
|
|
220
|
+
newStatus,
|
|
221
|
+
note,
|
|
222
|
+
});
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
/** Mark `oldId` superseded by `newId` and link them. */
|
|
226
|
+
export function supersede(oldId, newId) {
|
|
227
|
+
const current = sqlite
|
|
228
|
+
.prepare("SELECT status FROM lessons WHERE id = ?")
|
|
229
|
+
.get(oldId);
|
|
230
|
+
if (!current)
|
|
231
|
+
return false;
|
|
232
|
+
const now = new Date().toISOString();
|
|
233
|
+
const tx = sqlite.transaction(() => {
|
|
234
|
+
sqlite
|
|
235
|
+
.prepare("UPDATE lessons SET status = 'superseded', superseded_by = ?, updated_at = ? WHERE id = ?")
|
|
236
|
+
.run(newId, now, oldId);
|
|
237
|
+
recordEvent(oldId, "superseded", {
|
|
238
|
+
oldStatus: current.status,
|
|
239
|
+
newStatus: "superseded",
|
|
240
|
+
note: `superseded_by=${newId}`,
|
|
241
|
+
});
|
|
242
|
+
linkLessons(newId, oldId, "supersedes");
|
|
243
|
+
});
|
|
244
|
+
tx();
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
/** Update one or more editable fields (scope / confidence / valid_until). */
|
|
248
|
+
export function updateLesson(id, fields) {
|
|
249
|
+
const sets = [];
|
|
250
|
+
const params = [];
|
|
251
|
+
if (fields.scope !== undefined) {
|
|
252
|
+
sets.push("scope = ?");
|
|
253
|
+
params.push(fields.scope);
|
|
254
|
+
}
|
|
255
|
+
if (fields.confidence !== undefined) {
|
|
256
|
+
sets.push("confidence = ?");
|
|
257
|
+
params.push(fields.confidence);
|
|
258
|
+
}
|
|
259
|
+
if (fields.validUntil !== undefined) {
|
|
260
|
+
sets.push("valid_until = ?");
|
|
261
|
+
params.push(fields.validUntil);
|
|
262
|
+
}
|
|
263
|
+
if (sets.length === 0)
|
|
264
|
+
return false;
|
|
265
|
+
sets.push("updated_at = ?");
|
|
266
|
+
params.push(new Date().toISOString());
|
|
267
|
+
params.push(id);
|
|
268
|
+
const res = sqlite
|
|
269
|
+
.prepare(`UPDATE lessons SET ${sets.join(", ")} WHERE id = ?`)
|
|
270
|
+
.run(...params);
|
|
271
|
+
if (res.changes > 0)
|
|
272
|
+
recordEvent(id, "edited", { note: sets.join(",") });
|
|
273
|
+
return res.changes > 0;
|
|
274
|
+
}
|
|
275
|
+
/** Bump last_used_at when a lesson is surfaced by search/inject. */
|
|
276
|
+
export function markUsed(ids) {
|
|
277
|
+
if (ids.length === 0)
|
|
278
|
+
return;
|
|
279
|
+
const now = new Date().toISOString();
|
|
280
|
+
const stmt = sqlite.prepare("UPDATE lessons SET last_used_at = ? WHERE id = ?");
|
|
281
|
+
const tx = sqlite.transaction(() => {
|
|
282
|
+
for (const id of ids)
|
|
283
|
+
stmt.run(now, id);
|
|
284
|
+
});
|
|
285
|
+
tx();
|
|
286
|
+
}
|
|
287
|
+
export function linkLessons(lessonId, linkedLessonId, relation) {
|
|
288
|
+
sqlite
|
|
289
|
+
.prepare(`INSERT INTO lesson_links(id, lesson_id, linked_lesson_id, relation, created_at)
|
|
290
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
291
|
+
.run(randomUUID(), lessonId, linkedLessonId, relation, new Date().toISOString());
|
|
292
|
+
}
|
|
293
|
+
export function getEvents(lessonId) {
|
|
294
|
+
const rows = sqlite
|
|
295
|
+
.prepare(`SELECT id, event_type, old_status, new_status, note, created_at
|
|
296
|
+
FROM lesson_events WHERE lesson_id = ? ORDER BY created_at ASC`)
|
|
297
|
+
.all(lessonId);
|
|
298
|
+
return rows.map((r) => ({
|
|
299
|
+
id: r.id,
|
|
300
|
+
eventType: r.event_type,
|
|
301
|
+
oldStatus: r.old_status,
|
|
302
|
+
newStatus: r.new_status,
|
|
303
|
+
note: r.note,
|
|
304
|
+
createdAt: r.created_at,
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
/** Links where this lesson is either side (incoming + outgoing). */
|
|
308
|
+
export function getLinks(lessonId) {
|
|
309
|
+
const rows = sqlite
|
|
310
|
+
.prepare(`SELECT id, lesson_id, linked_lesson_id, relation, created_at
|
|
311
|
+
FROM lesson_links WHERE lesson_id = ? OR linked_lesson_id = ?
|
|
312
|
+
ORDER BY created_at ASC`)
|
|
313
|
+
.all(lessonId, lessonId);
|
|
314
|
+
return rows.map((r) => ({
|
|
315
|
+
id: r.id,
|
|
316
|
+
lessonId: r.lesson_id,
|
|
317
|
+
linkedLessonId: r.linked_lesson_id,
|
|
318
|
+
relation: r.relation,
|
|
319
|
+
createdAt: r.created_at,
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Turn a free-text query into a safe FTS5 OR-query of quoted terms (trigram
|
|
324
|
+
* tokenizer needs >=3-char terms). Mirrors vector-memory.toFtsQuery.
|
|
325
|
+
*/
|
|
326
|
+
function toFtsQuery(query) {
|
|
327
|
+
const terms = query
|
|
328
|
+
.replace(/["()*:^]/g, " ")
|
|
329
|
+
.split(/\s+/)
|
|
330
|
+
.filter((t) => t.length >= 3)
|
|
331
|
+
.map((t) => `"${t.replace(/"/g, "")}"`);
|
|
332
|
+
if (terms.length === 0)
|
|
333
|
+
return null;
|
|
334
|
+
return terms.join(" OR ");
|
|
335
|
+
}
|
package/dist/core/llm.js
CHANGED
|
@@ -63,6 +63,11 @@ async function completeAgentSdk(prompt, model) {
|
|
|
63
63
|
maxTurns: 1,
|
|
64
64
|
allowedTools: [],
|
|
65
65
|
permissionMode: "bypassPermissions",
|
|
66
|
+
// SDK isolation: do NOT load ~/.claude or project settings into the
|
|
67
|
+
// distillation sub-session. Otherwise the user's own hooks (e.g. a Stop
|
|
68
|
+
// hook that plays a sound) and claw-memory's own hooks fire on every
|
|
69
|
+
// spawned sub-session — a beep storm and needless recursion.
|
|
70
|
+
settingSources: [],
|
|
66
71
|
env: buildFullSdkEnv(),
|
|
67
72
|
},
|
|
68
73
|
});
|
|
Binary file
|
|
@@ -6,6 +6,28 @@ import os from "node:os";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
export const claudeProjectsRoot = path.join(os.homedir(), ".claude", "projects");
|
|
8
8
|
export const codexSessionsRoot = path.join(os.homedir(), ".codex", "sessions");
|
|
9
|
+
/**
|
|
10
|
+
* Where to look for ChatGPT web "Export data" bundles (`conversations.json`).
|
|
11
|
+
* Unlike Claude Code / Codex, ChatGPT web conversations are NOT stored locally —
|
|
12
|
+
* the user downloads the official export and drops the file(s) here. Override
|
|
13
|
+
* `CLAW_MEMORY_CHATGPT_EXPORT` with either a single file or a directory.
|
|
14
|
+
*/
|
|
15
|
+
export const chatgptExportRoot = process.env.CLAW_MEMORY_CHATGPT_EXPORT ||
|
|
16
|
+
path.join(os.homedir(), ".claw-memory", "chatgpt");
|
|
17
|
+
/**
|
|
18
|
+
* Stable synthetic "project" that distilled ChatGPT conversations are grouped
|
|
19
|
+
* under. Deliberately independent of where the export file lives, so moving the
|
|
20
|
+
* export doesn't fork a new project and the viewer always shows one tidy
|
|
21
|
+
* "chatgpt" project. ChatGPT conversations have no repo/cwd of their own.
|
|
22
|
+
*/
|
|
23
|
+
export const chatgptProjectPath = path.join(os.homedir(), ".claw-memory", "chatgpt");
|
|
9
24
|
/** Max transcript size to scan; larger files are skipped (cc-search parity). */
|
|
10
25
|
export const MAX_LOG_FILE_SIZE = 10 * 1024 * 1024;
|
|
26
|
+
/**
|
|
27
|
+
* ChatGPT exports are a single JSON file holding the whole account history, so
|
|
28
|
+
* they can be much larger than a per-session transcript. JSON.parse reads the
|
|
29
|
+
* whole file into memory; this cap (default 200 MB) bounds that. Override with
|
|
30
|
+
* `CLAW_MEMORY_CHATGPT_MAX_BYTES`.
|
|
31
|
+
*/
|
|
32
|
+
export const MAX_CHATGPT_FILE_SIZE = Number(process.env.CLAW_MEMORY_CHATGPT_MAX_BYTES ?? 200 * 1024 * 1024);
|
|
11
33
|
export const UUID_JSONL_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
|
|
@@ -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,11 @@
|
|
|
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);
|
|
10
14
|
export async function buildMemoryBlock(projectId, query, topK = 5) {
|
|
11
15
|
const prefs = getPreferences(projectId);
|
|
12
16
|
const summaries = getRecentSummaries(projectId, 5);
|
|
@@ -20,6 +24,16 @@ export async function buildMemoryBlock(projectId, query, topK = 5) {
|
|
|
20
24
|
console.error("[claw-memory] semantic search failed:", err);
|
|
21
25
|
}
|
|
22
26
|
}
|
|
27
|
+
// Approved, reusable lessons relevant to this request (best-effort).
|
|
28
|
+
let lessons = [];
|
|
29
|
+
if (query.trim() && RECALL_LESSON_LIMIT > 0) {
|
|
30
|
+
try {
|
|
31
|
+
lessons = await searchLessons(query, { projectId }, { limit: RECALL_LESSON_LIMIT });
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
console.error("[claw-memory] lesson recall failed:", err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
23
37
|
let text = "";
|
|
24
38
|
if (prefs.length > 0) {
|
|
25
39
|
text += '<user-preferences instruction="always-apply">\n';
|
|
@@ -50,10 +64,14 @@ export async function buildMemoryBlock(projectId, query, topK = 5) {
|
|
|
50
64
|
}
|
|
51
65
|
text += "</memory-context>\n";
|
|
52
66
|
}
|
|
67
|
+
const lessonBlock = formatLessonBlock(lessons);
|
|
68
|
+
if (lessonBlock)
|
|
69
|
+
text += `\n${lessonBlock}\n`;
|
|
53
70
|
return {
|
|
54
71
|
fullText: text.trim(),
|
|
55
72
|
preferences: prefs.map((p) => ({ key: p.key, value: p.value })),
|
|
56
73
|
summaries: summaries.map((s) => s.summary),
|
|
57
74
|
similar,
|
|
75
|
+
lessons,
|
|
58
76
|
};
|
|
59
77
|
}
|