@nogataka/claw-memory 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core/db.js CHANGED
@@ -1,16 +1,44 @@
1
1
  // src/core/db.ts
2
2
  //
3
- // Storage foundation: better-sqlite3 + sqlite-vec (in-process vector search) +
4
- // FTS5 keyword fallback. No ORM, no daemon, no Python.
5
- import Database from "better-sqlite3";
3
+ // Storage foundation: node:sqlite (built into Node >= 24, no native ABI to
4
+ // break on Node upgrades) + sqlite-vec (in-process vector search) + FTS5
5
+ // keyword fallback. No ORM, no daemon, no Python.
6
+ import { DatabaseSync } from "node:sqlite";
6
7
  import * as sqliteVec from "sqlite-vec";
7
8
  import { dbPath } from "./paths.js";
8
- export const sqlite = new Database(dbPath);
9
- sqlite.pragma("journal_mode = WAL");
10
- sqlite.pragma("busy_timeout = 5000");
11
- sqlite.pragma("foreign_keys = ON");
12
- // Load the sqlite-vec extension (provides the vec0 virtual table).
13
- sqliteVec.load(sqlite);
9
+ // allowExtension can only be set at construction time (node:sqlite security
10
+ // design) and is required to load sqlite-vec below.
11
+ export const sqlite = new DatabaseSync(dbPath, { allowExtension: true });
12
+ sqlite.exec("PRAGMA journal_mode = WAL");
13
+ sqlite.exec("PRAGMA busy_timeout = 5000");
14
+ sqlite.exec("PRAGMA foreign_keys = ON");
15
+ // Load the sqlite-vec extension (provides the vec0 virtual table). sqlite-vec's
16
+ // load() helper targets better-sqlite3; node:sqlite takes the raw path instead.
17
+ sqlite.loadExtension(sqliteVec.getLoadablePath());
18
+ /**
19
+ * Run fn inside BEGIN/COMMIT with ROLLBACK on throw — node:sqlite has no
20
+ * equivalent of better-sqlite3's db.transaction() helper. Callers never nest;
21
+ * if one ever does, the inner call joins the outer transaction.
22
+ */
23
+ export function transaction(fn) {
24
+ if (sqlite.isTransaction)
25
+ return fn();
26
+ sqlite.exec("BEGIN");
27
+ try {
28
+ const result = fn();
29
+ sqlite.exec("COMMIT");
30
+ return result;
31
+ }
32
+ catch (err) {
33
+ try {
34
+ sqlite.exec("ROLLBACK");
35
+ }
36
+ catch {
37
+ // rollback failure is secondary; surface the original error
38
+ }
39
+ throw err;
40
+ }
41
+ }
14
42
  sqlite.exec(`
15
43
  CREATE TABLE IF NOT EXISTS projects (
16
44
  id TEXT PRIMARY KEY,
@@ -90,3 +118,98 @@ sqlite.exec(`
90
118
  tokenize = 'trigram'
91
119
  );
