@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/.claude-plugin/marketplace.json +5 -3
- package/.claude-plugin/plugin.json +11 -3
- package/README.ja.md +55 -11
- package/README.md +60 -11
- package/dist/cli.js +215 -1
- package/dist/core/db.js +132 -9
- package/dist/core/distill.js +97 -5
- package/dist/core/hooks.js +8 -7
- package/dist/core/installer/codex.js +19 -5
- 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 +332 -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 +61 -0
- package/dist/core/vector-memory.js +27 -10
- package/dist/mcp/server.js +177 -5
- package/dist/ui/page.js +125 -4
- package/dist/ui/server.js +65 -1
- package/hooks/claw-hook.sh +33 -9
- package/hooks/hooks.codex.json +0 -11
- package/hooks/hooks.json +0 -11
- package/package.json +4 -6
package/dist/core/db.js
CHANGED
|
@@ -1,16 +1,44 @@
|
|
|
1
1
|
// src/core/db.ts
|
|
2
2
|
//
|
|
3
|
-
// Storage foundation:
|
|
4
|
-
//
|
|
5
|
-
|
|
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
|
-
|
|
9
|
-
sqlite.
|
|
10
|
-
sqlite
|
|
11
|
-
sqlite.
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
`);
|
package/dist/core/distill.js
CHANGED
|
@@ -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:
|
|
89
|
-
assistantText:
|
|
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). */
|
package/dist/core/hooks.js
CHANGED
|
@@ -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 {
|
|
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
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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 =
|
|
74
|
-
if (block
|
|
75
|
-
process.stdout.write(block
|
|
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
|
|
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 =
|
|
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:
|
|
180
|
+
// 4) lifecycle hooks: compact recall on SessionStart + auto distill on Stop
|
|
167
181
|
installHooks();
|
|
168
|
-
done.push("hooks.json: SessionStart
|
|
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
|
+
}
|