@memtensor/memos-local-openclaw-plugin 1.0.8-beta.2 → 1.0.8-beta.3

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 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
 
@@ -1872,6 +1877,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1872
1877
  const rawLocalCandidates = localHits.map((h) => ({
1873
1878
  score: h.score, role: h.source.role, summary: h.summary,
1874
1879
  content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local",
1880
+ owner: h.owner || "",
1875
1881
  }));
1876
1882
  const rawHubCandidates = allHubHits.map((h) => ({
1877
1883
  score: h.score, role: h.source.role, summary: h.summary,
@@ -2079,7 +2085,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2079
2085
  store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
2080
2086
  candidates: rawLocalCandidates,
2081
2087
  hubCandidates: rawHubCandidates,
2082
- 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 || "" })),
2083
2089
  }), recallDur, true);
2084
2090
  telemetry.trackAutoRecall(filteredHits.length, recallDur);
2085
2091
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memtensor/memos-local-openclaw-plugin",
3
- "version": "1.0.8-beta.2",
3
+ "version": "1.0.8-beta.3",
4
4
  "description": "MemOS Local memory plugin for OpenClaw — full-write, hybrid-recall, progressive retrieval",
5
5
  "type": "module",
6
6
  "main": "index.ts",
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 || ""),
@@ -778,8 +779,8 @@ export class HubServer {
778
779
  this.remoteHitMap.set(remoteHitId, { chunkId: id, type: "memory", expiresAt: Date.now() + 10 * 60 * 1000, requesterUserId: auth.userId });
779
780
  return {
780
781
  remoteHitId, summary: mhit.summary, excerpt: mhit.content.slice(0, 240), hubRank: rank + 1,
781
- taskTitle: null, ownerName: mhit.owner_name || "unknown", groupName: mhit.group_name,
782
- visibility: mhit.visibility, source: { ts: mhit.created_at, role: mhit.role },
782
+ taskTitle: null, ownerName: mhit.owner_name || "unknown", sourceAgent: (mhit as any).source_agent || "",
783
+ groupName: mhit.group_name, visibility: mhit.visibility, source: { ts: mhit.created_at, role: mhit.role },
783
784
  };
784
785
  }
785
786
  let hit = ftsMap.get(id);
@@ -792,8 +793,8 @@ export class HubServer {
792
793
  this.remoteHitMap.set(remoteHitId, { chunkId: id, type: "chunk", expiresAt: Date.now() + 10 * 60 * 1000, requesterUserId: auth.userId });
793
794
  return {
794
795
  remoteHitId, summary: hit!.summary, excerpt: hit!.content.slice(0, 240), hubRank: rank + 1,
795
- taskTitle: hit!.task_title, ownerName: hit!.owner_name || "unknown", groupName: hit!.group_name,
796
- visibility: hit!.visibility, source: { ts: hit!.created_at, role: hit!.role },
796
+ taskTitle: hit!.task_title, ownerName: hit!.owner_name || "unknown", sourceAgent: "",
797
+ groupName: hit!.group_name, visibility: hit!.visibility, source: { ts: hit!.created_at, role: hit!.role },
797
798
  };
798
799
  }).filter(Boolean);
799
800
  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
- - If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW
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
- - Different unrelated technologies discussed independently are NEW (e.g., Redis configcooking recipe = NEW)
162
- - When unsure, lean toward SAME for closely related topics, but do NOT hesitate to mark NEW for obvious domain shifts
163
- - Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "MySQL配置" → "K8s部署" in same infra project = SAME; "部署服务器" → "年会安排" = 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
- - If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW
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
- - Different unrelated technologies discussed independently are NEW (e.g., Redis configcooking recipe = NEW)
164
- - When unsure, lean toward SAME for closely related topics, but do NOT hesitate to mark NEW for obvious domain shifts
165
- - Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "MySQL配置" → "K8s部署" in same infra project = SAME; "部署服务器" → "年会安排" = 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
- - If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW
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
- - Different unrelated technologies discussed independently are NEW (e.g., Redis configcooking recipe = NEW)
162
- - When unsure, lean toward SAME for closely related topics, but do NOT hesitate to mark NEW for obvious domain shifts
163
- - Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "MySQL配置" → "K8s部署" in same infra project = SAME; "部署服务器" → "年会安排" = 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 chain: SummarizerConfig[] = [];
291
- if (this.strongCfg) chain.push(this.strongCfg);
292
- if (this.fallbackCfg) chain.push(this.fallbackCfg);
293
- if (chain.length === 0 && this.cfg) chain.push(this.cfg);
294
- if (chain.length === 0) return null;
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
- for (let i = 0; i < chain.length; i++) {
297
- const modelInfo = `${chain[i].provider}/${chain[i].model ?? "?"}`;
298
- try {
299
- const result = await callTopicJudge(chain[i], currentContext, newMessage, this.log);
300
- modelHealth.recordSuccess("judgeNewTopic", modelInfo);
301
- return result;
302
- } catch (err) {
303
- const level = i < chain.length - 1 ? "warn" : "error";
304
- this.log[level](`judgeNewTopic failed (${modelInfo}), ${i < chain.length - 1 ? "trying next" : "no more fallbacks"}: ${err}`);
305
- modelHealth.recordError("judgeNewTopic", modelInfo, String(err));
306
- }
307
- }
308
- return null;
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 {
@@ -188,19 +188,26 @@ SAME — the new message:
188
188
  - Reports a result, error, or feedback about the current task
189
189
  - Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME)
