@clawmem-ai/clawmem 0.1.13 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/clawmem/SKILL.md +4 -0
- package/skills/clawmem/references/manual-ops.md +1 -0
- package/skills/clawmem/references/schema.md +2 -0
- package/src/memory.test.ts +68 -0
- package/src/memory.ts +52 -15
- package/src/service.test.ts +141 -0
- package/src/service.ts +193 -8
- package/src/types.ts +1 -1
package/README.md
CHANGED
|
@@ -162,4 +162,4 @@ Full config with all options:
|
|
|
162
162
|
- `memory_update` updates one existing memory issue in place; use it for evolving canonical facts or active tasks instead of creating a duplicate node.
|
|
163
163
|
- Conversation lifecycle is stored in native issue state (`open` while live, `closed` after finalize); memory lifecycle uses native issue state too (`open` active, `closed` stale).
|
|
164
164
|
- Memory extraction now prefers one atomic fact per memory item instead of bundling whole sessions into a single node.
|
|
165
|
-
- Memory issue bodies store the durable detail plus flat metadata such as `memory_hash` and logical `date`;
|
|
165
|
+
- Memory issue bodies store the durable detail in a YAML `detail` field plus flat metadata such as `memory_hash` and logical `date`; this matches the current Console parser in `agent-git-service/web`.
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/skills/clawmem/SKILL.md
CHANGED
|
@@ -50,6 +50,8 @@ On every user turn, run this loop:
|
|
|
50
50
|
- Use `memory_update` when the same canonical fact or ongoing task should keep evolving as one node.
|
|
51
51
|
- When updating an existing memory, preserve that node's current language unless the user explicitly asks for a rewrite.
|
|
52
52
|
- Use `memory_store` when this is a genuinely new memory.
|
|
53
|
+
- When using `memory_store`, pass both `title` and `detail` when you can. Keep the title concise and human-readable, and keep `detail` as the full durable fact.
|
|
54
|
+
- When using `memory_update`, pass `title` as well if the existing title is too short, outdated, or less precise than the current canonical fact.
|
|
53
55
|
- For new memories, write the memory title and body in the user's current language by default.
|
|
54
56
|
- Use `memory_forget` when a memory is stale, superseded, or harmful if reused.
|
|
55
57
|
3. Keep the user posted.
|
|
@@ -68,6 +70,8 @@ Bias toward retrieving and saving. A missed search or missed memory is worse tha
|
|
|
68
70
|
- Project memory belongs in the relevant project repo.
|
|
69
71
|
- Shared or team knowledge belongs in the shared repo for that group.
|
|
70
72
|
- Memory titles and bodies default to the user's current language for new memories.
|
|
73
|
+
- Prefer a short standalone title plus a fuller `detail` body instead of stuffing the whole memory into the title.
|
|
74
|
+
- If you omit `title`, the plugin may derive it from `detail`, but providing an explicit title is preferred for readability in the Console.
|
|
71
75
|
- When updating an existing memory, keep that node in its current language unless the user explicitly asks to rewrite it.
|
|
72
76
|
- Keep schema labels and machine-oriented fields stable. Do not translate `type:*`, `kind:*`, `topic:*`, or other structural identifiers.
|
|
73
77
|
- If the user is asking about collaboration, organizations, teams, invitations, collaborators, shared repo access, or why someone can or cannot access a memory repo, switch from normal memory reasoning to the collaboration workflow in `references/collaboration.md`.
|
|
@@ -76,6 +76,7 @@ Do not export `GH_HOST` or `GH_ENTERPRISE_TOKEN` globally for unrelated github.c
|
|
|
76
76
|
Use the tool path first. If raw issue control is required:
|
|
77
77
|
|
|
78
78
|
- For new memories, write the issue title and body in the user's current language.
|
|
79
|
+
- Prefer a concise standalone title and keep the full durable fact in the body.
|
|
79
80
|
- When manually updating an existing memory, preserve that memory's current language unless the user explicitly asks for a rewrite.
|
|
80
81
|
- Keep labels and schema markers such as `type:*`, `kind:*`, and `topic:*` in their fixed machine-readable form.
|
|
81
82
|
|
|
@@ -61,6 +61,8 @@ If you create a curated memory manually, include:
|
|
|
61
61
|
## Storage language
|
|
62
62
|
|
|
63
63
|
- For new memory nodes, write the human-readable title and body in the user's current language by default.
|
|
64
|
+
- When using plugin tools, prefer passing an explicit short `title` plus a fuller `detail` body.
|
|
65
|
+
- Do not treat the title as the only durable content. The body detail should still contain the full reusable fact.
|
|
64
66
|
- When updating an existing memory node, preserve that node's current language unless the user explicitly asks for a rewrite.
|
|
65
67
|
- Do not translate schema or routing markers such as `type:*`, `kind:*`, `topic:*`, or other machine-oriented field names.
|
|
66
68
|
|
package/src/memory.test.ts
CHANGED
|
@@ -177,12 +177,36 @@ async function testStructuredStoreAndSchema(): Promise<void> {
|
|
|
177
177
|
assert(created[0]?.labels.includes("topic:rate-limit"), "expected created labels to include normalized topic");
|
|
178
178
|
assert(!created[0]?.labels.some((label) => label.startsWith("session:")), "expected manual memory_store writes to omit synthetic session labels");
|
|
179
179
|
assert(!created[0]?.labels.some((label) => label.startsWith("date:")), "expected new memory labels to omit date labels");
|
|
180
|
+
assert(created[0]?.body.includes("memory_hash:"), "expected new memory body to retain metadata fields");
|
|
181
|
+
assert(created[0]?.body.includes("detail: Redis Lua scripts are required for atomic rate limiting."), "expected new memory body to store detail in YAML");
|
|
180
182
|
assert(created[0]?.body.includes(`date: ${result.memory.date}`), "expected new memory body to retain logical date metadata");
|
|
181
183
|
assert(ensured[0]?.includes("kind:lesson"), "expected ensureLabels to include kind label");
|
|
182
184
|
assert(schema.kinds.includes("lesson"), "expected schema to expose existing kind labels");
|
|
183
185
|
assert(schema.topics.includes("redis"), "expected schema to expose existing topic labels");
|
|
184
186
|
}
|
|
185
187
|
|
|
188
|
+
async function testStoreKeepsFullAutoTitleAndSupportsExplicitTitle(): Promise<void> {
|
|
189
|
+
const created: Array<{ title: string; body: string; labels: string[] }> = [];
|
|
190
|
+
const client = {
|
|
191
|
+
listIssues: async () => [] as IssueRecord[],
|
|
192
|
+
listLabels: async () => [] as LabelRecord[],
|
|
193
|
+
ensureLabels: async () => {},
|
|
194
|
+
createIssue: async (payload: { title: string; body: string; labels: string[] }) => {
|
|
195
|
+
created.push(payload);
|
|
196
|
+
return { number: created.length + 100, title: payload.title };
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
const store = new MemoryStore(client as never, {} as never, testConfig());
|
|
200
|
+
const longDetail = "Tech Decision #001: Frontend = React Native, Backend = FastAPI, Database = PostgreSQL, and analytics events must stay append-only for auditability.";
|
|
201
|
+
const auto = await store.store({ detail: longDetail });
|
|
202
|
+
const explicit = await store.store({ title: "Architecture Decision #001", detail: "Use React Native + FastAPI for the first mobile stack." });
|
|
203
|
+
|
|
204
|
+
assert(auto.memory.title === `Memory: ${longDetail}`, "expected auto-generated memory title to keep the full detail without truncation");
|
|
205
|
+
assert(explicit.memory.title === "Memory: Architecture Decision #001", "expected explicit memory title to be preserved");
|
|
206
|
+
assert(created[0]?.title === `Memory: ${longDetail}`, "expected created issue title to keep the full auto title");
|
|
207
|
+
assert(created[1]?.title === "Memory: Architecture Decision #001", "expected created issue title to use the explicit title");
|
|
208
|
+
}
|
|
209
|
+
|
|
186
210
|
async function testGetAndListMemories(): Promise<void> {
|
|
187
211
|
const issues = [
|
|
188
212
|
issueFromMemory(memory({
|
|
@@ -312,10 +336,52 @@ async function testUpdateMemoryInPlace(): Promise<void> {
|
|
|
312
336
|
assert(JSON.stringify(updated?.topics) === JSON.stringify(["preferences", "sports"]), "expected topics to be replaced");
|
|
313
337
|
assert(updatedIssues.length === 1, "expected a single issue update");
|
|
314
338
|
assert(updatedIssues[0]?.title !== "Memory: xiangz preferences", "expected title to refresh from updated detail");
|
|
339
|
+
assert(updatedIssues[0]?.body?.includes("memory_hash:"), "expected updated body to retain metadata");
|
|
340
|
+
assert(updatedIssues[0]?.body?.includes("detail:"), "expected updated body to store a detail field in YAML");
|
|
341
|
+
assert(updatedIssues[0]?.body?.includes("recently follows tennis"), "expected updated body to contain the updated detail text");
|
|
315
342
|
assert(ensured[0]?.includes("topic:sports"), "expected new topic label to be ensured");
|
|
316
343
|
assert(syncedLabels[0]?.labels.includes("kind:core-fact"), "expected existing kind label to be preserved");
|
|
317
344
|
}
|
|
318
345
|
|
|
346
|
+
async function testUpdateSupportsExplicitRetitle(): Promise<void> {
|
|
347
|
+
const issues: IssueRecord[] = [
|
|
348
|
+
issueFromMemory(memory({
|
|
349
|
+
issueNumber: 20,
|
|
350
|
+
title: "Memory: old short title",
|
|
351
|
+
detail: "We use append-only audit events for billing changes.",
|
|
352
|
+
kind: "convention",
|
|
353
|
+
})),
|
|
354
|
+
];
|
|
355
|
+
const updatedIssues: Array<{ number: number; title?: string; body?: string }> = [];
|
|
356
|
+
const client = {
|
|
357
|
+
listIssues: async (params?: { labels?: string[]; state?: "open" | "closed" | "all" }) => {
|
|
358
|
+
const labels = params?.labels ?? [];
|
|
359
|
+
const state = params?.state ?? "open";
|
|
360
|
+
return issues.filter((issue) => {
|
|
361
|
+
const issueLabels = issue.labels ?? [];
|
|
362
|
+
if (!labels.every((label) => issueLabels.includes(label))) return false;
|
|
363
|
+
if (state === "all") return true;
|
|
364
|
+
return (issue.state ?? "open") === state;
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
ensureLabels: async () => {},
|
|
368
|
+
updateIssue: async (number: number, patch: { title?: string; body?: string }) => {
|
|
369
|
+
updatedIssues.push({ number, ...patch });
|
|
370
|
+
const issue = issues.find((entry) => entry.number === number);
|
|
371
|
+
if (!issue) throw new Error("issue missing");
|
|
372
|
+
if (patch.title) issue.title = patch.title;
|
|
373
|
+
if (patch.body) issue.body = patch.body;
|
|
374
|
+
return issue;
|
|
375
|
+
},
|
|
376
|
+
syncManagedLabels: async () => {},
|
|
377
|
+
};
|
|
378
|
+
const store = new MemoryStore(client as never, {} as never, testConfig());
|
|
379
|
+
const updated = await store.update("20", { title: "Billing Audit Convention" });
|
|
380
|
+
|
|
381
|
+
assert(updated?.title === "Memory: Billing Audit Convention", "expected memory_update to support explicit retitle");
|
|
382
|
+
assert(updatedIssues[0]?.title === "Memory: Billing Audit Convention", "expected issue title patch to use the explicit retitle");
|
|
383
|
+
}
|
|
384
|
+
|
|
319
385
|
async function testForgetClosesMemoryIssue(): Promise<void> {
|
|
320
386
|
const issues: IssueRecord[] = [
|
|
321
387
|
issueFromMemory(memory({
|
|
@@ -367,9 +433,11 @@ async function main(): Promise<void> {
|
|
|
367
433
|
await testBackendSearchFallsBackToLocalLexical();
|
|
368
434
|
testCjkScoring();
|
|
369
435
|
await testStructuredStoreAndSchema();
|
|
436
|
+
await testStoreKeepsFullAutoTitleAndSupportsExplicitTitle();
|
|
370
437
|
await testGetAndListMemories();
|
|
371
438
|
await testLegacyMemoriesWithoutSessionOrDate();
|
|
372
439
|
await testUpdateMemoryInPlace();
|
|
440
|
+
await testUpdateSupportsExplicitRetitle();
|
|
373
441
|
await testForgetClosesMemoryIssue();
|
|
374
442
|
console.log("memory tests passed");
|
|
375
443
|
}
|
package/src/memory.ts
CHANGED
|
@@ -84,8 +84,8 @@ export class MemoryStore {
|
|
|
84
84
|
|
|
85
85
|
const date = localDate();
|
|
86
86
|
const labels = memLabels(normalized.kind, normalized.topics);
|
|
87
|
-
const title =
|
|
88
|
-
const body =
|
|
87
|
+
const title = renderMemoryTitle(normalized);
|
|
88
|
+
const body = renderMemoryBody(detail, hash, date);
|
|
89
89
|
await this.client.ensureLabels(labels);
|
|
90
90
|
const issue = await this.client.createIssue({ title, body, labels });
|
|
91
91
|
return {
|
|
@@ -104,10 +104,15 @@ export class MemoryStore {
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
async update(memoryId: string, patch: { detail?: string; kind?: string; topics?: string[] }): Promise<ParsedMemoryIssue | null> {
|
|
107
|
+
async update(memoryId: string, patch: { title?: string; detail?: string; kind?: string; topics?: string[] }): Promise<ParsedMemoryIssue | null> {
|
|
108
108
|
const current = await this.get(memoryId, "all");
|
|
109
109
|
if (!current) return null;
|
|
110
110
|
const nextDetail = typeof patch.detail === "string" && patch.detail.trim() ? norm(patch.detail) : current.detail;
|
|
111
|
+
const nextTitle = typeof patch.title === "string" && patch.title.trim()
|
|
112
|
+
? renderMemoryTitle({ title: patch.title.trim(), detail: nextDetail })
|
|
113
|
+
: patch.detail !== undefined
|
|
114
|
+
? renderMemoryTitle({ detail: nextDetail })
|
|
115
|
+
: current.title || renderMemoryTitle({ detail: nextDetail });
|
|
111
116
|
const nextKind = patch.kind !== undefined ? normalizeLabelValue(patch.kind, "kind:") : current.kind;
|
|
112
117
|
const nextTopics = patch.topics !== undefined
|
|
113
118
|
? uniqueNormalized(patch.topics.map((topic) => normalizeLabelValue(topic, "topic:")).filter(Boolean) as string[])
|
|
@@ -118,8 +123,7 @@ export class MemoryStore {
|
|
|
118
123
|
return (memory.memoryHash || sha256(norm(memory.detail))) === nextHash;
|
|
119
124
|
});
|
|
120
125
|
if (duplicate) throw new Error(`another active memory already stores this detail as [${duplicate.memoryId}]`);
|
|
121
|
-
const
|
|
122
|
-
const nextBody = stringifyFlatYaml([["memory_hash", nextHash], ["date", current.date], ["detail", nextDetail]]);
|
|
126
|
+
const nextBody = renderMemoryBody(nextDetail, nextHash, current.date);
|
|
123
127
|
const nextLabels = memLabels(nextKind, nextTopics);
|
|
124
128
|
await this.client.ensureLabels(nextLabels);
|
|
125
129
|
await this.client.updateIssue(current.issueNumber, { title: nextTitle, body: nextBody });
|
|
@@ -201,16 +205,16 @@ export class MemoryStore {
|
|
|
201
205
|
const kind = labelVal(labels, "kind:");
|
|
202
206
|
const topics = labels.filter((l) => l.startsWith("topic:")).map((l) => l.slice(6).trim()).filter(Boolean);
|
|
203
207
|
const rawBody = (issue.body ?? "").trim();
|
|
204
|
-
const
|
|
205
|
-
const detail =
|
|
208
|
+
const parsed = parseStoredMemoryBody(rawBody);
|
|
209
|
+
const detail = parsed.detail?.trim() || rawBody;
|
|
206
210
|
const status = issue.state === "closed" || labels.includes(LABEL_MEMORY_STALE) ? "stale" : "active";
|
|
207
211
|
if (!detail) return null;
|
|
208
212
|
return {
|
|
209
213
|
issueNumber: issue.number,
|
|
210
214
|
title: issue.title?.trim() || "",
|
|
211
|
-
memoryId:
|
|
212
|
-
memoryHash:
|
|
213
|
-
date:
|
|
215
|
+
memoryId: parsed.meta.memory_id?.trim() || String(issue.number),
|
|
216
|
+
memoryHash: parsed.meta.memory_hash?.trim() || undefined,
|
|
217
|
+
date: parsed.meta.date?.trim() || "1970-01-01",
|
|
214
218
|
detail,
|
|
215
219
|
...(kind ? { kind } : {}),
|
|
216
220
|
...(topics.length > 0 ? { topics } : {}),
|
|
@@ -236,8 +240,8 @@ export class MemoryStore {
|
|
|
236
240
|
}
|
|
237
241
|
const labels = memLabels(draft.kind, draft.topics);
|
|
238
242
|
const date = localDate();
|
|
239
|
-
const title =
|
|
240
|
-
const body =
|
|
243
|
+
const title = renderMemoryTitle(draft);
|
|
244
|
+
const body = renderMemoryBody(detail, hash, date);
|
|
241
245
|
await this.client.ensureLabels(labels);
|
|
242
246
|
const issue = await this.client.createIssue({ title, body, labels });
|
|
243
247
|
activeByHash.set(hash, {
|
|
@@ -280,9 +284,10 @@ export class MemoryStore {
|
|
|
280
284
|
const sessionKey = subKey(session, "memory");
|
|
281
285
|
const message = [
|
|
282
286
|
"Extract durable memories from the conversation below.",
|
|
283
|
-
'Return JSON only in the form {"save":[{"detail":"...","kind":"...","topics":["..."]}],"stale":["memory-id"]}.',
|
|
287
|
+
'Return JSON only in the form {"save":[{"title":"...","detail":"...","kind":"...","topics":["..."]}],"stale":["memory-id"]}.',
|
|
284
288
|
"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.",
|
|
285
289
|
"Use save for stable, reusable facts, preferences, decisions, constraints, workflows, and ongoing context worth remembering later.",
|
|
290
|
+
"Title is optional. If you provide one, make it concise and human-readable.",
|
|
286
291
|
"Use stale for existing memory IDs only when the conversation clearly supersedes or invalidates them.",
|
|
287
292
|
"Infer kind and topics when they would help future retrieval. Reuse existing kinds and topics when possible.",
|
|
288
293
|
"If no existing kind fits, you may propose a new short kind label. Keep kinds concise and reusable.",
|
|
@@ -300,7 +305,7 @@ export class MemoryStore {
|
|
|
300
305
|
deliver: false,
|
|
301
306
|
lane: "clawmem-memory",
|
|
302
307
|
idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:memory-decision`),
|
|
303
|
-
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. Keep each save item to one durable fact.",
|
|
308
|
+
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.",
|
|
304
309
|
});
|
|
305
310
|
const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
|
|
306
311
|
if (wait.status === "timeout") throw new Error("memory decision subagent timed out");
|
|
@@ -341,6 +346,35 @@ function memLabels(kind?: string, topics?: string[]): string[] {
|
|
|
341
346
|
];
|
|
342
347
|
}
|
|
343
348
|
|
|
349
|
+
function renderMemoryTitle(draft: Pick<MemoryDraft, "detail" | "title">): string {
|
|
350
|
+
const raw = typeof draft.title === "string" && draft.title.trim() ? draft.title : draft.detail;
|
|
351
|
+
const normalized = norm(raw);
|
|
352
|
+
return normalized.startsWith(MEMORY_TITLE_PREFIX) ? normalized : `${MEMORY_TITLE_PREFIX}${normalized}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function renderMemoryBody(detail: string, memoryHash: string, date: string): string {
|
|
356
|
+
return stringifyFlatYaml([["memory_hash", memoryHash], ["date", date], ["detail", norm(detail)]]);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function parseStoredMemoryBody(rawBody: string): { detail: string; meta: Record<string, string> } {
|
|
360
|
+
const trimmed = rawBody.trim();
|
|
361
|
+
if (!trimmed) return { detail: "", meta: {} };
|
|
362
|
+
|
|
363
|
+
const legacyYaml = parseFlatYaml(trimmed);
|
|
364
|
+
if (legacyYaml.detail?.trim()) {
|
|
365
|
+
return { detail: legacyYaml.detail.trim(), meta: legacyYaml };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const hiddenMeta = /(?:^|\n)<!--\s*clawmem-meta\s*\n([\s\S]*?)\n-->\s*$/.exec(trimmed);
|
|
369
|
+
if (!hiddenMeta) {
|
|
370
|
+
return { detail: trimmed, meta: {} };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const meta = parseFlatYaml(hiddenMeta[1] ?? "");
|
|
374
|
+
const detail = trimmed.slice(0, hiddenMeta.index).trim() || meta.detail?.trim() || "";
|
|
375
|
+
return { detail, meta };
|
|
376
|
+
}
|
|
377
|
+
|
|
344
378
|
function norm(v: string): string { return v.replace(/\s+/g, " ").trim(); }
|
|
345
379
|
function trunc(v: string, max: number): string { const s = norm(v); return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`; }
|
|
346
380
|
function normalizeSearch(v: string): string {
|
|
@@ -429,9 +463,11 @@ export function scoreMemoryMatch(memory: ParsedMemoryIssue, rawQuery: string): n
|
|
|
429
463
|
function normalizeDraft(input: MemoryDraft): MemoryDraft {
|
|
430
464
|
const detail = norm(input.detail);
|
|
431
465
|
if (!detail) throw new Error("memory detail is empty");
|
|
466
|
+
const title = typeof input.title === "string" && input.title.trim() ? norm(input.title) : undefined;
|
|
432
467
|
const kind = normalizeLabelValue(input.kind, "kind:");
|
|
433
468
|
const topics = uniqueNormalized((input.topics ?? []).map((topic) => normalizeLabelValue(topic, "topic:")).filter(Boolean) as string[]);
|
|
434
469
|
return {
|
|
470
|
+
...(title ? { title } : {}),
|
|
435
471
|
detail,
|
|
436
472
|
...(kind ? { kind } : {}),
|
|
437
473
|
...(topics.length > 0 ? { topics } : {}),
|
|
@@ -490,12 +526,13 @@ function parseSaveItem(value: unknown): MemoryDraft | null {
|
|
|
490
526
|
}
|
|
491
527
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
492
528
|
const record = value as Record<string, unknown>;
|
|
529
|
+
const title = typeof record.title === "string" ? record.title : undefined;
|
|
493
530
|
const detail = typeof record.detail === "string" ? norm(record.detail) : "";
|
|
494
531
|
if (!detail) return null;
|
|
495
532
|
const kind = typeof record.kind === "string" ? record.kind : undefined;
|
|
496
533
|
const topics = Array.isArray(record.topics) ? record.topics.filter((v): v is string => typeof v === "string") : undefined;
|
|
497
534
|
try {
|
|
498
|
-
return normalizeDraft({ detail, ...(kind ? { kind } : {}), ...(topics ? { topics } : {}) });
|
|
535
|
+
return normalizeDraft({ ...(title ? { title } : {}), detail, ...(kind ? { kind } : {}), ...(topics ? { topics } : {}) });
|
|
499
536
|
} catch {
|
|
500
537
|
return null;
|
|
501
538
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildLegacyRelevantMemoriesContext,
|
|
3
|
+
buildRelevantMemoriesSystemContext,
|
|
4
|
+
extractPromptTextForRecall,
|
|
5
|
+
resolveOpenClawHostVersion,
|
|
6
|
+
resolvePromptHookMode,
|
|
7
|
+
} from "./service.js";
|
|
8
|
+
|
|
9
|
+
function assert(condition: unknown, message: string): void {
|
|
10
|
+
if (!condition) throw new Error(message);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function testExtractPromptFromString(): void {
|
|
14
|
+
assert(extractPromptTextForRecall(" help me fix redis ") === "help me fix redis", "expected direct string prompts to be trimmed");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function testExtractPromptFromPromptField(): void {
|
|
18
|
+
assert(
|
|
19
|
+
extractPromptTextForRecall({ prompt: "Summarize the release notes." }) === "Summarize the release notes.",
|
|
20
|
+
"expected prompt field to be used when present",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function testExtractPromptFromLatestUserMessage(): void {
|
|
25
|
+
const prompt = extractPromptTextForRecall({
|
|
26
|
+
messages: [
|
|
27
|
+
{ role: "assistant", text: "How can I help?" },
|
|
28
|
+
{ role: "user", text: "Please fix the login bug." },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
assert(prompt === "Please fix the login bug.", "expected the latest user message to drive recall");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function testExtractPromptFromStructuredContent(): void {
|
|
35
|
+
const prompt = extractPromptTextForRecall({
|
|
36
|
+
messages: [
|
|
37
|
+
{
|
|
38
|
+
role: "user",
|
|
39
|
+
content: [
|
|
40
|
+
{ type: "text", text: "Check the deployment logs" },
|
|
41
|
+
{ type: "text", text: "and verify nginx." },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
assert(prompt === "Check the deployment logs\nand verify nginx.", "expected structured text content to be flattened");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function testBuildRelevantMemoriesSystemContext(): void {
|
|
50
|
+
const context = buildRelevantMemoriesSystemContext([
|
|
51
|
+
{ detail: "OpenClaw main agent identity uses Gandalf." },
|
|
52
|
+
{ detail: "Shared memories can break if the repo path changes." },
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
assert(context.includes("ClawMem relevant memories:"), "expected a human-readable heading");
|
|
56
|
+
assert(context.includes("- OpenClaw main agent identity uses Gandalf."), "expected memories to be listed as bullets");
|
|
57
|
+
assert(!context.includes("<relevant-memories>"), "expected the legacy XML wrapper to be removed");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function testBuildLegacyRelevantMemoriesContext(): void {
|
|
61
|
+
const context = buildLegacyRelevantMemoriesContext([
|
|
62
|
+
{ detail: "Use the shared repo for team memory." },
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
assert(context.includes("Relevant ClawMem memories for this request:"), "expected a legacy-safe heading");
|
|
66
|
+
assert(context.includes("- Use the shared repo for team memory."), "expected memories to stay readable");
|
|
67
|
+
assert(!context.includes("<relevant-memories>"), "expected legacy context to avoid XML wrappers too");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function testResolveHostVersionFromRuntime(): void {
|
|
71
|
+
const version = resolveOpenClawHostVersion({ runtime: { version: "2026.3.28" } } as never);
|
|
72
|
+
assert(version === "2026.3.28", "expected runtime.version to take precedence");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function testResolveHostVersionFromEnvFallback(): void {
|
|
76
|
+
const previous = {
|
|
77
|
+
OPENCLAW_VERSION: process.env.OPENCLAW_VERSION,
|
|
78
|
+
OPENCLAW_SERVICE_VERSION: process.env.OPENCLAW_SERVICE_VERSION,
|
|
79
|
+
npm_package_version: process.env.npm_package_version,
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
delete process.env.OPENCLAW_VERSION;
|
|
83
|
+
process.env.OPENCLAW_SERVICE_VERSION = "2026.3.6";
|
|
84
|
+
delete process.env.npm_package_version;
|
|
85
|
+
const version = resolveOpenClawHostVersion({ runtime: {} } as never);
|
|
86
|
+
assert(version === "2026.3.6", "expected OPENCLAW_SERVICE_VERSION fallback");
|
|
87
|
+
} finally {
|
|
88
|
+
process.env.OPENCLAW_VERSION = previous.OPENCLAW_VERSION;
|
|
89
|
+
process.env.OPENCLAW_SERVICE_VERSION = previous.OPENCLAW_SERVICE_VERSION;
|
|
90
|
+
process.env.npm_package_version = previous.npm_package_version;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function testIgnoresNpmPackageVersionFallback(): void {
|
|
95
|
+
const previous = {
|
|
96
|
+
OPENCLAW_VERSION: process.env.OPENCLAW_VERSION,
|
|
97
|
+
OPENCLAW_SERVICE_VERSION: process.env.OPENCLAW_SERVICE_VERSION,
|
|
98
|
+
npm_package_version: process.env.npm_package_version,
|
|
99
|
+
};
|
|
100
|
+
try {
|
|
101
|
+
delete process.env.OPENCLAW_VERSION;
|
|
102
|
+
delete process.env.OPENCLAW_SERVICE_VERSION;
|
|
103
|
+
process.env.npm_package_version = "2026.3.99";
|
|
104
|
+
const version = resolveOpenClawHostVersion({ runtime: {} } as never);
|
|
105
|
+
assert(version === undefined, "expected npm_package_version to be ignored for host detection");
|
|
106
|
+
} finally {
|
|
107
|
+
process.env.OPENCLAW_VERSION = previous.OPENCLAW_VERSION;
|
|
108
|
+
process.env.OPENCLAW_SERVICE_VERSION = previous.OPENCLAW_SERVICE_VERSION;
|
|
109
|
+
process.env.npm_package_version = previous.npm_package_version;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function testResolvePromptHookModeModern(): void {
|
|
114
|
+
const mode = resolvePromptHookMode({ runtime: { version: "2026.3.28" } } as never);
|
|
115
|
+
assert(mode === "modern", "expected modern hook mode for OpenClaw 2026.3.28");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function testResolvePromptHookModeLegacy(): void {
|
|
119
|
+
const mode = resolvePromptHookMode({ runtime: { version: "2026.3.6" } } as never);
|
|
120
|
+
assert(mode === "legacy", "expected legacy hook mode before 2026.3.7");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function testResolvePromptHookModeLegacyForUnknownVersion(): void {
|
|
124
|
+
const mode = resolvePromptHookMode({ runtime: {} } as never);
|
|
125
|
+
assert(mode === "legacy", "expected unknown host versions to fall back to legacy mode");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
testExtractPromptFromString();
|
|
129
|
+
testExtractPromptFromPromptField();
|
|
130
|
+
testExtractPromptFromLatestUserMessage();
|
|
131
|
+
testExtractPromptFromStructuredContent();
|
|
132
|
+
testBuildRelevantMemoriesSystemContext();
|
|
133
|
+
testBuildLegacyRelevantMemoriesContext();
|
|
134
|
+
testResolveHostVersionFromRuntime();
|
|
135
|
+
testResolveHostVersionFromEnvFallback();
|
|
136
|
+
testIgnoresNpmPackageVersionFallback();
|
|
137
|
+
testResolvePromptHookModeModern();
|
|
138
|
+
testResolvePromptHookModeLegacy();
|
|
139
|
+
testResolvePromptHookModeLegacyForUnknownVersion();
|
|
140
|
+
|
|
141
|
+
console.log("service tests passed");
|
package/src/service.ts
CHANGED
|
@@ -17,6 +17,8 @@ type CollaborationPermission = "read" | "write" | "admin";
|
|
|
17
17
|
type CollaborationTeamRole = "member" | "maintainer";
|
|
18
18
|
|
|
19
19
|
const SESSION_MAINTENANCE_RETRY_DELAYS_MS = [5000, 30000, 120000] as const;
|
|
20
|
+
const MODERN_PROMPT_HOOK_MIN_HOST_VERSION = "2026.3.7";
|
|
21
|
+
type PromptHookMode = "modern" | "legacy";
|
|
20
22
|
|
|
21
23
|
class ClawMemService {
|
|
22
24
|
private readonly config: ClawMemPluginConfig;
|
|
@@ -36,7 +38,12 @@ class ClawMemService {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
register(): void {
|
|
39
|
-
|
|
41
|
+
const promptHookMode = resolvePromptHookMode(this.api);
|
|
42
|
+
if (promptHookMode === "modern") {
|
|
43
|
+
this.api.on("before_prompt_build", async (ev, ctx) => this.handleBeforePromptBuild(ev, ctx.agentId));
|
|
44
|
+
} else {
|
|
45
|
+
this.api.on("before_agent_start", async (ev, ctx) => this.handleBeforeAgentStart(ev, ctx.agentId));
|
|
46
|
+
}
|
|
40
47
|
this.api.on("agent_end", (ev, ctx) => this.scheduleTurn({ sessionId: ctx.sessionId, sessionKey: ctx.sessionKey, agentId: ctx.agentId, messages: ev.messages }));
|
|
41
48
|
this.api.on("before_reset", (ev, ctx) => this.enqueueFinalize({ sessionId: ctx.sessionId, sessionKey: ctx.sessionKey, sessionFile: ev.sessionFile, agentId: ctx.agentId, reason: ev.reason, messages: ev.messages }));
|
|
42
49
|
this.api.on("session_end", (ev, ctx) => this.enqueueFinalize({ sessionId: ev.sessionId ?? ctx.sessionId, sessionKey: ev.sessionKey ?? ctx.sessionKey, agentId: ctx.agentId, reason: "session_end" }));
|
|
@@ -55,10 +62,11 @@ class ClawMemService {
|
|
|
55
62
|
const route = resolveAgentRoute(this.config, agentId);
|
|
56
63
|
return isAgentConfigured(route) && hasDefaultRepo(route);
|
|
57
64
|
}).length;
|
|
65
|
+
const hostVersion = resolveOpenClawHostVersion(this.api);
|
|
58
66
|
this.api.logger.info?.(
|
|
59
67
|
configuredCount > 0
|
|
60
|
-
? `clawmem: ready with ${configuredCount} configured agent route(s); missing routes will provision on first use via ${this.config.baseUrl}`
|
|
61
|
-
: `clawmem: ready; agent routes will provision on first use via ${this.config.baseUrl}`,
|
|
68
|
+
? `clawmem: ready with ${configuredCount} configured agent route(s); prompt hook mode=${promptHookMode}${hostVersion ? ` for OpenClaw ${hostVersion}` : ""}; missing routes will provision on first use via ${this.config.baseUrl}`
|
|
69
|
+
: `clawmem: ready; prompt hook mode=${promptHookMode}${hostVersion ? ` for OpenClaw ${hostVersion}` : ""}; agent routes will provision on first use via ${this.config.baseUrl}`,
|
|
62
70
|
);
|
|
63
71
|
},
|
|
64
72
|
stop: async () => {
|
|
@@ -298,6 +306,7 @@ class ClawMemService {
|
|
|
298
306
|
type: "object",
|
|
299
307
|
additionalProperties: false,
|
|
300
308
|
properties: {
|
|
309
|
+
title: { type: "string", minLength: 1, description: "Optional human-readable memory title. Defaults to the full detail text when omitted." },
|
|
301
310
|
detail: { type: "string", minLength: 1, description: "The durable fact, lesson, decision, or preference to remember." },
|
|
302
311
|
kind: { type: "string", minLength: 1, description: "Optional schema kind, for example lesson, convention, skill, or task." },
|
|
303
312
|
topics: {
|
|
@@ -314,6 +323,7 @@ class ClawMemService {
|
|
|
314
323
|
},
|
|
315
324
|
execute: async (_id: string, params: unknown) => {
|
|
316
325
|
const p = asRecord(params);
|
|
326
|
+
const title = typeof p.title === "string" ? p.title.trim() : "";
|
|
317
327
|
const detail = typeof p.detail === "string" ? p.detail.trim() : "";
|
|
318
328
|
if (!detail) return toolText("Detail is empty.");
|
|
319
329
|
const agentId = this.resolveToolAgentId(p.agentId);
|
|
@@ -322,6 +332,7 @@ class ClawMemService {
|
|
|
322
332
|
const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
|
|
323
333
|
const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
|
|
324
334
|
const result = await resolved.mem.store({
|
|
335
|
+
...(title ? { title } : {}),
|
|
325
336
|
detail,
|
|
326
337
|
...(kind ? { kind } : {}),
|
|
327
338
|
...(topics && topics.length > 0 ? { topics } : {}),
|
|
@@ -340,6 +351,7 @@ class ClawMemService {
|
|
|
340
351
|
additionalProperties: false,
|
|
341
352
|
properties: {
|
|
342
353
|
memoryId: { type: "string", minLength: 1, description: "The memory id or issue number to update." },
|
|
354
|
+
title: { type: "string", minLength: 1, description: "Optional replacement title for the same memory record." },
|
|
343
355
|
detail: { type: "string", minLength: 1, description: "Optional replacement detail text for the same memory record." },
|
|
344
356
|
kind: { type: "string", minLength: 1, description: "Optional replacement kind label." },
|
|
345
357
|
topics: {
|
|
@@ -358,16 +370,17 @@ class ClawMemService {
|
|
|
358
370
|
const p = asRecord(params);
|
|
359
371
|
const memoryId = typeof p.memoryId === "string" ? p.memoryId.trim() : "";
|
|
360
372
|
if (!memoryId) return toolText("memoryId is empty.");
|
|
373
|
+
const title = typeof p.title === "string" && p.title.trim() ? p.title.trim() : undefined;
|
|
361
374
|
const detail = typeof p.detail === "string" && p.detail.trim() ? p.detail.trim() : undefined;
|
|
362
375
|
const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
|
|
363
376
|
const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
|
|
364
|
-
if (!detail && kind === undefined && topics === undefined) return toolText("Provide at least one of detail, kind, or topics.");
|
|
377
|
+
if (title === undefined && !detail && kind === undefined && topics === undefined) return toolText("Provide at least one of title, detail, kind, or topics.");
|
|
365
378
|
const agentId = this.resolveToolAgentId(p.agentId);
|
|
366
379
|
const resolved = await this.requireToolRoute(agentId, p.repo);
|
|
367
380
|
if ("error" in resolved) return toolText(resolved.error);
|
|
368
381
|
let updated;
|
|
369
382
|
try {
|
|
370
|
-
updated = await resolved.mem.update(memoryId, { ...(detail ? { detail } : {}), ...(kind !== undefined ? { kind } : {}), ...(topics !== undefined ? { topics } : {}) });
|
|
383
|
+
updated = await resolved.mem.update(memoryId, { ...(title ? { title } : {}), ...(detail ? { detail } : {}), ...(kind !== undefined ? { kind } : {}), ...(topics !== undefined ? { topics } : {}) });
|
|
371
384
|
} catch (error) {
|
|
372
385
|
return toolText(`Unable to update memory "${memoryId}": ${String(error)}`);
|
|
373
386
|
}
|
|
@@ -1293,17 +1306,31 @@ class ClawMemService {
|
|
|
1293
1306
|
});
|
|
1294
1307
|
}
|
|
1295
1308
|
|
|
1296
|
-
private async
|
|
1309
|
+
private async handleBeforePromptBuild(event: unknown, agentId?: string): Promise<{ prependSystemContext: string } | void> {
|
|
1310
|
+
const routeAgentId = normalizeAgentId(agentId);
|
|
1311
|
+
if (!(await this.ensureDefaultRepoConfigured(routeAgentId))) return;
|
|
1312
|
+
this.scheduleRecentSessionMaintenance(routeAgentId);
|
|
1313
|
+
const prompt = extractPromptTextForRecall(event);
|
|
1314
|
+
if (typeof prompt !== "string" || prompt.trim().length < 5) return;
|
|
1315
|
+
try {
|
|
1316
|
+
const { mem } = this.getServices(routeAgentId);
|
|
1317
|
+
const memories = await mem.search(prompt, this.config.memoryAutoRecallLimit);
|
|
1318
|
+
if (memories.length === 0) return;
|
|
1319
|
+
return { prependSystemContext: buildRelevantMemoriesSystemContext(memories) };
|
|
1320
|
+
} catch (error) { this.api.logger.warn(`clawmem: memory recall failed: ${String(error)}`); }
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
private async handleBeforeAgentStart(event: unknown, agentId?: string): Promise<{ prependContext: string } | void> {
|
|
1297
1324
|
const routeAgentId = normalizeAgentId(agentId);
|
|
1298
1325
|
if (!(await this.ensureDefaultRepoConfigured(routeAgentId))) return;
|
|
1299
1326
|
this.scheduleRecentSessionMaintenance(routeAgentId);
|
|
1327
|
+
const prompt = extractPromptTextForRecall(event);
|
|
1300
1328
|
if (typeof prompt !== "string" || prompt.trim().length < 5) return;
|
|
1301
1329
|
try {
|
|
1302
1330
|
const { mem } = this.getServices(routeAgentId);
|
|
1303
1331
|
const memories = await mem.search(prompt, this.config.memoryAutoRecallLimit);
|
|
1304
1332
|
if (memories.length === 0) return;
|
|
1305
|
-
|
|
1306
|
-
return { prependContext: `<relevant-memories>\nThe following active memories may be relevant to this conversation:\n${text}\n</relevant-memories>` };
|
|
1333
|
+
return { prependContext: buildLegacyRelevantMemoriesContext(memories) };
|
|
1307
1334
|
} catch (error) { this.api.logger.warn(`clawmem: memory recall failed: ${String(error)}`); }
|
|
1308
1335
|
}
|
|
1309
1336
|
|
|
@@ -1812,12 +1839,170 @@ function renderMemoryBlock(memory: {
|
|
|
1812
1839
|
return lines.join("\n");
|
|
1813
1840
|
}
|
|
1814
1841
|
|
|
1842
|
+
export function buildRelevantMemoriesSystemContext(memories: Array<{ detail: string }>): string {
|
|
1843
|
+
return [
|
|
1844
|
+
"ClawMem relevant memories:",
|
|
1845
|
+
"Use these as background context only when they help with the current request.",
|
|
1846
|
+
...memories.map((memory) => `- ${formatInjectedMemory(memory)}`),
|
|
1847
|
+
].join("\n");
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
export function buildLegacyRelevantMemoriesContext(memories: Array<{ detail: string }>): string {
|
|
1851
|
+
return [
|
|
1852
|
+
"Relevant ClawMem memories for this request:",
|
|
1853
|
+
...memories.map((memory) => `- ${formatInjectedMemory(memory)}`),
|
|
1854
|
+
].join("\n");
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1815
1857
|
function formatInjectedMemory(memory: {
|
|
1816
1858
|
detail: string;
|
|
1817
1859
|
}): string {
|
|
1818
1860
|
return memory.detail;
|
|
1819
1861
|
}
|
|
1820
1862
|
|
|
1863
|
+
export function extractPromptTextForRecall(event: unknown): string | undefined {
|
|
1864
|
+
const direct = normalizePromptText(event);
|
|
1865
|
+
if (direct) return direct;
|
|
1866
|
+
|
|
1867
|
+
const record = asRecord(event);
|
|
1868
|
+
for (const candidate of [record.prompt, record.userPrompt, record.input, record.query, record.text]) {
|
|
1869
|
+
const text = normalizePromptText(candidate);
|
|
1870
|
+
if (text) return text;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
return extractPromptTextFromMessages(record.messages) ?? extractPromptTextFromMessages(record.conversation);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function extractPromptTextFromMessages(value: unknown): string | undefined {
|
|
1877
|
+
if (!Array.isArray(value)) return undefined;
|
|
1878
|
+
let fallback: string | undefined;
|
|
1879
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
1880
|
+
const message = value[index];
|
|
1881
|
+
const record = asRecord(message);
|
|
1882
|
+
const role = typeof record.role === "string" ? record.role.trim().toLowerCase() : "";
|
|
1883
|
+
const text = normalizePromptText(record.text)
|
|
1884
|
+
?? normalizePromptText(record.prompt)
|
|
1885
|
+
?? normalizePromptText(record.content)
|
|
1886
|
+
?? normalizePromptText(record.message);
|
|
1887
|
+
if (!text) continue;
|
|
1888
|
+
if (!fallback) fallback = text;
|
|
1889
|
+
if (!role || role === "user") return text;
|
|
1890
|
+
}
|
|
1891
|
+
return fallback;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function normalizePromptText(value: unknown): string | undefined {
|
|
1895
|
+
if (typeof value === "string") {
|
|
1896
|
+
const trimmed = value.trim();
|
|
1897
|
+
return trimmed || undefined;
|
|
1898
|
+
}
|
|
1899
|
+
if (Array.isArray(value)) {
|
|
1900
|
+
const parts = value
|
|
1901
|
+
.map((entry) => {
|
|
1902
|
+
if (typeof entry === "string") return entry.trim();
|
|
1903
|
+
const record = asRecord(entry);
|
|
1904
|
+
if (record.type === "text" && typeof record.text === "string") return record.text.trim();
|
|
1905
|
+
if (typeof record.text === "string") return record.text.trim();
|
|
1906
|
+
return "";
|
|
1907
|
+
})
|
|
1908
|
+
.filter(Boolean);
|
|
1909
|
+
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
1910
|
+
}
|
|
1911
|
+
return undefined;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
export function resolvePromptHookMode(api: Pick<OpenClawPluginApi, "runtime">): PromptHookMode {
|
|
1915
|
+
const hostVersion = resolveOpenClawHostVersion(api);
|
|
1916
|
+
if (!hostVersion) return "legacy";
|
|
1917
|
+
const comparison = compareOpenClawVersions(hostVersion, MODERN_PROMPT_HOOK_MIN_HOST_VERSION);
|
|
1918
|
+
if (comparison === null) return "legacy";
|
|
1919
|
+
return comparison >= 0 ? "modern" : "legacy";
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
export function resolveOpenClawHostVersion(api: Pick<OpenClawPluginApi, "runtime">): string | undefined {
|
|
1923
|
+
const runtimeVersion = typeof api.runtime?.version === "string" ? api.runtime.version.trim() : "";
|
|
1924
|
+
if (isUsableOpenClawVersion(runtimeVersion)) return runtimeVersion;
|
|
1925
|
+
for (const candidate of [
|
|
1926
|
+
process.env.OPENCLAW_VERSION,
|
|
1927
|
+
process.env.OPENCLAW_SERVICE_VERSION,
|
|
1928
|
+
]) {
|
|
1929
|
+
const trimmed = candidate?.trim();
|
|
1930
|
+
if (isUsableOpenClawVersion(trimmed)) return trimmed;
|
|
1931
|
+
}
|
|
1932
|
+
return undefined;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function isUsableOpenClawVersion(version: string | undefined): version is string {
|
|
1936
|
+
return Boolean(version && version !== "0.0.0" && version !== "unknown");
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
function compareOpenClawVersions(left: string, right: string): number | null {
|
|
1940
|
+
const leftSemver = parseComparableSemver(left);
|
|
1941
|
+
const rightSemver = parseComparableSemver(right);
|
|
1942
|
+
if (!leftSemver || !rightSemver) return null;
|
|
1943
|
+
if (leftSemver.major !== rightSemver.major) return leftSemver.major < rightSemver.major ? -1 : 1;
|
|
1944
|
+
if (leftSemver.minor !== rightSemver.minor) return leftSemver.minor < rightSemver.minor ? -1 : 1;
|
|
1945
|
+
if (leftSemver.patch !== rightSemver.patch) return leftSemver.patch < rightSemver.patch ? -1 : 1;
|
|
1946
|
+
return comparePrereleaseIdentifiers(leftSemver.prerelease, rightSemver.prerelease);
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
type ComparableSemver = {
|
|
1950
|
+
major: number;
|
|
1951
|
+
minor: number;
|
|
1952
|
+
patch: number;
|
|
1953
|
+
prerelease: string[] | null;
|
|
1954
|
+
};
|
|
1955
|
+
|
|
1956
|
+
function parseComparableSemver(version: string | undefined): ComparableSemver | null {
|
|
1957
|
+
if (!version) return null;
|
|
1958
|
+
const normalized = normalizeLegacyDotBetaVersion(version);
|
|
1959
|
+
const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(normalized);
|
|
1960
|
+
if (!match) return null;
|
|
1961
|
+
const [, major, minor, patch, prereleaseRaw] = match;
|
|
1962
|
+
if (!major || !minor || !patch) return null;
|
|
1963
|
+
return {
|
|
1964
|
+
major: Number.parseInt(major, 10),
|
|
1965
|
+
minor: Number.parseInt(minor, 10),
|
|
1966
|
+
patch: Number.parseInt(patch, 10),
|
|
1967
|
+
prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null,
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
function normalizeLegacyDotBetaVersion(version: string): string {
|
|
1972
|
+
const trimmed = version.trim();
|
|
1973
|
+
const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed);
|
|
1974
|
+
if (!dotBetaMatch) return trimmed;
|
|
1975
|
+
const base = dotBetaMatch[1];
|
|
1976
|
+
const suffix = dotBetaMatch[2];
|
|
1977
|
+
return suffix ? `${base}-beta.${suffix}` : `${base}-beta`;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function comparePrereleaseIdentifiers(a: string[] | null, b: string[] | null): number {
|
|
1981
|
+
if (!a?.length && !b?.length) return 0;
|
|
1982
|
+
if (!a?.length) return 1;
|
|
1983
|
+
if (!b?.length) return -1;
|
|
1984
|
+
const max = Math.max(a.length, b.length);
|
|
1985
|
+
for (let index = 0; index < max; index += 1) {
|
|
1986
|
+
const left = a[index];
|
|
1987
|
+
const right = b[index];
|
|
1988
|
+
if (left == null && right == null) return 0;
|
|
1989
|
+
if (left == null) return -1;
|
|
1990
|
+
if (right == null) return 1;
|
|
1991
|
+
if (left === right) continue;
|
|
1992
|
+
const leftNumeric = /^[0-9]+$/.test(left);
|
|
1993
|
+
const rightNumeric = /^[0-9]+$/.test(right);
|
|
1994
|
+
if (leftNumeric && rightNumeric) {
|
|
1995
|
+
const leftNumber = Number.parseInt(left, 10);
|
|
1996
|
+
const rightNumber = Number.parseInt(right, 10);
|
|
1997
|
+
return leftNumber < rightNumber ? -1 : 1;
|
|
1998
|
+
}
|
|
1999
|
+
if (leftNumeric && !rightNumeric) return -1;
|
|
2000
|
+
if (!leftNumeric && rightNumeric) return 1;
|
|
2001
|
+
return left < right ? -1 : 1;
|
|
2002
|
+
}
|
|
2003
|
+
return 0;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
1821
2006
|
function renderOrgLine(org: { login?: string; name?: string; default_repository_permission?: string; description?: string }): string {
|
|
1822
2007
|
const login = org.login?.trim() || "unknown-org";
|
|
1823
2008
|
const name = org.name?.trim() ? ` (${org.name.trim()})` : "";
|
package/src/types.ts
CHANGED
|
@@ -44,7 +44,7 @@ export type SessionMirrorState = {
|
|
|
44
44
|
export type PluginState = { version: 2; sessions: Record<string, SessionMirrorState>; migrations?: Record<string, string> };
|
|
45
45
|
export type NormalizedMessage = { role: string; text: string; toolName?: string; timestamp?: string; stopReason?: string };
|
|
46
46
|
export type TranscriptSnapshot = { sessionId?: string; messages: NormalizedMessage[] };
|
|
47
|
-
export type MemoryDraft = { detail: string; kind?: string; topics?: string[] };
|
|
47
|
+
export type MemoryDraft = { title?: string; detail: string; kind?: string; topics?: string[] };
|
|
48
48
|
export type MemorySchema = { kinds: string[]; topics: string[] };
|
|
49
49
|
export type MemoryListOptions = {
|
|
50
50
|
status?: "active" | "stale" | "all";
|