@clawmem-ai/clawmem 0.1.8 → 0.1.10

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