@clawmem-ai/clawmem 0.1.16 → 0.1.17

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,18 +1,12 @@
1
- // Memory CRUD, sha256 dedup, and AI-driven memory extraction.
2
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
1
+ // Memory CRUD, recall search helpers, and candidate parsing.
3
2
  import { LABEL_MEMORY_STALE, MEMORY_TITLE_PREFIX, extractLabelNames, labelVal } from "./config.js";
4
3
  import type { GitHubIssueClient } from "./github-client.js";
5
- import { normalizeMessages } from "./transcript.js";
6
- import type { ClawMemPluginConfig, MemoryCandidate, MemoryDraft, MemoryListOptions, MemorySchema, ParsedMemoryIssue, SessionMirrorState, TranscriptSnapshot } from "./types.js";
7
- import { fmtTranscript, fmtTranscriptFrom, localDate, sha256, sliceTranscriptDelta, subKey } from "./utils.js";
4
+ import type { MemoryCandidate, MemoryDraft, MemoryListOptions, MemorySchema, ParsedMemoryIssue } from "./types.js";
5
+ import { localDate, sha256 } from "./utils.js";
8
6
  import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
9
7
  import { sanitizeRecallQueryInput } from "./recall-sanitize.js";
10
8
 
11
- type MemoryDecision = { save: MemoryDraft[]; stale: string[] };
12
- type SearchIndex = { title: string; detail: string; kind?: string; topics: string[] };
13
-
14
9
  const MAX_BACKEND_QUERY_CHARS = 1500;
15
- const MEMORY_RECONCILE_RECALL_LIMIT = 5;
16
10
 
