@openviking/openclaw-plugin 2026.8.31 → 2026.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -10
- package/auto-recall.ts +126 -135
- package/client.ts +95 -0
- package/config.ts +8 -6
- package/dist/auto-recall.js +113 -114
- package/dist/client.js +54 -0
- package/dist/config.js +6 -4
- package/dist/shared/recall-compress-core.mjs +188 -0
- package/dist/shared/recall-core.mjs +585 -0
- package/install-manifest.json +1 -0
- package/openclaw.plugin.json +2 -2
- package/package.json +2 -1
- package/shared/recall-compress-core.mjs +195 -0
- package/shared/recall-core.d.mts +39 -0
- package/shared/recall-core.mjs +613 -0
- package/skills/install-openviking-memory/SKILL.md +8 -11
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { compressRecallContext } from "./recall-compress-core.mjs";
|
|
7
|
+
|
|
8
|
+
const PREFERENCE_QUERY_RE = /prefer|preference|favorite|favourite|like|偏好|喜欢|爱好|更倾向/i;
|
|
9
|
+
const TEMPORAL_QUERY_RE = /when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|什么时候|何时|哪天|几月|几年|昨天|今天|明天/i;
|
|
10
|
+
const QUERY_TOKEN_RE = /[a-z0-9一-龥]{2,}/gi;
|
|
11
|
+
const STOPWORDS = new Set([
|
|
12
|
+
"what", "when", "where", "which", "who", "whom", "whose", "why", "how", "did", "does",
|
|
13
|
+
"is", "are", "was", "were", "the", "and", "for", "with", "from", "that", "this", "your", "you",
|
|
14
|
+
]);
|
|
15
|
+
const USER_RESERVED_DIRS = new Set(["memories", "skills"]);
|
|
16
|
+
const SOURCES = [
|
|
17
|
+
{ type: "memory", uri: "viking://~/memories", bucket: "memories" },
|
|
18
|
+
{ type: "skill", uri: "viking://~/skills", bucket: "skills" },
|
|
19
|
+
];
|
|
20
|
+
const DEFAULT_CONTEXT_LIMIT = 10;
|
|
21
|
+
const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
|
|
22
|
+
const DEFAULT_REWRITE_MAX_BULLETS = 6;
|
|
23
|
+
const CODING_QUOTA_WEIGHTS = {
|
|
24
|
+
events: 1,
|
|
25
|
+
entities: 2,
|
|
26
|
+
preferences: 1,
|
|
27
|
+
experiences: 1,
|
|
28
|
+
resources: 3,
|
|
29
|
+
skills: 2,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let userSpaceCache = "";
|
|
33
|
+
|
|
34
|
+
export function estimateTokens(text) {
|
|
35
|
+
return text ? Math.ceil(String(text).length / 4) : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function scaleQuotas(limit, weights) {
|
|
39
|
+
const slots = Math.max(1, Math.floor(Number(limit) || DEFAULT_CONTEXT_LIMIT));
|
|
40
|
+
const order = Object.keys(weights);
|
|
41
|
+
const quotas = Object.fromEntries(order.map((key) => [key, 0]));
|
|
42
|
+
if (slots < order.length) {
|
|
43
|
+
for (const key of order) quotas[key] = 1;
|
|
44
|
+
return quotas;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const key of order) quotas[key] = 1;
|
|
48
|
+
const totalWeight = Object.values(weights).reduce((sum, weight) => sum + weight, 0);
|
|
49
|
+
const ideals = Object.fromEntries(
|
|
50
|
+
order.map((key) => [key, slots * weights[key] / totalWeight]),
|
|
51
|
+
);
|
|
52
|
+
while (order.reduce((sum, key) => sum + quotas[key], 0) < slots) {
|
|
53
|
+
const key = order.reduce((best, candidate) => (
|
|
54
|
+
ideals[candidate] - quotas[candidate] > ideals[best] - quotas[best]
|
|
55
|
+
? candidate
|
|
56
|
+
: best
|
|
57
|
+
));
|
|
58
|
+
quotas[key] += 1;
|
|
59
|
+
}
|
|
60
|
+
return quotas;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function legacyMemoryQuotas(limit) {
|
|
64
|
+
return {
|
|
65
|
+
...scaleQuotas(limit, { events: 10, entities: 10, preferences: 3 }),
|
|
66
|
+
experiences: 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function codingQuotas(limit) {
|
|
71
|
+
return scaleQuotas(limit, CODING_QUOTA_WEIGHTS);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildRecallEndpointBody(cfg = {}) {
|
|
75
|
+
const limit = Math.max(Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT), 1);
|
|
76
|
+
const body = {
|
|
77
|
+
query: "",
|
|
78
|
+
quotas: legacyMemoryQuotas(limit),
|
|
79
|
+
max_chars: Math.max(Number(cfg.recallMaxContentChars || 0) * limit, 1000),
|
|
80
|
+
min_score: Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35,
|
|
81
|
+
render: true,
|
|
82
|
+
};
|
|
83
|
+
if (cfg.recallPeerScope === "actor") body.peer_scope = "actor";
|
|
84
|
+
return body;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Body for the server-side context face. The plugin declares intent (coding
|
|
89
|
+
* purpose, budget, session) and leaves the mechanics — quota ratios, tier
|
|
90
|
+
* degradation, cross-turn dedup — to the server's defaults.
|
|
91
|
+
*/
|
|
92
|
+
export function buildContextSearchBody(cfg = {}, options = {}) {
|
|
93
|
+
const rewriteMode = String(cfg.recallRewrite || "off").toLowerCase();
|
|
94
|
+
const limit = Math.max(1, Math.floor(Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT)));
|
|
95
|
+
const maxTokens = Math.max(
|
|
96
|
+
64,
|
|
97
|
+
Math.floor(Number(cfg.recallMaxTokens || DEFAULT_CONTEXT_MAX_TOKENS)),
|
|
98
|
+
);
|
|
99
|
+
const body = {
|
|
100
|
+
query: "",
|
|
101
|
+
mode: "context",
|
|
102
|
+
purpose: "coding",
|
|
103
|
+
score_threshold: Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35,
|
|
104
|
+
};
|
|
105
|
+
const limitConfigured = cfg.recallLimitConfigured === true;
|
|
106
|
+
const maxTokensConfigured = cfg.recallMaxTokensConfigured === true;
|
|
107
|
+
if (limitConfigured) body.quotas = codingQuotas(limit);
|
|
108
|
+
if (maxTokensConfigured) body.max_tokens = maxTokens;
|
|
109
|
+
if (cfg.recallPeerScope === "actor") body.peer_scope = "actor";
|
|
110
|
+
|
|
111
|
+
const sessionId = String(options.sessionId || "").trim();
|
|
112
|
+
if (sessionId) {
|
|
113
|
+
body.session_id = sessionId;
|
|
114
|
+
const queryExpansionConfigured = cfg.recallQueryExpansionConfigured === true;
|
|
115
|
+
if (queryExpansionConfigured) {
|
|
116
|
+
body.query_expansion = cfg.recallQueryExpansion === "off" ? "off" : "auto";
|
|
117
|
+
}
|
|
118
|
+
const dedupTurns = Number(cfg.recallDedupTurns);
|
|
119
|
+
const resolvedDedupTurns = Number.isFinite(dedupTurns)
|
|
120
|
+
? Math.max(0, Math.floor(dedupTurns))
|
|
121
|
+
: 5;
|
|
122
|
+
if (resolvedDedupTurns > 0) body.dedup_turns = resolvedDedupTurns;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const excludeUris = Array.isArray(options.excludeUris) ? options.excludeUris.slice(0, 200) : [];
|
|
126
|
+
if (excludeUris.length) body.exclude_uris = excludeUris;
|
|
127
|
+
|
|
128
|
+
if (rewriteMode === "server") body.rewrite = true;
|
|
129
|
+
else if (rewriteMode === "auto" && !options.localCompressorAvailable) body.rewrite = "auto";
|
|
130
|
+
const rewriteMaxBullets = Math.max(
|
|
131
|
+
1,
|
|
132
|
+
Math.floor(Number(cfg.recallCompressMaxBullets || DEFAULT_REWRITE_MAX_BULLETS)),
|
|
133
|
+
);
|
|
134
|
+
const rewriteMaxBulletsConfigured = cfg.recallCompressMaxBulletsConfigured === true;
|
|
135
|
+
if (body.rewrite !== undefined && rewriteMaxBulletsConfigured) {
|
|
136
|
+
body.rewrite_max_bullets = rewriteMaxBullets;
|
|
137
|
+
}
|
|
138
|
+
return body;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The server pipeline is serial and each optional stage has its own fuse. A
|
|
142
|
+
// request is aborted client-side unless its deadline covers every stage it
|
|
143
|
+
// asked for, and aborting discards the whole response rather than just the
|
|
144
|
+
// stage that ran long.
|
|
145
|
+
//
|
|
146
|
+
// session_id -> query expansion (retrieval.recall_intent_timeout_s, 5s)
|
|
147
|
+
// always -> retrieval, body reads, budget planning
|
|
148
|
+
// rewrite -> digest (retrieval.recall_rewrite_timeout_s, 30s)
|
|
149
|
+
//
|
|
150
|
+
// Both budgets stay inside the 60s prompt-hook allowance, the rewrite one with
|
|
151
|
+
// a quarter to spare.
|
|
152
|
+
const EXPANSION_REQUEST_TIMEOUT_MS = 15000;
|
|
153
|
+
const SERVER_REWRITE_REQUEST_TIMEOUT_MS = 45000;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* HTTP deadline for one context request, or undefined to keep the caller's own.
|
|
157
|
+
*
|
|
158
|
+
* Derived from the request body, because the body is what states which server
|
|
159
|
+
* stages will run: reading `cfg` alone cannot tell a bare retrieval from one
|
|
160
|
+
* that also spends the expansion or rewrite fuse.
|
|
161
|
+
*/
|
|
162
|
+
export function contextRequestTimeoutMs(cfg = {}, body = {}) {
|
|
163
|
+
const wantsRewrite = body.rewrite !== undefined;
|
|
164
|
+
// `query_expansion` defaults to "auto" server-side, so only an explicit "off"
|
|
165
|
+
// takes the expansion fuse back out of the budget.
|
|
166
|
+
const wantsExpansion = Boolean(body.session_id) && body.query_expansion !== "off";
|
|
167
|
+
const configured = Number(cfg.recallContextTimeoutMs);
|
|
168
|
+
if (Number.isFinite(configured) && configured > 0) return Math.max(1000, Math.floor(configured));
|
|
169
|
+
if (!wantsRewrite && !wantsExpansion) return undefined;
|
|
170
|
+
const floor = wantsRewrite ? SERVER_REWRITE_REQUEST_TIMEOUT_MS : EXPANSION_REQUEST_TIMEOUT_MS;
|
|
171
|
+
return Math.max(Number(cfg.timeoutMs) || 0, floor);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Strip the context-face fields a pre-context server rejects, converting the
|
|
176
|
+
* token budget back to v1's character budget.
|
|
177
|
+
*/
|
|
178
|
+
export function downgradeToRecallBody(contextBody = {}, cfg = {}) {
|
|
179
|
+
const body = buildRecallEndpointBody(cfg);
|
|
180
|
+
body.query = contextBody.query || "";
|
|
181
|
+
body.max_chars = Math.max(1000, Math.floor(Number(contextBody.max_tokens || 1600) * 4));
|
|
182
|
+
if (contextBody.peer_scope) body.peer_scope = contextBody.peer_scope;
|
|
183
|
+
return body;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function clampScore(v) {
|
|
187
|
+
if (typeof v !== "number" || Number.isNaN(v)) return 0;
|
|
188
|
+
return Math.max(0, Math.min(1, v));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildQueryProfile(query) {
|
|
192
|
+
const text = query.trim();
|
|
193
|
+
const allTokens = text.toLowerCase().match(QUERY_TOKEN_RE) || [];
|
|
194
|
+
return {
|
|
195
|
+
tokens: allTokens.filter((t) => !STOPWORDS.has(t)),
|
|
196
|
+
wantsPreference: PREFERENCE_QUERY_RE.test(text),
|
|
197
|
+
wantsTemporal: TEMPORAL_QUERY_RE.test(text),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function lexicalOverlapBoost(tokens, text) {
|
|
202
|
+
if (tokens.length === 0 || !text) return 0;
|
|
203
|
+
const haystack = ` ${text.toLowerCase()} `;
|
|
204
|
+
let matched = 0;
|
|
205
|
+
for (const token of tokens.slice(0, 8)) {
|
|
206
|
+
if (haystack.includes(token)) matched += 1;
|
|
207
|
+
}
|
|
208
|
+
return Math.min(0.2, (matched / Math.min(tokens.length, 4)) * 0.2);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function rankItem(item, profile) {
|
|
212
|
+
const base = clampScore(item.score);
|
|
213
|
+
const abstract = (item.abstract || item.overview || "").trim();
|
|
214
|
+
const cat = (item.category || "").toLowerCase();
|
|
215
|
+
const uri = (item.uri || "").toLowerCase();
|
|
216
|
+
const leafBoost = (item.level === 2 || uri.endsWith(".md")) ? 0.12 : 0;
|
|
217
|
+
const eventBoost = profile.wantsTemporal && (cat === "events" || uri.includes("/events/")) ? 0.1 : 0;
|
|
218
|
+
const prefBoost = profile.wantsPreference && (cat === "preferences" || uri.includes("/preferences/")) ? 0.08 : 0;
|
|
219
|
+
const overlapBoost = lexicalOverlapBoost(profile.tokens, `${item.uri} ${abstract}`);
|
|
220
|
+
return base + leafBoost + eventBoost + prefBoost + overlapBoost;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isEventOrCaseItem(item) {
|
|
224
|
+
const cat = (item.category || "").toLowerCase();
|
|
225
|
+
const uri = (item.uri || "").toLowerCase();
|
|
226
|
+
return cat === "events" || cat === "cases" || uri.includes("/events/") || uri.includes("/cases/");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function dedupeItems(items) {
|
|
230
|
+
const seen = new Set();
|
|
231
|
+
const out = [];
|
|
232
|
+
for (const item of items) {
|
|
233
|
+
const key = isEventOrCaseItem(item)
|
|
234
|
+
? `uri:${item.uri}`
|
|
235
|
+
: ((item.abstract || item.overview || "").trim().toLowerCase() || `uri:${item.uri}`);
|
|
236
|
+
if (seen.has(key)) continue;
|
|
237
|
+
seen.add(key);
|
|
238
|
+
out.push(item);
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function resolveUserSpace(fetchJSON, actorPeerId = "") {
|
|
244
|
+
if (userSpaceCache) return userSpaceCache;
|
|
245
|
+
|
|
246
|
+
let fallbackSpace = "default";
|
|
247
|
+
const status = await fetchJSON("/api/v1/system/status");
|
|
248
|
+
if (status.ok && typeof status.result?.user === "string" && status.result.user.trim()) {
|
|
249
|
+
fallbackSpace = status.result.user.trim();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const lsRes = await fetchJSON(
|
|
253
|
+
`/api/v1/fs/ls?uri=${encodeURIComponent("viking://user")}&output=original`,
|
|
254
|
+
{},
|
|
255
|
+
{ actorPeerId },
|
|
256
|
+
);
|
|
257
|
+
if (lsRes.ok && Array.isArray(lsRes.result)) {
|
|
258
|
+
const spaces = lsRes.result
|
|
259
|
+
.filter((e) => e?.isDir)
|
|
260
|
+
.map((e) => (typeof e.name === "string" ? e.name.trim() : ""))
|
|
261
|
+
.filter((n) => n && !n.startsWith(".") && !USER_RESERVED_DIRS.has(n));
|
|
262
|
+
if (spaces.length > 0) {
|
|
263
|
+
if (spaces.includes(fallbackSpace)) { userSpaceCache = fallbackSpace; return fallbackSpace; }
|
|
264
|
+
if (spaces.includes("default")) { userSpaceCache = "default"; return "default"; }
|
|
265
|
+
if (spaces.length === 1) { userSpaceCache = spaces[0]; return spaces[0]; }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
userSpaceCache = fallbackSpace;
|
|
269
|
+
return fallbackSpace;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function resolveTargetUri(fetchJSON, targetUri, actorPeerId = "") {
|
|
273
|
+
const trimmed = targetUri.trim().replace(/\/+$/, "");
|
|
274
|
+
// viking://~ is the home alias: the server expands it to the caller's own user
|
|
275
|
+
// space, so it needs no client-side rewrite.
|
|
276
|
+
if (trimmed === "viking://~" || trimmed.startsWith("viking://~/")) return trimmed;
|
|
277
|
+
// Legacy compat: uid-less viking://user/<reserved> URIs may still sit in plugin
|
|
278
|
+
// configs. Newer servers reject them, so rewrite to an explicit-uid URI here.
|
|
279
|
+
const m = trimmed.match(/^viking:\/\/user(?:\/(.*))?$/);
|
|
280
|
+
if (!m) return trimmed;
|
|
281
|
+
const rawRest = (m[1] ?? "").trim();
|
|
282
|
+
if (!rawRest) return trimmed;
|
|
283
|
+
const parts = rawRest.split("/").filter(Boolean);
|
|
284
|
+
if (parts.length === 0) return trimmed;
|
|
285
|
+
if (!USER_RESERVED_DIRS.has(parts[0])) return trimmed;
|
|
286
|
+
const space = await resolveUserSpace(fetchJSON, actorPeerId);
|
|
287
|
+
return `viking://user/${space}/${parts.join("/")}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = "") {
|
|
291
|
+
const resolvedUri = await resolveTargetUri(fetchJSON, source.uri, actorPeerId);
|
|
292
|
+
const body = { query, target_uri: resolvedUri, limit, score_threshold: 0 };
|
|
293
|
+
const res = await fetchJSON("/api/v1/search/find", {
|
|
294
|
+
method: "POST",
|
|
295
|
+
body: JSON.stringify(body),
|
|
296
|
+
}, { actorPeerId });
|
|
297
|
+
if (!res.ok) return [];
|
|
298
|
+
const items = res.result?.[source.bucket] || [];
|
|
299
|
+
return items.map((item) => ({ ...item, _sourceType: source.type }));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function searchAllSources(fetchJSON, query, perSourceLimit, actorPeerId = "", log = () => {}) {
|
|
303
|
+
const results = await Promise.all(
|
|
304
|
+
SOURCES.map((src) => searchOneSource(fetchJSON, query, src, perSourceLimit, actorPeerId)),
|
|
305
|
+
);
|
|
306
|
+
const all = results.flat();
|
|
307
|
+
log("recall_search_summary", {
|
|
308
|
+
counts: SOURCES.map((src, i) => ({ type: src.type, uri: src.uri, count: results[i].length })),
|
|
309
|
+
total: all.length,
|
|
310
|
+
});
|
|
311
|
+
return all;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function resolveItemContent(fetchJSON, item, cfg, actorPeerId = "") {
|
|
315
|
+
let content;
|
|
316
|
+
|
|
317
|
+
if (cfg.recallPreferAbstract && (item.abstract || item.overview || "").trim()) {
|
|
318
|
+
content = (item.abstract || item.overview).trim();
|
|
319
|
+
} else if (item.level === 2) {
|
|
320
|
+
try {
|
|
321
|
+
const res = await fetchJSON(
|
|
322
|
+
`/api/v1/content/read?uri=${encodeURIComponent(item.uri)}`,
|
|
323
|
+
{},
|
|
324
|
+
{ actorPeerId },
|
|
325
|
+
);
|
|
326
|
+
const body = res.ok && typeof res.result === "string" ? res.result.trim() : "";
|
|
327
|
+
content = body || (item.abstract || item.overview || "").trim() || item.uri;
|
|
328
|
+
} catch {
|
|
329
|
+
content = (item.abstract || item.overview || "").trim() || item.uri;
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
content = (item.abstract || item.overview || "").trim() || item.uri;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const maxChars = Math.max(50, Number(cfg.recallMaxContentChars || 500));
|
|
336
|
+
if (content.length > maxChars) content = `${content.slice(0, maxChars)}...`;
|
|
337
|
+
return content;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId = "", log = () => {}) {
|
|
341
|
+
if (items.length === 0) return null;
|
|
342
|
+
|
|
343
|
+
let budgetRemaining = Math.max(200, Number(cfg.recallTokenBudget || 2000));
|
|
344
|
+
const lines = [
|
|
345
|
+
"<openviking-context>",
|
|
346
|
+
"Relevant context from OpenViking. Use the read MCP tool to expand URIs.",
|
|
347
|
+
];
|
|
348
|
+
let contentCount = 0;
|
|
349
|
+
let hintCount = 0;
|
|
350
|
+
|
|
351
|
+
for (const item of items) {
|
|
352
|
+
const score = (clampScore(item.score) * 100).toFixed(0);
|
|
353
|
+
const uriLine = `- [${item._sourceType} ${score}%] ${item.uri}`;
|
|
354
|
+
|
|
355
|
+
if (budgetRemaining > 0) {
|
|
356
|
+
const content = await resolveItemContent(fetchJSON, item, cfg, actorPeerId);
|
|
357
|
+
const contentLine = `- [${item._sourceType} ${score}%] ${content}`;
|
|
358
|
+
const lineTokens = estimateTokens(contentLine);
|
|
359
|
+
|
|
360
|
+
if (lineTokens > budgetRemaining && contentCount > 0) {
|
|
361
|
+
lines.push(uriLine);
|
|
362
|
+
hintCount++;
|
|
363
|
+
} else {
|
|
364
|
+
lines.push(contentLine);
|
|
365
|
+
budgetRemaining -= lineTokens;
|
|
366
|
+
contentCount++;
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
lines.push(uriLine);
|
|
370
|
+
hintCount++;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
lines.push("</openviking-context>");
|
|
375
|
+
|
|
376
|
+
const budgetUsed = Math.max(200, Number(cfg.recallTokenBudget || 2000)) - budgetRemaining;
|
|
377
|
+
log("recall_injection_built", {
|
|
378
|
+
contentItems: contentCount,
|
|
379
|
+
hintItems: hintCount,
|
|
380
|
+
budgetUsed,
|
|
381
|
+
budgetTotal: Math.max(200, Number(cfg.recallTokenBudget || 2000)),
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return lines.join("\n");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const LEGACY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
388
|
+
|
|
389
|
+
function stateFile(name) {
|
|
390
|
+
const override = String(process.env.OPENVIKING_STATE_DIR || "").trim();
|
|
391
|
+
return override ? join(override, name) : join(homedir(), ".openviking", "state", name);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function readJsonFile(path) {
|
|
395
|
+
try { return JSON.parse(await readFile(path, "utf8")); } catch { return null; }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function writeJsonFile(path, value) {
|
|
399
|
+
try {
|
|
400
|
+
await mkdir(dirname(path), { recursive: true });
|
|
401
|
+
const tmp = `${path}.tmp`;
|
|
402
|
+
await writeFile(tmp, JSON.stringify(value));
|
|
403
|
+
await rename(tmp, path);
|
|
404
|
+
} catch { /* best effort */ }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Hooks are one-shot processes, so "this server has no context face" has to be
|
|
409
|
+
* remembered on disk or every turn pays for a rejected request.
|
|
410
|
+
*/
|
|
411
|
+
export async function isContextFaceLegacy(path = stateFile("context-face.json"), now = Date.now()) {
|
|
412
|
+
const cached = await readJsonFile(path);
|
|
413
|
+
return Boolean(cached?.legacyUntil && Number(cached.legacyUntil) > now);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export async function markContextFaceLegacy(path = stateFile("context-face.json"), now = Date.now()) {
|
|
417
|
+
await writeJsonFile(path, { legacyUntil: now + LEGACY_CACHE_TTL_MS });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function looksLikeUnknownField(res) {
|
|
421
|
+
const text = JSON.stringify(res?.error ?? res?.result ?? res?.detail ?? "").toLowerCase();
|
|
422
|
+
return text.includes("extra") || text.includes("mode") || text.includes("unexpected");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function wrapContext(body) {
|
|
426
|
+
return [
|
|
427
|
+
"<openviking-context>",
|
|
428
|
+
"Relevant memory from OpenViking. Use the search/read MCP tools to expand URIs.",
|
|
429
|
+
body,
|
|
430
|
+
"</openviking-context>",
|
|
431
|
+
].join("\n");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Server-assembled context: the context face when the deployment has it, else
|
|
436
|
+
* the deprecated /recall preset. Returns the injection block, "" when there was
|
|
437
|
+
* nothing relevant, or null when no server-side path was usable at all.
|
|
438
|
+
*/
|
|
439
|
+
export async function buildServerAssembledBlock(fetchJSON, cfg, query, options = {}) {
|
|
440
|
+
const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
|
|
441
|
+
const log = options.log || (() => {});
|
|
442
|
+
|
|
443
|
+
const block = await recallViaContextFace(fetchJSON, cfg, query, { ...options, actorPeerId }, log);
|
|
444
|
+
if (block !== null) return block;
|
|
445
|
+
return recallViaEndpoint(fetchJSON, cfg, query, actorPeerId, log);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Raw server-assembled context, or null when the deployment has no context face.
|
|
450
|
+
* Returns `{ rendered, entries, digest, stats }` — callers that need the entries
|
|
451
|
+
* (their own compression, their own envelope) use this instead of the block
|
|
452
|
+
* builders below.
|
|
453
|
+
*/
|
|
454
|
+
export async function fetchAssembledContext(fetchJSON, cfg, query, options = {}) {
|
|
455
|
+
const actorPeerId = options.actorPeerId || "";
|
|
456
|
+
const log = options.log || (() => {});
|
|
457
|
+
if (await isContextFaceLegacy(options.legacyCachePath)) return null;
|
|
458
|
+
|
|
459
|
+
const body = buildContextSearchBody(cfg, options);
|
|
460
|
+
body.query = query;
|
|
461
|
+
const res = await fetchJSON("/api/v1/search/search", {
|
|
462
|
+
method: "POST",
|
|
463
|
+
body: JSON.stringify(body),
|
|
464
|
+
}, { actorPeerId, timeoutMs: contextRequestTimeoutMs(cfg, body) });
|
|
465
|
+
|
|
466
|
+
if (!res.ok) {
|
|
467
|
+
const status = res.status || 0;
|
|
468
|
+
if ((status === 400 || status === 422) && looksLikeUnknownField(res)) {
|
|
469
|
+
await markContextFaceLegacy(options.legacyCachePath);
|
|
470
|
+
log("recall_context_face_unsupported", { status });
|
|
471
|
+
} else {
|
|
472
|
+
log("recall_context_face_error", { status });
|
|
473
|
+
}
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const result = res.result || {};
|
|
478
|
+
const stats = result.stats || {};
|
|
479
|
+
log("recall_context_assembled", {
|
|
480
|
+
entries: Array.isArray(result.entries) ? result.entries.length : 0,
|
|
481
|
+
usedTokens: stats.used_tokens || 0,
|
|
482
|
+
tiers: stats.tier_counts || {},
|
|
483
|
+
rewrite: stats.rewrite || "off",
|
|
484
|
+
});
|
|
485
|
+
return {
|
|
486
|
+
rendered: String(result.rendered || "").trim(),
|
|
487
|
+
entries: Array.isArray(result.entries) ? result.entries : [],
|
|
488
|
+
digest: String(result.digest || "").trim(),
|
|
489
|
+
stats,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Entry field compatibility: the context face returns `category`/`text`, while
|
|
495
|
+
* the deprecated /recall v1 shape used `type` plus `content`/`summary`.
|
|
496
|
+
*/
|
|
497
|
+
export function normalizeContextEntry(entry = {}) {
|
|
498
|
+
return {
|
|
499
|
+
uri: String(entry.uri || "").trim(),
|
|
500
|
+
category: String(entry.category || entry.type || "memory").trim() || "memory",
|
|
501
|
+
detail: String(entry.detail || entry.mode || "").trim(),
|
|
502
|
+
score: Number(entry.score) || 0,
|
|
503
|
+
text: String(
|
|
504
|
+
entry.text || entry.content || entry.summary || entry.abstract || entry.uri || "",
|
|
505
|
+
).trim(),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function recallViaContextFace(fetchJSON, cfg, query, options, log) {
|
|
510
|
+
const assembled = await fetchAssembledContext(fetchJSON, cfg, query, { ...options, log });
|
|
511
|
+
if (assembled === null) return null;
|
|
512
|
+
|
|
513
|
+
const { rendered, entries } = assembled;
|
|
514
|
+
let digest = assembled.digest;
|
|
515
|
+
const mode = String(cfg.recallRewrite || "off").toLowerCase();
|
|
516
|
+
if (String(assembled.stats?.rewrite || "").toLowerCase() === "no_relevant") {
|
|
517
|
+
log("recall_server_compression", { status: "empty" });
|
|
518
|
+
return "";
|
|
519
|
+
}
|
|
520
|
+
const wantsLocal = mode === "client" || (mode === "auto" && !digest);
|
|
521
|
+
if (wantsLocal && rendered && typeof options.runCompressor === "function") {
|
|
522
|
+
try {
|
|
523
|
+
const compression = await compressRecallContext({
|
|
524
|
+
query,
|
|
525
|
+
rendered,
|
|
526
|
+
entries,
|
|
527
|
+
cfg,
|
|
528
|
+
runCompressor: options.runCompressor,
|
|
529
|
+
cachePath: options.digestCachePath || stateFile("recall-digest.json"),
|
|
530
|
+
now: Date.now(),
|
|
531
|
+
});
|
|
532
|
+
log("recall_local_compression", { status: compression.status });
|
|
533
|
+
if (compression.status === "ok") digest = compression.context;
|
|
534
|
+
if (compression.status === "empty") return "";
|
|
535
|
+
} catch (err) {
|
|
536
|
+
log("recall_local_compression_failed", { error: String(err?.message || err) });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const injected = digest || rendered;
|
|
541
|
+
if (!injected) return "";
|
|
542
|
+
return wrapContext(injected);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log = () => {}) {
|
|
546
|
+
const body = buildRecallEndpointBody(cfg);
|
|
547
|
+
body.query = query;
|
|
548
|
+
const res = await postRecall(fetchJSON, body, { actorPeerId, log });
|
|
549
|
+
if (!res.ok) {
|
|
550
|
+
log("recall_endpoint_fallback", { status: res.status || 0 });
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
const rendered = String(res.result?.rendered || "").trim();
|
|
554
|
+
if (!rendered) return "";
|
|
555
|
+
return wrapContext(rendered);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export async function postRecall(fetchJSON, body, opts = {}) {
|
|
559
|
+
const actorPeerId = opts.actorPeerId || "";
|
|
560
|
+
const log = opts.log || (() => {});
|
|
561
|
+
const request = { ...body };
|
|
562
|
+
const res = await fetchJSON("/api/v1/search/recall", {
|
|
563
|
+
method: "POST",
|
|
564
|
+
body: JSON.stringify(request),
|
|
565
|
+
}, { actorPeerId });
|
|
566
|
+
if (!request.peer_scope || (res.status !== 400 && res.status !== 422)) {
|
|
567
|
+
return res;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const downgraded = { ...request };
|
|
571
|
+
delete downgraded.peer_scope;
|
|
572
|
+
log("recall_peer_scope_downgrade", { status: res.status || 0 });
|
|
573
|
+
return fetchJSON("/api/v1/search/recall", {
|
|
574
|
+
method: "POST",
|
|
575
|
+
body: JSON.stringify(downgraded),
|
|
576
|
+
}, { actorPeerId });
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
|
|
580
|
+
const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
|
|
581
|
+
const log = options.log || (() => {});
|
|
582
|
+
const trimmed = String(query || "").trim();
|
|
583
|
+
if (!trimmed) return null;
|
|
584
|
+
|
|
585
|
+
// Assembly happens server-side when the deployment offers the context face;
|
|
586
|
+
// older servers fall through to /recall, then to raw find.
|
|
587
|
+
const serverBlock = await buildServerAssembledBlock(fetchJSON, cfg, trimmed, {
|
|
588
|
+
...options,
|
|
589
|
+
actorPeerId,
|
|
590
|
+
log,
|
|
591
|
+
});
|
|
592
|
+
if (serverBlock !== null) return serverBlock || null;
|
|
593
|
+
|
|
594
|
+
const recallLimit = Math.max(1, Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT));
|
|
595
|
+
const perSourceLimit = Math.max(recallLimit * 2, 8);
|
|
596
|
+
const raw = await searchAllSources(fetchJSON, trimmed, perSourceLimit, actorPeerId, log);
|
|
597
|
+
if (raw.length === 0) return null;
|
|
598
|
+
|
|
599
|
+
const profile = buildQueryProfile(trimmed);
|
|
600
|
+
const scoreThreshold = Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35;
|
|
601
|
+
const filtered = raw.filter((it) => clampScore(it.score) >= scoreThreshold);
|
|
602
|
+
filtered.sort((a, b) => rankItem(b, profile) - rankItem(a, profile));
|
|
603
|
+
const picked = dedupeItems(filtered).slice(0, recallLimit);
|
|
604
|
+
log("recall_picked", {
|
|
605
|
+
rawCount: raw.length,
|
|
606
|
+
filteredCount: filtered.length,
|
|
607
|
+
pickedCount: picked.length,
|
|
608
|
+
items: picked.map((it) => ({ type: it._sourceType, uri: it.uri, score: clampScore(it.score) })),
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
if (picked.length === 0) return null;
|
|
612
|
+
return buildFallbackInjectionBlock(fetchJSON, picked, cfg, actorPeerId, log);
|
|
613
|
+
}
|
|
@@ -2,17 +2,14 @@
|
|
|
2
2
|
name: install-openviking-memory
|
|
3
3
|
description: >
|
|
4
4
|
Install and configure the OpenViking long-term memory plugin for OpenClaw via natural conversation.
|
|
5
|
-
Once installed, the plugin automatically captures
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
"帮我装 OpenViking", "配置记忆插件", "安装记忆功能", "接入 OpenViking"
|
|
13
|
-
The user does NOT need to know any CLI commands — the agent runs everything and only asks for a few values.
|
|
14
|
-
This skill assumes the OpenViking server is already running. If the server is not ready, the skill
|
|
15
|
-
tells the user to contact their admin or set it up via the OpenViking docs — it does NOT install the server.
|
|
5
|
+
Once installed, the plugin automatically captures facts from chats and recalls relevant context
|
|
6
|
+
before each reply (auto-capture + auto-recall, cross-session). Covers prerequisites, install through
|
|
7
|
+
OpenClaw's plugin manager (ov-install as backup), wizard-based configuration, slot activation,
|
|
8
|
+
gateway restart, verification, multi-tenant root keys, multi-instance and uninstall. The user needs
|
|
9
|
+
no CLI knowledge — the agent runs everything and only asks for a few values. Assumes the OpenViking
|
|
10
|
+
server is already running; it does NOT install the server. Trigger on: "install OpenViking",
|
|
11
|
+
"set up memory", "configure memory plugin", "add long-term memory", "semantic memory", "RAG",
|
|
12
|
+
"帮我装 OpenViking", "配置记忆插件", "安装记忆功能", "接入 OpenViking".
|
|
16
13
|
version: 2026.6.5
|
|
17
14
|
metadata:
|
|
18
15
|
openclaw:
|