@memtensor/memos-local-openclaw-plugin 1.0.8-beta → 1.0.8-beta.11
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/index.ts +68 -39
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
- package/prebuilds/darwin-x64/better_sqlite3.node +0 -0
- package/prebuilds/linux-x64/better_sqlite3.node +0 -0
- package/prebuilds/win32-x64/better_sqlite3.node +0 -0
- package/src/client/hub.ts +11 -0
- package/src/hub/server.ts +13 -6
- package/src/ingest/providers/anthropic.ts +9 -6
- package/src/ingest/providers/bedrock.ts +9 -6
- package/src/ingest/providers/gemini.ts +9 -6
- package/src/ingest/providers/index.ts +122 -21
- package/src/ingest/providers/openai.ts +141 -6
- package/src/ingest/task-processor.ts +61 -41
- package/src/ingest/worker.ts +32 -11
- package/src/recall/engine.ts +1 -0
- package/src/sharing/types.ts +1 -0
- package/src/storage/sqlite.ts +184 -9
- package/src/types.ts +3 -0
- package/src/viewer/html.ts +753 -274
- package/src/viewer/server.ts +293 -20
- package/telemetry.credentials.json +5 -0
package/index.ts
CHANGED
|
@@ -427,6 +427,7 @@ const memosLocalPlugin = {
|
|
|
427
427
|
body: JSON.stringify({
|
|
428
428
|
memory: {
|
|
429
429
|
sourceChunkId: chunk.id,
|
|
430
|
+
sourceAgent: chunk.owner || "",
|
|
430
431
|
role: chunk.role,
|
|
431
432
|
content: chunk.content,
|
|
432
433
|
summary: chunk.summary,
|
|
@@ -447,6 +448,7 @@ const memosLocalPlugin = {
|
|
|
447
448
|
id: memoryId,
|
|
448
449
|
sourceChunkId: chunk.id,
|
|
449
450
|
sourceUserId: hubClient.userId,
|
|
451
|
+
sourceAgent: chunk.owner || "",
|
|
450
452
|
role: chunk.role,
|
|
451
453
|
content: chunk.content,
|
|
452
454
|
summary: chunk.summary ?? "",
|
|
@@ -549,6 +551,7 @@ const memosLocalPlugin = {
|
|
|
549
551
|
summary: h.summary,
|
|
550
552
|
original_excerpt: (h.original_excerpt ?? "").slice(0, 200),
|
|
551
553
|
origin: h.origin || "local",
|
|
554
|
+
owner: h.owner || "",
|
|
552
555
|
}));
|
|
553
556
|
|
|
554
557
|
// Hub remote candidates (from HTTP call) + hub-memory candidates (from RecallEngine for Hub role)
|
|
@@ -685,6 +688,7 @@ const memosLocalPlugin = {
|
|
|
685
688
|
chunkId: h.ref.chunkId, taskId: effectiveTaskId, skillId: h.skillId,
|
|
686
689
|
role: h.source.role, score: h.score, summary: h.summary,
|
|
687
690
|
original_excerpt: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local",
|
|
691
|
+
owner: h.owner || "",
|
|
688
692
|
};
|
|
689
693
|
}),
|
|
690
694
|
...filteredHubRemoteHits.map((h: any) => ({
|
|
@@ -692,6 +696,7 @@ const memosLocalPlugin = {
|
|
|
692
696
|
role: h.source?.role ?? h.role ?? "assistant", score: h.score ?? 0,
|
|
693
697
|
summary: h.summary ?? "", original_excerpt: (h.excerpt ?? h.summary ?? "").slice(0, 200),
|
|
694
698
|
origin: "hub-remote", ownerName: h.ownerName ?? "", groupName: h.groupName ?? "",
|
|
699
|
+
sourceAgent: h.sourceAgent ?? "",
|
|
695
700
|
})),
|
|
696
701
|
];
|
|
697
702
|
|
|
@@ -1151,6 +1156,10 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
|
|
|
1151
1156
|
};
|
|
1152
1157
|
}
|
|
1153
1158
|
|
|
1159
|
+
const disabledWarning = skill.status === "archived"
|
|
1160
|
+
? "\n\n> **Warning:** This skill is currently **disabled** (archived). Its content is shown for reference only — it will not be used in search or auto-recall.\n\n"
|
|
1161
|
+
: "";
|
|
1162
|
+
|
|
1154
1163
|
const manifest = skillInstaller.getCompanionManifest(resolvedSkillId);
|
|
1155
1164
|
let footer = "\n\n---\n";
|
|
1156
1165
|
|
|
@@ -1175,7 +1184,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
|
|
|
1175
1184
|
return {
|
|
1176
1185
|
content: [{
|
|
1177
1186
|
type: "text",
|
|
1178
|
-
text: `## Skill: ${skill.name} (v${skill.version})\n\n${sv.content}${footer}`,
|
|
1187
|
+
text: `## Skill: ${skill.name} (v${skill.version})${disabledWarning}\n\n${sv.content}${footer}`,
|
|
1179
1188
|
}],
|
|
1180
1189
|
details: {
|
|
1181
1190
|
skillId: skill.id,
|
|
@@ -1868,6 +1877,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
|
|
|
1868
1877
|
const rawLocalCandidates = localHits.map((h) => ({
|
|
1869
1878
|
score: h.score, role: h.source.role, summary: h.summary,
|
|
1870
1879
|
content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local",
|
|
1880
|
+
owner: h.owner || "",
|
|
1871
1881
|
}));
|
|
1872
1882
|
const rawHubCandidates = allHubHits.map((h) => ({
|
|
1873
1883
|
score: h.score, role: h.source.role, summary: h.summary,
|
|
@@ -2075,7 +2085,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
|
|
|
2075
2085
|
store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
|
|
2076
2086
|
candidates: rawLocalCandidates,
|
|
2077
2087
|
hubCandidates: rawHubCandidates,
|
|
2078
|
-
filtered: filteredHits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt, origin: h.origin || "local" })),
|
|
2088
|
+
filtered: filteredHits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt, origin: h.origin || "local", owner: h.owner || "" })),
|
|
2079
2089
|
}), recallDur, true);
|
|
2080
2090
|
telemetry.trackAutoRecall(filteredHits.length, recallDur);
|
|
2081
2091
|
|
|
@@ -2329,48 +2339,54 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
|
|
|
2329
2339
|
|
|
2330
2340
|
// ─── Service lifecycle ───
|
|
2331
2341
|
|
|
2332
|
-
|
|
2333
|
-
id: "memos-local-openclaw-plugin",
|
|
2334
|
-
start: async () => {
|
|
2335
|
-
if (hubServer) {
|
|
2336
|
-
const hubUrl = await hubServer.start();
|
|
2337
|
-
api.logger.info(`memos-local: hub started at ${hubUrl}`);
|
|
2338
|
-
}
|
|
2342
|
+
let serviceStarted = false;
|
|
2339
2343
|
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
}
|
|
2344
|
+
const startServiceCore = async () => {
|
|
2345
|
+
if (serviceStarted) return;
|
|
2346
|
+
serviceStarted = true;
|
|
2347
|
+
|
|
2348
|
+
if (hubServer) {
|
|
2349
|
+
const hubUrl = await hubServer.start();
|
|
2350
|
+
api.logger.info(`memos-local: hub started at ${hubUrl}`);
|
|
2351
|
+
}
|
|
2349
2352
|
|
|
2353
|
+
if (ctx.config.sharing?.enabled && ctx.config.sharing.role === "client") {
|
|
2350
2354
|
try {
|
|
2351
|
-
const
|
|
2352
|
-
api.logger.info(`memos-local:
|
|
2353
|
-
api.logger.info(`╔══════════════════════════════════════════╗`);
|
|
2354
|
-
api.logger.info(`║ MemOS Memory Viewer ║`);
|
|
2355
|
-
api.logger.info(`║ → ${viewerUrl.padEnd(37)}║`);
|
|
2356
|
-
api.logger.info(`║ Open in browser to manage memories ║`);
|
|
2357
|
-
api.logger.info(`╚══════════════════════════════════════════╝`);
|
|
2358
|
-
api.logger.info(`memos-local: password reset token: ${viewer.getResetToken()}`);
|
|
2359
|
-
api.logger.info(`memos-local: forgot password? Use the reset token on the login page.`);
|
|
2360
|
-
skillEvolver.recoverOrphanedTasks().then((count) => {
|
|
2361
|
-
if (count > 0) api.logger.info(`memos-local: recovered ${count} orphaned skill tasks`);
|
|
2362
|
-
}).catch((err) => {
|
|
2363
|
-
api.logger.warn(`memos-local: skill recovery failed: ${err}`);
|
|
2364
|
-
});
|
|
2355
|
+
const session = await connectToHub(store, ctx.config, ctx.log);
|
|
2356
|
+
api.logger.info(`memos-local: connected to Hub as "${session.username}" (${session.userId})`);
|
|
2365
2357
|
} catch (err) {
|
|
2366
|
-
api.logger.warn(`memos-local:
|
|
2367
|
-
api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
|
|
2358
|
+
api.logger.warn(`memos-local: Hub connection failed: ${err}`);
|
|
2368
2359
|
}
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
);
|
|
2373
|
-
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
try {
|
|
2363
|
+
const viewerUrl = await viewer.start();
|
|
2364
|
+
api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
|
|
2365
|
+
api.logger.info(`╔══════════════════════════════════════════╗`);
|
|
2366
|
+
api.logger.info(`║ MemOS Memory Viewer ║`);
|
|
2367
|
+
api.logger.info(`║ → ${viewerUrl.padEnd(37)}║`);
|
|
2368
|
+
api.logger.info(`║ Open in browser to manage memories ║`);
|
|
2369
|
+
api.logger.info(`╚══════════════════════════════════════════╝`);
|
|
2370
|
+
api.logger.info(`memos-local: password reset token: ${viewer.getResetToken()}`);
|
|
2371
|
+
api.logger.info(`memos-local: forgot password? Use the reset token on the login page.`);
|
|
2372
|
+
skillEvolver.recoverOrphanedTasks().then((count) => {
|
|
2373
|
+
if (count > 0) api.logger.info(`memos-local: recovered ${count} orphaned skill tasks`);
|
|
2374
|
+
}).catch((err) => {
|
|
2375
|
+
api.logger.warn(`memos-local: skill recovery failed: ${err}`);
|
|
2376
|
+
});
|
|
2377
|
+
} catch (err) {
|
|
2378
|
+
api.logger.warn(`memos-local: viewer failed to start: ${err}`);
|
|
2379
|
+
api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
|
|
2380
|
+
}
|
|
2381
|
+
telemetry.trackPluginStarted(
|
|
2382
|
+
ctx.config.embedding?.provider ?? "local",
|
|
2383
|
+
ctx.config.summarizer?.provider ?? "none",
|
|
2384
|
+
);
|
|
2385
|
+
};
|
|
2386
|
+
|
|
2387
|
+
api.registerService({
|
|
2388
|
+
id: "memos-local-openclaw-plugin",
|
|
2389
|
+
start: async () => { await startServiceCore(); },
|
|
2374
2390
|
stop: async () => {
|
|
2375
2391
|
await worker.flush();
|
|
2376
2392
|
await telemetry.shutdown();
|
|
@@ -2380,6 +2396,19 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
|
|
|
2380
2396
|
api.logger.info("memos-local: stopped");
|
|
2381
2397
|
},
|
|
2382
2398
|
});
|
|
2399
|
+
|
|
2400
|
+
// Fallback: OpenClaw may load this plugin via deferred reload after
|
|
2401
|
+
// startPluginServices has already run, so service.start() never fires.
|
|
2402
|
+
// Self-start the viewer after a grace period if it hasn't been started.
|
|
2403
|
+
const SELF_START_DELAY_MS = 3000;
|
|
2404
|
+
setTimeout(() => {
|
|
2405
|
+
if (!serviceStarted) {
|
|
2406
|
+
api.logger.info("memos-local: service.start() not called by host, self-starting viewer...");
|
|
2407
|
+
startServiceCore().catch((err) => {
|
|
2408
|
+
api.logger.warn(`memos-local: self-start failed: ${err}`);
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
}, SELF_START_DELAY_MS);
|
|
2383
2412
|
},
|
|
2384
2413
|
};
|
|
2385
2414
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "MemOS Local Memory",
|
|
4
4
|
"description": "Full-write local conversation memory with hybrid search (RRF + MMR + recency), task summarization, skill evolution, and team sharing (Hub-Client). Provides memory_search, memory_get, task_summary, skill_search, task_share, network_skill_pull, network_team_info, memory_viewer for layered retrieval and team collaboration.",
|
|
5
5
|
"kind": "memory",
|
|
6
|
-
"version": "1.0.
|
|
6
|
+
"version": "1.0.8-beta.11",
|
|
7
7
|
"skills": [
|
|
8
8
|
"skill/memos-memory-guide"
|
|
9
9
|
],
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/client/hub.ts
CHANGED
|
@@ -177,6 +177,8 @@ function getClientIp(): string {
|
|
|
177
177
|
return "";
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
const HUB_FETCH_TIMEOUT_MS = 25_000;
|
|
181
|
+
|
|
180
182
|
export async function hubRequestJson(
|
|
181
183
|
hubUrl: string,
|
|
182
184
|
userToken: string,
|
|
@@ -184,8 +186,17 @@ export async function hubRequestJson(
|
|
|
184
186
|
init: RequestInit = {},
|
|
185
187
|
): Promise<unknown> {
|
|
186
188
|
const clientIp = getClientIp();
|
|
189
|
+
const timeoutSignal =
|
|
190
|
+
typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
|
|
191
|
+
? AbortSignal.timeout(HUB_FETCH_TIMEOUT_MS)
|
|
192
|
+
: undefined;
|
|
193
|
+
const mergedSignal =
|
|
194
|
+
timeoutSignal && init.signal
|
|
195
|
+
? AbortSignal.any([timeoutSignal, init.signal])
|
|
196
|
+
: (timeoutSignal ?? init.signal);
|
|
187
197
|
const res = await fetch(`${normalizeHubUrl(hubUrl)}${route}`, {
|
|
188
198
|
...init,
|
|
199
|
+
...(mergedSignal ? { signal: mergedSignal } : {}),
|
|
189
200
|
headers: {
|
|
190
201
|
authorization: `Bearer ${userToken}`,
|
|
191
202
|
...(clientIp ? { "x-client-ip": clientIp } : {}),
|
package/src/hub/server.ts
CHANGED
|
@@ -658,6 +658,7 @@ export class HubServer {
|
|
|
658
658
|
id: memoryId,
|
|
659
659
|
sourceChunkId,
|
|
660
660
|
sourceUserId: auth.userId,
|
|
661
|
+
sourceAgent: String(m.sourceAgent || ""),
|
|
661
662
|
role: String(m.role || "assistant"),
|
|
662
663
|
content: String(m.content || ""),
|
|
663
664
|
summary: String(m.summary || ""),
|
|
@@ -713,9 +714,14 @@ export class HubServer {
|
|
|
713
714
|
|
|
714
715
|
// Track which IDs are memories vs chunks
|
|
715
716
|
const memoryIdSet = new Set(memFtsHits.map(({ hit }) => hit.id));
|
|
717
|
+
const ftsHitIdSet = new Set<string>();
|
|
718
|
+
for (const { hit } of ftsHits) ftsHitIdSet.add(hit.id);
|
|
719
|
+
for (const { hit } of memFtsHits) ftsHitIdSet.add(hit.id);
|
|
716
720
|
|
|
717
721
|
// Two-stage retrieval: FTS candidates first, then embed + cosine rerank
|
|
718
722
|
let mergedIds: string[];
|
|
723
|
+
/** Vector RRF channel: require min cosine similarity unless id is already an FTS hit. */
|
|
724
|
+
const MIN_VECTOR_SIM = 0.45;
|
|
719
725
|
if (this.opts.embedder) {
|
|
720
726
|
try {
|
|
721
727
|
const [queryVec] = await this.opts.embedder.embed([query]);
|
|
@@ -738,8 +744,9 @@ export class HubServer {
|
|
|
738
744
|
memoryIdSet.add(e.memoryId);
|
|
739
745
|
}
|
|
740
746
|
|
|
741
|
-
scored.
|
|
742
|
-
|
|
747
|
+
const vecCandidates = scored.filter((s) => s.score >= MIN_VECTOR_SIM || ftsHitIdSet.has(s.id));
|
|
748
|
+
vecCandidates.sort((a, b) => b.score - a.score);
|
|
749
|
+
const topScored = vecCandidates.slice(0, maxResults * 2);
|
|
743
750
|
|
|
744
751
|
const K = 60;
|
|
745
752
|
const rrfScores = new Map<string, number>();
|
|
@@ -778,8 +785,8 @@ export class HubServer {
|
|
|
778
785
|
this.remoteHitMap.set(remoteHitId, { chunkId: id, type: "memory", expiresAt: Date.now() + 10 * 60 * 1000, requesterUserId: auth.userId });
|
|
779
786
|
return {
|
|
780
787
|
remoteHitId, summary: mhit.summary, excerpt: mhit.content.slice(0, 240), hubRank: rank + 1,
|
|
781
|
-
taskTitle: null, ownerName: mhit.owner_name || "unknown",
|
|
782
|
-
visibility: mhit.visibility, source: { ts: mhit.created_at, role: mhit.role },
|
|
788
|
+
taskTitle: null, ownerName: mhit.owner_name || "unknown", sourceAgent: (mhit as any).source_agent || "",
|
|
789
|
+
groupName: mhit.group_name, visibility: mhit.visibility, source: { ts: mhit.created_at, role: mhit.role },
|
|
783
790
|
};
|
|
784
791
|
}
|
|
785
792
|
let hit = ftsMap.get(id);
|
|
@@ -792,8 +799,8 @@ export class HubServer {
|
|
|
792
799
|
this.remoteHitMap.set(remoteHitId, { chunkId: id, type: "chunk", expiresAt: Date.now() + 10 * 60 * 1000, requesterUserId: auth.userId });
|
|
793
800
|
return {
|
|
794
801
|
remoteHitId, summary: hit!.summary, excerpt: hit!.content.slice(0, 240), hubRank: rank + 1,
|
|
795
|
-
taskTitle: hit!.task_title, ownerName: hit!.owner_name || "unknown",
|
|
796
|
-
visibility: hit!.visibility, source: { ts: hit!.created_at, role: hit!.role },
|
|
802
|
+
taskTitle: hit!.task_title, ownerName: hit!.owner_name || "unknown", sourceAgent: "",
|
|
803
|
+
groupName: hit!.group_name, visibility: hit!.visibility, source: { ts: hit!.created_at, role: hit!.role },
|
|
797
804
|
};
|
|
798
805
|
}).filter(Boolean);
|
|
799
806
|
return this.json(res, 200, { hits, meta: { totalCandidates: hits.length, searchedGroups: [], includedPublic: true } });
|
|
@@ -148,19 +148,22 @@ SAME — the new message:
|
|
|
148
148
|
- Reports a result, error, or feedback about the current task
|
|
149
149
|
- Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME)
|
|
150
150
|
- Is a short acknowledgment (ok, thanks, 好的) in response to the current flow
|
|
151
|
+
- Contains pronouns or references (那, 这, 它, 其中, 哪些, those, which, what about, etc.) pointing to items from the current conversation
|
|
152
|
+
- Asks about a sub-topic, tool, detail, dimension, or aspect of the current discussion topic
|
|
151
153
|
|
|
152
154
|
NEW — the new message:
|
|
153
|
-
- Introduces a subject from a DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel)
|
|
154
|
-
- Has NO logical connection to what was being discussed
|
|
155
|
+
- Introduces a subject from a COMPLETELY DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel)
|
|
156
|
+
- Has NO logical connection to what was being discussed — no shared entities, events, or themes
|
|
155
157
|
- Starts a request about a different project, system, or life area
|
|
156
158
|
- Begins with a new greeting/reset followed by a different topic
|
|
157
159
|
|
|
158
160
|
Key principles:
|
|
159
|
-
-
|
|
161
|
+
- Default to SAME unless the topic domain CLEARLY changed. When in doubt, choose SAME.
|
|
162
|
+
- CRITICAL: Short messages (under ~30 characters) that use pronouns or ask "what about X" / "哪些" / "那XX呢" are almost always follow-ups referring to the current topic. Only mark them NEW if they explicitly name a completely unrelated domain.
|
|
160
163
|
- Different aspects of the SAME project/system are SAME (e.g., Nginx SSL → Nginx gzip = SAME)
|
|
161
|
-
-
|
|
162
|
-
-
|
|
163
|
-
- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "
|
|
164
|
+
- Asking about tools, systems, or methods for the current topic is SAME (e.g., "港股调研" → "那处理系统有哪些" = SAME; "数据分析" → "用什么工具" = SAME)
|
|
165
|
+
- Different unrelated domains discussed independently are NEW (e.g., Redis config → cooking recipe = NEW)
|
|
166
|
+
- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "港股调研" → "那处理系统有哪些" = SAME; "部署服务器" → "年会安排" = NEW
|
|
164
167
|
|
|
165
168
|
Output exactly one word: NEW or SAME`;
|
|
166
169
|
|
|
@@ -150,19 +150,22 @@ SAME — the new message:
|
|
|
150
150
|
- Reports a result, error, or feedback about the current task
|
|
151
151
|
- Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME)
|
|
152
152
|
- Is a short acknowledgment (ok, thanks, 好的) in response to the current flow
|
|
153
|
+
- Contains pronouns or references (那, 这, 它, 其中, 哪些, those, which, what about, etc.) pointing to items from the current conversation
|
|
154
|
+
- Asks about a sub-topic, tool, detail, dimension, or aspect of the current discussion topic
|
|
153
155
|
|
|
154
156
|
NEW — the new message:
|
|
155
|
-
- Introduces a subject from a DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel)
|
|
156
|
-
- Has NO logical connection to what was being discussed
|
|
157
|
+
- Introduces a subject from a COMPLETELY DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel)
|
|
158
|
+
- Has NO logical connection to what was being discussed — no shared entities, events, or themes
|
|
157
159
|
- Starts a request about a different project, system, or life area
|
|
158
160
|
- Begins with a new greeting/reset followed by a different topic
|
|
159
161
|
|
|
160
162
|
Key principles:
|
|
161
|
-
-
|
|
163
|
+
- Default to SAME unless the topic domain CLEARLY changed. When in doubt, choose SAME.
|
|
164
|
+
- CRITICAL: Short messages (under ~30 characters) that use pronouns or ask "what about X" / "哪些" / "那XX呢" are almost always follow-ups referring to the current topic. Only mark them NEW if they explicitly name a completely unrelated domain.
|
|
162
165
|
- Different aspects of the SAME project/system are SAME (e.g., Nginx SSL → Nginx gzip = SAME)
|
|
163
|
-
-
|
|
164
|
-
-
|
|
165
|
-
- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "
|
|
166
|
+
- Asking about tools, systems, or methods for the current topic is SAME (e.g., "港股调研" → "那处理系统有哪些" = SAME; "数据分析" → "用什么工具" = SAME)
|
|
167
|
+
- Different unrelated domains discussed independently are NEW (e.g., Redis config → cooking recipe = NEW)
|
|
168
|
+
- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "港股调研" → "那处理系统有哪些" = SAME; "部署服务器" → "年会安排" = NEW
|
|
166
169
|
|
|
167
170
|
Output exactly one word: NEW or SAME`;
|
|
168
171
|
|
|
@@ -148,19 +148,22 @@ SAME — the new message:
|
|
|
148
148
|
- Reports a result, error, or feedback about the current task
|
|
149
149
|
- Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME)
|
|
150
150
|
- Is a short acknowledgment (ok, thanks, 好的) in response to the current flow
|
|
151
|
+
- Contains pronouns or references (那, 这, 它, 其中, 哪些, those, which, what about, etc.) pointing to items from the current conversation
|
|
152
|
+
- Asks about a sub-topic, tool, detail, dimension, or aspect of the current discussion topic
|
|
151
153
|
|
|
152
154
|
NEW — the new message:
|
|
153
|
-
- Introduces a subject from a DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel)
|
|
154
|
-
- Has NO logical connection to what was being discussed
|
|
155
|
+
- Introduces a subject from a COMPLETELY DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel)
|
|
156
|
+
- Has NO logical connection to what was being discussed — no shared entities, events, or themes
|
|
155
157
|
- Starts a request about a different project, system, or life area
|
|
156
158
|
- Begins with a new greeting/reset followed by a different topic
|
|
157
159
|
|
|
158
160
|
Key principles:
|
|
159
|
-
-
|
|
161
|
+
- Default to SAME unless the topic domain CLEARLY changed. When in doubt, choose SAME.
|
|
162
|
+
- CRITICAL: Short messages (under ~30 characters) that use pronouns or ask "what about X" / "哪些" / "那XX呢" are almost always follow-ups referring to the current topic. Only mark them NEW if they explicitly name a completely unrelated domain.
|
|
160
163
|
- Different aspects of the SAME project/system are SAME (e.g., Nginx SSL → Nginx gzip = SAME)
|
|
161
|
-
-
|
|
162
|
-
-
|
|
163
|
-
- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "
|
|
164
|
+
- Asking about tools, systems, or methods for the current topic is SAME (e.g., "港股调研" → "那处理系统有哪些" = SAME; "数据分析" → "用什么工具" = SAME)
|
|
165
|
+
- Different unrelated domains discussed independently are NEW (e.g., Redis config → cooking recipe = NEW)
|
|
166
|
+
- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "港股调研" → "那处理系统有哪些" = SAME; "部署服务器" → "年会安排" = NEW
|
|
164
167
|
|
|
165
168
|
Output exactly one word: NEW or SAME`;
|
|
166
169
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import type { SummarizerConfig, SummaryProvider, Logger, OpenClawAPI } from "../../types";
|
|
4
|
-
import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult } from "./openai";
|
|
5
|
-
import type { FilterResult, DedupResult } from "./openai";
|
|
6
|
-
export type { FilterResult, DedupResult } from "./openai";
|
|
4
|
+
import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, classifyTopicOpenAI, arbitrateTopicSplitOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult, parseTopicClassifyResult } from "./openai";
|
|
5
|
+
import type { FilterResult, DedupResult, TopicClassifyResult } from "./openai";
|
|
6
|
+
export type { FilterResult, DedupResult, TopicClassifyResult } from "./openai";
|
|
7
7
|
import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic } from "./anthropic";
|
|
8
8
|
import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini } from "./gemini";
|
|
9
9
|
import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock } from "./bedrock";
|
|
@@ -287,25 +287,30 @@ export class Summarizer {
|
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
async judgeNewTopic(currentContext: string, newMessage: string): Promise<boolean | null> {
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
290
|
+
const result = await this.tryChain("judgeNewTopic", (cfg) =>
|
|
291
|
+
cfg.provider === "openclaw"
|
|
292
|
+
? this.judgeNewTopicOpenClaw(currentContext, newMessage)
|
|
293
|
+
: callTopicJudge(cfg, currentContext, newMessage, this.log),
|
|
294
|
+
);
|
|
295
|
+
return result ?? null;
|
|
296
|
+
}
|
|
295
297
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
298
|
+
async classifyTopic(taskState: string, newMessage: string): Promise<TopicClassifyResult | null> {
|
|
299
|
+
const result = await this.tryChain("classifyTopic", (cfg) =>
|
|
300
|
+
cfg.provider === "openclaw"
|
|
301
|
+
? this.classifyTopicOpenClaw(taskState, newMessage)
|
|
302
|
+
: callTopicClassifier(cfg, taskState, newMessage, this.log),
|
|
303
|
+
);
|
|
304
|
+
return result ?? null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async arbitrateTopicSplit(taskState: string, newMessage: string): Promise<string | null> {
|
|
308
|
+
const result = await this.tryChain("arbitrateTopicSplit", (cfg) =>
|
|
309
|
+
cfg.provider === "openclaw"
|
|
310
|
+
? this.arbitrateTopicSplitOpenClaw(taskState, newMessage)
|
|
311
|
+
: callTopicArbitration(cfg, taskState, newMessage, this.log),
|
|
312
|
+
);
|
|
313
|
+
return result ?? null;
|
|
309
314
|
}
|
|
310
315
|
|
|
311
316
|
async filterRelevant(
|
|
@@ -346,8 +351,19 @@ export class Summarizer {
|
|
|
346
351
|
|
|
347
352
|
static readonly OPENCLAW_TOPIC_JUDGE_PROMPT = `You are a conversation topic change detector.
|
|
348
353
|
Given a CURRENT CONVERSATION SUMMARY and a NEW USER MESSAGE, decide: has the user started a COMPLETELY NEW topic that is unrelated to the current conversation?
|
|
354
|
+
Default to SAME unless the domain clearly changed. If the new message shares the same person, event, entity, or theme with the current conversation, answer SAME.
|
|
355
|
+
CRITICAL: Short messages (under ~30 characters) that use pronouns (那/这/它/哪些) or ask about tools/details/dimensions of the current topic are almost always follow-ups — answer SAME unless they explicitly name a completely unrelated domain.
|
|
349
356
|
Reply with a single word: "NEW" if topic changed, "SAME" if it continues.`;
|
|
350
357
|
|
|
358
|
+
static readonly OPENCLAW_TOPIC_CLASSIFIER_PROMPT = `Classify if NEW MESSAGE continues current task or starts an unrelated one.
|
|
359
|
+
Output ONLY JSON: {"d":"S"|"N","c":0.0-1.0}
|
|
360
|
+
d=S(same) or N(new). c=confidence. Default S. Only N if completely unrelated domain.
|
|
361
|
+
Sub-questions, tools, methods, details of current topic = S.`;
|
|
362
|
+
|
|
363
|
+
static readonly OPENCLAW_TOPIC_ARBITRATION_PROMPT = `A classifier flagged this message as possibly new topic (low confidence). Is it truly UNRELATED, or a sub-question/follow-up?
|
|
364
|
+
Tools/methods/details of current task = SAME. Shared entity/theme = SAME. Entirely different domain = NEW.
|
|
365
|
+
Reply one word: NEW or SAME`;
|
|
366
|
+
|
|
351
367
|
static readonly OPENCLAW_FILTER_RELEVANT_PROMPT = `You are a memory relevance judge.
|
|
352
368
|
Given a QUERY and CANDIDATE memories, decide: does each candidate help answer the query?
|
|
353
369
|
RULES:
|
|
@@ -433,6 +449,45 @@ Reply with JSON: {"action":"MERGE","mergeTarget":2,"reason":"..."} or {"action":
|
|
|
433
449
|
return answer.startsWith("NEW");
|
|
434
450
|
}
|
|
435
451
|
|
|
452
|
+
private async classifyTopicOpenClaw(taskState: string, newMessage: string): Promise<TopicClassifyResult> {
|
|
453
|
+
this.requireOpenClawAPI();
|
|
454
|
+
const prompt = [
|
|
455
|
+
Summarizer.OPENCLAW_TOPIC_CLASSIFIER_PROMPT,
|
|
456
|
+
``,
|
|
457
|
+
`TASK:\n${taskState}`,
|
|
458
|
+
`\nMSG:\n${newMessage}`,
|
|
459
|
+
].join("\n");
|
|
460
|
+
|
|
461
|
+
const response = await this.openclawAPI!.complete({
|
|
462
|
+
prompt,
|
|
463
|
+
maxTokens: 60,
|
|
464
|
+
temperature: 0,
|
|
465
|
+
model: this.cfg?.model,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
return parseTopicClassifyResult(response.text.trim(), this.log);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private async arbitrateTopicSplitOpenClaw(taskState: string, newMessage: string): Promise<string> {
|
|
472
|
+
this.requireOpenClawAPI();
|
|
473
|
+
const prompt = [
|
|
474
|
+
Summarizer.OPENCLAW_TOPIC_ARBITRATION_PROMPT,
|
|
475
|
+
``,
|
|
476
|
+
`TASK:\n${taskState}`,
|
|
477
|
+
`\nMSG:\n${newMessage}`,
|
|
478
|
+
].join("\n");
|
|
479
|
+
|
|
480
|
+
const response = await this.openclawAPI!.complete({
|
|
481
|
+
prompt,
|
|
482
|
+
maxTokens: 10,
|
|
483
|
+
temperature: 0,
|
|
484
|
+
model: this.cfg?.model,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const answer = response.text.trim().toUpperCase();
|
|
488
|
+
return answer.startsWith("NEW") ? "NEW" : "SAME";
|
|
489
|
+
}
|
|
490
|
+
|
|
436
491
|
private async filterRelevantOpenClaw(
|
|
437
492
|
query: string,
|
|
438
493
|
candidates: Array<{ index: number; role: string; content: string; time?: string }>,
|
|
@@ -643,6 +698,52 @@ function callJudgeDedup(cfg: SummarizerConfig, newSummary: string, candidates: A
|
|
|
643
698
|
}
|
|
644
699
|
}
|
|
645
700
|
|
|
701
|
+
function callTopicClassifier(cfg: SummarizerConfig, taskState: string, newMessage: string, log: Logger): Promise<TopicClassifyResult> {
|
|
702
|
+
switch (cfg.provider) {
|
|
703
|
+
case "openai":
|
|
704
|
+
case "openai_compatible":
|
|
705
|
+
case "azure_openai":
|
|
706
|
+
case "zhipu":
|
|
707
|
+
case "siliconflow":
|
|
708
|
+
case "deepseek":
|
|
709
|
+
case "moonshot":
|
|
710
|
+
case "bailian":
|
|
711
|
+
case "cohere":
|
|
712
|
+
case "mistral":
|
|
713
|
+
case "voyage":
|
|
714
|
+
return classifyTopicOpenAI(taskState, newMessage, cfg, log);
|
|
715
|
+
case "anthropic":
|
|
716
|
+
case "gemini":
|
|
717
|
+
case "bedrock":
|
|
718
|
+
return classifyTopicOpenAI(taskState, newMessage, cfg, log);
|
|
719
|
+
default:
|
|
720
|
+
throw new Error(`Unknown summarizer provider: ${cfg.provider}`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function callTopicArbitration(cfg: SummarizerConfig, taskState: string, newMessage: string, log: Logger): Promise<string> {
|
|
725
|
+
switch (cfg.provider) {
|
|
726
|
+
case "openai":
|
|
727
|
+
case "openai_compatible":
|
|
728
|
+
case "azure_openai":
|
|
729
|
+
case "zhipu":
|
|
730
|
+
case "siliconflow":
|
|
731
|
+
case "deepseek":
|
|
732
|
+
case "moonshot":
|
|
733
|
+
case "bailian":
|
|
734
|
+
case "cohere":
|
|
735
|
+
case "mistral":
|
|
736
|
+
case "voyage":
|
|
737
|
+
return arbitrateTopicSplitOpenAI(taskState, newMessage, cfg, log);
|
|
738
|
+
case "anthropic":
|
|
739
|
+
case "gemini":
|
|
740
|
+
case "bedrock":
|
|
741
|
+
return arbitrateTopicSplitOpenAI(taskState, newMessage, cfg, log);
|
|
742
|
+
default:
|
|
743
|
+
throw new Error(`Unknown summarizer provider: ${cfg.provider}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
646
747
|
// ─── Fallbacks ───
|
|
647
748
|
|
|
648
749
|
function ruleFallback(text: string): string {
|