@clawmem-ai/clawmem 0.1.7 → 0.1.9
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/README.md +21 -15
- package/openclaw.plugin.json +9 -5
- package/package.json +1 -1
- package/src/config.ts +3 -2
- package/src/conversation.test.ts +70 -0
- package/src/conversation.ts +215 -23
- package/src/github-client.ts +36 -2
- package/src/memory.test.ts +373 -0
- package/src/memory.ts +370 -45
- package/src/service.ts +373 -10
- package/src/state.ts +16 -0
- package/src/types.ts +13 -3
package/src/memory.ts
CHANGED
|
@@ -1,101 +1,266 @@
|
|
|
1
1
|
// Memory CRUD, sha256 dedup, and AI-driven memory extraction.
|
|
2
2
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
3
|
-
import {
|
|
3
|
+
import { LABEL_MEMORY_STALE, MEMORY_TITLE_PREFIX, extractLabelNames, labelVal } from "./config.js";
|
|
4
4
|
import type { GitHubIssueClient } from "./github-client.js";
|
|
5
5
|
import { normalizeMessages } from "./transcript.js";
|
|
6
|
-
import type { ClawMemPluginConfig, NormalizedMessage, ParsedMemoryIssue, SessionMirrorState, TranscriptSnapshot } from "./types.js";
|
|
6
|
+
import type { ClawMemPluginConfig, MemoryDraft, MemoryListOptions, MemorySchema, NormalizedMessage, ParsedMemoryIssue, SessionMirrorState, TranscriptSnapshot } from "./types.js";
|
|
7
7
|
import { fmtTranscript, localDate, sha256, subKey } from "./utils.js";
|
|
8
8
|
import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
|
|
9
9
|
|
|
10
|
-
type MemoryDecision = { save:
|
|
10
|
+
type MemoryDecision = { save: MemoryDraft[]; stale: string[] };
|
|
11
|
+
type SearchIndex = { title: string; detail: string; kind?: string; topics: string[] };
|
|
11
12
|
|
|
12
13
|
export class MemoryStore {
|
|
13
14
|
constructor(private readonly client: GitHubIssueClient, private readonly api: OpenClawPluginApi, private readonly config: ClawMemPluginConfig) {}
|
|
14
15
|
|
|
15
16
|
async search(query: string, limit: number): Promise<ParsedMemoryIssue[]> {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
const q = normalizeSearch(query);
|
|
18
|
+
if (!q) return [];
|
|
19
|
+
try {
|
|
20
|
+
const results = await this.searchViaBackend(query, limit);
|
|
21
|
+
if (results.length > 0) return results;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
this.api.logger?.warn?.(`clawmem: backend memory search failed, falling back to local lexical ranking: ${String(error)}`);
|
|
24
|
+
}
|
|
25
|
+
return this.searchLocally(q, limit);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async listSchema(): Promise<MemorySchema> {
|
|
29
|
+
const kinds = new Set<string>();
|
|
30
|
+
const topics = new Set<string>();
|
|
31
|
+
for (let page = 1; page <= 20; page++) {
|
|
32
|
+
const batch = await this.client.listLabels({ page, perPage: 100 });
|
|
33
|
+
for (const label of batch) {
|
|
34
|
+
const name = typeof label?.name === "string" ? label.name.trim() : "";
|
|
35
|
+
if (name.startsWith("kind:")) {
|
|
36
|
+
const kind = labelVal([name], "kind:");
|
|
37
|
+
if (kind) kinds.add(kind);
|
|
38
|
+
}
|
|
39
|
+
if (name.startsWith("topic:")) {
|
|
40
|
+
const topic = labelVal([name], "topic:");
|
|
41
|
+
if (topic) topics.add(topic);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (batch.length < 100) break;
|
|
45
|
+
}
|
|
46
|
+
for (const memory of await this.listByStatus("all")) {
|
|
47
|
+
if (memory.kind) kinds.add(memory.kind);
|
|
48
|
+
for (const topic of memory.topics ?? []) topics.add(topic);
|
|
49
|
+
}
|
|
50
|
+
return { kinds: [...kinds].sort(), topics: [...topics].sort() };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async get(memoryId: string, status: "active" | "stale" | "all" = "all"): Promise<ParsedMemoryIssue | null> {
|
|
54
|
+
const id = memoryId.trim();
|
|
55
|
+
if (!id) throw new Error("memoryId is empty");
|
|
56
|
+
return (await this.listByStatus(status)).find((m) => m.memoryId === id || String(m.issueNumber) === id) ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async listMemories(options: MemoryListOptions = {}): Promise<ParsedMemoryIssue[]> {
|
|
60
|
+
const status = options.status ?? "active";
|
|
61
|
+
const kind = normalizeOptionalLabelValue(options.kind, "kind:");
|
|
62
|
+
const topic = normalizeOptionalLabelValue(options.topic, "topic:");
|
|
63
|
+
const limit = Math.min(200, Math.max(1, options.limit ?? 20));
|
|
64
|
+
return (await this.listByStatus(status))
|
|
65
|
+
.filter((memory) => {
|
|
66
|
+
if (kind && memory.kind !== kind) return false;
|
|
67
|
+
if (topic && !(memory.topics ?? []).includes(topic)) return false;
|
|
68
|
+
return true;
|
|
25
69
|
})
|
|
26
|
-
.
|
|
27
|
-
.
|
|
28
|
-
.slice(0, limit)
|
|
29
|
-
.map((e) => e.m);
|
|
70
|
+
.sort((a, b) => b.issueNumber - a.issueNumber)
|
|
71
|
+
.slice(0, limit);
|
|
30
72
|
}
|
|
31
73
|
|
|
32
|
-
async
|
|
74
|
+
async store(draft: MemoryDraft, sessionId = "manual"): Promise<{ created: boolean; memory: ParsedMemoryIssue }> {
|
|
75
|
+
const normalized = normalizeDraft(draft);
|
|
76
|
+
const detail = norm(normalized.detail);
|
|
77
|
+
const allActive = await this.listByStatus("active");
|
|
78
|
+
const hash = sha256(detail);
|
|
79
|
+
const existing = allActive.find((m) => (m.memoryHash || sha256(norm(m.detail))) === hash);
|
|
80
|
+
if (existing) {
|
|
81
|
+
const memory = await this.mergeSchema(existing, normalized);
|
|
82
|
+
return { created: false, memory };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const date = localDate();
|
|
86
|
+
const labels = memLabels(sessionId, normalized.kind, normalized.topics);
|
|
87
|
+
const title = `${MEMORY_TITLE_PREFIX}${trunc(detail, 72)}`;
|
|
88
|
+
const body = stringifyFlatYaml([["memory_hash", hash], ["date", date], ["detail", detail]]);
|
|
89
|
+
await this.client.ensureLabels(labels);
|
|
90
|
+
const issue = await this.client.createIssue({ title, body, labels });
|
|
91
|
+
return {
|
|
92
|
+
created: true,
|
|
93
|
+
memory: {
|
|
94
|
+
issueNumber: issue.number,
|
|
95
|
+
title,
|
|
96
|
+
memoryId: String(issue.number),
|
|
97
|
+
memoryHash: hash,
|
|
98
|
+
sessionId,
|
|
99
|
+
date,
|
|
100
|
+
detail,
|
|
101
|
+
...(normalized.kind ? { kind: normalized.kind } : {}),
|
|
102
|
+
...(normalized.topics && normalized.topics.length > 0 ? { topics: normalized.topics } : {}),
|
|
103
|
+
status: "active",
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async update(memoryId: string, patch: { detail?: string; kind?: string; topics?: string[] }): Promise<ParsedMemoryIssue | null> {
|
|
109
|
+
const current = await this.get(memoryId, "all");
|
|
110
|
+
if (!current) return null;
|
|
111
|
+
const nextDetail = typeof patch.detail === "string" && patch.detail.trim() ? norm(patch.detail) : current.detail;
|
|
112
|
+
const nextKind = patch.kind !== undefined ? normalizeLabelValue(patch.kind, "kind:") : current.kind;
|
|
113
|
+
const nextTopics = patch.topics !== undefined
|
|
114
|
+
? uniqueNormalized(patch.topics.map((topic) => normalizeLabelValue(topic, "topic:")).filter(Boolean) as string[])
|
|
115
|
+
: uniqueNormalized(current.topics ?? []);
|
|
116
|
+
const nextHash = sha256(nextDetail);
|
|
117
|
+
const duplicate = (await this.listByStatus("active")).find((memory) => {
|
|
118
|
+
if (memory.issueNumber === current.issueNumber) return false;
|
|
119
|
+
return (memory.memoryHash || sha256(norm(memory.detail))) === nextHash;
|
|
120
|
+
});
|
|
121
|
+
if (duplicate) throw new Error(`another active memory already stores this detail as [${duplicate.memoryId}]`);
|
|
122
|
+
const nextTitle = `${MEMORY_TITLE_PREFIX}${trunc(nextDetail, 72)}`;
|
|
123
|
+
const nextBody = stringifyFlatYaml([["memory_hash", nextHash], ["date", current.date], ["detail", nextDetail]]);
|
|
124
|
+
const nextLabels = memLabels(current.sessionId, nextKind, nextTopics);
|
|
125
|
+
await this.client.ensureLabels(nextLabels);
|
|
126
|
+
await this.client.updateIssue(current.issueNumber, { title: nextTitle, body: nextBody });
|
|
127
|
+
await this.client.syncManagedLabels(current.issueNumber, nextLabels);
|
|
128
|
+
return {
|
|
129
|
+
...current,
|
|
130
|
+
title: nextTitle,
|
|
131
|
+
memoryHash: nextHash,
|
|
132
|
+
detail: nextDetail,
|
|
133
|
+
...(nextKind ? { kind: nextKind } : {}),
|
|
134
|
+
...(nextTopics.length > 0 ? { topics: nextTopics } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async forget(memoryId: string): Promise<ParsedMemoryIssue | null> {
|
|
139
|
+
const id = memoryId.trim();
|
|
140
|
+
if (!id) throw new Error("memoryId is empty");
|
|
141
|
+
const mem = await this.get(id, "active");
|
|
142
|
+
if (!mem) return null;
|
|
143
|
+
await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.sessionId, mem.kind, mem.topics));
|
|
144
|
+
await this.client.updateIssue(mem.issueNumber, { state: "closed" });
|
|
145
|
+
return { ...mem, status: "stale" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async syncFromConversation(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<boolean> {
|
|
33
149
|
try {
|
|
34
150
|
const decision = await this.generateDecision(session, snapshot);
|
|
35
151
|
const { savedCount, staledCount } = await this.applyDecision(session.sessionId, decision);
|
|
36
152
|
if (savedCount > 0 || staledCount > 0)
|
|
37
153
|
this.api.logger.info?.(`clawmem: synced memories for ${session.sessionId} (saved=${savedCount}, stale=${staledCount})`);
|
|
154
|
+
return true;
|
|
38
155
|
} catch (error) {
|
|
39
156
|
this.api.logger.warn(`clawmem: memory capture failed: ${String(error)}`);
|
|
157
|
+
return false;
|
|
40
158
|
}
|
|
41
159
|
}
|
|
42
160
|
|
|
43
|
-
private async
|
|
161
|
+
private async listByStatus(status: "active" | "stale" | "all"): Promise<ParsedMemoryIssue[]> {
|
|
44
162
|
const labels = ["type:memory"];
|
|
45
|
-
|
|
46
|
-
else if (status === "stale") labels.push(LABEL_MEMORY_STALE);
|
|
163
|
+
const state = status === "active" ? "open" : "all";
|
|
47
164
|
const out: ParsedMemoryIssue[] = [];
|
|
48
165
|
for (let page = 1; page <= 20; page++) {
|
|
49
|
-
const batch = await this.client.listIssues({ labels, state
|
|
50
|
-
for (const issue of batch) {
|
|
166
|
+
const batch = await this.client.listIssues({ labels, state, page, perPage: 100 });
|
|
167
|
+
for (const issue of batch) {
|
|
168
|
+
const parsed = this.parseIssue(issue);
|
|
169
|
+
if (!parsed) continue;
|
|
170
|
+
if (status !== "all" && parsed.status !== status) continue;
|
|
171
|
+
out.push(parsed);
|
|
172
|
+
}
|
|
51
173
|
if (batch.length < 100) break;
|
|
52
174
|
}
|
|
53
175
|
return out;
|
|
54
176
|
}
|
|
55
177
|
|
|
56
|
-
private
|
|
178
|
+
private async searchViaBackend(query: string, limit: number): Promise<ParsedMemoryIssue[]> {
|
|
179
|
+
const repo = this.client.repo();
|
|
180
|
+
if (!repo) return [];
|
|
181
|
+
const qualified = buildMemorySearchQuery(query, repo);
|
|
182
|
+
const batch = await this.client.searchIssues(qualified, { perPage: Math.min(100, Math.max(limit * 3, 20)) });
|
|
183
|
+
return batch
|
|
184
|
+
.map((issue) => this.parseIssue(issue))
|
|
185
|
+
.filter((memory): memory is ParsedMemoryIssue => memory !== null && memory.status === "active")
|
|
186
|
+
.slice(0, limit);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private async searchLocally(normalizedQuery: string, limit: number): Promise<ParsedMemoryIssue[]> {
|
|
190
|
+
const memories = await this.listByStatus("active");
|
|
191
|
+
return memories
|
|
192
|
+
.map((m) => ({ m, score: scoreMemoryMatch(m, normalizedQuery) }))
|
|
193
|
+
.filter((e) => e.score > 0)
|
|
194
|
+
.sort((a, b) => b.score - a.score || b.m.issueNumber - a.m.issueNumber)
|
|
195
|
+
.slice(0, limit)
|
|
196
|
+
.map((e) => e.m);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private parseIssue(issue: { number: number; title?: string; body?: string; state?: string; labels?: Array<{ name?: string } | string> }): ParsedMemoryIssue | null {
|
|
57
200
|
const labels = extractLabelNames(issue.labels);
|
|
58
201
|
if (!labels.includes("type:memory")) return null;
|
|
59
|
-
const sessionId = labelVal(labels, "session:"),
|
|
202
|
+
const sessionId = labelVal(labels, "session:"), kind = labelVal(labels, "kind:");
|
|
60
203
|
const topics = labels.filter((l) => l.startsWith("topic:")).map((l) => l.slice(6).trim()).filter(Boolean);
|
|
61
204
|
const rawBody = (issue.body ?? "").trim();
|
|
62
205
|
const body = rawBody ? parseFlatYaml(rawBody) : {};
|
|
63
206
|
const detail = body.detail?.trim() || rawBody;
|
|
64
|
-
|
|
207
|
+
const status = issue.state === "closed" || labels.includes(LABEL_MEMORY_STALE) ? "stale" : "active";
|
|
208
|
+
if (!detail) return null;
|
|
65
209
|
return {
|
|
66
210
|
issueNumber: issue.number, title: issue.title?.trim() || "",
|
|
67
211
|
memoryId: body.memory_id?.trim() || String(issue.number),
|
|
68
212
|
memoryHash: body.memory_hash?.trim() || undefined,
|
|
69
|
-
sessionId
|
|
213
|
+
sessionId: sessionId || "legacy",
|
|
214
|
+
date: body.date?.trim() || "1970-01-01",
|
|
215
|
+
detail,
|
|
216
|
+
...(kind ? { kind } : {}),
|
|
70
217
|
...(topics.length > 0 ? { topics } : {}),
|
|
71
|
-
status
|
|
218
|
+
status,
|
|
72
219
|
};
|
|
73
220
|
}
|
|
74
221
|
|
|
75
222
|
private async applyDecision(sessionId: string, decision: MemoryDecision): Promise<{ savedCount: number; staledCount: number }> {
|
|
76
|
-
const allActive = await this.
|
|
223
|
+
const allActive = await this.listByStatus("active");
|
|
77
224
|
const activeById = new Map(allActive.map((m) => [m.memoryId, m]));
|
|
78
|
-
const
|
|
225
|
+
const activeByHash = new Map(allActive.map((m) => [m.memoryHash || sha256(norm(m.detail)), m]));
|
|
79
226
|
let savedCount = 0;
|
|
80
227
|
for (const raw of decision.save) {
|
|
81
|
-
const
|
|
228
|
+
const draft = normalizeDraft(raw);
|
|
229
|
+
const detail = norm(draft.detail);
|
|
82
230
|
if (!detail) continue;
|
|
83
231
|
const hash = sha256(detail);
|
|
84
|
-
|
|
85
|
-
|
|
232
|
+
const existing = activeByHash.get(hash);
|
|
233
|
+
if (existing) {
|
|
234
|
+
const merged = await this.mergeSchema(existing, draft);
|
|
235
|
+
activeByHash.set(hash, merged);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const labels = memLabels(sessionId, draft.kind, draft.topics);
|
|
239
|
+
const date = localDate();
|
|
86
240
|
const title = `${MEMORY_TITLE_PREFIX}${trunc(detail, 72)}`;
|
|
87
|
-
const body = stringifyFlatYaml([["memory_hash", hash], ["detail", detail]]);
|
|
241
|
+
const body = stringifyFlatYaml([["memory_hash", hash], ["date", date], ["detail", detail]]);
|
|
88
242
|
await this.client.ensureLabels(labels);
|
|
89
|
-
await this.client.createIssue({ title, body, labels });
|
|
90
|
-
|
|
243
|
+
const issue = await this.client.createIssue({ title, body, labels });
|
|
244
|
+
activeByHash.set(hash, {
|
|
245
|
+
issueNumber: issue.number,
|
|
246
|
+
title,
|
|
247
|
+
memoryId: String(issue.number),
|
|
248
|
+
memoryHash: hash,
|
|
249
|
+
sessionId,
|
|
250
|
+
date,
|
|
251
|
+
detail,
|
|
252
|
+
...(draft.kind ? { kind: draft.kind } : {}),
|
|
253
|
+
...(draft.topics && draft.topics.length > 0 ? { topics: draft.topics } : {}),
|
|
254
|
+
status: "active",
|
|
255
|
+
});
|
|
91
256
|
savedCount++;
|
|
92
257
|
}
|
|
93
258
|
let staledCount = 0;
|
|
94
259
|
for (const id of [...new Set(decision.stale.map((s) => s.trim()).filter(Boolean))]) {
|
|
95
260
|
const mem = activeById.get(id);
|
|
96
261
|
if (!mem) continue;
|
|
97
|
-
await this.client.
|
|
98
|
-
await this.client.
|
|
262
|
+
await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.sessionId, mem.kind, mem.topics));
|
|
263
|
+
await this.client.updateIssue(mem.issueNumber, { state: "closed" });
|
|
99
264
|
staledCount++;
|
|
100
265
|
}
|
|
101
266
|
return { savedCount, staledCount };
|
|
@@ -103,17 +268,29 @@ export class MemoryStore {
|
|
|
103
268
|
|
|
104
269
|
private async generateDecision(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<MemoryDecision> {
|
|
105
270
|
if (snapshot.messages.length === 0) return { save: [], stale: [] };
|
|
106
|
-
const recent = (await this.
|
|
107
|
-
const existingBlock = recent.length === 0 ? "None." : recent.map((m) =>
|
|
271
|
+
const recent = (await this.listByStatus("active")).sort((a, b) => b.issueNumber - a.issueNumber).slice(0, 20);
|
|
272
|
+
const existingBlock = recent.length === 0 ? "None." : recent.map((m) => {
|
|
273
|
+
const schema = [m.kind ? `kind=${m.kind}` : "", ...(m.topics ?? []).map((topic) => `topic=${topic}`)].filter(Boolean).join(", ");
|
|
274
|
+
return `[${m.memoryId}] ${schema ? `${schema} | ` : ""}${m.detail}`;
|
|
275
|
+
}).join("\n");
|
|
276
|
+
const schema = await this.listSchema();
|
|
277
|
+
const schemaBlock = [
|
|
278
|
+
`Existing kinds: ${schema.kinds.length > 0 ? schema.kinds.join(", ") : "None."}`,
|
|
279
|
+
`Existing topics: ${schema.topics.length > 0 ? schema.topics.join(", ") : "None."}`,
|
|
280
|
+
].join("\n");
|
|
108
281
|
const subagent = this.api.runtime.subagent;
|
|
109
282
|
const sessionKey = subKey(session, "memory");
|
|
110
283
|
const message = [
|
|
111
284
|
"Extract durable memories from the conversation below.",
|
|
112
|
-
'Return JSON only in the form {"save":["..."],"stale":["memory-id"]}.',
|
|
113
|
-
"Use save for stable, reusable facts, preferences, decisions, constraints, and ongoing context worth remembering later.",
|
|
285
|
+
'Return JSON only in the form {"save":[{"detail":"...","kind":"...","topics":["..."]}],"stale":["memory-id"]}.',
|
|
286
|
+
"Use save for stable, reusable facts, preferences, decisions, constraints, workflows, and ongoing context worth remembering later.",
|
|
114
287
|
"Use stale for existing memory IDs only when the conversation clearly supersedes or invalidates them.",
|
|
288
|
+
"Infer kind and topics when they would help future retrieval. Reuse existing kinds and topics when possible.",
|
|
289
|
+
"If no existing kind fits, you may propose a new short kind label. Keep kinds concise and reusable.",
|
|
290
|
+
"Topics should be short reusable tags, not sentences. Prefer 0-3 topics per memory.",
|
|
115
291
|
"Do not save temporary requests, startup boilerplate, tool chatter, summaries about internal helper sessions, or one-off operational details.",
|
|
116
292
|
"Prefer empty arrays when nothing durable should be remembered.",
|
|
293
|
+
"", "<existing-schema>", schemaBlock, "</existing-schema>",
|
|
117
294
|
"", "<existing-active-memories>", existingBlock, "</existing-active-memories>",
|
|
118
295
|
"", "<conversation>", fmtTranscript(snapshot.messages), "</conversation>",
|
|
119
296
|
].join("\n");
|
|
@@ -121,7 +298,7 @@ export class MemoryStore {
|
|
|
121
298
|
const run = await subagent.run({
|
|
122
299
|
sessionKey, message, deliver: false, lane: "clawmem-memory",
|
|
123
300
|
idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:memory-decision`),
|
|
124
|
-
extraSystemPrompt: "You extract durable memory updates from OpenClaw conversations. Output JSON only with
|
|
301
|
+
extraSystemPrompt: "You extract durable memory updates from OpenClaw conversations. Output JSON only with save objects containing detail, optional kind, and optional topics, plus stale string ids.",
|
|
125
302
|
});
|
|
126
303
|
const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
|
|
127
304
|
if (wait.status === "timeout") throw new Error("memory decision subagent timed out");
|
|
@@ -132,18 +309,149 @@ export class MemoryStore {
|
|
|
132
309
|
return parseDecision(text);
|
|
133
310
|
} finally { subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {}); }
|
|
134
311
|
}
|
|
312
|
+
|
|
313
|
+
private async mergeSchema(memory: ParsedMemoryIssue, draft: MemoryDraft): Promise<ParsedMemoryIssue> {
|
|
314
|
+
const normalized = normalizeDraft(draft);
|
|
315
|
+
const nextKind = normalized.kind ?? memory.kind;
|
|
316
|
+
const currentTopics = uniqueNormalized(memory.topics ?? []);
|
|
317
|
+
const nextTopics = uniqueNormalized([...currentTopics, ...(normalized.topics ?? [])]);
|
|
318
|
+
const sameKind = (memory.kind ?? "") === (nextKind ?? "");
|
|
319
|
+
const sameTopics = JSON.stringify(currentTopics) === JSON.stringify(nextTopics);
|
|
320
|
+
if (sameKind && sameTopics) return memory;
|
|
321
|
+
const labels = memLabels(memory.sessionId, nextKind, nextTopics);
|
|
322
|
+
await this.client.ensureLabels(labels);
|
|
323
|
+
await this.client.syncManagedLabels(memory.issueNumber, labels);
|
|
324
|
+
return {
|
|
325
|
+
...memory,
|
|
326
|
+
...(nextKind ? { kind: nextKind } : {}),
|
|
327
|
+
...(nextTopics.length > 0 ? { topics: nextTopics } : {}),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
135
330
|
}
|
|
136
331
|
|
|
137
|
-
function memLabels(sessionId: string,
|
|
138
|
-
return [
|
|
332
|
+
function memLabels(sessionId: string, kind?: string, topics?: string[]): string[] {
|
|
333
|
+
return [
|
|
334
|
+
"type:memory",
|
|
335
|
+
`session:${sessionId}`,
|
|
336
|
+
...(kind ? [`kind:${kind}`] : []),
|
|
337
|
+
...((topics ?? []).map((topic) => `topic:${topic}`)),
|
|
338
|
+
];
|
|
139
339
|
}
|
|
140
340
|
function norm(v: string): string { return v.replace(/\s+/g, " ").trim(); }
|
|
141
341
|
function trunc(v: string, max: number): string { const s = norm(v); return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`; }
|
|
342
|
+
function normalizeSearch(v: string): string {
|
|
343
|
+
return v.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim();
|
|
344
|
+
}
|
|
345
|
+
function buildSearchIndex(memory: ParsedMemoryIssue): SearchIndex {
|
|
346
|
+
return {
|
|
347
|
+
title: normalizeSearch(memory.title),
|
|
348
|
+
detail: normalizeSearch(memory.detail),
|
|
349
|
+
...(memory.kind ? { kind: normalizeSearch(memory.kind) } : {}),
|
|
350
|
+
topics: (memory.topics ?? []).map(normalizeSearch).filter(Boolean),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function searchTokens(v: string): string[] {
|
|
354
|
+
const seen = new Set<string>();
|
|
355
|
+
for (const token of v.split(/[^0-9\p{L}]+/u)) {
|
|
356
|
+
if (token.length > 1) seen.add(token);
|
|
357
|
+
}
|
|
358
|
+
for (const chunk of v.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]{2,}/gu) ?? []) {
|
|
359
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
360
|
+
seen.add(chunk[i]!);
|
|
361
|
+
if (i + 1 < chunk.length) seen.add(chunk.slice(i, i + 2));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return [...seen];
|
|
365
|
+
}
|
|
366
|
+
function charBigrams(v: string): Set<string> {
|
|
367
|
+
const compact = v.replace(/\s+/g, "");
|
|
368
|
+
if (compact.length < 2) return new Set(compact ? [compact] : []);
|
|
369
|
+
const out = new Set<string>();
|
|
370
|
+
for (let i = 0; i < compact.length - 1; i++) out.add(compact.slice(i, i + 2));
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
function overlapRatio(left: Set<string>, right: Set<string>): number {
|
|
374
|
+
if (left.size === 0 || right.size === 0) return 0;
|
|
375
|
+
let hits = 0;
|
|
376
|
+
for (const token of left) if (right.has(token)) hits++;
|
|
377
|
+
return hits / Math.max(left.size, right.size);
|
|
378
|
+
}
|
|
379
|
+
function buildMemorySearchQuery(query: string, repo: string): string {
|
|
380
|
+
const parts = [query.trim(), `repo:${repo}`, "is:issue", "state:open", 'label:"type:memory"'].filter(Boolean);
|
|
381
|
+
return parts.join(" ");
|
|
382
|
+
}
|
|
383
|
+
export function scoreMemoryMatch(memory: ParsedMemoryIssue, rawQuery: string): number {
|
|
384
|
+
const query = normalizeSearch(rawQuery);
|
|
385
|
+
if (!query) return 0;
|
|
386
|
+
const idx = buildSearchIndex(memory);
|
|
387
|
+
const tokens = searchTokens(query);
|
|
388
|
+
const queryTokenSet = new Set(tokens);
|
|
389
|
+
const titleTokenSet = new Set(searchTokens(idx.title));
|
|
390
|
+
const detailTokenSet = new Set(searchTokens(idx.detail));
|
|
391
|
+
const kindTokenSet = new Set(searchTokens(idx.kind ?? ""));
|
|
392
|
+
const topicTokenSet = new Set(idx.topics.flatMap(searchTokens));
|
|
393
|
+
let score = 0;
|
|
394
|
+
|
|
395
|
+
if (idx.title.includes(query)) score += 18;
|
|
396
|
+
if (idx.detail.includes(query)) score += 12;
|
|
397
|
+
if (idx.kind?.includes(query)) score += 8;
|
|
398
|
+
for (const topic of idx.topics) if (topic.includes(query)) score += 10;
|
|
399
|
+
|
|
400
|
+
for (const token of tokens) {
|
|
401
|
+
if (idx.title.includes(token)) score += 4;
|
|
402
|
+
if (idx.detail.includes(token)) score += 2;
|
|
403
|
+
if (idx.kind?.includes(token)) score += 3;
|
|
404
|
+
if (idx.topics.some((topic) => topic.includes(token))) score += 3;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
score += overlapRatio(queryTokenSet, titleTokenSet) * 10;
|
|
408
|
+
score += overlapRatio(queryTokenSet, detailTokenSet) * 6;
|
|
409
|
+
score += overlapRatio(queryTokenSet, kindTokenSet) * 6;
|
|
410
|
+
score += overlapRatio(queryTokenSet, topicTokenSet) * 8;
|
|
411
|
+
|
|
412
|
+
const queryBigrams = charBigrams(query);
|
|
413
|
+
score += overlapRatio(queryBigrams, charBigrams(idx.title)) * 6;
|
|
414
|
+
score += overlapRatio(queryBigrams, charBigrams(idx.detail)) * 3;
|
|
415
|
+
|
|
416
|
+
return score;
|
|
417
|
+
}
|
|
418
|
+
function normalizeDraft(input: MemoryDraft): MemoryDraft {
|
|
419
|
+
const detail = norm(input.detail);
|
|
420
|
+
if (!detail) throw new Error("memory detail is empty");
|
|
421
|
+
const kind = normalizeLabelValue(input.kind, "kind:");
|
|
422
|
+
const topics = uniqueNormalized((input.topics ?? []).map((topic) => normalizeLabelValue(topic, "topic:")).filter(Boolean) as string[]);
|
|
423
|
+
return {
|
|
424
|
+
detail,
|
|
425
|
+
...(kind ? { kind } : {}),
|
|
426
|
+
...(topics.length > 0 ? { topics } : {}),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function normalizeLabelValue(value: string | undefined, prefix: string): string | undefined {
|
|
430
|
+
if (!value) return undefined;
|
|
431
|
+
const raw = value.trim().replace(new RegExp(`^${prefix}`, "i"), "");
|
|
432
|
+
const normalized = raw.normalize("NFKC")
|
|
433
|
+
.toLowerCase()
|
|
434
|
+
.replace(/[\s_]+/g, "-")
|
|
435
|
+
.replace(/[^\p{L}\p{N}-]+/gu, "-")
|
|
436
|
+
.replace(/-{2,}/g, "-")
|
|
437
|
+
.replace(/^-+|-+$/g, "");
|
|
438
|
+
return normalized || undefined;
|
|
439
|
+
}
|
|
440
|
+
function normalizeOptionalLabelValue(value: string | undefined, prefix: string): string | undefined {
|
|
441
|
+
try {
|
|
442
|
+
return normalizeLabelValue(value, prefix);
|
|
443
|
+
} catch {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function uniqueNormalized(values: string[]): string[] {
|
|
448
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
|
449
|
+
}
|
|
142
450
|
function parseDecision(raw: string): MemoryDecision {
|
|
143
451
|
const tryParse = (s: string): MemoryDecision | null => {
|
|
144
452
|
try {
|
|
145
453
|
const p = JSON.parse(s) as Record<string, unknown>;
|
|
146
|
-
return { save: Array.isArray(p.save) ? p.save.filter((v): v is
|
|
454
|
+
return { save: Array.isArray(p.save) ? p.save.map(parseSaveItem).filter((v): v is MemoryDraft => Boolean(v)) : [],
|
|
147
455
|
stale: Array.isArray(p.stale) ? p.stale.filter((v): v is string => typeof v === "string") : [] };
|
|
148
456
|
} catch { return null; }
|
|
149
457
|
};
|
|
@@ -155,3 +463,20 @@ function parseDecision(raw: string): MemoryDecision {
|
|
|
155
463
|
throw new Error("memory decision subagent returned invalid JSON");
|
|
156
464
|
})();
|
|
157
465
|
}
|
|
466
|
+
function parseSaveItem(value: unknown): MemoryDraft | null {
|
|
467
|
+
if (typeof value === "string") {
|
|
468
|
+
const detail = norm(value);
|
|
469
|
+
return detail ? { detail } : null;
|
|
470
|
+
}
|
|
471
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
472
|
+
const record = value as Record<string, unknown>;
|
|
473
|
+
const detail = typeof record.detail === "string" ? norm(record.detail) : "";
|
|
474
|
+
if (!detail) return null;
|
|
475
|
+
const kind = typeof record.kind === "string" ? record.kind : undefined;
|
|
476
|
+
const topics = Array.isArray(record.topics) ? record.topics.filter((v): v is string => typeof v === "string") : undefined;
|
|
477
|
+
try {
|
|
478
|
+
return normalizeDraft({ detail, ...(kind ? { kind } : {}), ...(topics ? { topics } : {}) });
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|