190
190
  - Is a short acknowledgment (ok, thanks, 好的) in response to the current flow
191
+ - Is a follow-up, update, or different angle on the same news event, person, or story
192
+ - Shares the same core entity (person, company, event) even if the specific detail or angle differs
193
+ - Contains pronouns or references (那, 这, 它, 其中, 哪些, those, which, what about, etc.) pointing to items from the current conversation
194
+ - Asks about a sub-topic, tool, detail, dimension, or aspect of the current discussion topic
191
195
 
192
196
  NEW — the new message:
193
- - Introduces a subject from a DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel)
194
- - Has NO logical connection to what was being discussed
197
+ - Introduces a subject from a COMPLETELY DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel)
198
+ - Has NO logical connection to what was being discussed — no shared entities, events, or themes
195
199
  - Starts a request about a different project, system, or life area
196
200
  - Begins with a new greeting/reset followed by a different topic
197
201
 
198
202
  Key principles:
199
- - If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW
203
+ - Default to SAME unless the topic domain CLEARLY changed. When in doubt, choose SAME.
204
+ - 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.
205
+ - If the new message mentions the same person, event, product, or entity as the current task, it is SAME regardless of the angle
200
206
  - Different aspects of the SAME project/system are SAME (e.g., Nginx SSL → Nginx gzip = SAME)
201
- - Different unrelated technologies discussed independently are NEW (e.g., Redis configcooking recipe = NEW)
202
- - When unsure, lean toward SAME for closely related topics, but do NOT hesitate to mark NEW for obvious domain shifts
203
- - Examples: "配置Nginx" "加gzip压缩" = SAME; "配置Nginx" "做红烧肉" = NEW; "MySQL配置" "K8s部署" in same infra project = SAME; "部署服务器" → "年会安排" = NEW
207
+ - Asking about tools, systems, or methods for the current topic is SAME (e.g., "港股调研" "那处理系统有哪些" = SAME; "数据分析" "用什么工具" = SAME)
208
+ - Follow-up news about the same event is SAME (e.g., "博士失联" "博士遗体被找到" = SAME; "产品发布" "产品销量" = SAME)
209
+ - Different unrelated domains discussed independently are NEW (e.g., Redis configcooking recipe = NEW)
210
+ - Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "港股调研" → "那处理系统有哪些" = SAME; "部署服务器" → "年会安排" = NEW
204
211
 
