@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/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 { 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, 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 list(status: "active" | "stale" | "all"): Promise<ParsedMemoryIssue[]> {
161
+ private async listByStatus(status: "active" | "stale" | "all"): Promise<ParsedMemoryIssue[]> {
44
162
  const labels = ["type:memory"];
45
- if (status === "active") labels.push(LABEL_MEMORY_ACTIVE);
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: "all", page, perPage: 100 });
50
- for (const issue of batch) { const p = this.parseIssue(issue); if (p) out.push(p); }
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 parseIssue(issue: { number: number; title?: string; body?: string; labels?: Array<{ name?: string } | string> }): ParsedMemoryIssue | null {
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:"), date = labelVal(labels, "date:");
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
- if (!sessionId || !date || !detail) return null;
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, date, detail,
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: labels.includes(LABEL_MEMORY_STALE) ? "stale" : "active",
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.list("active");
223
+ const allActive = await this.listByStatus("active");
77
224
  const activeById = new Map(allActive.map((m) => [m.memoryId, m]));
78
- const existingHashes = new Set(allActive.map((m) => m.memoryHash || sha256(norm(m.detail))));
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 detail = norm(raw);
228
+ const draft = normalizeDraft(raw);
229
+ const detail = norm(draft.detail);
82
230
  if (!detail) continue;
83
231
  const hash = sha256(detail);
84
- if (existingHashes.has(hash)) continue;
85
- const date = localDate(), labels = memLabels(sessionId, date, "active");
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
- existingHashes.add(hash);
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.ensureLabels([LABEL_MEMORY_STALE]);
98
- await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.sessionId, mem.date, "stale"));
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.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");
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 string arrays save and stale.",
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, date: string, status: "active" | "stale"): string[] {
138
- return ["type:memory", `session:${sessionId}`, `date:${date}`, status === "active" ? LABEL_MEMORY_ACTIVE : LABEL_MEMORY_STALE];
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 string => typeof v === "string") : [],
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
+ }