@memtensor/memos-local-openclaw-plugin 1.0.2-beta.4 → 1.0.2-beta.6
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/dist/capture/index.js +52 -8
- package/dist/capture/index.js.map +1 -1
- package/dist/ingest/chunker.d.ts +3 -4
- package/dist/ingest/chunker.d.ts.map +1 -1
- package/dist/ingest/chunker.js +19 -24
- package/dist/ingest/chunker.js.map +1 -1
- package/dist/ingest/providers/anthropic.d.ts +3 -1
- package/dist/ingest/providers/anthropic.d.ts.map +1 -1
- package/dist/ingest/providers/anthropic.js +90 -51
- package/dist/ingest/providers/anthropic.js.map +1 -1
- package/dist/ingest/providers/bedrock.d.ts +3 -1
- package/dist/ingest/providers/bedrock.d.ts.map +1 -1
- package/dist/ingest/providers/bedrock.js +90 -51
- package/dist/ingest/providers/bedrock.js.map +1 -1
- package/dist/ingest/providers/gemini.d.ts +3 -1
- package/dist/ingest/providers/gemini.d.ts.map +1 -1
- package/dist/ingest/providers/gemini.js +88 -51
- package/dist/ingest/providers/gemini.js.map +1 -1
- package/dist/ingest/providers/index.d.ts +3 -1
- package/dist/ingest/providers/index.d.ts.map +1 -1
- package/dist/ingest/providers/index.js +70 -30
- package/dist/ingest/providers/index.js.map +1 -1
- package/dist/ingest/providers/openai.d.ts +3 -1
- package/dist/ingest/providers/openai.d.ts.map +1 -1
- package/dist/ingest/providers/openai.js +91 -51
- package/dist/ingest/providers/openai.js.map +1 -1
- package/dist/ingest/task-processor.d.ts +1 -0
- package/dist/ingest/task-processor.d.ts.map +1 -1
- package/dist/ingest/task-processor.js +33 -9
- package/dist/ingest/task-processor.js.map +1 -1
- package/dist/ingest/worker.d.ts.map +1 -1
- package/dist/ingest/worker.js +29 -13
- package/dist/ingest/worker.js.map +1 -1
- package/dist/recall/engine.d.ts.map +1 -1
- package/dist/recall/engine.js +19 -14
- package/dist/recall/engine.js.map +1 -1
- package/dist/skill/bundled-memory-guide.d.ts +1 -5
- package/dist/skill/bundled-memory-guide.d.ts.map +1 -1
- package/dist/skill/bundled-memory-guide.js +38 -88
- package/dist/skill/bundled-memory-guide.js.map +1 -1
- package/dist/skill/evaluator.js +1 -1
- package/dist/storage/sqlite.d.ts +1 -2
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +90 -17
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/tools/memory-get.d.ts.map +1 -1
- package/dist/tools/memory-get.js +1 -3
- package/dist/tools/memory-get.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/dist/update-check.d.ts +21 -0
- package/dist/update-check.d.ts.map +1 -0
- package/dist/update-check.js +111 -0
- package/dist/update-check.js.map +1 -0
- package/dist/viewer/html.d.ts +1 -1
- package/dist/viewer/html.d.ts.map +1 -1
- package/dist/viewer/html.js +608 -234
- package/dist/viewer/html.js.map +1 -1
- package/dist/viewer/server.d.ts +2 -1
- package/dist/viewer/server.d.ts.map +1 -1
- package/dist/viewer/server.js +201 -90
- package/dist/viewer/server.js.map +1 -1
- package/index.ts +206 -198
- package/openclaw.plugin.json +3 -0
- package/package.json +6 -1
- package/scripts/postinstall.cjs +69 -2
- package/skill/memos-memory-guide/SKILL.md +73 -36
- package/src/capture/index.ts +52 -8
- package/src/ingest/chunker.ts +22 -30
- package/src/ingest/providers/anthropic.ts +100 -53
- package/src/ingest/providers/bedrock.ts +101 -53
- package/src/ingest/providers/gemini.ts +100 -53
- package/src/ingest/providers/index.ts +81 -35
- package/src/ingest/providers/openai.ts +101 -53
- package/src/ingest/task-processor.ts +29 -8
- package/src/ingest/worker.ts +31 -13
- package/src/recall/engine.ts +20 -13
- package/src/skill/bundled-memory-guide.ts +5 -87
- package/src/skill/evaluator.ts +1 -1
- package/src/storage/sqlite.ts +93 -21
- package/src/tools/memory-get.ts +1 -4
- package/src/types.ts +2 -9
- package/src/update-check.ts +96 -0
- package/src/viewer/html.ts +607 -233
- package/src/viewer/server.ts +152 -82
|
@@ -1,29 +1,35 @@
|
|
|
1
1
|
import type { SummarizerConfig, Logger } from "../../types";
|
|
2
2
|
|
|
3
|
-
const SYSTEM_PROMPT = `You
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
3
|
+
const SYSTEM_PROMPT = `You generate a retrieval-friendly title.
|
|
4
|
+
|
|
5
|
+
Return exactly one noun phrase that names the topic AND its key details.
|
|
6
|
+
|
|
7
|
+
Requirements:
|
|
8
|
+
- Same language as input
|
|
9
|
+
- Keep proper nouns, API/function names, specific parameters, versions, error codes
|
|
10
|
+
- Include WHO/WHAT/WHERE details when present (e.g. person name + event, tool name + what it does)
|
|
11
|
+
- Prefer concrete topic words over generic words
|
|
12
|
+
- No verbs unless unavoidable
|
|
13
|
+
- No generic endings like:
|
|
14
|
+
功能说明、使用说明、简介、介绍、用途、summary、overview、basics
|
|
15
|
+
- Chinese: 10-50 characters (aim for 15-30)
|
|
16
|
+
- Non-Chinese: 5-15 words (aim for 8-12)
|
|
17
|
+
- Output title only`;
|
|
13
18
|
|
|
14
19
|
const TASK_SUMMARY_PROMPT = `You create a DETAILED task summary from a multi-turn conversation. This summary will be the ONLY record of this conversation, so it must preserve ALL important information.
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
## LANGUAGE RULE (HIGHEST PRIORITY)
|
|
22
|
+
Detect the PRIMARY language of the user's messages. If most user messages are Chinese, ALL output (title, goal, steps, result, details) MUST be in Chinese. If English, output in English. NEVER mix. This rule overrides everything below.
|
|
17
23
|
|
|
18
24
|
Output EXACTLY this structure:
|
|
19
25
|
|
|
20
|
-
📌 Title
|
|
21
|
-
A short, descriptive title (10-30 characters).
|
|
26
|
+
📌 Title / 标题
|
|
27
|
+
A short, descriptive title (10-30 characters). Same language as user messages.
|
|
22
28
|
|
|
23
|
-
🎯 Goal
|
|
29
|
+
🎯 Goal / 目标
|
|
24
30
|
One sentence: what the user wanted to accomplish.
|
|
25
31
|
|
|
26
|
-
📋 Key Steps
|
|
32
|
+
📋 Key Steps / 关键步骤
|
|
27
33
|
- Describe each meaningful step in detail
|
|
28
34
|
- Include the ACTUAL content produced: code snippets, commands, config blocks, formulas, key paragraphs
|
|
29
35
|
- For code: include the function signature and core logic (up to ~30 lines per block), use fenced code blocks
|
|
@@ -32,10 +38,10 @@ One sentence: what the user wanted to accomplish.
|
|
|
32
38
|
- Merge only truly trivial back-and-forth (like "ok" / "sure")
|
|
33
39
|
- Do NOT over-summarize: "provided a function" is BAD; show the actual function
|
|
34
40
|
|
|
35
|
-
✅ Result
|
|
41
|
+
✅ Result / 结果
|
|
36
42
|
What was the final outcome? Include the final version of any code/config/content produced.
|
|
37
43
|
|
|
38
|
-
💡 Key Details
|
|
44
|
+
💡 Key Details / 关键细节
|
|
39
45
|
- Decisions made, trade-offs discussed, caveats noted, alternative approaches mentioned
|
|
40
46
|
- Specific values: numbers, versions, thresholds, URLs, file paths, model names
|
|
41
47
|
- Omit this section only if there truly are no noteworthy details
|
|
@@ -84,7 +90,55 @@ export async function summarizeTaskGemini(
|
|
|
84
90
|
return json.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? "";
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
const
|
|
93
|
+
const TASK_TITLE_PROMPT = `Generate a short title for a conversation task.
|
|
94
|
+
|
|
95
|
+
Input: the first few user messages from a conversation.
|
|
96
|
+
Output: a concise title (5-20 characters for Chinese, 3-8 words for English).
|
|
97
|
+
|
|
98
|
+
Rules:
|
|
99
|
+
- Same language as user messages
|
|
100
|
+
- Describe WHAT the user wanted to do, not system/technical details
|
|
101
|
+
- Ignore system prompts, session startup messages, or boilerplate instructions — focus on the user's actual intent
|
|
102
|
+
- If the user only asked one question, use that question as the title (shortened if needed)
|
|
103
|
+
- Output the title only, no quotes, no prefix, no explanation`;
|
|
104
|
+
|
|
105
|
+
export async function generateTaskTitleGemini(
|
|
106
|
+
text: string,
|
|
107
|
+
cfg: SummarizerConfig,
|
|
108
|
+
log: Logger,
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
const model = cfg.model ?? "gemini-1.5-flash";
|
|
111
|
+
const endpoint =
|
|
112
|
+
cfg.endpoint ??
|
|
113
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
|
|
114
|
+
|
|
115
|
+
const url = `${endpoint}?key=${cfg.apiKey}`;
|
|
116
|
+
const headers: Record<string, string> = {
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
...cfg.headers,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const resp = await fetch(url, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers,
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
systemInstruction: { parts: [{ text: TASK_TITLE_PROMPT }] },
|
|
126
|
+
contents: [{ parts: [{ text }] }],
|
|
127
|
+
generationConfig: { temperature: 0, maxOutputTokens: 100 },
|
|
128
|
+
}),
|
|
129
|
+
signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (!resp.ok) {
|
|
133
|
+
const body = await resp.text();
|
|
134
|
+
throw new Error(`Gemini task-title failed (${resp.status}): ${body}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const json = (await resp.json()) as { candidates: Array<{ content: { parts: Array<{ text: string }> } }> };
|
|
138
|
+
return json.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? "";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context and a NEW user message, decide if the new message belongs to the SAME task or starts a NEW one.
|
|
88
142
|
|
|
89
143
|
Answer ONLY "NEW" or "SAME".
|
|
90
144
|
|
|
@@ -92,22 +146,21 @@ SAME — the new message:
|
|
|
92
146
|
- Continues, follows up on, refines, or corrects the same subject/project/task
|
|
93
147
|
- Asks a clarification or next-step question about what was just discussed
|
|
94
148
|
- Reports a result, error, or feedback about the current task
|
|
95
|
-
- Discusses different tools
|
|
96
|
-
-
|
|
97
|
-
- Is a short acknowledgment (ok, thanks, 好的, 嗯) in direct response to the current flow
|
|
149
|
+
- Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME)
|
|
150
|
+
- Is a short acknowledgment (ok, thanks, 好的) in response to the current flow
|
|
98
151
|
|
|
99
152
|
NEW — the new message:
|
|
100
|
-
- Introduces a
|
|
101
|
-
-
|
|
102
|
-
- Starts a request about a
|
|
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
|
+
- Starts a request about a different project, system, or life area
|
|
103
156
|
- Begins with a new greeting/reset followed by a different topic
|
|
104
157
|
|
|
105
158
|
Key principles:
|
|
106
|
-
-
|
|
107
|
-
- Different aspects
|
|
108
|
-
-
|
|
109
|
-
-
|
|
110
|
-
- Examples: "
|
|
159
|
+
- If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW
|
|
160
|
+
- 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 config → cooking 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
|
|
111
164
|
|
|
112
165
|
Output exactly one word: NEW or SAME`;
|
|
113
166
|
|
|
@@ -152,39 +205,30 @@ export async function judgeNewTopicGemini(
|
|
|
152
205
|
return answer.startsWith("NEW");
|
|
153
206
|
}
|
|
154
207
|
|
|
155
|
-
const FILTER_RELEVANT_PROMPT = `You are a
|
|
156
|
-
|
|
157
|
-
1. Select ONLY candidates that are DIRECTLY relevant to the query's topic.
|
|
158
|
-
- A candidate is relevant ONLY if it shares the same subject/topic as the query.
|
|
159
|
-
- EXCLUDE candidates about unrelated topics, even if they are from the same user.
|
|
160
|
-
- For list/history questions (e.g. "which companies did I work at"), include all MATCHING items.
|
|
161
|
-
- For factual lookups, a single direct answer is enough.
|
|
162
|
-
- When in doubt, EXCLUDE the candidate. Precision is more important than recall.
|
|
163
|
-
2. Judge whether the selected memories are SUFFICIENT to fully answer the query.
|
|
208
|
+
const FILTER_RELEVANT_PROMPT = `You are a memory relevance judge.
|
|
164
209
|
|
|
165
|
-
|
|
166
|
-
- Query: "recipe for braised beef" → ONLY include candidates about cooking/recipes/beef. EXCLUDE candidates about weather, deployment, identity, etc.
|
|
167
|
-
- Query: "我是谁" → ONLY include candidates about user identity/name/profile. EXCLUDE candidates about cooking, news, technical issues, etc.
|
|
168
|
-
- Query: "SSH port" → ONLY include candidates mentioning SSH or port configuration.
|
|
210
|
+
Given a QUERY and CANDIDATE memories, decide: does each candidate's content contain information that would HELP ANSWER the query?
|
|
169
211
|
|
|
170
|
-
|
|
171
|
-
-
|
|
172
|
-
-
|
|
212
|
+
CORE QUESTION: "If I include this memory, will it help produce a better answer?"
|
|
213
|
+
- YES → include
|
|
214
|
+
- NO → exclude
|
|
173
215
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
- "sufficient": true ONLY if the memories contain a direct answer; false otherwise.
|
|
216
|
+
RULES:
|
|
217
|
+
1. A candidate is relevant if its content provides facts, context, or data that directly supports answering the query.
|
|
218
|
+
2. A candidate that merely shares the same broad topic/domain but contains NO useful information for answering is NOT relevant.
|
|
219
|
+
3. If NO candidate can help answer the query, return {"relevant":[],"sufficient":false} — do NOT force-pick the "least irrelevant" one.
|
|
179
220
|
|
|
180
|
-
|
|
221
|
+
OUTPUT — JSON only:
|
|
222
|
+
{"relevant":[1,3],"sufficient":true}
|
|
223
|
+
- "relevant": candidate numbers whose content helps answer the query. [] if none can help.
|
|
224
|
+
- "sufficient": true only if the selected memories fully answer the query.`;
|
|
181
225
|
|
|
182
226
|
import type { FilterResult } from "./openai";
|
|
183
227
|
export type { FilterResult } from "./openai";
|
|
184
228
|
|
|
185
229
|
export async function filterRelevantGemini(
|
|
186
230
|
query: string,
|
|
187
|
-
candidates: Array<{ index: number;
|
|
231
|
+
candidates: Array<{ index: number; role: string; content: string; time?: string }>,
|
|
188
232
|
cfg: SummarizerConfig,
|
|
189
233
|
log: Logger,
|
|
190
234
|
): Promise<FilterResult> {
|
|
@@ -200,7 +244,10 @@ export async function filterRelevantGemini(
|
|
|
200
244
|
};
|
|
201
245
|
|
|
202
246
|
const candidateText = candidates
|
|
203
|
-
.map((c) =>
|
|
247
|
+
.map((c) => {
|
|
248
|
+
const timeTag = c.time ? ` (${c.time})` : "";
|
|
249
|
+
return `${c.index}. [${c.role}]${timeTag}\n ${c.content}`;
|
|
250
|
+
})
|
|
204
251
|
.join("\n");
|
|
205
252
|
|
|
206
253
|
const resp = await fetch(url, {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import type { SummarizerConfig, Logger } from "../../types";
|
|
4
|
-
import { summarizeOpenAI, summarizeTaskOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI } from "./openai";
|
|
4
|
+
import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI } from "./openai";
|
|
5
5
|
import type { FilterResult, DedupResult } from "./openai";
|
|
6
6
|
export type { FilterResult, DedupResult } from "./openai";
|
|
7
|
-
import { summarizeAnthropic, summarizeTaskAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic } from "./anthropic";
|
|
8
|
-
import { summarizeGemini, summarizeTaskGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini } from "./gemini";
|
|
9
|
-
import { summarizeBedrock, summarizeTaskBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock } from "./bedrock";
|
|
7
|
+
import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic } from "./anthropic";
|
|
8
|
+
import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini } from "./gemini";
|
|
9
|
+
import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock } from "./bedrock";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Build a SummarizerConfig from OpenClaw's native model configuration (openclaw.json).
|
|
@@ -163,34 +163,52 @@ export class Summarizer {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
async summarize(text: string): Promise<string> {
|
|
166
|
+
const cleaned = stripMarkdown(text).trim();
|
|
167
|
+
|
|
168
|
+
if (wordCount(cleaned) <= 10) {
|
|
169
|
+
return cleaned;
|
|
170
|
+
}
|
|
171
|
+
|
|
166
172
|
if (!this.cfg && !this.fallbackCfg) {
|
|
167
|
-
return ruleFallback(
|
|
173
|
+
return ruleFallback(cleaned);
|
|
168
174
|
}
|
|
169
175
|
|
|
170
|
-
const
|
|
176
|
+
const accept = (s: string | undefined): s is string =>
|
|
177
|
+
!!s && s.length > 0 && s.length < cleaned.length;
|
|
171
178
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
179
|
+
let llmCalled = false;
|
|
180
|
+
try {
|
|
181
|
+
const result = await this.tryChain("summarize", (cfg) => callSummarize(cfg, text, this.log));
|
|
182
|
+
llmCalled = true;
|
|
183
|
+
const resultCleaned = result ? stripMarkdown(result).trim() : undefined;
|
|
175
184
|
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
if (accept(resultCleaned)) {
|
|
186
|
+
return resultCleaned;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (resultCleaned !== undefined) {
|
|
190
|
+
this.log.warn(`summarize: result (${(resultCleaned as string).length}) >= input (${cleaned.length}), retrying`);
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
this.log.warn(`summarize primary failed: ${err}`);
|
|
178
194
|
}
|
|
179
195
|
|
|
180
196
|
const fallback = this.fallbackCfg ?? this.cfg;
|
|
181
197
|
if (fallback) {
|
|
182
198
|
try {
|
|
183
199
|
const retry = await callSummarize(fallback, text, this.log);
|
|
184
|
-
|
|
200
|
+
llmCalled = true;
|
|
201
|
+
const retryCleaned = retry ? stripMarkdown(retry).trim() : undefined;
|
|
202
|
+
if (accept(retryCleaned)) {
|
|
185
203
|
modelHealth.recordSuccess("summarize", `${fallback.provider}/${fallback.model ?? "?"}`);
|
|
186
|
-
return
|
|
204
|
+
return retryCleaned;
|
|
187
205
|
}
|
|
188
206
|
} catch (err) {
|
|
189
207
|
this.log.warn(`summarize fallback retry failed: ${err}`);
|
|
190
208
|
}
|
|
191
209
|
}
|
|
192
210
|
|
|
193
|
-
return ruleFallback(
|
|
211
|
+
return llmCalled ? cleaned : ruleFallback(cleaned);
|
|
194
212
|
}
|
|
195
213
|
|
|
196
214
|
async summarizeTask(text: string): Promise<string> {
|
|
@@ -202,6 +220,12 @@ export class Summarizer {
|
|
|
202
220
|
return result ?? taskFallback(text);
|
|
203
221
|
}
|
|
204
222
|
|
|
223
|
+
async generateTaskTitle(text: string): Promise<string> {
|
|
224
|
+
if (!this.cfg && !this.fallbackCfg) return "";
|
|
225
|
+
const result = await this.tryChain("generateTaskTitle", (cfg) => callGenerateTaskTitle(cfg, text, this.log));
|
|
226
|
+
return result ?? "";
|
|
227
|
+
}
|
|
228
|
+
|
|
205
229
|
async judgeNewTopic(currentContext: string, newMessage: string): Promise<boolean | null> {
|
|
206
230
|
const chain: SummarizerConfig[] = [];
|
|
207
231
|
if (this.strongCfg) chain.push(this.strongCfg);
|
|
@@ -226,7 +250,7 @@ export class Summarizer {
|
|
|
226
250
|
|
|
227
251
|
async filterRelevant(
|
|
228
252
|
query: string,
|
|
229
|
-
candidates: Array<{ index: number;
|
|
253
|
+
candidates: Array<{ index: number; role: string; content: string; time?: string }>,
|
|
230
254
|
): Promise<FilterResult | null> {
|
|
231
255
|
if (!this.cfg && !this.fallbackCfg) return null;
|
|
232
256
|
if (candidates.length === 0) return { relevant: [], sufficient: true };
|
|
@@ -287,6 +311,23 @@ function callSummarizeTask(cfg: SummarizerConfig, text: string, log: Logger): Pr
|
|
|
287
311
|
}
|
|
288
312
|
}
|
|
289
313
|
|
|
314
|
+
function callGenerateTaskTitle(cfg: SummarizerConfig, text: string, log: Logger): Promise<string> {
|
|
315
|
+
switch (cfg.provider) {
|
|
316
|
+
case "openai":
|
|
317
|
+
case "openai_compatible":
|
|
318
|
+
case "azure_openai":
|
|
319
|
+
return generateTaskTitleOpenAI(text, cfg, log);
|
|
320
|
+
case "anthropic":
|
|
321
|
+
return generateTaskTitleAnthropic(text, cfg, log);
|
|
322
|
+
case "gemini":
|
|
323
|
+
return generateTaskTitleGemini(text, cfg, log);
|
|
324
|
+
case "bedrock":
|
|
325
|
+
return generateTaskTitleBedrock(text, cfg, log);
|
|
326
|
+
default:
|
|
327
|
+
throw new Error(`Unknown summarizer provider: ${cfg.provider}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
290
331
|
function callTopicJudge(cfg: SummarizerConfig, currentContext: string, newMessage: string, log: Logger): Promise<boolean> {
|
|
291
332
|
switch (cfg.provider) {
|
|
292
333
|
case "openai":
|
|
@@ -304,7 +345,7 @@ function callTopicJudge(cfg: SummarizerConfig, currentContext: string, newMessag
|
|
|
304
345
|
}
|
|
305
346
|
}
|
|
306
347
|
|
|
307
|
-
function callFilterRelevant(cfg: SummarizerConfig, query: string, candidates: Array<{ index: number;
|
|
348
|
+
function callFilterRelevant(cfg: SummarizerConfig, query: string, candidates: Array<{ index: number; role: string; content: string; time?: string }>, log: Logger): Promise<FilterResult> {
|
|
308
349
|
switch (cfg.provider) {
|
|
309
350
|
case "openai":
|
|
310
351
|
case "openai_compatible":
|
|
@@ -340,29 +381,34 @@ function callJudgeDedup(cfg: SummarizerConfig, newSummary: string, candidates: A
|
|
|
340
381
|
|
|
341
382
|
// ─── Fallbacks ───
|
|
342
383
|
|
|
384
|
+
function ruleFallback(text: string): string {
|
|
385
|
+
const lines = text.split("\n").filter((l) => l.trim().length > 5);
|
|
386
|
+
return (lines[0] ?? text).trim();
|
|
387
|
+
}
|
|
388
|
+
|
|
343
389
|
function taskFallback(text: string): string {
|
|
344
390
|
const lines = text.split("\n").filter((l) => l.trim().length > 10);
|
|
345
391
|
return lines.slice(0, 30).join("\n").slice(0, 2000);
|
|
346
392
|
}
|
|
347
393
|
|
|
348
|
-
function
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
}
|
|
394
|
+
function stripMarkdown(text: string): string {
|
|
395
|
+
return text
|
|
396
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
397
|
+
.replace(/\*([^*]+)\*/g, "$1")
|
|
398
|
+
.replace(/^#{1,6}\s+/gm, "")
|
|
399
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
400
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
401
|
+
.trim();
|
|
402
|
+
}
|
|
359
403
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
let
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
404
|
+
/** Count "words": CJK characters count as 1 word each, latin words separated by spaces. */
|
|
405
|
+
function wordCount(text: string): number {
|
|
406
|
+
let count = 0;
|
|
407
|
+
const cjk = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g;
|
|
408
|
+
const cjkMatches = text.match(cjk);
|
|
409
|
+
if (cjkMatches) count += cjkMatches.length;
|
|
410
|
+
const noCjk = text.replace(cjk, " ").trim();
|
|
411
|
+
if (noCjk) count += noCjk.split(/\s+/).filter(Boolean).length;
|
|
412
|
+
return count;
|
|
368
413
|
}
|
|
414
|
+
|
|
@@ -1,29 +1,35 @@
|
|
|
1
1
|
import type { SummarizerConfig, Logger } from "../../types";
|
|
2
2
|
|
|
3
|
-
const SYSTEM_PROMPT = `You
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
3
|
+
const SYSTEM_PROMPT = `You generate a retrieval-friendly title.
|
|
4
|
+
|
|
5
|
+
Return exactly one noun phrase that names the topic AND its key details.
|
|
6
|
+
|
|
7
|
+
Requirements:
|
|
8
|
+
- Same language as input
|
|
9
|
+
- Keep proper nouns, API/function names, specific parameters, versions, error codes
|
|
10
|
+
- Include WHO/WHAT/WHERE details when present (e.g. person name + event, tool name + what it does)
|
|
11
|
+
- Prefer concrete topic words over generic words
|
|
12
|
+
- No verbs unless unavoidable
|
|
13
|
+
- No generic endings like:
|
|
14
|
+
功能说明、使用说明、简介、介绍、用途、summary、overview、basics
|
|
15
|
+
- Chinese: 10-50 characters (aim for 15-30)
|
|
16
|
+
- Non-Chinese: 5-15 words (aim for 8-12)
|
|
17
|
+
- Output title only`;
|
|
13
18
|
|
|
14
19
|
const TASK_SUMMARY_PROMPT = `You create a DETAILED task summary from a multi-turn conversation. This summary will be the ONLY record of this conversation, so it must preserve ALL important information.
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
## LANGUAGE RULE (HIGHEST PRIORITY)
|
|
22
|
+
Detect the PRIMARY language of the user's messages. If most user messages are Chinese, ALL output (title, goal, steps, result, details) MUST be in Chinese. If English, output in English. NEVER mix. This rule overrides everything below.
|
|
17
23
|
|
|
18
24
|
Output EXACTLY this structure:
|
|
19
25
|
|
|
20
|
-
📌 Title
|
|
21
|
-
A short, descriptive title (10-30 characters).
|
|
26
|
+
📌 Title / 标题
|
|
27
|
+
A short, descriptive title (10-30 characters). Same language as user messages.
|
|
22
28
|
|
|
23
|
-
🎯 Goal
|
|
29
|
+
🎯 Goal / 目标
|
|
24
30
|
One sentence: what the user wanted to accomplish.
|
|
25
31
|
|
|
26
|
-
📋 Key Steps
|
|
32
|
+
📋 Key Steps / 关键步骤
|
|
27
33
|
- Describe each meaningful step in detail
|
|
28
34
|
- Include the ACTUAL content produced: code snippets, commands, config blocks, formulas, key paragraphs
|
|
29
35
|
- For code: include the function signature and core logic (up to ~30 lines per block), use fenced code blocks
|
|
@@ -32,10 +38,10 @@ One sentence: what the user wanted to accomplish.
|
|
|
32
38
|
- Merge only truly trivial back-and-forth (like "ok" / "sure")
|
|
33
39
|
- Do NOT over-summarize: "provided a function" is BAD; show the actual function
|
|
34
40
|
|
|
35
|
-
✅ Result
|
|
41
|
+
✅ Result / 结果
|
|
36
42
|
What was the final outcome? Include the final version of any code/config/content produced.
|
|
37
43
|
|
|
38
|
-
💡 Key Details
|
|
44
|
+
💡 Key Details / 关键细节
|
|
39
45
|
- Decisions made, trade-offs discussed, caveats noted, alternative approaches mentioned
|
|
40
46
|
- Specific values: numbers, versions, thresholds, URLs, file paths, model names
|
|
41
47
|
- Omit this section only if there truly are no noteworthy details
|
|
@@ -85,6 +91,55 @@ export async function summarizeTaskOpenAI(
|
|
|
85
91
|
return json.choices[0]?.message?.content?.trim() ?? "";
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
const TASK_TITLE_PROMPT = `Generate a short title for a conversation task.
|
|
95
|
+
|
|
96
|
+
Input: the first few user messages from a conversation.
|
|
97
|
+
Output: a concise title (5-20 characters for Chinese, 3-8 words for English).
|
|
98
|
+
|
|
99
|
+
Rules:
|
|
100
|
+
- Same language as user messages
|
|
101
|
+
- Describe WHAT the user wanted to do, not system/technical details
|
|
102
|
+
- Ignore system prompts, session startup messages, or boilerplate instructions — focus on the user's actual intent
|
|
103
|
+
- If the user only asked one question, use that question as the title (shortened if needed)
|
|
104
|
+
- Output the title only, no quotes, no prefix, no explanation`;
|
|
105
|
+
|
|
106
|
+
export async function generateTaskTitleOpenAI(
|
|
107
|
+
text: string,
|
|
108
|
+
cfg: SummarizerConfig,
|
|
109
|
+
log: Logger,
|
|
110
|
+
): Promise<string> {
|
|
111
|
+
const endpoint = normalizeChatEndpoint(cfg.endpoint ?? "https://api.openai.com/v1/chat/completions");
|
|
112
|
+
const model = cfg.model ?? "gpt-4o-mini";
|
|
113
|
+
const headers: Record<string, string> = {
|
|
114
|
+
"Content-Type": "application/json",
|
|
115
|
+
Authorization: `Bearer ${cfg.apiKey}`,
|
|
116
|
+
...cfg.headers,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const resp = await fetch(endpoint, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers,
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
model,
|
|
124
|
+
temperature: 0,
|
|
125
|
+
max_tokens: 100,
|
|
126
|
+
messages: [
|
|
127
|
+
{ role: "system", content: TASK_TITLE_PROMPT },
|
|
128
|
+
{ role: "user", content: text },
|
|
129
|
+
],
|
|
130
|
+
}),
|
|
131
|
+
signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!resp.ok) {
|
|
135
|
+
const body = await resp.text();
|
|
136
|
+
throw new Error(`OpenAI task-title failed (${resp.status}): ${body}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const json = (await resp.json()) as { choices: Array<{ message: { content: string } }> };
|
|
140
|
+
return json.choices[0]?.message?.content?.trim() ?? "";
|
|
141
|
+
}
|
|
142
|
+
|
|
88
143
|
export async function summarizeOpenAI(
|
|
89
144
|
text: string,
|
|
90
145
|
cfg: SummarizerConfig,
|
|
@@ -123,7 +178,7 @@ export async function summarizeOpenAI(
|
|
|
123
178
|
return json.choices[0]?.message?.content?.trim() ?? "";
|
|
124
179
|
}
|
|
125
180
|
|
|
126
|
-
const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context
|
|
181
|
+
const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context and a NEW user message, decide if the new message belongs to the SAME task or starts a NEW one.
|
|
127
182
|
|
|
128
183
|
Answer ONLY "NEW" or "SAME".
|
|
129
184
|
|
|
@@ -131,22 +186,21 @@ SAME — the new message:
|
|
|
131
186
|
- Continues, follows up on, refines, or corrects the same subject/project/task
|
|
132
187
|
- Asks a clarification or next-step question about what was just discussed
|
|
133
188
|
- Reports a result, error, or feedback about the current task
|
|
134
|
-
- Discusses different tools
|
|
135
|
-
-
|
|
136
|
-
- Is a short acknowledgment (ok, thanks, 好的, 嗯) in direct response to the current flow
|
|
189
|
+
- Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME)
|
|
190
|
+
- Is a short acknowledgment (ok, thanks, 好的) in response to the current flow
|
|
137
191
|
|
|
138
192
|
NEW — the new message:
|
|
139
|
-
- Introduces a
|
|
140
|
-
-
|
|
141
|
-
- Starts a request about a
|
|
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
|
|
195
|
+
- Starts a request about a different project, system, or life area
|
|
142
196
|
- Begins with a new greeting/reset followed by a different topic
|
|
143
197
|
|
|
144
198
|
Key principles:
|
|
145
|
-
-
|
|
146
|
-
- Different aspects
|
|
147
|
-
-
|
|
148
|
-
-
|
|
149
|
-
- Examples: "
|
|
199
|
+
- If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW
|
|
200
|
+
- 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 config → cooking 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
|
|
150
204
|
|
|
151
205
|
Output exactly one word: NEW or SAME`;
|
|
152
206
|
|
|
@@ -192,32 +246,23 @@ export async function judgeNewTopicOpenAI(
|
|
|
192
246
|
return answer.startsWith("NEW");
|
|
193
247
|
}
|
|
194
248
|
|
|
195
|
-
const FILTER_RELEVANT_PROMPT = `You are a
|
|
196
|
-
|
|
197
|
-
1. Select ONLY candidates that are DIRECTLY relevant to the query's topic.
|
|
198
|
-
- A candidate is relevant ONLY if it shares the same subject/topic as the query.
|
|
199
|
-
- EXCLUDE candidates about unrelated topics, even if they are from the same user.
|
|
200
|
-
- For list/history questions (e.g. "which companies did I work at"), include all MATCHING items.
|
|
201
|
-
- For factual lookups, a single direct answer is enough.
|
|
202
|
-
- When in doubt, EXCLUDE the candidate. Precision is more important than recall.
|
|
203
|
-
2. Judge whether the selected memories are SUFFICIENT to fully answer the query.
|
|
249
|
+
const FILTER_RELEVANT_PROMPT = `You are a memory relevance judge.
|
|
204
250
|
|
|
205
|
-
|
|
206
|
-
- Query: "recipe for braised beef" → ONLY include candidates about cooking/recipes/beef. EXCLUDE candidates about weather, deployment, identity, etc.
|
|
207
|
-
- Query: "我是谁" → ONLY include candidates about user identity/name/profile. EXCLUDE candidates about cooking, news, technical issues, etc.
|
|
208
|
-
- Query: "SSH port" → ONLY include candidates mentioning SSH or port configuration.
|
|
251
|
+
Given a QUERY and CANDIDATE memories, decide: does each candidate's content contain information that would HELP ANSWER the query?
|
|
209
252
|
|
|
210
|
-
|
|
211
|
-
-
|
|
212
|
-
-
|
|
253
|
+
CORE QUESTION: "If I include this memory, will it help produce a better answer?"
|
|
254
|
+
- YES → include
|
|
255
|
+
- NO → exclude
|
|
213
256
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
- "sufficient": true ONLY if the memories contain a direct answer; false otherwise.
|
|
257
|
+
RULES:
|
|
258
|
+
1. A candidate is relevant if its content provides facts, context, or data that directly supports answering the query.
|
|
259
|
+
2. A candidate that merely shares the same broad topic/domain but contains NO useful information for answering is NOT relevant.
|
|
260
|
+
3. If NO candidate can help answer the query, return {"relevant":[],"sufficient":false} — do NOT force-pick the "least irrelevant" one.
|
|
219
261
|
|
|
220
|
-
|
|
262
|
+
OUTPUT — JSON only:
|
|
263
|
+
{"relevant":[1,3],"sufficient":true}
|
|
264
|
+
- "relevant": candidate numbers whose content helps answer the query. [] if none can help.
|
|
265
|
+
- "sufficient": true only if the selected memories fully answer the query.`;
|
|
221
266
|
|
|
222
267
|
export interface FilterResult {
|
|
223
268
|
relevant: number[];
|
|
@@ -226,7 +271,7 @@ export interface FilterResult {
|
|
|
226
271
|
|
|
227
272
|
export async function filterRelevantOpenAI(
|
|
228
273
|
query: string,
|
|
229
|
-
candidates: Array<{ index: number;
|
|
274
|
+
candidates: Array<{ index: number; role: string; content: string; time?: string }>,
|
|
230
275
|
cfg: SummarizerConfig,
|
|
231
276
|
log: Logger,
|
|
232
277
|
): Promise<FilterResult> {
|
|
@@ -239,7 +284,10 @@ export async function filterRelevantOpenAI(
|
|
|
239
284
|
};
|
|
240
285
|
|
|
241
286
|
const candidateText = candidates
|
|
242
|
-
.map((c) =>
|
|
287
|
+
.map((c) => {
|
|
288
|
+
const timeTag = c.time ? ` (${c.time})` : "";
|
|
289
|
+
return `${c.index}. [${c.role}]${timeTag}\n ${c.content}`;
|
|
290
|
+
})
|
|
243
291
|
.join("\n");
|
|
244
292
|
|
|
245
293
|
const resp = await fetch(endpoint, {
|