@nogataka/claw-memory 0.1.3 → 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.
@@ -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
+ }
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
+ }
@@ -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
  }