92
120
  `);
121
+ // --- Lesson layer ----------------------------------------------------------
122
+ // A "lesson" is reusable, abstracted knowledge distilled from sessions (bug-fix
123
+ // patterns, project constraints, design decisions, user preferences). Unlike
124
+ // conversation_chunks (raw history), lessons are explicit, structured, scoped,
125
+ // and lifecycle-managed (candidate → approved/rejected/archived/superseded).
126
+ // JSON-array columns (applies_when / avoid_when / concepts / files /
127
+ // source_chunk_ids) are stored as TEXT to keep the schema flat and ORM-free,
128
+ // mirroring conversation_chunks.concepts.
129
+ sqlite.exec(`
130
+ CREATE TABLE IF NOT EXISTS lessons (
131
+ id TEXT PRIMARY KEY,
132
+ project_id TEXT,
133
+ repo_id TEXT,
134
+ session_id TEXT,
135
+ vec_rowid INTEGER,
136
+ title TEXT NOT NULL,
137
+ lesson TEXT NOT NULL,
138
+ applies_when TEXT,
139
+ avoid_when TEXT,
140
+ evidence TEXT,
141
+ scope TEXT NOT NULL DEFAULT 'repo',
142
+ obs_type TEXT,
143
+ concepts TEXT,
144
+ files TEXT,
145
+ confidence REAL NOT NULL DEFAULT 0.5,
146
+ source_chunk_ids TEXT,
147
+ status TEXT NOT NULL DEFAULT 'candidate',
148
+ superseded_by TEXT,
149
+ valid_until TEXT,
150
+ last_used_at TEXT,
151
+ created_at TEXT NOT NULL,
152
+ updated_at TEXT NOT NULL
153
+ );
154
+
155
+ -- Lifecycle audit trail: every status transition / decay / scope change.
156
+ CREATE TABLE IF NOT EXISTS lesson_events (
157
+ id TEXT PRIMARY KEY,
158
+ lesson_id TEXT NOT NULL,
159
+ event_type TEXT NOT NULL,
160
+ old_status TEXT,
161
+ new_status TEXT,
162
+ note TEXT,
163
+ created_at TEXT NOT NULL
164
+ );
165
+
166
+ -- Relations between lessons: duplicate / conflicts_with / supersedes /
167
+ -- related_to / derived_from (populated by dedup + conflict detection).
168
+ CREATE TABLE IF NOT EXISTS lesson_links (
169
+ id TEXT PRIMARY KEY,
170
+ lesson_id TEXT NOT NULL,
171
+ linked_lesson_id TEXT NOT NULL,
172
+ relation TEXT NOT NULL,
173
+ created_at TEXT NOT NULL
174
+ );
175
+
176
+ CREATE INDEX IF NOT EXISTS idx_lessons_status ON lessons(status);
177
+ CREATE INDEX IF NOT EXISTS idx_lessons_project ON lessons(project_id);
178
+ CREATE INDEX IF NOT EXISTS idx_lesson_events_lesson ON lesson_events(lesson_id);
179
+ CREATE INDEX IF NOT EXISTS idx_lesson_links_lesson ON lesson_links(lesson_id);
180
+ `);
181
+ // Additive, migration-less column evolution for lessons (same approach as
182
+ // conversation_chunks above): newly-introduced columns are ALTER-added in a
183
+ // try/catch so older ~/.claw-memory/memory.db files keep working.
184
+ for (const col of [
185
+ "repo_id TEXT",
186
+ "valid_until TEXT",
187
+ "last_used_at TEXT",
188
+ "superseded_by TEXT",
189
+ ]) {
190
+ try {
191
+ sqlite.exec(`ALTER TABLE lessons ADD COLUMN ${col}`);
192
+ }
193
+ catch {
194
+ // column already exists
195
+ }
196
+ }
197
+ // vec0 virtual table for lesson embeddings (same 384-dim e5 space as
198
+ // vec_chunks). scope + project_id metadata columns let KNN filter inside the
199
+ // MATCH query without post-filter loss.
200
+ sqlite.exec(`
201
+ CREATE VIRTUAL TABLE IF NOT EXISTS lesson_vec USING vec0(
202
+ embedding float[384] distance_metric=cosine,
203
+ project_id text,
204
+ scope text
205
+ );
206
+ `);
207
+ // FTS5 keyword index over lesson title + body (trigram, same rationale as
208
+ // chunks_fts). lesson_id links back to lessons.id.
209
+ sqlite.exec(`
210
+ CREATE VIRTUAL TABLE IF NOT EXISTS lessons_fts USING fts5(
211
+ lesson_id UNINDEXED,
212
+ text,
213
+ tokenize = 'trigram'
214
+ );
215
+ `);
@@ -10,9 +10,57 @@ import { saveChunks, deleteChunksBySession, chunkExists, } from "./vector-memory
10
10
  import { loadTranscript } from "./transcript.js";
11
11
  import { stripPrivate } from "./private.js";
12
12
  import { log } from "./logger.js";
13
+ import { saveCandidates, extractDedicated, dedicatedEnabled, } from "./lesson-extract.js";
13
14
  const MIN_MESSAGES = 2;
14
15
  const MIN_TEXT_LENGTH = 100;
15
16
  const MAX_CHARS_PER_MESSAGE = 500;
17
+ // --- Chunk hygiene -----------------------------------------------------------
18
+ // Raw JSON payloads and code dumps stored as chunks poison recall: they get
19
+ // embedded, matched, and re-injected as "past conversation" forever (a past
20
+ // distill JSON response was once re-stored verbatim this way). Strip fenced
21
+ // code and drop messages that are effectively raw JSON before chunking.
22
+ /** Replace fenced code blocks (incl. an unterminated trailing fence) with a placeholder. */
23
+ export function stripCodeFences(text) {
24
+ return text
25
+ .replace(/```[\s\S]*?```/g, "[code]")
26
+ .replace(/```[\s\S]*$/, "[code]")
27
+ .trim();
28
+ }
29
+ /**
30
+ * True when the text is a raw JSON payload rather than prose. Handles
31
+ * truncated JSON (sliced mid-document) via the `{"key":`-shape heuristic,
32
+ * which complete-parse alone would miss.
33
+ */
34
+ export function isJsonPayload(text) {
35
+ const t = text.trim();
36
+ if (!/^[[{]/.test(t))
37
+ return false;
38
+ if (/^[[{]\s*"/.test(t))
39
+ return true;
40
+ try {
41
+ JSON.parse(t);
42
+ return true;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ /**
49
+ * True when stored chunk text is garbage for recall: a raw JSON payload, a
50
+ * fence-wrapped JSON payload (```json …), or nothing but code once fences are
51
+ * stripped. Used by the new distill filter and by `cleanse` on legacy rows.
52
+ */
53
+ export function isContaminatedChunkText(text) {
54
+ const t = text.trim();
55
+ if (!t)
56
+ return false;
57
+ if (isJsonPayload(t))
58
+ return true;
59
+ const fenced = t.match(/^```[a-zA-Z]*\s*([\s\S]*?)(?:```|$)/);
60
+ if (fenced && isJsonPayload(fenced[1] ?? ""))
61
+ return true;
62
+ return stripCodeFences(t).replace(/\[code\]/g, "").trim().length === 0;
63
+ }
16
64
  const OBS_TYPES = ["discovery", "bugfix", "feature", "decision", "change", "other"];
17
65
  const PROMPT = (transcript) => `以下の会話を分析して JSON で回答してください。
18
66
 
@@ -26,9 +74,15 @@ const PROMPT = (transcript) => `以下の会話を分析して JSON で回答し
26
74
  key は必ず次のいずれか(該当しなければ出さない):
27
75
  language | response_style | detail_level | code_style | framework | tone | tools
28
76
  変更がなければ空配列。
77
+ 7. lessons: 次回以降の作業で再利用できる「教訓」だけを抽出(無ければ空配列)。
78
+ 一時的な会話・未検証の推測・秘密情報・一回限りのコマンド・重複は抽出しない。
79
+ 各 lesson は実行可能・具体的・再利用可能であること。
80
+ 各要素: {"title","lesson","applies_when":[],"avoid_when":[],
81
+ "scope":"global|project|repo|file|task|user_preference","confidence":0.0-1.0,
82
+ "evidence","concepts":[],"files":[]}
29
83
 
30
84
  JSON のみ回答:
31
- {"summary": "...", "obs_type": "...", "concepts": ["..."], "files_read": ["..."], "files_modified": ["..."], "preferences": [{"key": "...", "value": "..."}]}
85
+ {"summary": "...", "obs_type": "...", "concepts": ["..."], "files_read": ["..."], "files_modified": ["..."], "preferences": [{"key": "...", "value": "..."}], "lessons": []}
32
86
 
33
87
  <conversation>
34
88
  ${transcript}
@@ -44,7 +98,7 @@ export async function distill(input) {
44
98
  return { skipped: true, reason: "too few messages" };
45
99
  }
46
100
  const transcript = messages
47
- .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.text.slice(0, MAX_CHARS_PER_MESSAGE)}`)
101
+ .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${stripCodeFences(m.text).slice(0, MAX_CHARS_PER_MESSAGE)}`)
48
102
  .join("\n");
49
103
  if (transcript.length < MIN_TEXT_LENGTH) {
50
104
  return { skipped: true, reason: "too short" };
@@ -77,6 +131,7 @@ export async function distill(input) {
77
131
  const filesModified = (result.files_modified ?? []).map(String).filter(Boolean);
78
132
  // --- Vector memory: re-chunk this session (idempotent) ---
79
133
  let chunkCount = 0;
134
+ let savedChunkIds = [];
80
135
  try {
81
136
  deleteChunksBySession(input.sessionId);
82
137
  const pairs = [];
@@ -84,9 +139,20 @@ export async function distill(input) {
84
139
  const m = messages[i];
85
140
  if (m.role === "user" && m.text.trim()) {
86
141
  const next = messages[i + 1];
142
+ // Hygiene: skip pairs whose user side is contaminated (raw/fenced JSON
143
+ // or code-only); blank a contaminated assistant side. The full text
144
+ // stays searchable in raw logs.
145
+ if (isContaminatedChunkText(m.text))
146
+ continue;
147
+ const userText = stripCodeFences(m.text);
148
+ if (!userText)
149
+ continue;
150
+ const assistantContaminated = !next || next.role !== "assistant" || isContaminatedChunkText(next.text);
87
151
  pairs.push({
88
- userText: m.text.slice(0, 500),
89
- assistantText: next?.role === "assistant" ? next.text.slice(0, 500) : "",
152
+ userText: userText.slice(0, 500),
153
+ assistantText: assistantContaminated
154
+ ? ""
155
+ : stripCodeFences(next.text).slice(0, 500),
90
156
  });
91
157
  }
92
158
  }
@@ -110,24 +176,50 @@ export async function distill(input) {
110
176
  });
111
177
  }
112
178
  if (toSave.length > 0)
113
- saveChunks(toSave);
179
+ savedChunkIds = saveChunks(toSave);
114
180
  chunkCount = toSave.length;
115
181
  }
116
182
  }
117
183
  catch (err) {
118
184
  console.error("[claw-memory] chunk embed failed:", err);
119
185
  }
186
+ // --- Lesson layer: extract reusable lessons (best-effort) ---
187
+ // Candidates ride along in the summary JSON above (no extra LLM call). When
188
+ // CLAW_MEMORY_LESSON_DEDICATED=1, run a separate higher-quality extraction
189
+ // pass instead. Failures here must never break the summary path.
190
+ let lessonCount = 0;
191
+ try {
192
+ const candidates = dedicatedEnabled()
193
+ ? await extractDedicated(transcript)
194
+ : Array.isArray(result.lessons)
195
+ ? result.lessons
196
+ : [];
197
+ if (candidates.length > 0) {
198
+ const ids = await saveCandidates({
199
+ projectId: input.projectId,
200
+ sessionId: input.sessionId,
201
+ candidates,
202
+ sourceChunkIds: savedChunkIds,
203
+ });
204
+ lessonCount = ids.length;
205
+ }
206
+ }
207
+ catch (err) {
208
+ console.error("[claw-memory] lesson extract failed:", err);
209
+ }
120
210
  log("distill", {
121
211
  projectId: input.projectId,
122
212
  sessionId: input.sessionId,
123
213
  obsType,
124
214
  chunks: chunkCount,
125
215
  preferences: result.preferences?.length ?? 0,
216
+ lessons: lessonCount,
126
217
  });
127
218
  return {
128
219
  summary: result.summary,
129
220
  preferencesCount: result.preferences?.length ?? 0,
130
221
  chunks: chunkCount,
222
+ lessons: lessonCount,
131
223
  };
132
224
  }
133
225
  /** Store a single free-text note as an embedded chunk (memory_remember). */
@@ -8,7 +8,7 @@ import { statSync } from "node:fs";
8
8
  import { spawn } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { getOrCreateProjectByPath } from "./projects.js";
11
- import { buildMemoryBlock } from "./recall.js";
11
+ import { buildStartupBlock } from "./recall.js";
12
12
  import { shouldDistill } from "./watermark.js";
13
13
  import { isExcludedPath } from "./excludes.js";
14
14
  import { log } from "./logger.js";
@@ -61,16 +61,17 @@ export function runDistillHook(input) {
61
61
  child.unref();
62
62
  }
63
63
  /**
64
- * SessionStart / UserPromptSubmit: print the memory block to stdout. When the
65
- * event carries the user's prompt it also pulls semantically similar past
66
- * conversations; otherwise just preferences + recent summaries.
64
+ * SessionStart: print the compact startup block (preferences + one-line
65
+ * summaries + pull pointer) to stdout. Prompt-driven semantic recall is pull
66
+ * only the memory_recall MCP tool / memory-recall skill — so hooks no longer
67
+ * re-inject a large memory block into every turn.
67
68
  */
68
69
  export async function runRecallHook(input) {
69
70
  const cwd = input.cwd ?? process.cwd();
70
71
  if (isExcludedPath(cwd))
71
72
  return;
72
73
  const project = getOrCreateProjectByPath(cwd);
73
- const block = await buildMemoryBlock(project.id, input.prompt ?? "", 5);
74
- if (block.fullText.trim())
75
- process.stdout.write(block.fullText + "\n");
74
+ const block = buildStartupBlock(project.id);
75
+ if (block)
76
+ process.stdout.write(block + "\n");
76
77
  }
@@ -16,12 +16,12 @@ const AGENTS = join(CODEX_DIR, "AGENTS.md");
16
16
  const HOOKS = join(CODEX_DIR, "hooks.json");
17
17
  const SKILL_DIR = join(CODEX_DIR, "skills", "memory-recall");
18
18
  // Codex lifecycle hooks for the manual (non-plugin) install path. Mirrors the
19
- // Claude Code hook wiring: recall on session start / prompt, distill on stop.
19
+ // Claude Code hook wiring: compact recall on session start only (pull model
20
+ // per-prompt injection was removed to stop context bloat), distill on stop.
20
21
  // Codex Stop carries no Claude-format transcript, so distill scans recent Codex
21
22
  // session files (watermark-guarded) instead of a single transcript_path.
22
23
  const HOOK_EVENTS = [
23
24
  ["SessionStart", "hook recall", false],
24
- ["UserPromptSubmit", "hook recall", false],
25
25
  ["Stop", "distill-codex --limit 5", true],
26
26
  ];
27
27
  const BEGIN = "# >>> claw-memory >>>";
@@ -75,8 +75,22 @@ function installHooks() {
75
75
  const cli = cliInvoker();
76
76
  const f = readHooks();
77
77
  f.hooks = f.hooks ?? {};
78
+ // Sweep our hooks from every event first so events we no longer wire (e.g.
79
+ // the removed per-prompt UserPromptSubmit recall) don't linger on upgrade.
80
+ // Filter at hook granularity (like removeHooks) so a user hook sharing a
81
+ // group with ours is preserved, not swept along.
82
+ for (const event of Object.keys(f.hooks)) {
83
+ f.hooks[event] = f.hooks[event]
84
+ .map((g) => ({
85
+ ...g,
86
+ hooks: (g.hooks ?? []).filter((h) => !isOurHook(h.command)),
87
+ }))
88
+ .filter((g) => g.hooks.length > 0);
89
+ if (f.hooks[event].length === 0)
90
+ delete f.hooks[event];
91
+ }
78
92
  for (const [event, sub, isAsync] of HOOK_EVENTS) {
79
- const list = (f.hooks[event] ?? []).filter((g) => !g.hooks?.some((h) => isOurHook(h.command)));
93
+ const list = f.hooks[event] ?? [];
80
94
  list.push({ hooks: [{ type: "command", command: `${cli} ${sub}`, async: isAsync }] });
81
95
  f.hooks[event] = list;
82
96
  }
@@ -163,9 +177,9 @@ export function installCodex() {
163
177
  const agentsPrev = existsSync(AGENTS) ? readFileSync(AGENTS, "utf-8") : "";
164
178
  writeFileSync(AGENTS, upsertBlock(agentsPrev, A_BEGIN, A_END, agentsBlock));
165
179
  done.push("AGENTS.md: recall instruction");
166
- // 4) lifecycle hooks: auto recall (SessionStart/UserPromptSubmit) + auto distill (Stop)
180
+ // 4) lifecycle hooks: compact recall on SessionStart + auto distill on Stop
167
181
  installHooks();
168
- done.push("hooks.json: SessionStart/UserPromptSubmit→recall, Stop→distill-codex");
182
+ done.push("hooks.json: SessionStart→recall, Stop→distill-codex");
169
183
  return done;
170
184
  }
171
185
  export function uninstallCodex() {
@@ -0,0 +1,233 @@
1
+ // src/core/lesson-extract.ts
2
+ //
3
+ // Turn distilled session signal into candidate lessons. Two entry points:
4
+ //
5
+ // - saveCandidates(): persist already-extracted LessonCandidate[] (the default
6
+ // path — candidates ride along in distill()'s existing summary JSON, so no
7
+ // extra LLM call).
8
+ // - extractDedicated(): a separate, higher-quality extraction pass over the
9
+ // transcript using the section-12 prompt + the "smart" tier. Opt-in via
10
+ // CLAW_MEMORY_LESSON_DEDICATED=1.
11
+ //
12
+ // All lessons are saved as status='candidate'; only human/rule approval promotes
13
+ // them to 'approved' (the status that lesson_search / recall actually surface).
14
+ import { execFileSync } from "node:child_process";
15
+ import { complete } from "./llm.js";
16
+ import { embedPassage } from "./embeddings.js";
17
+ import { saveLesson, linkLessons, searchSimilarLessons, } from "./lessons.js";
18
+ import { getProject } from "./projects.js";
19
+ import { log } from "./logger.js";
20
+ // Distance thresholds (cosine, 0..2) for relation classification.
21
+ const DUP_DISTANCE = Number(process.env.LESSON_DUP_DISTANCE ?? 0.08);
22
+ const RELATED_DISTANCE = Number(process.env.LESSON_RELATED_DISTANCE ?? 0.28);
23
+ const VALID_SCOPES = [
24
+ "global",
25
+ "project",
26
+ "repo",
27
+ "file",
28
+ "task",
29
+ "user_preference",
30
+ "team",
31
+ ];
32
+ export function normalizeScope(scope) {
33
+ const s = (scope ?? "").trim().toLowerCase();
34
+ return VALID_SCOPES.includes(s) ? s : "repo";
35
+ }
36
+ export function clampConfidence(c) {
37
+ if (typeof c !== "number" || Number.isNaN(c))
38
+ return 0.5;
39
+ return Math.max(0, Math.min(1, c));
40
+ }
41
+ /**
42
+ * Resolve a stable repo id for a project: the git top-level path, falling back
43
+ * to the project's own path. Best-effort — git failures are non-fatal. Cached
44
+ * per project path so repeated distills don't re-spawn git.
45
+ */
46
+ const repoCache = new Map();
47
+ export function resolveRepoId(projectId) {
48
+ if (!projectId)
49
+ return null;
50
+ const project = getProject(projectId);
51
+ if (!project)
52
+ return null;
53
+ const cached = repoCache.get(project.path);
54
+ if (cached !== undefined)
55
+ return cached;
56
+ let repo = project.path;
57
+ try {
58
+ repo = execFileSync("git", ["-C", project.path, "rev-parse", "--show-toplevel"], {
59
+ encoding: "utf-8",
60
+ stdio: ["ignore", "pipe", "ignore"],
61
+ }).trim() || project.path;
62
+ }
63
+ catch {
64
+ // not a git repo — repo id is the project path itself
65
+ }
66
+ repoCache.set(project.path, repo);
67
+ return repo;
68
+ }
69
+ /** Persist candidate lessons (embed + saveLesson as 'candidate'). */
70
+ export async function saveCandidates(input) {
71
+ const repoId = resolveRepoId(input.projectId);
72
+ const ids = [];
73
+ for (const c of input.candidates) {
74
+ const title = (c.title ?? "").trim();
75
+ const lesson = (c.lesson ?? "").trim();
76
+ if (!title || !lesson)
77
+ continue;
78
+ const embedding = await embedPassage(`${title}\n${lesson}`);
79
+ const id = saveLesson({
80
+ projectId: input.projectId ?? null,
81
+ repoId,
82
+ sessionId: input.sessionId ?? null,
83
+ title: title.slice(0, 200),
84
+ lesson: lesson.slice(0, 2000),
85
+ appliesWhen: (c.applies_when ?? []).map(String).filter(Boolean),
86
+ avoidWhen: (c.avoid_when ?? []).map(String).filter(Boolean),
87
+ evidence: c.evidence ? String(c.evidence).slice(0, 1000) : null,
88
+ scope: normalizeScope(c.scope),
89
+ concepts: (c.concepts ?? []).map(String).filter(Boolean),
90
+ files: (c.files ?? []).map(String).filter(Boolean),
91
+ confidence: clampConfidence(c.confidence),
92
+ sourceChunkIds: input.sourceChunkIds ?? [],
93
+ status: "candidate",
94
+ embedding,
95
+ });
96
+ // Detect duplicates / related / conflicts vs existing lessons (best-effort).
97
+ try {
98
+ await detectRelations({ lessonId: id, embedding, candidate: c });
99
+ }
100
+ catch (err) {
101
+ console.error("[claw-memory] lesson relation detection failed:", err);
102
+ }
103
+ ids.push(id);
104
+ }
105
+ log("lesson-extract", {
106
+ projectId: input.projectId,
107
+ sessionId: input.sessionId,
108
+ saved: ids.length,
109
+ });
110
+ return ids;
111
+ }
112
+ function shareAny(a, b) {
113
+ if (!a.length || !b.length)
114
+ return false;
115
+ const set = new Set(a.map((s) => s.toLowerCase()));
116
+ return b.some((s) => set.has(s.toLowerCase()));
117
+ }
118
+ const CONFLICT_PROMPT = (a, b) => `次の2つのレッスンの関係を判定してください。
119
+
120
+ Lesson A: ${a}
121
+ Lesson B: ${b}
122
+
123
+ 関係を1つ選び JSON のみで回答:
124
+ {"relation": "duplicate" | "conflict" | "related" | "none"}
125
+ - duplicate: 実質同じ内容
126
+ - conflict: 互いに矛盾する主張
127
+ - related: 関連するが別の知識
128
+ - none: 無関係`;
129
+ function conflictLlmEnabled() {
130
+ return process.env.CLAW_MEMORY_LESSON_CONFLICT_LLM === "1";
131
+ }
132
+ /**
133
+ * Compare a freshly-saved lesson against existing lessons and record
134
+ * lesson_links (duplicate / related_to / conflicts_with). Embedding distance
135
+ * gives duplicate/related deterministically; an opt-in LLM pass (env
136
+ * CLAW_MEMORY_LESSON_CONFLICT_LLM=1) adds semantic conflict detection. A
137
+ * detected conflict leaves the new lesson as 'candidate' for Conflict Review.
138
+ */
139
+ export async function detectRelations(opts) {
140
+ const result = { duplicates: 0, related: 0, conflicts: 0 };
141
+ const neighbors = searchSimilarLessons(opts.embedding, {
142
+ topK: 6,
143
+ maxDistance: RELATED_DISTANCE,
144
+ }).filter((n) => n.id !== opts.lessonId &&
145
+ n.status !== "rejected" &&
146
+ n.status !== "superseded");
147
+ if (neighbors.length === 0)
148
+ return result;
149
+ const newConcepts = (opts.candidate.concepts ?? []).map(String);
150
+ const newFiles = (opts.candidate.files ?? []).map(String);
151
+ const link = (target, relation) => linkLessons(opts.lessonId, target, relation);
152
+ // Deterministic: nearest neighbor classification by distance + overlap.
153
+ let llmDone = false;
154
+ for (const n of neighbors) {
155
+ if (n.distance <= DUP_DISTANCE) {
156
+ link(n.id, "duplicate");
157
+ result.duplicates++;
158
+ continue;
159
+ }
160
+ const overlaps = shareAny(newConcepts, n.concepts) || shareAny(newFiles, n.files);
161
+ // Opt-in LLM judgment on the closest overlapping neighbor only (1 call max).
162
+ if (conflictLlmEnabled() && !llmDone && overlaps) {
163
+ llmDone = true;
164
+ try {
165
+ const text = await complete({
166
+ prompt: CONFLICT_PROMPT(`${opts.candidate.title}: ${opts.candidate.lesson}`, `${n.title}: ${n.lesson}`),
167
+ tier: "simple",
168
+ });
169
+ const m = text.match(/"relation"\s*:\s*"(\w+)"/);
170
+ const rel = m?.[1];
171
+ if (rel === "conflict") {
172
+ link(n.id, "conflicts_with");
173
+ result.conflicts++;
174
+ continue;
175
+ }
176
+ if (rel === "duplicate") {
177
+ link(n.id, "duplicate");
178
+ result.duplicates++;
179
+ continue;
180
+ }
181
+ }
182
+ catch {
183
+ // LLM unavailable — fall through to deterministic related link
184
+ }
185
+ }
186
+ if (overlaps) {
187
+ link(n.id, "related_to");
188
+ result.related++;
189
+ }
190
+ }
191
+ return result;
192
+ }
193
+ const DEDICATED_PROMPT = (transcript) => `あなたはAIコーディングセッションから、将来再利用可能なレッスンを抽出する役割です。
194
+
195
+ 抽出するのは、次回以降の作業で役立つ可能性が高い教訓だけです。
196
+
197
+ 抽出しないもの:
198
+ - 一時的な会話
199
+ - 検証されていない推測
200
+ - 秘密情報(APIキー・トークン・個人情報)
201
+ - 生ログの丸写し
202
+ - 再利用性の低い一回限りのコマンド
203
+ - 既存レッスンと重複する内容
204
+
205
+ 各レッスンは、実行可能で、具体的で、再利用可能である必要があります。
206
+ 該当が無ければ空配列を返してください。
207
+
208
+ JSON のみ回答:
209
+ {"lessons": [{"title": "...", "lesson": "...", "applies_when": ["..."], "avoid_when": ["..."], "scope": "global|project|repo|file|task|user_preference", "confidence": 0.0, "evidence": "...", "concepts": ["..."], "files": ["..."]}]}
210
+
211
+ <conversation>
212
+ ${transcript}
213
+ </conversation>`;
214
+ /** Dedicated extraction pass (opt-in). Returns parsed candidates. */
215
+ export async function extractDedicated(transcript) {
216
+ const responseText = await complete({
217
+ prompt: DEDICATED_PROMPT(transcript),
218
+ tier: "smart",
219
+ });
220
+ const jsonMatch = responseText.match(/\{[\s\S]*\}/);
221
+ if (!jsonMatch)
222
+ return [];
223
+ try {
224
+ const parsed = JSON.parse(jsonMatch[0]);
225
+ return Array.isArray(parsed.lessons) ? parsed.lessons : [];
226
+ }
227
+ catch {
228
+ return [];
229
+ }
230
+ }
231
+ export function dedicatedEnabled() {
232
+ return process.env.CLAW_MEMORY_LESSON_DEDICATED === "1";
233
+ }