openclaw-openviking-plugin 0.1.0

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 ADDED
@@ -0,0 +1,101 @@
1
+ # openclaw-openviking-plugin
2
+
3
+ An [OpenClaw](https://openclaw.ai) plugin that integrates with [OpenViking](https://github.com/volcengine/openviking) for long-term memory.
4
+
5
+ **Hook-only** — does not register as a context engine. Works alongside LCM or any other context engine without conflict.
6
+
7
+ ## Features
8
+
9
+ - **autoRecall** — searches OpenViking memories before each prompt and injects relevant context
10
+ - **autoCapture** — commits new conversation messages to OpenViking after each turn for memory extraction
11
+ - **memory_recall** tool — model-triggered memory search
12
+ - **memory_store** tool — model-triggered memory write
13
+ - **memory_forget** tool — model-triggered memory deletion
14
+
15
+ ## Requirements
16
+
17
+ - OpenClaw gateway
18
+ - OpenViking server running and accessible via HTTP
19
+
20
+ ## Installation
21
+
22
+ ### Using `install.sh` (recommended)
23
+
24
+ ```bash
25
+ git clone https://github.com/liushuangls/openclaw-openviking-plugin
26
+ cd openclaw-openviking-plugin
27
+ ./install.sh
28
+ ```
29
+
30
+ The script copies plugin files, installs dependencies, updates `openclaw.json`, and restarts the gateway automatically. Re-running it on an already-installed plugin performs an **update** (syncs files + restarts, config unchanged).
31
+
32
+ ```bash
33
+ # Custom OV server address
34
+ OV_BASE_URL=http://192.168.1.100:1934 ./install.sh
35
+ ```
36
+
37
+ ### Manual
38
+
39
+ Copy the directory to `~/.openclaw/extensions/openclaw-openviking-plugin/`, run `npm install --omit=dev` inside it, then add the plugin to `openclaw.json` (see Configuration below) and restart the gateway.
40
+
41
+ ## Configuration
42
+
43
+ Add to your `openclaw.json`:
44
+
45
+ ```json
46
+ {
47
+ "plugins": {
48
+ "allow": ["openclaw-openviking-plugin"],
49
+ "entries": {
50
+ "openclaw-openviking-plugin": {
51
+ "enabled": true,
52
+ "config": {
53
+ "baseUrl": "http://127.0.0.1:1934",
54
+ "apiKey": "",
55
+ "autoRecall": true,
56
+ "autoCapture": true,
57
+ "recallLimit": 6,
58
+ "recallScoreThreshold": 0.15,
59
+ "recallTokenBudget": 2000,
60
+ "recallMaxContentChars": 500,
61
+ "commitTokenThreshold": 0
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ | Field | Default | Description |
70
+ |---|---|---|
71
+ | `baseUrl` | `http://127.0.0.1:1934` | OpenViking server URL |
72
+ | `apiKey` | `""` | API key (if required) |
73
+ | `autoRecall` | `true` | Inject relevant memories before each prompt |
74
+ | `autoCapture` | `true` | Commit conversation turns to OV after each response |
75
+ | `recallLimit` | `6` | Max memories to inject per turn |
76
+ | `recallScoreThreshold` | `0.15` | Minimum relevance score (0–1) |
77
+ | `recallTokenBudget` | `2000` | Max tokens for injected memory context |
78
+ | `recallMaxContentChars` | `500` | Max characters per memory snippet |
79
+ | `commitTokenThreshold` | `0` | Min tokens in a turn before committing (0 = always) |
80
+
81
+ ## Testing
82
+
83
+ ```bash
84
+ npm install
85
+
86
+ # Unit tests (no server required)
87
+ npm run test:unit
88
+
89
+ # Integration tests (requires OV server)
90
+ OV_BASE_URL=http://127.0.0.1:1934 npm run test:integration
91
+ ```
92
+
93
+ Integration tests skip automatically if the server is unreachable.
94
+
95
+ ## Coexistence with LCM
96
+
97
+ This plugin uses **hooks only** (`before_prompt_build` + `agent_end`). It does not set `kind: "context-engine"` and does not occupy the exclusive context engine slot, so it runs alongside [lossless-claw](https://github.com/martian-engineering/lossless-claw) or any other context engine without conflict.
98
+
99
+ ## License
100
+
101
+ MIT
package/README_CN.md ADDED
@@ -0,0 +1,110 @@
1
+ # openclaw-openviking-plugin
2
+
3
+ [OpenClaw](https://openclaw.ai) 的长期记忆插件,通过集成 [OpenViking](https://github.com/volcengine/openviking) 实现 AI Agent 的持久化记忆管理。
4
+
5
+ **Hook-only 设计** — 不注册为 context engine,不占用独占槽位,可与 LCM 或其他 context engine 无冲突共存。
6
+
7
+ ## 功能
8
+
9
+ - **autoRecall** — 每次对话前自动搜索 OpenViking 记忆,将相关内容注入 prompt
10
+ - **autoCapture** — 每轮对话结束后自动将新增消息提交到 OpenViking,触发记忆提取
11
+ - **memory_recall** 工具 — 模型主动触发记忆搜索
12
+ - **memory_store** 工具 — 模型主动写入记忆
13
+ - **memory_forget** 工具 — 模型主动删除记忆
14
+
15
+ ## 依赖
16
+
17
+ - OpenClaw gateway
18
+ - OpenViking server(本地或远程,HTTP 可访问)
19
+
20
+ ## 安装
21
+
22
+ ### 使用 `install.sh`(推荐)
23
+
24
+ ```bash
25
+ git clone https://github.com/liushuangls/openclaw-openviking-plugin
26
+ cd openclaw-openviking-plugin
27
+ ./install.sh
28
+ ```
29
+
30
+ 脚本会自动完成:复制插件文件 → 安装依赖 → 更新 `openclaw.json` → 重启 gateway。
31
+
32
+ 已安装的情况下重复执行为**更新模式**,只同步文件和重启,不覆盖配置。
33
+
34
+ ```bash
35
+ # 指定 OV server 地址
36
+ OV_BASE_URL=http://192.168.1.100:1934 ./install.sh
37
+ ```
38
+
39
+ ### 手动安装
40
+
41
+ 将目录复制到 `~/.openclaw/extensions/openclaw-openviking-plugin/`,在目录内执行 `npm install --omit=dev`,然后按下方配置格式更新 `openclaw.json`,重启 gateway 生效。
42
+
43
+ ## 配置
44
+
45
+ 在 `openclaw.json` 中添加:
46
+
47
+ ```json
48
+ {
49
+ "plugins": {
50
+ "allow": ["openclaw-openviking-plugin"],
51
+ "entries": {
52
+ "openclaw-openviking-plugin": {
53
+ "enabled": true,
54
+ "config": {
55
+ "baseUrl": "http://127.0.0.1:1934",
56
+ "apiKey": "",
57
+ "autoRecall": true,
58
+ "autoCapture": true,
59
+ "recallLimit": 6,
60
+ "recallScoreThreshold": 0.15,
61
+ "recallTokenBudget": 2000,
62
+ "recallMaxContentChars": 500,
63
+ "commitTokenThreshold": 0
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ | 字段 | 默认值 | 说明 |
72
+ |---|---|---|
73
+ | `baseUrl` | `http://127.0.0.1:1934` | OpenViking server 地址 |
74
+ | `apiKey` | `""` | API Key(按需填写) |
75
+ | `autoRecall` | `true` | 每次 prompt 前自动召回相关记忆 |
76
+ | `autoCapture` | `true` | 每轮对话结束后自动提交并提取记忆 |
77
+ | `recallLimit` | `6` | 单次最多注入的记忆条数 |
78
+ | `recallScoreThreshold` | `0.15` | 最低相关性分数(0–1) |
79
+ | `recallTokenBudget` | `2000` | 注入记忆的最大 token 数 |
80
+ | `recallMaxContentChars` | `500` | 单条记忆最大字符数 |
81
+ | `commitTokenThreshold` | `0` | 提交记忆的最低 token 阈值(0 = 每轮都提交) |
82
+
83
+ ## 测试
84
+
85
+ ```bash
86
+ npm install
87
+
88
+ # 单元测试(不需要 OV server)
89
+ npm run test:unit
90
+
91
+ # 集成测试(需要 OV server 运行中)
92
+ OV_BASE_URL=http://127.0.0.1:1934 npm run test:integration
93
+ ```
94
+
95
+ OV server 不可达时集成测试自动跳过。
96
+
97
+ ## 与 LCM 共存
98
+
99
+ 本插件只使用 hook(`before_prompt_build` + `agent_end`),不设置 `kind: "context-engine"`,不占用独占的 context engine 槽位,可与 [lossless-claw](https://github.com/martian-engineering/lossless-claw) 等插件同时运行。
100
+
101
+ - **LCM** 负责对话压缩与上下文管理
102
+ - **OpenViking 插件** 负责跨 session 的长期记忆自动捕获与召回
103
+
104
+ ## 升级注意事项
105
+
106
+ 升级 OpenViking server 前,建议先对比官方插件 `client.ts` 是否有变更,有则同步后跑一次集成测试再升级。最敏感的接口是 `fs/ls` 和 `system/status`(URI 归一化依赖这两个)。
107
+
108
+ ## License
109
+
110
+ MIT
package/client.ts ADDED
@@ -0,0 +1,381 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export type FindResultItem = {
4
+ uri: string;
5
+ level?: number;
6
+ abstract?: string;
7
+ overview?: string;
8
+ category?: string;
9
+ score?: number;
10
+ match_reason?: string;
11
+ };
12
+
13
+ export type FindResult = {
14
+ memories?: FindResultItem[];
15
+ resources?: FindResultItem[];
16
+ skills?: FindResultItem[];
17
+ total?: number;
18
+ };
19
+
20
+ export type CommitSessionResult = {
21
+ session_id: string;
22
+ status: string;
23
+ task_id?: string;
24
+ archive_uri?: string;
25
+ archived?: boolean;
26
+ memories_extracted?: Record<string, number>;
27
+ error?: string;
28
+ };
29
+
30
+ export type TaskResult = {
31
+ status: string;
32
+ result?: unknown;
33
+ error?: string;
34
+ };
35
+
36
+ type ScopeName = "user" | "agent";
37
+ type RuntimeIdentity = { userId: string; agentId: string };
38
+
39
+ export class OpenVikingRequestError extends Error {
40
+ readonly status: number;
41
+ readonly code?: string;
42
+
43
+ constructor(status: number, message: string, code?: string) {
44
+ super(
45
+ `OpenViking request failed (status ${status}${code ? `, code ${code}` : ""}): ${message}`,
46
+ );
47
+ this.name = "OpenVikingRequestError";
48
+ this.status = status;
49
+ this.code = code;
50
+ }
51
+ }
52
+
53
+ type CommitSessionOptions = {
54
+ wait?: boolean;
55
+ timeoutMs?: number;
56
+ agentId?: string;
57
+ };
58
+
59
+ type OpenVikingClientOptions = {
60
+ baseUrl: string;
61
+ apiKey?: string;
62
+ agentId?: string;
63
+ timeoutMs?: number;
64
+ };
65
+
66
+ const USER_STRUCTURE_DIRS = new Set(["memories"]);
67
+ const AGENT_STRUCTURE_DIRS = new Set(["memories", "skills", "instructions", "workspaces"]);
68
+
69
+ function md5Short(input: string): string {
70
+ return createHash("md5").update(input).digest("hex").slice(0, 12);
71
+ }
72
+
73
+ export class OpenVikingClient {
74
+ private readonly baseUrl: string;
75
+ private readonly apiKey: string;
76
+ private readonly defaultAgentId: string;
77
+ private readonly timeoutMs: number;
78
+ private spaceCache = new Map<string, Partial<Record<ScopeName, string>>>();
79
+ private identityCache = new Map<string, RuntimeIdentity>();
80
+
81
+ constructor(options: OpenVikingClientOptions) {
82
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
83
+ this.apiKey = options.apiKey?.trim() ?? "";
84
+ this.defaultAgentId = options.agentId?.trim() ?? "";
85
+ this.timeoutMs = options.timeoutMs ?? 15_000;
86
+ }
87
+
88
+ async find(
89
+ query: string,
90
+ targetUri: string,
91
+ limit: number,
92
+ scoreThreshold = 0,
93
+ agentId?: string,
94
+ ): Promise<FindResult> {
95
+ const normalizedUri = await this.normalizeTargetUri(targetUri, agentId);
96
+
97
+ return this.request<FindResult>(
98
+ "/api/v1/search/find",
99
+ {
100
+ method: "POST",
101
+ body: JSON.stringify({
102
+ query,
103
+ target_uri: normalizedUri,
104
+ limit,
105
+ score_threshold: scoreThreshold,
106
+ }),
107
+ },
108
+ agentId,
109
+ );
110
+ }
111
+
112
+ private async ls(uri: string, agentId?: string): Promise<Array<Record<string, unknown>>> {
113
+ return this.request<Array<Record<string, unknown>>>(
114
+ `/api/v1/fs/ls?uri=${encodeURIComponent(uri)}&output=original`,
115
+ { method: "GET" },
116
+ agentId,
117
+ );
118
+ }
119
+
120
+ private async getRuntimeIdentity(agentId?: string): Promise<RuntimeIdentity> {
121
+ const effectiveAgentId = agentId?.trim() || this.defaultAgentId;
122
+ const cached = this.identityCache.get(effectiveAgentId);
123
+ if (cached) {
124
+ return cached;
125
+ }
126
+
127
+ const fallback: RuntimeIdentity = {
128
+ userId: "default",
129
+ agentId: effectiveAgentId || "default",
130
+ };
131
+
132
+ try {
133
+ const status = await this.request<{ user?: unknown }>(
134
+ "/api/v1/system/status",
135
+ { method: "GET" },
136
+ agentId,
137
+ );
138
+ const userId =
139
+ typeof status.user === "string" && status.user.trim() ? status.user.trim() : "default";
140
+ const identity: RuntimeIdentity = { userId, agentId: effectiveAgentId || "default" };
141
+ this.identityCache.set(effectiveAgentId, identity);
142
+ return identity;
143
+ } catch {
144
+ this.identityCache.set(effectiveAgentId, fallback);
145
+ return fallback;
146
+ }
147
+ }
148
+
149
+ private async resolveScopeSpace(scope: ScopeName, agentId?: string): Promise<string> {
150
+ const effectiveAgentId = agentId?.trim() || this.defaultAgentId;
151
+ const agentScopes = this.spaceCache.get(effectiveAgentId);
152
+ const cached = agentScopes?.[scope];
153
+ if (cached) {
154
+ return cached;
155
+ }
156
+
157
+ const identity = await this.getRuntimeIdentity(agentId);
158
+ const fallbackSpace =
159
+ scope === "user" ? identity.userId : md5Short(`${identity.userId}:${identity.agentId}`);
160
+ const reservedDirs = scope === "user" ? USER_STRUCTURE_DIRS : AGENT_STRUCTURE_DIRS;
161
+ const preferredSpace =
162
+ scope === "user" ? identity.userId : md5Short(`${identity.userId}:${identity.agentId}`);
163
+
164
+ const saveSpace = (space: string) => {
165
+ const existing = this.spaceCache.get(effectiveAgentId) ?? {};
166
+ existing[scope] = space;
167
+ this.spaceCache.set(effectiveAgentId, existing);
168
+ };
169
+
170
+ try {
171
+ const entries = await this.ls(`viking://${scope}`, agentId);
172
+ const spaces = entries
173
+ .filter((entry) => entry?.isDir === true)
174
+ .map((entry) => (typeof entry.name === "string" ? entry.name.trim() : ""))
175
+ .filter((name) => name && !name.startsWith(".") && !reservedDirs.has(name));
176
+
177
+ if (spaces.length > 0) {
178
+ if (spaces.includes(preferredSpace)) {
179
+ saveSpace(preferredSpace);
180
+ return preferredSpace;
181
+ }
182
+ if (scope === "user" && spaces.includes("default")) {
183
+ saveSpace("default");
184
+ return "default";
185
+ }
186
+ if (spaces.length === 1) {
187
+ saveSpace(spaces[0]!);
188
+ return spaces[0]!;
189
+ }
190
+ }
191
+ } catch {
192
+ // Fall back to identity-derived space when listing fails.
193
+ }
194
+
195
+ saveSpace(fallbackSpace);
196
+ return fallbackSpace;
197
+ }
198
+
199
+ private async normalizeTargetUri(targetUri: string, agentId?: string): Promise<string> {
200
+ const trimmed = targetUri.trim().replace(/\/+$/, "");
201
+ const match = trimmed.match(/^viking:\/\/(user|agent)(?:\/(.*))?$/);
202
+ if (!match) {
203
+ return trimmed;
204
+ }
205
+
206
+ const scope = match[1] as ScopeName;
207
+ const rawRest = (match[2] ?? "").trim();
208
+ if (!rawRest) {
209
+ return trimmed;
210
+ }
211
+
212
+ const parts = rawRest.split("/").filter(Boolean);
213
+ if (parts.length === 0) {
214
+ return trimmed;
215
+ }
216
+
217
+ const reservedDirs = scope === "user" ? USER_STRUCTURE_DIRS : AGENT_STRUCTURE_DIRS;
218
+ if (!reservedDirs.has(parts[0]!)) {
219
+ return trimmed;
220
+ }
221
+
222
+ const space = await this.resolveScopeSpace(scope, agentId);
223
+ return `viking://${scope}/${space}/${parts.join("/")}`;
224
+ }
225
+
226
+ async read(uri: string, agentId?: string): Promise<string> {
227
+ try {
228
+ return await this.request<string>(
229
+ `/api/v1/content/read?uri=${encodeURIComponent(uri)}`,
230
+ { method: "GET" },
231
+ agentId,
232
+ );
233
+ } catch (error) {
234
+ if (error instanceof OpenVikingRequestError && error.status === 404) {
235
+ return "";
236
+ }
237
+ throw error;
238
+ }
239
+ }
240
+
241
+ async addSessionMessage(
242
+ sessionId: string,
243
+ role: string,
244
+ content: string,
245
+ agentId?: string,
246
+ ): Promise<void> {
247
+ await this.request<{ session_id: string }>(
248
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`,
249
+ {
250
+ method: "POST",
251
+ body: JSON.stringify({ role, content }),
252
+ },
253
+ agentId,
254
+ );
255
+ }
256
+
257
+ async commitSession(
258
+ sessionId: string,
259
+ optionsOrAgentId?: string | CommitSessionOptions,
260
+ ): Promise<CommitSessionResult> {
261
+ const options: CommitSessionOptions =
262
+ typeof optionsOrAgentId === "string"
263
+ ? { agentId: optionsOrAgentId }
264
+ : (optionsOrAgentId ?? {});
265
+
266
+ const result = await this.request<CommitSessionResult>(
267
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`,
268
+ {
269
+ method: "POST",
270
+ body: JSON.stringify({}),
271
+ },
272
+ options.agentId,
273
+ );
274
+
275
+ if (!options.wait || !result.task_id) {
276
+ return result;
277
+ }
278
+
279
+ const deadline = Date.now() + (options.timeoutMs ?? 120_000);
280
+ while (Date.now() < deadline) {
281
+ await sleep(500);
282
+ const task = await this.getTask(result.task_id, options.agentId).catch(() => null);
283
+ if (!task) {
284
+ break;
285
+ }
286
+ if (task.status === "completed") {
287
+ const taskResult =
288
+ task.result && typeof task.result === "object"
289
+ ? (task.result as Record<string, unknown>)
290
+ : {};
291
+ result.status = "completed";
292
+ result.memories_extracted = (taskResult.memories_extracted ??
293
+ {}) as Record<string, number>;
294
+ return result;
295
+ }
296
+ if (task.status === "failed") {
297
+ result.status = "failed";
298
+ result.error = task.error;
299
+ return result;
300
+ }
301
+ }
302
+
303
+ result.status = "timeout";
304
+ return result;
305
+ }
306
+
307
+ async getTask(taskId: string, agentId?: string): Promise<TaskResult> {
308
+ return this.request<TaskResult>(
309
+ `/api/v1/tasks/${encodeURIComponent(taskId)}`,
310
+ { method: "GET" },
311
+ agentId,
312
+ );
313
+ }
314
+
315
+ async delete(uri: string, agentId?: string): Promise<void> {
316
+ try {
317
+ await this.request<void>(
318
+ `/api/v1/fs/delete?uri=${encodeURIComponent(uri)}`,
319
+ { method: "DELETE" },
320
+ agentId,
321
+ );
322
+ } catch (error) {
323
+ if (error instanceof OpenVikingRequestError && error.status === 404) {
324
+ return;
325
+ }
326
+ throw error;
327
+ }
328
+ }
329
+
330
+ private async request<T>(path: string, init: RequestInit, agentId?: string): Promise<T> {
331
+ const controller = new AbortController();
332
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
333
+
334
+ try {
335
+ const headers = new Headers(init.headers ?? {});
336
+ if (this.apiKey) {
337
+ headers.set("X-Api-Key", this.apiKey);
338
+ }
339
+
340
+ const effectiveAgentId = agentId?.trim() || this.defaultAgentId;
341
+ if (effectiveAgentId) {
342
+ headers.set("X-OpenViking-Agent", effectiveAgentId);
343
+ }
344
+
345
+ if (init.body && !headers.has("Content-Type")) {
346
+ headers.set("Content-Type", "application/json");
347
+ }
348
+
349
+ const response = await fetch(`${this.baseUrl}${path}`, {
350
+ ...init,
351
+ headers,
352
+ signal: controller.signal,
353
+ });
354
+
355
+ const payload = (await response.json().catch(() => undefined)) as
356
+ | {
357
+ status?: string;
358
+ result?: T;
359
+ error?: { code?: string; message?: string };
360
+ }
361
+ | undefined;
362
+
363
+ if (!response.ok || payload?.status === "error") {
364
+ const code = payload?.error?.code;
365
+ const message =
366
+ payload?.error?.message ??
367
+ response.statusText ??
368
+ `HTTP ${response.status}`;
369
+ throw new OpenVikingRequestError(response.status, message, code);
370
+ }
371
+
372
+ return (payload?.result ?? payload) as T;
373
+ } finally {
374
+ clearTimeout(timer);
375
+ }
376
+ }
377
+ }
378
+
379
+ function sleep(ms: number): Promise<void> {
380
+ return new Promise((resolve) => setTimeout(resolve, ms));
381
+ }