@openviking/openclaw-plugin 2026.5.8 → 2026.5.21
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/INSTALL-AGENT.md +10 -10
- package/INSTALL-ZH.md +8 -8
- package/INSTALL.md +8 -8
- package/README.md +60 -37
- package/auto-recall.ts +257 -257
- package/client.ts +939 -828
- package/commands/setup.ts +1002 -1002
- package/config.ts +476 -476
- package/context-engine.ts +1737 -1700
- package/dist/client.js +30 -0
- package/dist/context-engine.js +40 -8
- package/dist/index.js +389 -69
- package/dist/process-manager.js +0 -87
- package/dist/runtime-utils.js +0 -3
- package/dist/text-utils.js +31 -51
- package/images/openclaw-plugin-engine-overview.png +0 -0
- package/images/openclaw-plugin-recall-flow.png +0 -0
- package/images/openclaw-plugin-runtime-routing.png +0 -0
- package/images/openclaw-plugin-session-lifecycle.png +0 -0
- package/index.ts +1985 -1597
- package/install-manifest.json +47 -47
- package/memory-ranking.ts +267 -267
- package/openclaw.plugin.json +335 -331
- package/package.json +2 -1
- package/process-manager.ts +45 -169
- package/runtime-utils.ts +5 -8
- package/session-transcript-repair.ts +530 -530
- package/skills/install-openviking-memory/SKILL.md +665 -665
- package/text-utils.ts +507 -524
- package/tool-call-id.ts +331 -331
package/install-manifest.json
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
{
|
|
2
|
-
"manifestVersion": "1.0",
|
|
3
|
-
"pluginVersion": "2026.5.8",
|
|
4
|
-
"plugin": {
|
|
5
|
-
"id": "openviking",
|
|
6
|
-
"kind": "context-engine",
|
|
7
|
-
"slot": "contextEngine"
|
|
8
|
-
},
|
|
9
|
-
"files": {
|
|
10
|
-
"required": [
|
|
11
|
-
"index.ts",
|
|
12
|
-
"config.ts",
|
|
13
|
-
"context-engine.ts",
|
|
14
|
-
"auto-recall.ts",
|
|
15
|
-
"client.ts",
|
|
16
|
-
"process-manager.ts",
|
|
17
|
-
"memory-ranking.ts",
|
|
18
|
-
"text-utils.ts",
|
|
19
|
-
"tool-call-id.ts",
|
|
20
|
-
"session-transcript-repair.ts",
|
|
21
|
-
"runtime-utils.ts",
|
|
22
|
-
"commands/setup.ts",
|
|
23
|
-
"tsconfig.json",
|
|
24
|
-
"tsconfig.build.json",
|
|
25
|
-
"package.json",
|
|
26
|
-
"openclaw.plugin.json"
|
|
27
|
-
],
|
|
28
|
-
"optional": [
|
|
29
|
-
"package-lock.json",
|
|
30
|
-
".gitignore",
|
|
31
|
-
"skills/install-openviking-memory/SKILL.md",
|
|
32
|
-
"INSTALL-AGENT.md"
|
|
33
|
-
]
|
|
34
|
-
},
|
|
35
|
-
"npm": {
|
|
36
|
-
"install": true,
|
|
37
|
-
"omitDev": true,
|
|
38
|
-
"build": true,
|
|
39
|
-
"buildMinOpenclawVersion": "2026.5.3",
|
|
40
|
-
"buildScript": "build",
|
|
41
|
-
"pruneAfterBuild": true
|
|
42
|
-
},
|
|
43
|
-
"compatibility": {
|
|
44
|
-
"minOpenclawVersion": "2026.4.8",
|
|
45
|
-
"minOpenvikingVersion": "0.2.9"
|
|
46
|
-
}
|
|
47
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"manifestVersion": "1.0",
|
|
3
|
+
"pluginVersion": "2026.5.8",
|
|
4
|
+
"plugin": {
|
|
5
|
+
"id": "openviking",
|
|
6
|
+
"kind": "context-engine",
|
|
7
|
+
"slot": "contextEngine"
|
|
8
|
+
},
|
|
9
|
+
"files": {
|
|
10
|
+
"required": [
|
|
11
|
+
"index.ts",
|
|
12
|
+
"config.ts",
|
|
13
|
+
"context-engine.ts",
|
|
14
|
+
"auto-recall.ts",
|
|
15
|
+
"client.ts",
|
|
16
|
+
"process-manager.ts",
|
|
17
|
+
"memory-ranking.ts",
|
|
18
|
+
"text-utils.ts",
|
|
19
|
+
"tool-call-id.ts",
|
|
20
|
+
"session-transcript-repair.ts",
|
|
21
|
+
"runtime-utils.ts",
|
|
22
|
+
"commands/setup.ts",
|
|
23
|
+
"tsconfig.json",
|
|
24
|
+
"tsconfig.build.json",
|
|
25
|
+
"package.json",
|
|
26
|
+
"openclaw.plugin.json"
|
|
27
|
+
],
|
|
28
|
+
"optional": [
|
|
29
|
+
"package-lock.json",
|
|
30
|
+
".gitignore",
|
|
31
|
+
"skills/install-openviking-memory/SKILL.md",
|
|
32
|
+
"INSTALL-AGENT.md"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"npm": {
|
|
36
|
+
"install": true,
|
|
37
|
+
"omitDev": true,
|
|
38
|
+
"build": true,
|
|
39
|
+
"buildMinOpenclawVersion": "2026.5.3",
|
|
40
|
+
"buildScript": "build",
|
|
41
|
+
"pruneAfterBuild": true
|
|
42
|
+
},
|
|
43
|
+
"compatibility": {
|
|
44
|
+
"minOpenclawVersion": "2026.4.8",
|
|
45
|
+
"minOpenvikingVersion": "0.2.9"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/memory-ranking.ts
CHANGED
|
@@ -1,267 +1,267 @@
|
|
|
1
|
-
import type { FindResultItem } from "./client.js";
|
|
2
|
-
|
|
3
|
-
export function clampScore(value: number | undefined): number {
|
|
4
|
-
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
5
|
-
return 0;
|
|
6
|
-
}
|
|
7
|
-
return Math.max(0, Math.min(1, value));
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function normalizeDedupeText(text: string): string {
|
|
11
|
-
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function isEventOrCaseMemory(item: FindResultItem): boolean {
|
|
15
|
-
const category = (item.category ?? "").toLowerCase();
|
|
16
|
-
const uri = item.uri.toLowerCase();
|
|
17
|
-
return (
|
|
18
|
-
category === "events" ||
|
|
19
|
-
category === "cases" ||
|
|
20
|
-
uri.includes("/events/") ||
|
|
21
|
-
uri.includes("/cases/")
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function getMemoryDedupeKey(item: FindResultItem): string {
|
|
26
|
-
const abstract = normalizeDedupeText(item.abstract ?? item.overview ?? "");
|
|
27
|
-
const category = (item.category ?? "").toLowerCase() || "unknown";
|
|
28
|
-
if (abstract && !isEventOrCaseMemory(item)) {
|
|
29
|
-
return `abstract:${category}:${abstract}`;
|
|
30
|
-
}
|
|
31
|
-
return `uri:${item.uri}`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function postProcessMemories(
|
|
35
|
-
items: FindResultItem[],
|
|
36
|
-
options: {
|
|
37
|
-
limit: number;
|
|
38
|
-
scoreThreshold: number;
|
|
39
|
-
leafOnly?: boolean;
|
|
40
|
-
},
|
|
41
|
-
): FindResultItem[] {
|
|
42
|
-
const deduped: FindResultItem[] = [];
|
|
43
|
-
const seen = new Set<string>();
|
|
44
|
-
const sorted = [...items].sort((a, b) => clampScore(b.score) - clampScore(a.score));
|
|
45
|
-
for (const item of sorted) {
|
|
46
|
-
if (options.leafOnly && item.level !== 2) {
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
if (clampScore(item.score) < options.scoreThreshold) {
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
const key = getMemoryDedupeKey(item);
|
|
53
|
-
if (seen.has(key)) {
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
seen.add(key);
|
|
57
|
-
deduped.push(item);
|
|
58
|
-
if (deduped.length >= options.limit) {
|
|
59
|
-
break;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return deduped;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function formatMemoryLines(items: FindResultItem[]): string {
|
|
66
|
-
return items
|
|
67
|
-
.map((item, index) => {
|
|
68
|
-
const score = clampScore(item.score);
|
|
69
|
-
const abstract = item.abstract?.trim() || item.overview?.trim() || item.uri;
|
|
70
|
-
const category = item.category ?? "memory";
|
|
71
|
-
return `${index + 1}. [${category}] ${abstract} (${(score * 100).toFixed(0)}%)`;
|
|
72
|
-
})
|
|
73
|
-
.join("\n");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function trimForLog(value: string, limit = 260): string {
|
|
77
|
-
const normalized = value.trim();
|
|
78
|
-
if (normalized.length <= limit) {
|
|
79
|
-
return normalized;
|
|
80
|
-
}
|
|
81
|
-
return `${normalized.slice(0, limit)}...`;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function toJsonLog(value: unknown, maxLen = 6000): string {
|
|
85
|
-
try {
|
|
86
|
-
const json = JSON.stringify(value);
|
|
87
|
-
if (json.length <= maxLen) {
|
|
88
|
-
return json;
|
|
89
|
-
}
|
|
90
|
-
return JSON.stringify({
|
|
91
|
-
truncated: true,
|
|
92
|
-
length: json.length,
|
|
93
|
-
preview: `${json.slice(0, maxLen)}...`,
|
|
94
|
-
});
|
|
95
|
-
} catch {
|
|
96
|
-
return JSON.stringify({ error: "stringify_failed" });
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function summarizeInjectionMemories(items: FindResultItem[]): Array<Record<string, unknown>> {
|
|
101
|
-
return items.map((item) => ({
|
|
102
|
-
uri: item.uri,
|
|
103
|
-
category: item.category ?? null,
|
|
104
|
-
abstract: trimForLog(item.abstract?.trim() || item.overview?.trim() || item.uri, 180),
|
|
105
|
-
score: clampScore(item.score),
|
|
106
|
-
is_leaf: item.level === 2,
|
|
107
|
-
}));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function summarizeExtractedMemories(
|
|
111
|
-
items: Array<Record<string, unknown>>,
|
|
112
|
-
): Array<Record<string, unknown>> {
|
|
113
|
-
return items.slice(0, 10).map((item) => {
|
|
114
|
-
const abstractRaw =
|
|
115
|
-
typeof item.abstract === "string"
|
|
116
|
-
? item.abstract
|
|
117
|
-
: typeof item.overview === "string"
|
|
118
|
-
? item.overview
|
|
119
|
-
: typeof item.title === "string"
|
|
120
|
-
? item.title
|
|
121
|
-
: "";
|
|
122
|
-
return {
|
|
123
|
-
uri: typeof item.uri === "string" ? item.uri : null,
|
|
124
|
-
category: typeof item.category === "string" ? item.category : null,
|
|
125
|
-
abstract: trimForLog(abstractRaw, 180),
|
|
126
|
-
is_leaf: item.level === 2,
|
|
127
|
-
};
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function isPreferencesMemory(item: FindResultItem): boolean {
|
|
132
|
-
return (
|
|
133
|
-
item.category === "preferences" ||
|
|
134
|
-
item.uri.includes("/preferences/") ||
|
|
135
|
-
item.uri.endsWith("/preferences")
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function isEventMemory(item: FindResultItem): boolean {
|
|
140
|
-
const category = (item.category ?? "").toLowerCase();
|
|
141
|
-
return category === "events" || item.uri.includes("/events/");
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function isLeafLikeMemory(item: FindResultItem): boolean {
|
|
145
|
-
return item.level === 2;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const PREFERENCE_QUERY_RE = /prefer|preference|favorite|favourite|like|偏好|喜欢|爱好|更倾向/i;
|
|
149
|
-
const TEMPORAL_QUERY_RE =
|
|
150
|
-
/when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|什么时候|何时|哪天|几月|几年|昨天|今天|明天|上周|下周|上个月|下个月|去年|明年/i;
|
|
151
|
-
const QUERY_TOKEN_RE = /[a-z0-9]{2,}/gi;
|
|
152
|
-
const QUERY_TOKEN_STOPWORDS = new Set([
|
|
153
|
-
"what",
|
|
154
|
-
"when",
|
|
155
|
-
"where",
|
|
156
|
-
"which",
|
|
157
|
-
"who",
|
|
158
|
-
"whom",
|
|
159
|
-
"whose",
|
|
160
|
-
"why",
|
|
161
|
-
"how",
|
|
162
|
-
"did",
|
|
163
|
-
"does",
|
|
164
|
-
"is",
|
|
165
|
-
"are",
|
|
166
|
-
"was",
|
|
167
|
-
"were",
|
|
168
|
-
"the",
|
|
169
|
-
"and",
|
|
170
|
-
"for",
|
|
171
|
-
"with",
|
|
172
|
-
"from",
|
|
173
|
-
"that",
|
|
174
|
-
"this",
|
|
175
|
-
"your",
|
|
176
|
-
"you",
|
|
177
|
-
]);
|
|
178
|
-
|
|
179
|
-
type RecallQueryProfile = {
|
|
180
|
-
tokens: string[];
|
|
181
|
-
wantsPreference: boolean;
|
|
182
|
-
wantsTemporal: boolean;
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
function buildRecallQueryProfile(query: string): RecallQueryProfile {
|
|
186
|
-
const text = query.trim();
|
|
187
|
-
const allTokens = text.toLowerCase().match(QUERY_TOKEN_RE) ?? [];
|
|
188
|
-
const tokens = allTokens.filter((token) => !QUERY_TOKEN_STOPWORDS.has(token));
|
|
189
|
-
return {
|
|
190
|
-
tokens,
|
|
191
|
-
wantsPreference: PREFERENCE_QUERY_RE.test(text),
|
|
192
|
-
wantsTemporal: TEMPORAL_QUERY_RE.test(text),
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function lexicalOverlapBoost(tokens: string[], text: string): number {
|
|
197
|
-
if (tokens.length === 0 || !text) {
|
|
198
|
-
return 0;
|
|
199
|
-
}
|
|
200
|
-
const haystack = ` ${text.toLowerCase()} `;
|
|
201
|
-
let matched = 0;
|
|
202
|
-
for (const token of tokens.slice(0, 8)) {
|
|
203
|
-
if (haystack.includes(` ${token} `) || haystack.includes(token)) {
|
|
204
|
-
matched += 1;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return Math.min(0.2, (matched / Math.min(tokens.length, 4)) * 0.2);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function rankForInjection(item: FindResultItem, query: RecallQueryProfile): number {
|
|
211
|
-
// Keep ranking simple and stable: semantic score + light query-aware boosts.
|
|
212
|
-
const baseScore = clampScore(item.score);
|
|
213
|
-
const abstract = (item.abstract ?? item.overview ?? "").trim();
|
|
214
|
-
const leafBoost = isLeafLikeMemory(item) ? 0.12 : 0;
|
|
215
|
-
const eventBoost = query.wantsTemporal && isEventMemory(item) ? 0.1 : 0;
|
|
216
|
-
const preferenceBoost = query.wantsPreference && isPreferencesMemory(item) ? 0.08 : 0;
|
|
217
|
-
const overlapBoost = lexicalOverlapBoost(query.tokens, `${item.uri} ${abstract}`);
|
|
218
|
-
return baseScore + leafBoost + eventBoost + preferenceBoost + overlapBoost;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export function pickMemoriesForInjection(
|
|
222
|
-
items: FindResultItem[],
|
|
223
|
-
limit: number,
|
|
224
|
-
queryText: string,
|
|
225
|
-
scoreThreshold: number = 0,
|
|
226
|
-
): FindResultItem[] {
|
|
227
|
-
if (items.length === 0 || limit <= 0) {
|
|
228
|
-
return [];
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const query = buildRecallQueryProfile(queryText);
|
|
232
|
-
const sorted = [...items].sort((a, b) => rankForInjection(b, query) - rankForInjection(a, query));
|
|
233
|
-
const deduped: FindResultItem[] = [];
|
|
234
|
-
const seen = new Set<string>();
|
|
235
|
-
for (const item of sorted) {
|
|
236
|
-
const abstractKey = (item.abstract ?? item.overview ?? "").trim().toLowerCase();
|
|
237
|
-
const key = abstractKey || item.uri;
|
|
238
|
-
if (seen.has(key)) {
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
seen.add(key);
|
|
242
|
-
deduped.push(item);
|
|
243
|
-
}
|
|
244
|
-
const leaves = deduped.filter((item) => isLeafLikeMemory(item));
|
|
245
|
-
if (leaves.length >= limit) {
|
|
246
|
-
return leaves.slice(0, limit);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const picked = [...leaves];
|
|
250
|
-
const used = new Set(leaves.map((item) => item.uri));
|
|
251
|
-
for (const item of deduped) {
|
|
252
|
-
if (picked.length >= limit) {
|
|
253
|
-
break;
|
|
254
|
-
}
|
|
255
|
-
if (used.has(item.uri)) {
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
// Respect score threshold when supplementing leaf memories with
|
|
259
|
-
// non-leaf items. Without this check, low-scoring memories bypass
|
|
260
|
-
// the threshold configured in recallScoreThreshold (see #1106).
|
|
261
|
-
if (clampScore(item.score) < scoreThreshold) {
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
picked.push(item);
|
|
265
|
-
}
|
|
266
|
-
return picked;
|
|
267
|
-
}
|
|
1
|
+
import type { FindResultItem } from "./client.js";
|
|
2
|
+
|
|
3
|
+
export function clampScore(value: number | undefined): number {
|
|
4
|
+
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
5
|
+
return 0;
|
|
6
|
+
}
|
|
7
|
+
return Math.max(0, Math.min(1, value));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeDedupeText(text: string): string {
|
|
11
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isEventOrCaseMemory(item: FindResultItem): boolean {
|
|
15
|
+
const category = (item.category ?? "").toLowerCase();
|
|
16
|
+
const uri = item.uri.toLowerCase();
|
|
17
|
+
return (
|
|
18
|
+
category === "events" ||
|
|
19
|
+
category === "cases" ||
|
|
20
|
+
uri.includes("/events/") ||
|
|
21
|
+
uri.includes("/cases/")
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getMemoryDedupeKey(item: FindResultItem): string {
|
|
26
|
+
const abstract = normalizeDedupeText(item.abstract ?? item.overview ?? "");
|
|
27
|
+
const category = (item.category ?? "").toLowerCase() || "unknown";
|
|
28
|
+
if (abstract && !isEventOrCaseMemory(item)) {
|
|
29
|
+
return `abstract:${category}:${abstract}`;
|
|
30
|
+
}
|
|
31
|
+
return `uri:${item.uri}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function postProcessMemories(
|
|
35
|
+
items: FindResultItem[],
|
|
36
|
+
options: {
|
|
37
|
+
limit: number;
|
|
38
|
+
scoreThreshold: number;
|
|
39
|
+
leafOnly?: boolean;
|
|
40
|
+
},
|
|
41
|
+
): FindResultItem[] {
|
|
42
|
+
const deduped: FindResultItem[] = [];
|
|
43
|
+
const seen = new Set<string>();
|
|
44
|
+
const sorted = [...items].sort((a, b) => clampScore(b.score) - clampScore(a.score));
|
|
45
|
+
for (const item of sorted) {
|
|
46
|
+
if (options.leafOnly && item.level !== 2) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (clampScore(item.score) < options.scoreThreshold) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const key = getMemoryDedupeKey(item);
|
|
53
|
+
if (seen.has(key)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
seen.add(key);
|
|
57
|
+
deduped.push(item);
|
|
58
|
+
if (deduped.length >= options.limit) {
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return deduped;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatMemoryLines(items: FindResultItem[]): string {
|
|
66
|
+
return items
|
|
67
|
+
.map((item, index) => {
|
|
68
|
+
const score = clampScore(item.score);
|
|
69
|
+
const abstract = item.abstract?.trim() || item.overview?.trim() || item.uri;
|
|
70
|
+
const category = item.category ?? "memory";
|
|
71
|
+
return `${index + 1}. [${category}] ${abstract} (${(score * 100).toFixed(0)}%)`;
|
|
72
|
+
})
|
|
73
|
+
.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function trimForLog(value: string, limit = 260): string {
|
|
77
|
+
const normalized = value.trim();
|
|
78
|
+
if (normalized.length <= limit) {
|
|
79
|
+
return normalized;
|
|
80
|
+
}
|
|
81
|
+
return `${normalized.slice(0, limit)}...`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function toJsonLog(value: unknown, maxLen = 6000): string {
|
|
85
|
+
try {
|
|
86
|
+
const json = JSON.stringify(value);
|
|
87
|
+
if (json.length <= maxLen) {
|
|
88
|
+
return json;
|
|
89
|
+
}
|
|
90
|
+
return JSON.stringify({
|
|
91
|
+
truncated: true,
|
|
92
|
+
length: json.length,
|
|
93
|
+
preview: `${json.slice(0, maxLen)}...`,
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
return JSON.stringify({ error: "stringify_failed" });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function summarizeInjectionMemories(items: FindResultItem[]): Array<Record<string, unknown>> {
|
|
101
|
+
return items.map((item) => ({
|
|
102
|
+
uri: item.uri,
|
|
103
|
+
category: item.category ?? null,
|
|
104
|
+
abstract: trimForLog(item.abstract?.trim() || item.overview?.trim() || item.uri, 180),
|
|
105
|
+
score: clampScore(item.score),
|
|
106
|
+
is_leaf: item.level === 2,
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function summarizeExtractedMemories(
|
|
111
|
+
items: Array<Record<string, unknown>>,
|
|
112
|
+
): Array<Record<string, unknown>> {
|
|
113
|
+
return items.slice(0, 10).map((item) => {
|
|
114
|
+
const abstractRaw =
|
|
115
|
+
typeof item.abstract === "string"
|
|
116
|
+
? item.abstract
|
|
117
|
+
: typeof item.overview === "string"
|
|
118
|
+
? item.overview
|
|
119
|
+
: typeof item.title === "string"
|
|
120
|
+
? item.title
|
|
121
|
+
: "";
|
|
122
|
+
return {
|
|
123
|
+
uri: typeof item.uri === "string" ? item.uri : null,
|
|
124
|
+
category: typeof item.category === "string" ? item.category : null,
|
|
125
|
+
abstract: trimForLog(abstractRaw, 180),
|
|
126
|
+
is_leaf: item.level === 2,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isPreferencesMemory(item: FindResultItem): boolean {
|
|
132
|
+
return (
|
|
133
|
+
item.category === "preferences" ||
|
|
134
|
+
item.uri.includes("/preferences/") ||
|
|
135
|
+
item.uri.endsWith("/preferences")
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isEventMemory(item: FindResultItem): boolean {
|
|
140
|
+
const category = (item.category ?? "").toLowerCase();
|
|
141
|
+
return category === "events" || item.uri.includes("/events/");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isLeafLikeMemory(item: FindResultItem): boolean {
|
|
145
|
+
return item.level === 2;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const PREFERENCE_QUERY_RE = /prefer|preference|favorite|favourite|like|偏好|喜欢|爱好|更倾向/i;
|
|
149
|
+
const TEMPORAL_QUERY_RE =
|
|
150
|
+
/when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|什么时候|何时|哪天|几月|几年|昨天|今天|明天|上周|下周|上个月|下个月|去年|明年/i;
|
|
151
|
+
const QUERY_TOKEN_RE = /[a-z0-9]{2,}/gi;
|
|
152
|
+
const QUERY_TOKEN_STOPWORDS = new Set([
|
|
153
|
+
"what",
|
|
154
|
+
"when",
|
|
155
|
+
"where",
|
|
156
|
+
"which",
|
|
157
|
+
"who",
|
|
158
|
+
"whom",
|
|
159
|
+
"whose",
|
|
160
|
+
"why",
|
|
161
|
+
"how",
|
|
162
|
+
"did",
|
|
163
|
+
"does",
|
|
164
|
+
"is",
|
|
165
|
+
"are",
|
|
166
|
+
"was",
|
|
167
|
+
"were",
|
|
168
|
+
"the",
|
|
169
|
+
"and",
|
|
170
|
+
"for",
|
|
171
|
+
"with",
|
|
172
|
+
"from",
|
|
173
|
+
"that",
|
|
174
|
+
"this",
|
|
175
|
+
"your",
|
|
176
|
+
"you",
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
type RecallQueryProfile = {
|
|
180
|
+
tokens: string[];
|
|
181
|
+
wantsPreference: boolean;
|
|
182
|
+
wantsTemporal: boolean;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
function buildRecallQueryProfile(query: string): RecallQueryProfile {
|
|
186
|
+
const text = query.trim();
|
|
187
|
+
const allTokens = text.toLowerCase().match(QUERY_TOKEN_RE) ?? [];
|
|
188
|
+
const tokens = allTokens.filter((token) => !QUERY_TOKEN_STOPWORDS.has(token));
|
|
189
|
+
return {
|
|
190
|
+
tokens,
|
|
191
|
+
wantsPreference: PREFERENCE_QUERY_RE.test(text),
|
|
192
|
+
wantsTemporal: TEMPORAL_QUERY_RE.test(text),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function lexicalOverlapBoost(tokens: string[], text: string): number {
|
|
197
|
+
if (tokens.length === 0 || !text) {
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
const haystack = ` ${text.toLowerCase()} `;
|
|
201
|
+
let matched = 0;
|
|
202
|
+
for (const token of tokens.slice(0, 8)) {
|
|
203
|
+
if (haystack.includes(` ${token} `) || haystack.includes(token)) {
|
|
204
|
+
matched += 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return Math.min(0.2, (matched / Math.min(tokens.length, 4)) * 0.2);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function rankForInjection(item: FindResultItem, query: RecallQueryProfile): number {
|
|
211
|
+
// Keep ranking simple and stable: semantic score + light query-aware boosts.
|
|
212
|
+
const baseScore = clampScore(item.score);
|
|
213
|
+
const abstract = (item.abstract ?? item.overview ?? "").trim();
|
|
214
|
+
const leafBoost = isLeafLikeMemory(item) ? 0.12 : 0;
|
|
215
|
+
const eventBoost = query.wantsTemporal && isEventMemory(item) ? 0.1 : 0;
|
|
216
|
+
const preferenceBoost = query.wantsPreference && isPreferencesMemory(item) ? 0.08 : 0;
|
|
217
|
+
const overlapBoost = lexicalOverlapBoost(query.tokens, `${item.uri} ${abstract}`);
|
|
218
|
+
return baseScore + leafBoost + eventBoost + preferenceBoost + overlapBoost;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function pickMemoriesForInjection(
|
|
222
|
+
items: FindResultItem[],
|
|
223
|
+
limit: number,
|
|
224
|
+
queryText: string,
|
|
225
|
+
scoreThreshold: number = 0,
|
|
226
|
+
): FindResultItem[] {
|
|
227
|
+
if (items.length === 0 || limit <= 0) {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const query = buildRecallQueryProfile(queryText);
|
|
232
|
+
const sorted = [...items].sort((a, b) => rankForInjection(b, query) - rankForInjection(a, query));
|
|
233
|
+
const deduped: FindResultItem[] = [];
|
|
234
|
+
const seen = new Set<string>();
|
|
235
|
+
for (const item of sorted) {
|
|
236
|
+
const abstractKey = (item.abstract ?? item.overview ?? "").trim().toLowerCase();
|
|
237
|
+
const key = abstractKey || item.uri;
|
|
238
|
+
if (seen.has(key)) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
seen.add(key);
|
|
242
|
+
deduped.push(item);
|
|
243
|
+
}
|
|
244
|
+
const leaves = deduped.filter((item) => isLeafLikeMemory(item));
|
|
245
|
+
if (leaves.length >= limit) {
|
|
246
|
+
return leaves.slice(0, limit);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const picked = [...leaves];
|
|
250
|
+
const used = new Set(leaves.map((item) => item.uri));
|
|
251
|
+
for (const item of deduped) {
|
|
252
|
+
if (picked.length >= limit) {
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
if (used.has(item.uri)) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
// Respect score threshold when supplementing leaf memories with
|
|
259
|
+
// non-leaf items. Without this check, low-scoring memories bypass
|
|
260
|
+
// the threshold configured in recallScoreThreshold (see #1106).
|
|
261
|
+
if (clampScore(item.score) < scoreThreshold) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
picked.push(item);
|
|
265
|
+
}
|
|
266
|
+
return picked;
|
|
267
|
+
}
|