@clawmem-ai/clawmem 0.1.15 → 0.1.16

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
@@ -3,8 +3,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
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, MemoryDraft, MemoryListOptions, MemorySchema, ParsedMemoryIssue, SessionMirrorState, TranscriptSnapshot } from "./types.js";
7
- import { fmtTranscript, localDate, sha256, subKey } from "./utils.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";
8
8
  import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
9
9
  import { sanitizeRecallQueryInput } from "./recall-sanitize.js";
10
10
 
@@ -12,6 +12,7 @@ type MemoryDecision = { save: MemoryDraft[]; stale: string[] };
12
12
  type SearchIndex = { title: string; detail: string; kind?: string; topics: string[] };
13
13
 
14
14
  const MAX_BACKEND_QUERY_CHARS = 1500;
15
+ const MEMORY_RECONCILE_RECALL_LIMIT = 5;
15
16
 
16
17
  const RECALL_INJECTED_BLOCKS = [
17
18
  /<clawmem-context>[\s\S]*?<\/clawmem-context>/gi,
@@ -166,6 +167,128 @@ export class MemoryStore {
166
167
  }
167
168
  }
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
+
169
292
  private async listByStatus(status: "active" | "stale" | "all"): Promise<ParsedMemoryIssue[]> {
170
293
  const labels = ["type:memory"];
171
294
  const state = status === "active" ? "open" : "all";
@@ -531,6 +654,29 @@ function parseDecision(raw: string): MemoryDecision {
531
654
  })();
532
655
  }
533
656
 
657
+ export function parseCandidates(raw: string): MemoryCandidate[] {
658
+ const tryParse = (s: string): MemoryCandidate[] | null => {
659
+ try {
660
+ const payload = JSON.parse(s) as Record<string, unknown>;
661
+ const candidates = Array.isArray(payload.candidates)
662
+ ? payload.candidates.map(parseCandidateItem).filter((candidate): candidate is MemoryCandidate => Boolean(candidate))
663
+ : [];
664
+ return mergeMemoryCandidates([], candidates);
665
+ } catch {
666
+ return null;
667
+ }
668
+ };
669
+ const trimmed = raw.trim();
670
+ const direct = tryParse(trimmed);
671
+ if (direct) return direct;
672
+ const fenced = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(trimmed);
673
+ if (fenced?.[1]) {
674
+ const nested = tryParse(fenced[1].trim());
675
+ if (nested) return nested;
676
+ }
677
+ throw new Error("memory extraction subagent returned invalid JSON");
678
+ }
679
+
534
680
  function parseSaveItem(value: unknown): MemoryDraft | null {
535
681
  if (typeof value === "string") {
536
682
  const detail = norm(value);
@@ -549,3 +695,61 @@ function parseSaveItem(value: unknown): MemoryDraft | null {
549
695
  return null;
550
696
  }
551
697
  }
698
+
699
+ function parseCandidateItem(value: unknown): MemoryCandidate | null {
700
+ if (typeof value === "string") {
701
+ const detail = norm(value);
702
+ return detail ? { candidateId: sha256(detail), detail } : null;
703
+ }
704
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
705
+ const record = value as Record<string, unknown>;
706
+ const detail = typeof record.detail === "string" ? norm(record.detail) : "";
707
+ if (!detail) return null;
708
+ const title = typeof record.title === "string" ? record.title : undefined;
709
+ const kind = typeof record.kind === "string" ? record.kind : undefined;
710
+ const topics = Array.isArray(record.topics) ? record.topics.filter((topic): topic is string => typeof topic === "string") : undefined;
711
+ const evidence = typeof record.evidence === "string" ? norm(record.evidence) : undefined;
712
+ try {
713
+ const draft = normalizeDraft({
714
+ ...(title ? { title } : {}),
715
+ detail,
716
+ ...(kind ? { kind } : {}),
717
+ ...(topics ? { topics } : {}),
718
+ });
719
+ return {
720
+ candidateId: sha256(draft.detail),
721
+ detail: draft.detail,
722
+ ...(draft.title ? { title: draft.title } : {}),
723
+ ...(draft.kind ? { kind: draft.kind } : {}),
724
+ ...(draft.topics ? { topics: draft.topics } : {}),
725
+ ...(evidence ? { evidence } : {}),
726
+ };
727
+ } catch {
728
+ return null;
729
+ }
730
+ }
731
+
732
+ export function mergeMemoryCandidates(base: MemoryCandidate[], next: MemoryCandidate[]): MemoryCandidate[] {
733
+ const out = new Map<string, MemoryCandidate>();
734
+ for (const candidate of [...base, ...next]) {
735
+ const existing = out.get(candidate.candidateId);
736
+ if (!existing) {
737
+ out.set(candidate.candidateId, {
738
+ ...candidate,
739
+ ...(candidate.topics ? { topics: uniqueNormalized(candidate.topics) } : {}),
740
+ });
741
+ continue;
742
+ }
743
+ out.set(candidate.candidateId, {
744
+ candidateId: candidate.candidateId,
745
+ detail: candidate.detail || existing.detail,
746
+ ...(candidate.title || existing.title ? { title: candidate.title || existing.title } : {}),
747
+ ...(candidate.kind || existing.kind ? { kind: candidate.kind || existing.kind } : {}),
748
+ ...((candidate.topics || existing.topics)
749
+ ? { topics: uniqueNormalized([...(existing.topics ?? []), ...(candidate.topics ?? [])]) }
750
+ : {}),
751
+ ...(candidate.evidence || existing.evidence ? { evidence: candidate.evidence || existing.evidence } : {}),
752
+ });
753
+ }
754
+ return [...out.values()];
755
+ }
@@ -0,0 +1,12 @@
1
+ export function getOpenClawAgentIdFromEnv(): string | undefined {
2
+ const value = process.env.OPENCLAW_AGENT_ID;
3
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
4
+ }
5
+
6
+ export function getOpenClawHostVersionFromEnv(): string | undefined {
7
+ for (const candidate of [process.env.OPENCLAW_VERSION, process.env.OPENCLAW_SERVICE_VERSION]) {
8
+ const trimmed = candidate?.trim();
9
+ if (trimmed) return trimmed;
10
+ }
11
+ return undefined;
12
+ }