@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,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
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/core/lesson-quality.ts
|
|
2
|
+
//
|
|
3
|
+
// Lesson hygiene over time (design doc section 19 + section 7's quality phase):
|
|
4
|
+
// - confidence decay: lessons not surfaced in a while lose confidence, so
|
|
5
|
+
// stale advice naturally sinks in ranking. Long-lived general knowledge
|
|
6
|
+
// (global / user_preference) decays toward a higher floor.
|
|
7
|
+
// - expiry: lessons past their valid_until are auto-archived.
|
|
8
|
+
// - stale detection: report lessons unused for N days (no mutation).
|
|
9
|
+
//
|
|
10
|
+
// Intended to run periodically (CLI `claw-memory lessons decay`), not on the
|
|
11
|
+
// hot path.
|
|
12
|
+
import { sqlite } from "./db.js";
|
|
13
|
+
import { listLessons, setStatus, recordEvent } from "./lessons.js";
|
|
14
|
+
const DECAY_FACTOR = Number(process.env.LESSON_DECAY_FACTOR ?? 0.9);
|
|
15
|
+
const STALE_DAYS = Number(process.env.LESSON_STALE_DAYS ?? 30);
|
|
16
|
+
const FLOOR_DEFAULT = Number(process.env.LESSON_CONFIDENCE_FLOOR ?? 0.2);
|
|
17
|
+
// General, broadly-true knowledge shouldn't decay into irrelevance.
|
|
18
|
+
const FLOOR_GENERAL = Number(process.env.LESSON_CONFIDENCE_FLOOR_GENERAL ?? 0.5);
|
|
19
|
+
function ageDays(ref, now) {
|
|
20
|
+
const t = new Date(ref).getTime();
|
|
21
|
+
if (!Number.isFinite(t))
|
|
22
|
+
return 0;
|
|
23
|
+
return (now - t) / 86_400_000;
|
|
24
|
+
}
|
|
25
|
+
function floorFor(scope) {
|
|
26
|
+
return scope === "global" || scope === "user_preference"
|
|
27
|
+
? FLOOR_GENERAL
|
|
28
|
+
: FLOOR_DEFAULT;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Decay confidence of stale candidate/approved lessons and auto-archive expired
|
|
32
|
+
* ones. Returns counts. Idempotent enough to run on a schedule.
|
|
33
|
+
*/
|
|
34
|
+
export function decayConfidence(opts = {}) {
|
|
35
|
+
const factor = opts.factor ?? DECAY_FACTOR;
|
|
36
|
+
const staleDays = opts.staleDays ?? STALE_DAYS;
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const result = { scanned: 0, decayed: 0, archivedExpired: 0 };
|
|
39
|
+
const updateConfidence = sqlite.prepare("UPDATE lessons SET confidence = ?, updated_at = ? WHERE id = ?");
|
|
40
|
+
for (const status of ["candidate", "approved"]) {
|
|
41
|
+
for (const l of listLessons({ status }, 100_000)) {
|
|
42
|
+
result.scanned++;
|
|
43
|
+
// Expiry: valid_until in the past → archive.
|
|
44
|
+
if (l.validUntil && new Date(l.validUntil).getTime() < now) {
|
|
45
|
+
if (!opts.dryRun)
|
|
46
|
+
setStatus(l.id, "archived", "valid_until passed");
|
|
47
|
+
result.archivedExpired++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const age = ageDays(l.lastUsedAt ?? l.createdAt, now);
|
|
51
|
+
if (age < staleDays)
|
|
52
|
+
continue;
|
|
53
|
+
const floor = floorFor(l.scope);
|
|
54
|
+
const next = Math.max(floor, Math.round(l.confidence * factor * 10000) / 10000);
|
|
55
|
+
if (next < l.confidence - 1e-9) {
|
|
56
|
+
if (!opts.dryRun) {
|
|
57
|
+
updateConfidence.run(next, new Date().toISOString(), l.id);
|
|
58
|
+
recordEvent(l.id, "decay", {
|
|
59
|
+
note: `confidence ${l.confidence.toFixed(4)}→${next.toFixed(4)} (age ${Math.round(age)}d)`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
result.decayed++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
/** Lessons unused for >= `days` (no mutation) — candidates for review/removal. */
|
|
69
|
+
export function listStale(days = STALE_DAYS) {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const status of ["candidate", "approved"]) {
|
|
73
|
+
for (const l of listLessons({ status }, 100_000)) {
|
|
74
|
+
if (ageDays(l.lastUsedAt ?? l.createdAt, now) >= days)
|
|
75
|
+
out.push(l);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/core/lesson-search.ts
|
|
2
|
+
//
|
|
3
|
+
// Lesson retrieval. Unlike conversation search (pure vector distance), lessons
|
|
4
|
+
// are ranked by a weighted blend (design doc section 20): semantic similarity,
|
|
5
|
+
// scope fit, confidence, recency, repo match and file match. Only 'approved'
|
|
6
|
+
// lessons are surfaced by default — candidates stay out of agent context until
|
|
7
|
+
// a human/rule promotes them.
|
|
8
|
+
import { embedQuery } from "./embeddings.js";
|
|
9
|
+
import { searchSimilarLessons, searchKeywordLessons, markUsed, } from "./lessons.js";
|
|
10
|
+
import { resolveRepoId } from "./lesson-extract.js";
|
|
11
|
+
const MAX_DISTANCE = Number(process.env.LESSON_SIMILARITY_MAX_DISTANCE ?? 0.6);
|
|
12
|
+
const RECENCY_HALF_DAYS = Number(process.env.LESSON_RECENCY_DAYS ?? 180);
|
|
13
|
+
// Section-20 weights (sum = 1.0).
|
|
14
|
+
const W = {
|
|
15
|
+
semantic: 0.4,
|
|
16
|
+
scope: 0.2,
|
|
17
|
+
confidence: 0.15,
|
|
18
|
+
recency: 0.1,
|
|
19
|
+
repo: 0.1,
|
|
20
|
+
file: 0.05,
|
|
21
|
+
};
|
|
22
|
+
function fileOverlap(a, b) {
|
|
23
|
+
if (!b || b.length === 0 || a.length === 0)
|
|
24
|
+
return false;
|
|
25
|
+
return a.some((f) => b.some((g) => f.includes(g) || g.includes(f)));
|
|
26
|
+
}
|
|
27
|
+
/** How well a lesson's scope applies to the current context (0..1). */
|
|
28
|
+
function scopeMatch(lesson, ctx) {
|
|
29
|
+
switch (lesson.scope) {
|
|
30
|
+
case "global":
|
|
31
|
+
case "user_preference":
|
|
32
|
+
return 0.7;
|
|
33
|
+
case "team":
|
|
34
|
+
return 0.6;
|
|
35
|
+
case "project":
|
|
36
|
+
return ctx.projectId && lesson.projectId === ctx.projectId ? 1 : 0.2;
|
|
37
|
+
case "repo":
|
|
38
|
+
return ctx.repoId && lesson.repoId === ctx.repoId ? 1 : 0.2;
|
|
39
|
+
case "file":
|
|
40
|
+
return fileOverlap(lesson.files, ctx.files) ? 1 : 0.2;
|
|
41
|
+
case "task":
|
|
42
|
+
return 0.3;
|
|
43
|
+
default:
|
|
44
|
+
return 0.3;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function recencyScore(lesson) {
|
|
48
|
+
const ref = lesson.lastUsedAt ?? lesson.createdAt;
|
|
49
|
+
const ageMs = Date.now() - new Date(ref).getTime();
|
|
50
|
+
const ageDays = ageMs / 86_400_000;
|
|
51
|
+
if (!Number.isFinite(ageDays) || ageDays < 0)
|
|
52
|
+
return 1;
|
|
53
|
+
return Math.max(0, 1 - ageDays / RECENCY_HALF_DAYS);
|
|
54
|
+
}
|
|
55
|
+
/** Cosine distance (0..2) → similarity (0..1). Keyword-only hits get a baseline. */
|
|
56
|
+
function semanticScore(distance) {
|
|
57
|
+
if (distance == null)
|
|
58
|
+
return 0.5;
|
|
59
|
+
return Math.max(0, Math.min(1, 1 - distance));
|
|
60
|
+
}
|
|
61
|
+
function computeScore(lesson, distance, ctx) {
|
|
62
|
+
const repoMatch = ctx.repoId && lesson.repoId === ctx.repoId ? 1 : 0;
|
|
63
|
+
const fMatch = fileOverlap(lesson.files, ctx.files) ? 1 : 0;
|
|
64
|
+
return (W.semantic * semanticScore(distance) +
|
|
65
|
+
W.scope * scopeMatch(lesson, ctx) +
|
|
66
|
+
W.confidence * lesson.confidence +
|
|
67
|
+
W.recency * recencyScore(lesson) +
|
|
68
|
+
W.repo * repoMatch +
|
|
69
|
+
W.file * fMatch);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Hybrid lesson search: semantic (vector) + FTS keyword, de-duplicated by id,
|
|
73
|
+
* re-ranked by the section-20 blend. Returns highest-scoring first.
|
|
74
|
+
*/
|
|
75
|
+
export async function searchLessons(query, ctx = {}, opts = {}) {
|
|
76
|
+
const limit = opts.limit ?? 5;
|
|
77
|
+
const status = opts.status ?? "approved";
|
|
78
|
+
const repoId = ctx.repoId ?? resolveRepoId(ctx.projectId);
|
|
79
|
+
const fullCtx = { ...ctx, repoId };
|
|
80
|
+
const byId = new Map();
|
|
81
|
+
if (query.trim()) {
|
|
82
|
+
try {
|
|
83
|
+
const emb = await embedQuery(query);
|
|
84
|
+
for (const l of searchSimilarLessons(emb, {
|
|
85
|
+
topK: limit * 3,
|
|
86
|
+
maxDistance: MAX_DISTANCE,
|
|
87
|
+
status,
|
|
88
|
+
})) {
|
|
89
|
+
byId.set(l.id, { lesson: l, distance: l.distance });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.error("[claw-memory] lesson semantic search failed:", err);
|
|
94
|
+
}
|
|
95
|
+
for (const l of searchKeywordLessons(query, { topK: limit * 3, status })) {
|
|
96
|
+
if (!byId.has(l.id))
|
|
97
|
+
byId.set(l.id, { lesson: l });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const ranked = [...byId.values()]
|
|
101
|
+
.map(({ lesson, distance }) => ({
|
|
102
|
+
...lesson,
|
|
103
|
+
distance: distance ?? 1,
|
|
104
|
+
score: computeScore(lesson, distance, fullCtx),
|
|
105
|
+
}))
|
|
106
|
+
.sort((a, b) => b.score - a.score)
|
|
107
|
+
.slice(0, limit);
|
|
108
|
+
if (opts.touch !== false && ranked.length > 0) {
|
|
109
|
+
markUsed(ranked.map((l) => l.id));
|
|
110
|
+
}
|
|
111
|
+
return ranked;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Format lessons as an agent-ready context block (design doc section 21).
|
|
115
|
+
* Lessons are presented as HINTS, not absolute facts — the agent must verify
|
|
116
|
+
* against current repository state before applying.
|
|
117
|
+
*/
|
|
118
|
+
export function formatLessonBlock(lessons) {
|
|
119
|
+
if (lessons.length === 0)
|
|
120
|
+
return "";
|
|
121
|
+
let text = "<relevant-lessons>\n";
|
|
122
|
+
text +=
|
|
123
|
+
"以下は、過去の類似セッションから抽出されたレッスンです。\n" +
|
|
124
|
+
"絶対的な事実ではなく、作業時のヒントとして扱ってください。\n" +
|
|
125
|
+
"適用前に現在のリポジトリ状態を確認してください。\n\n";
|
|
126
|
+
lessons.forEach((l, i) => {
|
|
127
|
+
text += `${i + 1}. ${l.lesson}\n`;
|
|
128
|
+
if (l.appliesWhen.length > 0) {
|
|
129
|
+
text += ` Applies when: ${l.appliesWhen.join("; ")}\n`;
|
|
130
|
+
}
|
|
131
|
+
if (l.avoidWhen.length > 0) {
|
|
132
|
+
text += ` Avoid when: ${l.avoidWhen.join("; ")}\n`;
|
|
133
|
+
}
|
|
134
|
+
text += ` Scope: ${l.scope} | Confidence: ${l.confidence.toFixed(2)}\n`;
|
|
135
|
+
if (l.sessionId)
|
|
136
|
+
text += ` Source: ${l.sessionId}\n`;
|
|
137
|
+
});
|
|
138
|
+
text += "</relevant-lessons>";
|
|
139
|
+
return text;
|
|
140
|
+
}
|
|
141
|
+
/** Convenience: search + format in one call (lesson_inject). */
|
|
142
|
+
export async function injectLessons(query, ctx = {}, opts = {}) {
|
|
143
|
+
const lessons = await searchLessons(query, ctx, opts);
|
|
144
|
+
return formatLessonBlock(lessons);
|
|
145
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/core/lesson-share.ts
|
|
2
|
+
//
|
|
3
|
+
// Team sharing (design doc section 8 / Phase 8). claw-memory is local-first, so
|
|
4
|
+
// there is no daemon and no automatic external sync. Sharing is explicit and
|
|
5
|
+
// file-based: export a portable JSON bundle, import it elsewhere. Embeddings are
|
|
6
|
+
// NOT exported — they are recomputed on import (the local e5 model is
|
|
7
|
+
// deterministic), keeping bundles small and engine-version-independent.
|
|
8
|
+
//
|
|
9
|
+
// Imported lessons default to status 'candidate' so shared knowledge is
|
|
10
|
+
// reviewed before it enters another developer's recall — the "public/team
|
|
11
|
+
// 共有時の追加レビュー" requirement.
|
|
12
|
+
import { embedPassage } from "./embeddings.js";
|
|
13
|
+
import { listLessons, saveLesson, } from "./lessons.js";
|
|
14
|
+
export const EXPORT_VERSION = 1;
|
|
15
|
+
function toExported(l) {
|
|
16
|
+
return {
|
|
17
|
+
title: l.title,
|
|
18
|
+
lesson: l.lesson,
|
|
19
|
+
applies_when: l.appliesWhen,
|
|
20
|
+
avoid_when: l.avoidWhen,
|
|
21
|
+
evidence: l.evidence,
|
|
22
|
+
scope: l.scope,
|
|
23
|
+
obs_type: l.obsType,
|
|
24
|
+
concepts: l.concepts,
|
|
25
|
+
files: l.files,
|
|
26
|
+
confidence: l.confidence,
|
|
27
|
+
status: l.status,
|
|
28
|
+
created_at: l.createdAt,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Build a portable bundle. Defaults to approved lessons. */
|
|
32
|
+
export function exportLessons(opts = {}) {
|
|
33
|
+
const status = opts.status ?? "approved";
|
|
34
|
+
const rows = listLessons({ projectId: opts.projectId ?? undefined, status }, 100_000);
|
|
35
|
+
return {
|
|
36
|
+
version: EXPORT_VERSION,
|
|
37
|
+
exported_at: new Date().toISOString(),
|
|
38
|
+
count: rows.length,
|
|
39
|
+
lessons: rows.map(toExported),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Import a bundle: re-embed each lesson and store it (default 'candidate'). */
|
|
43
|
+
export async function importLessons(bundle, opts = {}) {
|
|
44
|
+
if (!bundle || !Array.isArray(bundle.lessons)) {
|
|
45
|
+
throw new Error("invalid lesson bundle: missing lessons[]");
|
|
46
|
+
}
|
|
47
|
+
if (bundle.version !== EXPORT_VERSION) {
|
|
48
|
+
throw new Error(`unsupported bundle version ${bundle.version} (expected ${EXPORT_VERSION})`);
|
|
49
|
+
}
|
|
50
|
+
const status = opts.status ?? "candidate";
|
|
51
|
+
let imported = 0;
|
|
52
|
+
let skipped = 0;
|
|
53
|
+
for (const it of bundle.lessons) {
|
|
54
|
+
const title = (it.title ?? "").trim();
|
|
55
|
+
const lesson = (it.lesson ?? "").trim();
|
|
56
|
+
if (!title || !lesson) {
|
|
57
|
+
skipped++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const embedding = await embedPassage(`${title}\n${lesson}`);
|
|
61
|
+
saveLesson({
|
|
62
|
+
projectId: opts.projectId ?? null,
|
|
63
|
+
sessionId: opts.sessionId ?? "import",
|
|
64
|
+
title: title.slice(0, 200),
|
|
65
|
+
lesson: lesson.slice(0, 2000),
|
|
66
|
+
appliesWhen: (it.applies_when ?? []).map(String),
|
|
67
|
+
avoidWhen: (it.avoid_when ?? []).map(String),
|
|
68
|
+
evidence: it.evidence ?? null,
|
|
69
|
+
scope: it.scope ?? "repo",
|
|
70
|
+
obsType: it.obs_type ?? null,
|
|
71
|
+
concepts: (it.concepts ?? []).map(String),
|
|
72
|
+
files: (it.files ?? []).map(String),
|
|
73
|
+
confidence: typeof it.confidence === "number" ? it.confidence : 0.5,
|
|
74
|
+
status,
|
|
75
|
+
embedding,
|
|
76
|
+
});
|
|
77
|
+
imported++;
|
|
78
|
+
}
|
|
79
|
+
return { imported, skipped };
|
|
80
|
+
}
|