205
212
  Output exactly one word: NEW or SAME`;
206
213
 
@@ -246,6 +253,134 @@ export async function judgeNewTopicOpenAI(
246
253
  return answer.startsWith("NEW");
247
254
  }
248
255
 
256
+ // ─── Structured Topic Classifier ───
257
+
258
+ export interface TopicClassifyResult {
259
+ decision: "NEW" | "SAME";
260
+ confidence: number;
261
+ boundaryType: string;
262
+ reason: string; // may be empty for compact responses
263
+ }
264
+
265
+ const TOPIC_CLASSIFIER_PROMPT = `Classify if NEW MESSAGE continues current task or starts an unrelated one.
266
+ Output ONLY JSON: {"d":"S"|"N","c":0.0-1.0}
267
+ d=S(same) or N(new). c=confidence. Default S. Only N if completely unrelated domain.
268
+ Sub-questions, tools, methods, details of current topic = S.`;
269
+
270
+ export async function classifyTopicOpenAI(
271
+ taskState: string,
272
+ newMessage: string,
273
+ cfg: SummarizerConfig,
274
+ log: Logger,
275
+ ): Promise<TopicClassifyResult> {
276
+ const endpoint = normalizeChatEndpoint(cfg.endpoint ?? "https://api.openai.com/v1/chat/completions");
277
+ const model = cfg.model ?? "gpt-4o-mini";
278
+ const headers: Record<string, string> = {
279
+ "Content-Type": "application/json",
280
+ Authorization: `Bearer ${cfg.apiKey}`,
281
+ ...cfg.headers,
282
+ };
283
+
284
+ const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`;
285
+
286
+ const resp = await fetch(endpoint, {
287
+ method: "POST",
288
+ headers,
289
+ body: JSON.stringify(buildRequestBody(cfg, {
290
+ model,
291
+ temperature: 0,
292
+ max_tokens: 60,
293
+ messages: [
294
+ { role: "system", content: TOPIC_CLASSIFIER_PROMPT },
295
+ { role: "user", content: userContent },
296
+ ],
297
+ })),
298
+ signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000),
299
+ });
300
+
301
+ if (!resp.ok) {
302
+ const body = await resp.text();
303
+ throw new Error(`OpenAI topic-classifier failed (${resp.status}): ${body}`);
304
+ }
305
+
306
+ const json = (await resp.json()) as { choices: Array<{ message: { content: string } }> };
307
+ const raw = json.choices[0]?.message?.content?.trim() ?? "";
308
+ log.debug(`Topic classifier raw: "${raw}"`);
309
+
310
+ return parseTopicClassifyResult(raw, log);
311
+ }
312
+
313
+ const TOPIC_ARBITRATION_PROMPT = `A classifier flagged this message as possibly new topic (low confidence). Is it truly UNRELATED, or a sub-question/follow-up?
314
+ Tools/methods/details of current task = SAME. Shared entity/theme = SAME. Entirely different domain = NEW.
315
+ Reply one word: NEW or SAME`;
316
+
317
+ export async function arbitrateTopicSplitOpenAI(
318
+ taskState: string,
319
+ newMessage: string,
320
+ cfg: SummarizerConfig,
321
+ log: Logger,
322
+ ): Promise<string> {
323
+ const endpoint = normalizeChatEndpoint(cfg.endpoint ?? "https://api.openai.com/v1/chat/completions");
324
+ const model = cfg.model ?? "gpt-4o-mini";
325
+ const headers: Record<string, string> = {
326
+ "Content-Type": "application/json",
327
+ Authorization: `Bearer ${cfg.apiKey}`,
328
+ ...cfg.headers,
329
+ };
330
+
331
+ const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`;
332
+
333
+ const resp = await fetch(endpoint, {
334
+ method: "POST",
335
+ headers,
336
+ body: JSON.stringify(buildRequestBody(cfg, {
337
+ model,
338
+ temperature: 0,
339
+ max_tokens: 10,
340
+ messages: [
341
+ { role: "system", content: TOPIC_ARBITRATION_PROMPT },
342
+ { role: "user", content: userContent },
343
+ ],
344
+ })),
345
+ signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000),
346
+ });
347
+
348
+ if (!resp.ok) {
349
+ const body = await resp.text();
350
+ throw new Error(`OpenAI topic-arbitration failed (${resp.status}): ${body}`);
351
+ }
352
+
353
+ const json = (await resp.json()) as { choices: Array<{ message: { content: string } }> };
354
+ const answer = json.choices[0]?.message?.content?.trim().toUpperCase() ?? "";
355
+ log.debug(`Topic arbitration result: "${answer}"`);
356
+ return answer.startsWith("NEW") ? "NEW" : "SAME";
357
+ }
358
+
359
+ export function parseTopicClassifyResult(raw: string, log: Logger): TopicClassifyResult {
360
+ try {
361
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
362
+ if (jsonMatch) {
363
+ const p = JSON.parse(jsonMatch[0]);
364
+ const decision: "NEW" | "SAME" =
365
+ (p.d === "N" || p.decision === "NEW") ? "NEW" : "SAME";
366
+ const confidence: number =
367
+ typeof p.c === "number" ? p.c : typeof p.confidence === "number" ? p.confidence : 0.5;
368
+ return {
369
+ decision,
370
+ confidence,
371
+ boundaryType: p.boundaryType || "",
372
+ reason: p.reason || "",
373
+ };
374
+ }
375
+ } catch (err) {
376
+ log.debug(`Failed to parse topic classify JSON: ${err}`);
377
+ }
378
+ const upper = raw.toUpperCase();
379
+ if (upper.startsWith("NEW") || upper.startsWith("N"))
380
+ return { decision: "NEW", confidence: 0.5, boundaryType: "", reason: "parse fallback" };
381
+ return { decision: "SAME", confidence: 0.5, boundaryType: "", reason: "parse fallback" };
382
+ }
383
+
249
384
  const FILTER_RELEVANT_PROMPT = `You are a memory relevance judge.
250
385
 
251
386
  Given a QUERY and CANDIDATE memories, decide: does each candidate's content contain information that would HELP ANSWER the query?