17
11
  const RECALL_INJECTED_BLOCKS = [
18
12
  /<clawmem-context>[\s\S]*?<\/clawmem-context>/gi,
@@ -23,7 +17,7 @@ const RECALL_INJECTED_BLOCKS = [
23
17
  const URL_RE = /https?:\/\/\S+/gi;
24
18
 
25
19
  export class MemoryStore {
26
- constructor(private readonly client: GitHubIssueClient, private readonly api: OpenClawPluginApi, private readonly config: ClawMemPluginConfig) {}
20
+ constructor(private readonly client: GitHubIssueClient) {}
27
21
 
28
22
  async search(query: string, limit: number): Promise<ParsedMemoryIssue[]> {
29
23
  const q = normalizeSearch(query);
@@ -49,17 +43,13 @@ export class MemoryStore {
49
43
  }
50
44
  if (batch.length < 100) break;
51
45
  }
52
- for (const memory of await this.listByStatus("all")) {
53
- if (memory.kind) kinds.add(memory.kind);
54
- for (const topic of memory.topics ?? []) topics.add(topic);
55
- }
56
46
  return { kinds: [...kinds].sort(), topics: [...topics].sort() };
57
47
  }
58
48
 
59
49
  async get(memoryId: string, status: "active" | "stale" | "all" = "all"): Promise<ParsedMemoryIssue | null> {
60
50
  const id = memoryId.trim();
61
51
  if (!id) throw new Error("memoryId is empty");
62
- return (await this.listByStatus(status)).find((m) => m.memoryId === id || String(m.issueNumber) === id) ?? null;
52
+ return this.findByRef(id, status);
63
53
  }
64
54
 
65
55
  async listMemories(options: MemoryListOptions = {}): Promise<ParsedMemoryIssue[]> {
@@ -67,22 +57,28 @@ export class MemoryStore {
67
57
  const kind = normalizeOptionalLabelValue(options.kind, "kind:");
68
58
  const topic = normalizeOptionalLabelValue(options.topic, "topic:");
69
59
  const limit = Math.min(200, Math.max(1, options.limit ?? 20));
70
- return (await this.listByStatus(status))
71
- .filter((memory) => {
72
- if (kind && memory.kind !== kind) return false;
73
- if (topic && !(memory.topics ?? []).includes(topic)) return false;
74
- return true;
75
- })
76
- .sort((a, b) => b.issueNumber - a.issueNumber)
77
- .slice(0, limit);
60
+ const labels = ["type:memory", ...(kind ? [`kind:${kind}`] : []), ...(topic ? [`topic:${topic}`] : [])];
61
+ const state = status === "active" ? "open" : "all";
62
+ const out: ParsedMemoryIssue[] = [];
63
+ for (let page = 1; page <= 20 && out.length < limit; page++) {
64
+ const batch = await this.client.listIssues({ labels, state, page, perPage: Math.min(100, limit) });
65
+ for (const issue of batch) {
66
+ const memory = this.parseIssue(issue);
67
+ if (!memory) continue;
68
+ if (status !== "all" && memory.status !== status) continue;
69
+ out.push(memory);
70
+ if (out.length >= limit) break;
71
+ }
72
+ if (batch.length < Math.min(100, limit)) break;
73
+ }
74
+ return out.sort((a, b) => b.issueNumber - a.issueNumber).slice(0, limit);
78
75
  }
79
76
 
80
77
  async store(draft: MemoryDraft): Promise<{ created: boolean; memory: ParsedMemoryIssue }> {
81
78
  const normalized = normalizeDraft(draft);
82
79
  const detail = norm(normalized.detail);
83
- const allActive = await this.listByStatus("active");
84
80
  const hash = sha256(detail);
85
- const existing = allActive.find((m) => (m.memoryHash || sha256(norm(m.detail))) === hash);
81
+ const existing = await this.findActiveByHash(hash);
86
82
  if (existing) {
87
83
  const memory = await this.mergeSchema(existing, normalized);
88
84
  return { created: false, memory };
@@ -124,11 +120,12 @@ export class MemoryStore {
124
120
  ? uniqueNormalized(patch.topics.map((topic) => normalizeLabelValue(topic, "topic:")).filter(Boolean) as string[])
125
121
  : uniqueNormalized(current.topics ?? []);
126
122
  const nextHash = sha256(nextDetail);
127
- const duplicate = (await this.listByStatus("active")).find((memory) => {
128
- if (memory.issueNumber === current.issueNumber) return false;
129
- return (memory.memoryHash || sha256(norm(memory.detail))) === nextHash;
130
- });
131
- if (duplicate) throw new Error(`another active memory already stores this detail as [${duplicate.memoryId}]`);
123
+ const duplicate = await this.findActiveByHash(nextHash);
124
+ if (duplicate?.issueNumber === current.issueNumber) {
125
+ // Updating schema/title without changing the underlying detail is always safe.
126
+ } else if (duplicate) {
127
+ throw new Error(`another active memory already stores this detail as [${duplicate.memoryId}]`);
128
+ }
132
129
  const nextBody = renderMemoryBody(nextDetail, nextHash, current.date);
133
130
  const nextLabels = memLabels(nextKind, nextTopics);
134
131
  await this.client.ensureLabels(nextLabels);
@@ -154,158 +151,6 @@ export class MemoryStore {
154
151
  return { ...mem, status: "stale" };
155
152
  }
156
153
 
157
- async syncFromConversation(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<boolean> {
158
- try {
159
- const decision = await this.generateDecision(session, snapshot);
160
- const { savedCount, staledCount } = await this.applyDecision(decision);
161
- if (savedCount > 0 || staledCount > 0)
162
- this.api.logger.info?.(`clawmem: synced memories for ${session.sessionId} (saved=${savedCount}, stale=${staledCount})`);
163
- return true;
164
- } catch (error) {
165
- this.api.logger.warn(`clawmem: memory capture failed: ${String(error)}`);
166
- return false;
167
- }
168
- }
169
-
170
- async applyReconciledDecision(decision: { save: MemoryDraft[]; stale: string[] }): Promise<{ savedCount: number; staledCount: number }> {
171
- return this.applyDecision(decision);
172
- }
173
-
174
- async extractCandidates(
175
- session: SessionMirrorState,
176
- snapshot: TranscriptSnapshot,
177
- fromCursor: number,
178
- digestText?: string,
179
- ): Promise<MemoryCandidate[]> {
180
- const { anchorStart, deltaStart, anchorMessages, deltaMessages } = sliceTranscriptDelta(snapshot.messages, fromCursor, 2);
181
- if (deltaMessages.length === 0) return [];
182
- const subagent = this.api.runtime.subagent;
183
- const sessionKey = subKey(session, "memory-extract");
184
- const message = [
185
- "Extract atomic durable memory candidates from the conversation delta below.",
186
- 'Return JSON only in the form {"candidates":[{"title":"...","detail":"...","kind":"...","topics":["..."],"evidence":"..."}]}.',
187
- "Only extract durable facts, preferences, decisions, constraints, workflows, and ongoing context worth remembering later.",
188
- "Use the anchor messages and rolling digest only for context resolution. The new messages are the only source that may add new candidates now.",
189
- "Each candidate must represent one durable fact. Split independent facts into separate candidates.",
190
- "Do not extract temporary requests, tool chatter, startup boilerplate, or summaries about internal helper sessions.",
191
- "Kind and topics are optional. Keep them short, reusable, and low-cardinality.",
192
- "Evidence is optional. If present, keep it short and quote-free.",
193
- "Prefer an empty candidates array when nothing durable was added.",
194
- "",
195
- "<rolling-digest>",
196
- digestText?.trim() || "None.",
197
- "</rolling-digest>",
198
- "",
199
- "<anchor-messages>",
200
- anchorMessages.length > 0 ? fmtTranscriptFrom(anchorMessages, anchorStart) : "None.",
201
- "</anchor-messages>",
202
- "",
203
- "<new-messages>",
204
- fmtTranscriptFrom(deltaMessages, deltaStart),
205
- "</new-messages>",
206
- ].join("\n");
207
- try {
208
- const run = await subagent.run({
209
- sessionKey,
210
- message,
211
- deliver: false,
212
- lane: "clawmem-memory-extract",
213
- idempotencyKey: sha256(`${session.sessionId}:${fromCursor}:${snapshot.messages.length}:memory-extract-v1`),
214
- extraSystemPrompt: "You extract atomic durable memory candidates for ClawMem. Output JSON only with an array field candidates.",
215
- });
216
- const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.memoryExtractWaitTimeoutMs });
217
- if (wait.status === "timeout") throw new Error("memory extraction subagent timed out");
218
- if (wait.status === "error") throw new Error(wait.error || "memory extraction subagent failed");
219
- const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
220
- const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
221
- if (!text) throw new Error("memory extraction subagent returned no assistant text");
222
- return parseCandidates(text);
223
- } finally {
224
- subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
225
- }
226
- }
227
-
228
- async reconcileCandidates(session: SessionMirrorState, candidates: MemoryCandidate[]): Promise<MemoryDecision> {
229
- const pending = mergeMemoryCandidates([], candidates);
230
- if (pending.length === 0) return { save: [], stale: [] };
231
- const existingByCandidate = await Promise.all(pending.map(async (candidate) => ({
232
- candidate,
233
- matches: await this.searchViaBackend(candidate.detail, MEMORY_RECONCILE_RECALL_LIMIT),
234
- })));
235
- const candidateBlock = pending.map((candidate) => [
236
- `[${candidate.candidateId}] ${candidate.title ? `${candidate.title} | ` : ""}${candidate.detail}`,
237
- ...(candidate.kind ? [`kind=${candidate.kind}`] : []),
238
- ...(candidate.topics && candidate.topics.length > 0 ? [`topics=${candidate.topics.join(", ")}`] : []),
239
- ...(candidate.evidence ? [`evidence=${candidate.evidence}`] : []),
240
- ].join("\n")).join("\n\n");
241
- const existingBlock = existingByCandidate.map(({ candidate, matches }) => {
242
- const lines = matches.length > 0
243
- ? matches.map((memory) => {
244
- const schema = [memory.kind ? `kind=${memory.kind}` : "", ...(memory.topics ?? []).map((topic) => `topic=${topic}`)]
245
- .filter(Boolean)
246
- .join(", ");
247
- return `- [${memory.memoryId}] ${schema ? `${schema} | ` : ""}${memory.detail}`;
248
- })
249
- : ["- None."];
250
- return [`Candidate [${candidate.candidateId}] matches:`, ...lines].join("\n");
251
- }).join("\n\n");
252
- const subagent = this.api.runtime.subagent;
253
- const sessionKey = subKey(session, "memory-reconcile");
254
- const message = [
255
- "Reconcile extracted durable memory candidates against existing memories.",
256
- 'Return JSON only in the form {"save":[{"title":"...","detail":"...","kind":"...","topics":["..."]}],"stale":["memory-id"]}.',
257
- "Use save only for candidates that should become durable memories after comparing them with existing memories.",
258
- "If a candidate is already fully covered by an existing memory, omit it from save.",
259
- "Use stale only when a candidate clearly supersedes or invalidates an existing memory.",
260
- "Do not stale memories just because they overlap or are related. Prefer keeping both when they can coexist.",
261
- "Keep each save item atomic and durable.",
262
- "",
263
- "<candidates>",
264
- candidateBlock,
265
- "</candidates>",
266
- "",
267
- "<matching-existing-memories>",
268
- existingBlock,
269
- "</matching-existing-memories>",
270
- ].join("\n");
271
- try {
272
- const run = await subagent.run({
273
- sessionKey,
274
- message,
275
- deliver: false,
276
- lane: "clawmem-memory-reconcile",
277
- idempotencyKey: sha256(`${session.sessionId}:${pending.map((candidate) => candidate.candidateId).join(",")}:memory-reconcile-v1`),
278
- extraSystemPrompt: "You reconcile extracted durable memory candidates for ClawMem. Output JSON only with save memory drafts and stale memory ids.",
279
- });
280
- const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.memoryReconcileWaitTimeoutMs });
281
- if (wait.status === "timeout") throw new Error("memory reconcile subagent timed out");
282
- if (wait.status === "error") throw new Error(wait.error || "memory reconcile subagent failed");
283
- const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
284
- const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
285
- if (!text) throw new Error("memory reconcile subagent returned no assistant text");
286
- return parseDecision(text);
287
- } finally {
288
- subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
289
- }
290
- }
291
-
292
- private async listByStatus(status: "active" | "stale" | "all"): Promise<ParsedMemoryIssue[]> {
293
- const labels = ["type:memory"];
294
- const state = status === "active" ? "open" : "all";
295
- const out: ParsedMemoryIssue[] = [];
296
- for (let page = 1; page <= 20; page++) {
297
- const batch = await this.client.listIssues({ labels, state, page, perPage: 100 });
298
- for (const issue of batch) {
299
- const parsed = this.parseIssue(issue);
300
- if (!parsed) continue;
301
- if (status !== "all" && parsed.status !== status) continue;
302
- out.push(parsed);
303
- }
304
- if (batch.length < 100) break;
305
- }
306
- return out;
307
- }
308
-
309
154
  private async searchViaBackend(query: string, limit: number): Promise<ParsedMemoryIssue[]> {
310
155
  const repo = this.client.repo();
311
156
  if (!repo) throw new Error("ClawMem memory recall requires a configured repo.");
@@ -317,6 +162,46 @@ export class MemoryStore {
317
162
  .slice(0, limit);
318
163
  }
319
164
 
165
+ private async findActiveByHash(hash: string): Promise<ParsedMemoryIssue | null> {
166
+ const repo = this.client.repo?.();
167
+ if (!repo) return null;
168
+ const query = buildMemoryHashSearchQuery(hash, repo);
169
+ const batch = await this.client.searchIssues(query, { perPage: 10 });
170
+ return batch
171
+ .map((issue) => this.parseIssue(issue))
172
+ .find((memory): memory is ParsedMemoryIssue =>
173
+ memory !== null && memory.status === "active" && (memory.memoryHash || sha256(norm(memory.detail))) === hash,
174
+ ) ?? null;
175
+ }
176
+
177
+ private async findByRef(id: string, status: "active" | "stale" | "all"): Promise<ParsedMemoryIssue | null> {
178
+ const trimmed = id.trim();
179
+ if (!trimmed) return null;
180
+ if (/^\d+$/.test(trimmed)) {
181
+ try {
182
+ const issue = await this.client.getIssue(Number(trimmed));
183
+ const parsed = this.parseIssue(issue);
184
+ if (!parsed) return null;
185
+ if (status !== "all" && parsed.status !== status) return null;
186
+ return parsed;
187
+ } catch {
188
+ // Fall through to memory-id search for nonstandard repos that expose custom memory ids.
189
+ }
190
+ }
191
+ const repo = this.client.repo?.();
192
+ if (!repo) return null;
193
+ const batch = await this.client.searchIssues(buildMemoryRefSearchQuery(trimmed, repo, status), { perPage: 10 });
194
+ return batch
195
+ .map((issue) => this.parseIssue(issue))
196
+ .find((memory): memory is ParsedMemoryIssue =>
197
+ memory !== null && (status === "all" || memory.status === status) && (memory.memoryId === trimmed || String(memory.issueNumber) === trimmed),
198
+ ) ?? null;
199
+ }
200
+
201
+ private async findActiveByRef(id: string): Promise<ParsedMemoryIssue | null> {
202
+ return this.findByRef(id, "active");
203
+ }
204
+
320
205
  private parseIssue(issue: { number: number; title?: string; body?: string; state?: string; labels?: Array<{ name?: string } | string> }): ParsedMemoryIssue | null {
321
206
  const labels = extractLabelNames(issue.labels);
322
207
  if (!labels.includes("type:memory")) return null;
@@ -340,103 +225,6 @@ export class MemoryStore {
340
225
  };
341
226
  }
342
227
 
343
- private async applyDecision(decision: MemoryDecision): Promise<{ savedCount: number; staledCount: number }> {
344
- const allActive = await this.listByStatus("active");
345
- const activeById = new Map(allActive.map((m) => [m.memoryId, m]));
346
- const activeByHash = new Map(allActive.map((m) => [m.memoryHash || sha256(norm(m.detail)), m]));
347
- let savedCount = 0;
348
- for (const raw of decision.save) {
349
- const draft = normalizeDraft(raw);
350
- const detail = norm(draft.detail);
351
- if (!detail) continue;
352
- const hash = sha256(detail);
353
- const existing = activeByHash.get(hash);
354
- if (existing) {
355
- const merged = await this.mergeSchema(existing, draft);
356
- activeByHash.set(hash, merged);
357
- continue;
358
- }
359
- const labels = memLabels(draft.kind, draft.topics);
360
- const date = localDate();
361
- const title = renderMemoryTitle(draft);
362
- const body = renderMemoryBody(detail, hash, date);
363
- await this.client.ensureLabels(labels);
364
- const issue = await this.client.createIssue({ title, body, labels });
365
- activeByHash.set(hash, {
366
- issueNumber: issue.number,
367
- title,
368
- memoryId: String(issue.number),
369
- memoryHash: hash,
370
- date,
371
- detail,
372
- ...(draft.kind ? { kind: draft.kind } : {}),
373
- ...(draft.topics && draft.topics.length > 0 ? { topics: draft.topics } : {}),
374
- status: "active",
375
- });
376
- savedCount++;
377
- }
378
- let staledCount = 0;
379
- for (const id of [...new Set(decision.stale.map((s) => s.trim()).filter(Boolean))]) {
380
- const mem = activeById.get(id);
381
- if (!mem) continue;
382
- await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.kind, mem.topics));
383
- await this.client.updateIssue(mem.issueNumber, { state: "closed" });
384
- staledCount++;
385
- }
386
- return { savedCount, staledCount };
387
- }
388
-
389
- private async generateDecision(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<MemoryDecision> {
390
- if (snapshot.messages.length === 0) return { save: [], stale: [] };
391
- const recent = (await this.listByStatus("active")).sort((a, b) => b.issueNumber - a.issueNumber).slice(0, 20);
392
- const existingBlock = recent.length === 0 ? "None." : recent.map((m) => {
393
- const schema = [m.kind ? `kind=${m.kind}` : "", ...(m.topics ?? []).map((topic) => `topic=${topic}`)].filter(Boolean).join(", ");
394
- return `[${m.memoryId}] ${schema ? `${schema} | ` : ""}${m.detail}`;
395
- }).join("\n");
396
- const schema = await this.listSchema();
397
- const schemaBlock = [
398
- `Existing kinds: ${schema.kinds.length > 0 ? schema.kinds.join(", ") : "None."}`,
399
- `Existing topics: ${schema.topics.length > 0 ? schema.topics.join(", ") : "None."}`,
400
- ].join("\n");
401
- const subagent = this.api.runtime.subagent;
402
- const sessionKey = subKey(session, "memory");
403
- const message = [
404
- "Extract durable memories from the conversation below.",
405
- 'Return JSON only in the form {"save":[{"title":"...","detail":"...","kind":"...","topics":["..."]}],"stale":["memory-id"]}.',
406
- "Each save item must contain one durable fact. If a turn contains several independent facts, save them separately instead of bundling them into one summary memory.",
407
- "Use save for stable, reusable facts, preferences, decisions, constraints, workflows, and ongoing context worth remembering later.",
408
- "Title is optional. If you provide one, make it concise and human-readable.",
409
- "Use stale for existing memory IDs only when the conversation clearly supersedes or invalidates them.",
410
- "Infer kind and topics when they would help future retrieval. Reuse existing kinds and topics when possible.",
411
- "If no existing kind fits, you may propose a new short kind label. Keep kinds concise and reusable.",
412
- "Topics should be short reusable tags, not sentences. Prefer 0-3 topics per memory.",
413
- "Do not save temporary requests, startup boilerplate, tool chatter, summaries about internal helper sessions, or one-off operational details.",
414
- "Prefer empty arrays when nothing durable should be remembered.",
415
- "", "<existing-schema>", schemaBlock, "</existing-schema>",
416
- "", "<existing-active-memories>", existingBlock, "</existing-active-memories>",
417
- "", "<conversation>", fmtTranscript(snapshot.messages), "</conversation>",
418
- ].join("\n");
419
- try {
420
- const run = await subagent.run({
421
- sessionKey,
422
- message,
423
- deliver: false,
424
- lane: "clawmem-memory",
425
- idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:memory-decision`),
426
- extraSystemPrompt: "You extract durable memory updates from OpenClaw conversations. Output JSON only with save objects containing detail, optional title, optional kind, and optional topics, plus stale string ids. Keep each save item to one durable fact.",
427
- });
428
- const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
429
- if (wait.status === "timeout") throw new Error("memory decision subagent timed out");
430
- if (wait.status === "error") throw new Error(wait.error || "memory decision subagent failed");
431
- const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
432
- const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
433
- if (!text) throw new Error("memory decision subagent returned no assistant text");
434
- return parseDecision(text);
435
- } finally {
436
- subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
437
- }
438
- }
439
-
440
228
  private async mergeSchema(memory: ParsedMemoryIssue, draft: MemoryDraft): Promise<ParsedMemoryIssue> {
441
229
  const normalized = normalizeDraft(draft);
442
230
  const nextKind = normalized.kind ?? memory.kind;
@@ -499,46 +287,23 @@ function normalizeSearch(v: string): string {
499
287
  return v.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim();
500
288
  }
501
289
 
502
- function buildSearchIndex(memory: ParsedMemoryIssue): SearchIndex {
503
- return {
504
- title: normalizeSearch(memory.title),
505
- detail: normalizeSearch(memory.detail),
506
- ...(memory.kind ? { kind: normalizeSearch(memory.kind) } : {}),
507
- topics: (memory.topics ?? []).map(normalizeSearch).filter(Boolean),
508
- };
509
- }
510
-
511
- function searchTokens(v: string): string[] {
512
- const seen = new Set<string>();
513
- for (const token of v.split(/[^0-9\p{L}]+/u)) {
514
- if (token.length > 1) seen.add(token);
515
- }
516
- for (const chunk of v.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]{2,}/gu) ?? []) {
517
- for (let i = 0; i < chunk.length; i++) {
518
- seen.add(chunk[i]!);
519
- if (i + 1 < chunk.length) seen.add(chunk.slice(i, i + 2));
520
- }
521
- }
522
- return [...seen];
523
- }
524
-
525
- function charBigrams(v: string): Set<string> {
526
- const compact = v.replace(/\s+/g, "");
527
- if (compact.length < 2) return new Set(compact ? [compact] : []);
528
- const out = new Set<string>();
529
- for (let i = 0; i < compact.length - 1; i++) out.add(compact.slice(i, i + 2));
530
- return out;
290
+ function buildMemorySearchQuery(query: string, repo: string): string {
291
+ const parts = [buildRecallSearchText(query), `repo:${repo}`, "is:issue", "state:open", 'label:"type:memory"'].filter(Boolean);
292
+ return parts.join(" ");
531
293
  }
532
294
 
533
- function overlapRatio(left: Set<string>, right: Set<string>): number {
534
- if (left.size === 0 || right.size === 0) return 0;
535
- let hits = 0;
536
- for (const token of left) if (right.has(token)) hits++;
537
- return hits / Math.max(left.size, right.size);
295
+ function buildMemoryHashSearchQuery(hash: string, repo: string): string {
296
+ const needle = hash.trim();
297
+ if (!needle) return "";
298
+ return [`"${needle}"`, `repo:${repo}`, "is:issue", "state:open", 'label:"type:memory"'].join(" ");
538
299
  }
539
300
 
540
- function buildMemorySearchQuery(query: string, repo: string): string {
541
- const parts = [buildRecallSearchText(query), `repo:${repo}`, "is:issue", "state:open", 'label:"type:memory"'].filter(Boolean);
301
+ function buildMemoryRefSearchQuery(memoryId: string, repo: string, status: "active" | "stale" | "all"): string {
302
+ const needle = memoryId.trim();
303
+ if (!needle) return "";
304
+ const parts = [`"${needle}"`, `repo:${repo}`, "is:issue", 'label:"type:memory"'];
305
+ if (status === "active") parts.push("state:open");
306
+ if (status === "stale") parts.push("state:closed");
542
307
  return parts.join(" ");
543
308
  }
544
309
 
@@ -559,42 +324,6 @@ function truncateRecallQuery(text: string, maxLen: number): string {
559
324
  return compact.length <= maxLen ? compact : compact.slice(0, maxLen).trimEnd();
560
325
  }
561
326
 
562
- export function scoreMemoryMatch(memory: ParsedMemoryIssue, rawQuery: string): number {
563
- const query = normalizeSearch(rawQuery);
564
- if (!query) return 0;
565
- const idx = buildSearchIndex(memory);
566
- const tokens = searchTokens(query);
567
- const queryTokenSet = new Set(tokens);
568
- const titleTokenSet = new Set(searchTokens(idx.title));
569
- const detailTokenSet = new Set(searchTokens(idx.detail));
570
- const kindTokenSet = new Set(searchTokens(idx.kind ?? ""));
571
- const topicTokenSet = new Set(idx.topics.flatMap(searchTokens));
572
- let score = 0;
573
-
574
- if (idx.title.includes(query)) score += 18;
575
- if (idx.detail.includes(query)) score += 12;
576
- if (idx.kind?.includes(query)) score += 8;
577
- for (const topic of idx.topics) if (topic.includes(query)) score += 10;
578
-
579
- for (const token of tokens) {
580
- if (idx.title.includes(token)) score += 4;
581
- if (idx.detail.includes(token)) score += 2;
582
- if (idx.kind?.includes(token)) score += 3;
583
- if (idx.topics.some((topic) => topic.includes(token))) score += 3;
584
- }
585
-
586
- score += overlapRatio(queryTokenSet, titleTokenSet) * 10;
587
- score += overlapRatio(queryTokenSet, detailTokenSet) * 6;
588
- score += overlapRatio(queryTokenSet, kindTokenSet) * 6;
589
- score += overlapRatio(queryTokenSet, topicTokenSet) * 8;
590
-
591
- const queryBigrams = charBigrams(query);
592
- score += overlapRatio(queryBigrams, charBigrams(idx.title)) * 6;
593
- score += overlapRatio(queryBigrams, charBigrams(idx.detail)) * 3;
594
-
595
- return score;
596
- }
597
-
598
327
  function normalizeDraft(input: MemoryDraft): MemoryDraft {
599
328
  const detail = norm(input.detail);
600
329
  if (!detail) throw new Error("memory detail is empty");
@@ -633,27 +362,6 @@ function uniqueNormalized(values: string[]): string[] {
633
362
  return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
634
363
  }
635
364
 
636
- function parseDecision(raw: string): MemoryDecision {
637
- const tryParse = (s: string): MemoryDecision | null => {
638
- try {
639
- const p = JSON.parse(s) as Record<string, unknown>;
640
- return {
641
- save: Array.isArray(p.save) ? p.save.map(parseSaveItem).filter((v): v is MemoryDraft => Boolean(v)) : [],
642
- stale: Array.isArray(p.stale) ? p.stale.filter((v): v is string => typeof v === "string") : [],
643
- };
644
- } catch {
645
- return null;
646
- }
647
- };
648
- const t = raw.trim();
649
- return tryParse(t) ?? (() => {
650
- const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
651
- const nested = f?.[1] ? tryParse(f[1].trim()) : null;
652
- if (nested) return nested;
653
- throw new Error("memory decision subagent returned invalid JSON");
654
- })();
655
- }
656
-
657
365
  export function parseCandidates(raw: string): MemoryCandidate[] {
658
366
  const tryParse = (s: string): MemoryCandidate[] | null => {
659
367
  try {
@@ -674,26 +382,7 @@ export function parseCandidates(raw: string): MemoryCandidate[] {
674
382
  const nested = tryParse(fenced[1].trim());
675
383
  if (nested) return nested;
676
384
  }
677
- throw new Error("memory extraction subagent returned invalid JSON");
678
- }
679
-
680
- function parseSaveItem(value: unknown): MemoryDraft | null {
681
- if (typeof value === "string") {
682
- const detail = norm(value);
683
- return detail ? { detail } : null;
684
- }
685
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
686
- const record = value as Record<string, unknown>;
687
- const title = typeof record.title === "string" ? record.title : undefined;
688
- const detail = typeof record.detail === "string" ? norm(record.detail) : "";
689
- if (!detail) return null;
690
- const kind = typeof record.kind === "string" ? record.kind : undefined;
691
- const topics = Array.isArray(record.topics) ? record.topics.filter((v): v is string => typeof v === "string") : undefined;
692
- try {
693
- return normalizeDraft({ ...(title ? { title } : {}), detail, ...(kind ? { kind } : {}), ...(topics ? { topics } : {}) });
694
- } catch {
695
- return null;
696
- }
385
+ throw new Error("finalize memory candidates returned invalid JSON");
697
386
  }
698
387
 
699
388
  function parseCandidateItem(value: unknown): MemoryCandidate | null {