@openviking/openclaw-plugin 2026.7.15 → 2026.8.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/INSTALL-ZH.md CHANGED
@@ -203,6 +203,45 @@ openclaw openviking setup --base-url <OPENVIKING_URL> --api-key <API_KEY> --forc
203
203
  openclaw openviking setup --base-url <OPENVIKING_URL> --api-key <API_KEY> --peer-role assistant --peer-prefix <PREFIX> --json
204
204
  ```
205
205
 
206
+ #### 无法执行 CLI 时直接配置文件
207
+
208
+ 如果容器内无法执行 `openclaw` CLI,可以把以下字段合并到 OpenClaw 实际读取的配置文件。设置了 `OPENCLAW_CONFIG_PATH` 时使用该路径;否则通常是 `$OPENCLAW_STATE_DIR/openclaw.json`(默认 `~/.openclaw/openclaw.json`)。
209
+
210
+ ```json
211
+ {
212
+ "plugins": {
213
+ "entries": {
214
+ "openviking": {
215
+ "enabled": true,
216
+ "config": {
217
+ "mode": "remote",
218
+ "baseUrl": "http://openviking:1933",
219
+ "apiKey": "<API_KEY>",
220
+ "peer_role": "assistant"
221
+ }
222
+ }
223
+ },
224
+ "slots": {
225
+ "contextEngine": "openviking"
226
+ }
227
+ }
228
+ }
229
+ ```
230
+
231
+ - 插件必须已经安装;编辑前请备份配置,并把以上字段合并到现有 `plugins` 配置。
232
+ - 如果配置中已有 `plugins.allow`,把 `openviking` 追加进去;如果没有,不要仅为本插件新建 allowlist。
233
+ - `contextEngine` 是独占 slot;若已有其他 context engine,只有确认替换后再修改该字段。使用 root API key 时,还需在 `config` 中设置 `accountId` 和 `userId`。
234
+ - 容器连接其他服务时,`baseUrl` 应使用容器内可访问的服务地址,而不是 `127.0.0.1`。
235
+ - 推荐使用 `SecretRef` 对象作为 `apiKey`(而不是明文字符串),避免密钥直接落盘写入 `openclaw.json`。支持的形式与 OpenClaw 核心中 LLM/TTS/MCP 等 provider 配置使用的标准 `SecretRef` 一致:
236
+
237
+ | 类型 | 示例 | 说明 |
238
+ | --- | --- | --- |
239
+ | `env` | `{ "source": "env", "id": "OPENVIKING_API_KEY" }` | 启动时读取同名环境变量。 |
240
+ | `file` | `{ "source": "file", "id": "/etc/secrets/openviking.key" }` | 以 UTF-8 读取并去除首尾空白;`~` 可展开,适配 Kubernetes `secretKeyRef` 卷挂载、0600 权限文件。 |
241
+ | `exec` | `{ "source": "exec", "provider": "op", "id": "op://vault/openviking/credential" }` | 运行 `<provider> <id>`,去除 stdout 首尾空白(1Password `op`、Vault、gopass 等均符合此形态)。 |
242
+
243
+ 仍可使用纯字符串形式(含 `${ENV_VAR}` 插值)作为向后兼容路径;此时请限制文件权限或通过受控 Secret 卷提供,并在修改后重启 Gateway、容器或 Pod。
244
+
206
245
  ### 3. 重启 OpenClaw Gateway
207
246
 
208
247
  ```bash
package/INSTALL.md CHANGED
@@ -147,6 +147,45 @@ If you want assistant messages to carry a prefixed `peer_id` and data-plane reca
147
147
  openclaw openviking setup --base-url <OPENVIKING_URL> --api-key <API_KEY> --peer-role assistant --peer-prefix <PREFIX> --json
148
148
  ```
149
149
 
150
+ #### Configure The File Directly When The CLI Is Unavailable
151
+
152
+ If the `openclaw` CLI cannot run inside the container, merge the following fields into the config file that OpenClaw actually reads. Use `OPENCLAW_CONFIG_PATH` when it is set; otherwise the file is usually `$OPENCLAW_STATE_DIR/openclaw.json` (default: `~/.openclaw/openclaw.json`).
153
+
154
+ ```json
155
+ {
156
+ "plugins": {
157
+ "entries": {
158
+ "openviking": {
159
+ "enabled": true,
160
+ "config": {
161
+ "mode": "remote",
162
+ "baseUrl": "http://openviking:1933",
163
+ "apiKey": "<API_KEY>",
164
+ "peer_role": "assistant"
165
+ }
166
+ }
167
+ },
168
+ "slots": {
169
+ "contextEngine": "openviking"
170
+ }
171
+ }
172
+ }
173
+ ```
174
+
175
+ - The plugin must already be installed. Back up the file and merge these fields into the existing `plugins` config.
176
+ - If `plugins.allow` already exists, append `openviking`; otherwise, do not create an allowlist only for this plugin.
177
+ - `contextEngine` is an exclusive slot. If another context engine is configured, change it only after confirming the replacement. Root API keys also require `accountId` and `userId` in `config`.
178
+ - When connecting to another service from a container, use a `baseUrl` that is reachable from the container rather than `127.0.0.1`.
179
+ - Prefer a `SecretRef` for `apiKey` instead of a plaintext string so the key is never stored inside `openclaw.json`. Supported shapes match OpenClaw's standard `SecretRef` used by LLM/TTS/MCP provider configs:
180
+
181
+ | Shape | Example | Notes |
182
+ | --- | --- | --- |
183
+ | `env` | `{ "source": "env", "id": "OPENVIKING_API_KEY" }` | Reads the named env var at plugin load time. |
184
+ | `file` | `{ "source": "file", "id": "/etc/secrets/openviking.key" }` | Reads UTF-8, trims whitespace. `~` is expanded; ideal for Kubernetes `secretKeyRef` volumes and 0600-managed files. |
185
+ | `exec` | `{ "source": "exec", "provider": "op", "id": "op://vault/openviking/credential" }` | Runs `<provider> <id>` and trims stdout (1Password `op`, Vault, gopass, etc. all match this shape). |
186
+
187
+ A plain `string` (including `${ENV_VAR}` interpolation) is still accepted, but only as a backward-compatibility path; in that case, restrict file permissions or provide the file through a managed Secret volume, then restart the Gateway, container, or Pod.
188
+
150
189
  ### 3. Restart OpenClaw Gateway
151
190
 
152
191
  ```bash
package/README.md CHANGED
@@ -46,6 +46,8 @@ Once installed, the plugin provides these agent tools:
46
46
  | `ov_read` | Read the full original content of one exact OpenViking URI |
47
47
  | `ov_multi_read` | Read the full original content of multiple OpenViking URIs |
48
48
  | `ov_list` | List OpenViking directories after search to inspect sibling chunks and overview files |
49
+ | `search_experience` | Search reusable execution experiences for the current OpenViking user |
50
+ | `read_experience` | Read one exact Experience returned by `search_experience` |
49
51
  | `openviking_tool_result_read` | Restore the full original content of an externalized tool result |
50
52
  | `openviking_tool_result_search` | Search inside an externalized tool result by keyword |
51
53
  | `openviking_tool_result_list` | List externalized tool results in the current session |
@@ -248,6 +250,8 @@ Beyond automatic behavior, the plugin exposes these tools directly:
248
250
  - `ov_read`: read one exact `viking://` URI returned by `ov_search` or `ov_list`
249
251
  - `ov_multi_read`: read multiple exact `viking://` URIs, useful for an overview plus sibling chunks
250
252
  - `ov_list`: list a hit's parent directory after `ov_search` to recover sibling chunks, `.overview.md`, and related split-document context
253
+ - `search_experience`: search reusable execution experiences for the current OpenViking user
254
+ - `read_experience`: read one exact Experience returned by `search_experience`
251
255
 
252
256
  They serve different roles:
253
257
 
@@ -261,6 +265,7 @@ They serve different roles:
261
265
  - `ov_read` turns a ranked hit into original evidence before answering precise documentation, codebase, configuration, or procedural questions
262
266
  - `ov_multi_read` reads overview and sibling chunks together when a split document needs more context than a single hit
263
267
  - `ov_list` complements `ov_search` when a ranked hit is only one chunk of a larger procedure or document
268
+ - `search_experience` and `read_experience` provide the exact tool-call contract used by the bundled Experience Memory skill and usage reporting
264
269
 
265
270
  `ov_archive_expand` is especially important because `assemble()` normally returns archive summaries and indexes, not the full raw transcript.
266
271
 
package/config.ts CHANGED
@@ -1,13 +1,38 @@
1
1
  import { homedir } from "node:os";
2
+ import { readFileSync } from "node:fs";
2
3
 
3
4
  import { getEnv } from "./runtime-utils.js";
4
5
 
6
+ /**
7
+ * Subset of OpenClaw's standard `SecretRef` shape supported by the OpenViking
8
+ * plugin. The full runtime SDK also recognises `source: "exec"` with a
9
+ * provider plugin, but the plugin self-hosts its own env/file resolution so
10
+ * users on pluginSdkVersion < 2026.6.x (no host-level secret-resolver) still
11
+ * get vault-free secret management.
12
+ *
13
+ * source: "env" -> `process.env[id]` (same as the bare `${ENV}` string
14
+ * interpolation, but declarative and visible to UI hints).
15
+ * source: "file" -> contents of `id` path (``~`` expansion supported),
16
+ * leading/trailing whitespace trimmed — convenient for
17
+ * Kubernetes `secretKeyRef` mount volumes and
18
+ * file-permission-only deployments.
19
+ * source: "exec" -> delegate to host `openclaw` secret resolver when the
20
+ * runtime exposes it; today the plugin performs a
21
+ * best-effort `provider + id` CLI exec call so that
22
+ * 1Password / Vault / gopass users can share OpenViking
23
+ * credentials with their other providers.
24
+ */
25
+ export type OpenVikingSecretRef =
26
+ | { source: "env"; id: string }
27
+ | { source: "file"; id: string }
28
+ | { source: "exec"; provider: string; id: string };
29
+
5
30
  export type MemoryOpenVikingConfig = {
6
31
  mode?: "remote";
7
32
  baseUrl?: string;
8
33
  peer_role?: "none" | "assistant" | "person";
9
34
  peer_prefix?: string;
10
- apiKey?: string;
35
+ apiKey?: string | OpenVikingSecretRef;
11
36
  /** Optional HTTP headers merged into every OpenViking request. */
12
37
  headers?: Record<string, string>;
13
38
  /** Advanced option. Only needed when explicitly sending tenant identity headers. With a user key the server derives identity from the key. */
@@ -102,8 +127,10 @@ export type MemoryOpenVikingConfig = {
102
127
 
103
128
  /** Runtime config after memoryOpenVikingConfigSchema.parse() has applied defaults. */
104
129
  export type ParsedMemoryOpenVikingConfig = Required<
105
- Omit<MemoryOpenVikingConfig, "agentExperience" | "recallTargetTypes">
130
+ Omit<MemoryOpenVikingConfig, "agentExperience" | "recallTargetTypes" | "apiKey">
106
131
  > & {
132
+ /** parse() resolves SecretRef values, so the runtime shape is always a plain string. */
133
+ apiKey: string;
107
134
  agentExperience: Required<NonNullable<MemoryOpenVikingConfig["agentExperience"]>>;
108
135
  recallTargetTypes: Array<"resource" | "user" | "agent">;
109
136
  };
@@ -143,6 +170,8 @@ export const OPENVIKING_DEFAULT_ENABLED_TOOL_NAMES = [
143
170
  "ov_read",
144
171
  "ov_multi_read",
145
172
  "ov_list",
173
+ "search_experience",
174
+ "read_experience",
146
175
  "memory_recall",
147
176
  "ov_recall_trace",
148
177
  "memory_store",
@@ -163,6 +192,7 @@ export const OPENVIKING_TOOL_GROUPS: Record<string, readonly OpenVikingToolName[
163
192
  default: OPENVIKING_DEFAULT_ENABLED_TOOL_NAMES,
164
193
  memory: ["memory_recall", "memory_store", "memory_forget"],
165
194
  resource_query: ["ov_search", "ov_read", "ov_multi_read", "ov_list"],
195
+ experience: ["search_experience", "read_experience"],
166
196
  import: ["add_resource", "add_skill"],
167
197
  recall_trace: ["ov_recall_trace"],
168
198
  archive: ["ov_archive_search", "ov_archive_expand"],
@@ -188,6 +218,105 @@ function resolvePeerPrefix(configured: unknown): string {
188
218
  return DEFAULT_PEER_PREFIX;
189
219
  }
190
220
 
221
+ /**
222
+ * Resolve an {@link OpenVikingSecretRef} to the actual secret string. Plain
223
+ * strings pass through untouched so callers can compose this with the legacy
224
+ * `resolveEnvVars` pipeline without branching.
225
+ *
226
+ * Intentional behaviours:
227
+ * * `env`: uses `getEnv` (process.env but exposed for vitest overrides). An
228
+ * unset / empty env var is an error — the user asked for a named secret
229
+ * so silent empty-string fallback would mask misconfigurations.
230
+ * * `file`: ~ expanded, then read as UTF-8, then leading/trailing whitespace
231
+ * stripped (de-facto standard for files written with `echo xxx > key`).
232
+ * Missing / unreadable files propagate the original node error with an
233
+ * error prefix that names the OpenViking field, so the user knows which
234
+ * secretRef failed to load.
235
+ * * `exec`: shells out `<provider> <id>`. Providers commonly expose this
236
+ * shape (1Password CLI, gopass, HashiCorp Vault), which matches how
237
+ * OpenClaw's own `@transmitt0r/openclaw-plugin-onepassword` provider
238
+ * resolves `{source:"exec", provider:"onepassword", id:"..."}`. Exec is
239
+ * best-effort; failures surface with an error prefix so users can
240
+ * distinguish `provider not installed` from `provider configured but id
241
+ * missing`.
242
+ */
243
+ function resolveSecret(
244
+ value: string | OpenVikingSecretRef | undefined | null,
245
+ label: string,
246
+ ): string {
247
+ if (value === undefined || value === null) return "";
248
+ if (typeof value === "string") return value;
249
+ if (!value || typeof value !== "object") {
250
+ throw new Error(
251
+ `OpenViking ${label} must be a plain string or a SecretRef object ` +
252
+ `({source:"env"|"file"|"exec", id})`,
253
+ );
254
+ }
255
+ const obj = value as Record<string, unknown>;
256
+ if (typeof obj.id !== "string" || !obj.id) {
257
+ throw new Error(`OpenViking ${label} SecretRef requires a non-empty string "id"`);
258
+ }
259
+ const id = obj.id;
260
+ switch (obj.source) {
261
+ case "env": {
262
+ const envValue = getEnv(id);
263
+ if (!envValue) {
264
+ throw new Error(
265
+ `OpenViking ${label} SecretRef env source: environment variable ${id} is not set or empty`,
266
+ );
267
+ }
268
+ return envValue;
269
+ }
270
+ case "file": {
271
+ const resolvedPath = expandHomeDir(id);
272
+ try {
273
+ const contents = readFileSync(resolvedPath, "utf8");
274
+ return contents.trim();
275
+ } catch (err) {
276
+ const msg = err instanceof Error ? err.message : String(err);
277
+ throw new Error(
278
+ `OpenViking ${label} SecretRef file source: cannot read ${resolvedPath} (${msg})`,
279
+ );
280
+ }
281
+ }
282
+ case "exec": {
283
+ const provider = typeof (obj as Record<string, unknown>).provider === "string"
284
+ ? (obj as Record<string, unknown>).provider as string
285
+ : "";
286
+ if (!provider) {
287
+ throw new Error(
288
+ `OpenViking ${label} SecretRef exec source requires a "provider" field naming the secret-manager CLI on PATH`,
289
+ );
290
+ }
291
+ try {
292
+ // Avoid top-level `import "node:child_process"` — this branch is rare
293
+ // and keeping the require lazy makes startup marginally faster for
294
+ // the 95% of users who pick env/file.
295
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
296
+ const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
297
+ const out = execFileSync(provider, [id], {
298
+ encoding: "utf8",
299
+ stdio: ["ignore", "pipe", "pipe"],
300
+ timeout: 15_000,
301
+ env: process.env,
302
+ }) as string;
303
+ return out.trim();
304
+ } catch (err) {
305
+ const msg = err instanceof Error ? err.message : String(err);
306
+ throw new Error(
307
+ `OpenViking ${label} SecretRef exec source: provider "${provider}" failed for id "${id}" (${msg})`,
308
+ );
309
+ }
310
+ }
311
+ default:
312
+ throw new Error(
313
+ `OpenViking ${label} SecretRef has unknown source "${String(
314
+ (obj as Record<string, unknown>).source,
315
+ )}". Supported: "env" | "file" | "exec".`,
316
+ );
317
+ }
318
+ }
319
+
191
320
  function resolvePeerRole(configured: unknown) {
192
321
  if (typeof configured === "string") {
193
322
  const role = configured.trim().toLowerCase();
@@ -465,7 +594,14 @@ export const memoryOpenVikingConfigSchema = {
465
594
  const peerPrefix = resolvePeerPrefix(cfg.peer_prefix);
466
595
  const rawBaseUrl = typeof cfg.baseUrl === "string" ? cfg.baseUrl : resolveDefaultBaseUrl();
467
596
  const resolvedBaseUrl = resolveEnvVars(rawBaseUrl).replace(/\/+$/, "");
468
- const rawApiKey = typeof cfg.apiKey === "string" ? cfg.apiKey : getEnv("OPENVIKING_API_KEY");
597
+ // Support plain string, SecretRef object, and OPENVIKING_API_KEY fallback.
598
+ // A user writing a SecretRef has explicitly opted out of the bare env
599
+ // interpolation path, so the fallback kicks in only when nothing is
600
+ // configured at all (matches existing behaviour).
601
+ const rawApiKey: string | OpenVikingSecretRef | undefined =
602
+ cfg.apiKey !== undefined && cfg.apiKey !== null
603
+ ? (cfg.apiKey as string | OpenVikingSecretRef)
604
+ : getEnv("OPENVIKING_API_KEY") || undefined;
469
605
  const captureMode = cfg.captureMode;
470
606
  if (
471
607
  typeof captureMode !== "undefined" &&
@@ -508,7 +644,7 @@ export const memoryOpenVikingConfigSchema = {
508
644
  baseUrl: resolvedBaseUrl,
509
645
  peer_role: peerRole,
510
646
  peer_prefix: peerPrefix,
511
- apiKey: rawApiKey ? resolveEnvVars(rawApiKey) : "",
647
+ apiKey: rawApiKey ? resolveEnvVars(resolveSecret(rawApiKey, "config.apiKey")) : "",
512
648
  headers: toStringRecord(cfg.headers, "openviking config headers"),
513
649
  accountId,
514
650
  userId,
@@ -678,7 +814,10 @@ export const memoryOpenVikingConfigSchema = {
678
814
  label: "OpenViking API Key",
679
815
  sensitive: true,
680
816
  placeholder: "${OPENVIKING_API_KEY}",
681
- help: "Optional API key for OpenViking server",
817
+ help:
818
+ "Optional API key for OpenViking server. Accepts a plain string, " +
819
+ "${ENV_VAR} interpolation, or a SecretRef object ({source: env/file/exec, id}). " +
820
+ "Prefer the SecretRef shapes so the key never sits as plaintext in openclaw.json.",
682
821
  },
683
822
  headers: {
684
823
  label: "Headers",
@@ -836,7 +975,7 @@ export const memoryOpenVikingConfigSchema = {
836
975
  enabledTools: {
837
976
  label: "Enabled Tools",
838
977
  placeholder: "default",
839
- help: "Agent-visible tool allowlist. Accepts tool names or groups: default, all, memory, resource_query, import, recall_trace, archive, tool_result. add_resource also requires enableAddResourceTool=true.",
978
+ help: "Agent-visible tool allowlist. Accepts tool names or groups: default, all, memory, resource_query, experience, import, recall_trace, archive, tool_result. add_resource also requires enableAddResourceTool=true.",
840
979
  advanced: true,
841
980
  },
842
981
  disabledTools: {
package/dist/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { homedir } from "node:os";
2
+ import { readFileSync } from "node:fs";
2
3
  import { getEnv } from "./runtime-utils.js";
3
4
  const DEFAULT_BASE_URL = "http://127.0.0.1:1933";
4
5
  const DEFAULT_TARGET_URI = "viking://user/memories";
@@ -34,6 +35,8 @@ export const OPENVIKING_DEFAULT_ENABLED_TOOL_NAMES = [
34
35
  "ov_read",
35
36
  "ov_multi_read",
36
37
  "ov_list",
38
+ "search_experience",
39
+ "read_experience",
37
40
  "memory_recall",
38
41
  "ov_recall_trace",
39
42
  "memory_store",
@@ -53,6 +56,7 @@ export const OPENVIKING_TOOL_GROUPS = {
53
56
  default: OPENVIKING_DEFAULT_ENABLED_TOOL_NAMES,
54
57
  memory: ["memory_recall", "memory_store", "memory_forget"],
55
58
  resource_query: ["ov_search", "ov_read", "ov_multi_read", "ov_list"],
59
+ experience: ["search_experience", "read_experience"],
56
60
  import: ["add_resource", "add_skill"],
57
61
  recall_trace: ["ov_recall_trace"],
58
62
  archive: ["ov_archive_search", "ov_archive_expand"],
@@ -76,6 +80,91 @@ function resolvePeerPrefix(configured) {
76
80
  }
77
81
  return DEFAULT_PEER_PREFIX;
78
82
  }
83
+ /**
84
+ * Resolve an {@link OpenVikingSecretRef} to the actual secret string. Plain
85
+ * strings pass through untouched so callers can compose this with the legacy
86
+ * `resolveEnvVars` pipeline without branching.
87
+ *
88
+ * Intentional behaviours:
89
+ * * `env`: uses `getEnv` (process.env but exposed for vitest overrides). An
90
+ * unset / empty env var is an error — the user asked for a named secret
91
+ * so silent empty-string fallback would mask misconfigurations.
92
+ * * `file`: ~ expanded, then read as UTF-8, then leading/trailing whitespace
93
+ * stripped (de-facto standard for files written with `echo xxx > key`).
94
+ * Missing / unreadable files propagate the original node error with an
95
+ * error prefix that names the OpenViking field, so the user knows which
96
+ * secretRef failed to load.
97
+ * * `exec`: shells out `<provider> <id>`. Providers commonly expose this
98
+ * shape (1Password CLI, gopass, HashiCorp Vault), which matches how
99
+ * OpenClaw's own `@transmitt0r/openclaw-plugin-onepassword` provider
100
+ * resolves `{source:"exec", provider:"onepassword", id:"..."}`. Exec is
101
+ * best-effort; failures surface with an error prefix so users can
102
+ * distinguish `provider not installed` from `provider configured but id
103
+ * missing`.
104
+ */
105
+ function resolveSecret(value, label) {
106
+ if (value === undefined || value === null)
107
+ return "";
108
+ if (typeof value === "string")
109
+ return value;
110
+ if (!value || typeof value !== "object") {
111
+ throw new Error(`OpenViking ${label} must be a plain string or a SecretRef object ` +
112
+ `({source:"env"|"file"|"exec", id})`);
113
+ }
114
+ const obj = value;
115
+ if (typeof obj.id !== "string" || !obj.id) {
116
+ throw new Error(`OpenViking ${label} SecretRef requires a non-empty string "id"`);
117
+ }
118
+ const id = obj.id;
119
+ switch (obj.source) {
120
+ case "env": {
121
+ const envValue = getEnv(id);
122
+ if (!envValue) {
123
+ throw new Error(`OpenViking ${label} SecretRef env source: environment variable ${id} is not set or empty`);
124
+ }
125
+ return envValue;
126
+ }
127
+ case "file": {
128
+ const resolvedPath = expandHomeDir(id);
129
+ try {
130
+ const contents = readFileSync(resolvedPath, "utf8");
131
+ return contents.trim();
132
+ }
133
+ catch (err) {
134
+ const msg = err instanceof Error ? err.message : String(err);
135
+ throw new Error(`OpenViking ${label} SecretRef file source: cannot read ${resolvedPath} (${msg})`);
136
+ }
137
+ }
138
+ case "exec": {
139
+ const provider = typeof obj.provider === "string"
140
+ ? obj.provider
141
+ : "";
142
+ if (!provider) {
143
+ throw new Error(`OpenViking ${label} SecretRef exec source requires a "provider" field naming the secret-manager CLI on PATH`);
144
+ }
145
+ try {
146
+ // Avoid top-level `import "node:child_process"` — this branch is rare
147
+ // and keeping the require lazy makes startup marginally faster for
148
+ // the 95% of users who pick env/file.
149
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
150
+ const { execFileSync } = require("node:child_process");
151
+ const out = execFileSync(provider, [id], {
152
+ encoding: "utf8",
153
+ stdio: ["ignore", "pipe", "pipe"],
154
+ timeout: 15_000,
155
+ env: process.env,
156
+ });
157
+ return out.trim();
158
+ }
159
+ catch (err) {
160
+ const msg = err instanceof Error ? err.message : String(err);
161
+ throw new Error(`OpenViking ${label} SecretRef exec source: provider "${provider}" failed for id "${id}" (${msg})`);
162
+ }
163
+ }
164
+ default:
165
+ throw new Error(`OpenViking ${label} SecretRef has unknown source "${String(obj.source)}". Supported: "env" | "file" | "exec".`);
166
+ }
167
+ }
79
168
  function resolvePeerRole(configured) {
80
169
  if (typeof configured === "string") {
81
170
  const role = configured.trim().toLowerCase();
@@ -320,7 +409,13 @@ export const memoryOpenVikingConfigSchema = {
320
409
  const peerPrefix = resolvePeerPrefix(cfg.peer_prefix);
321
410
  const rawBaseUrl = typeof cfg.baseUrl === "string" ? cfg.baseUrl : resolveDefaultBaseUrl();
322
411
  const resolvedBaseUrl = resolveEnvVars(rawBaseUrl).replace(/\/+$/, "");
323
- const rawApiKey = typeof cfg.apiKey === "string" ? cfg.apiKey : getEnv("OPENVIKING_API_KEY");
412
+ // Support plain string, SecretRef object, and OPENVIKING_API_KEY fallback.
413
+ // A user writing a SecretRef has explicitly opted out of the bare env
414
+ // interpolation path, so the fallback kicks in only when nothing is
415
+ // configured at all (matches existing behaviour).
416
+ const rawApiKey = cfg.apiKey !== undefined && cfg.apiKey !== null
417
+ ? cfg.apiKey
418
+ : getEnv("OPENVIKING_API_KEY") || undefined;
324
419
  const captureMode = cfg.captureMode;
325
420
  if (typeof captureMode !== "undefined" &&
326
421
  captureMode !== "semantic" &&
@@ -342,7 +437,7 @@ export const memoryOpenVikingConfigSchema = {
342
437
  baseUrl: resolvedBaseUrl,
343
438
  peer_role: peerRole,
344
439
  peer_prefix: peerPrefix,
345
- apiKey: rawApiKey ? resolveEnvVars(rawApiKey) : "",
440
+ apiKey: rawApiKey ? resolveEnvVars(resolveSecret(rawApiKey, "config.apiKey")) : "",
346
441
  headers: toStringRecord(cfg.headers, "openviking config headers"),
347
442
  accountId,
348
443
  userId,
@@ -423,7 +518,9 @@ export const memoryOpenVikingConfigSchema = {
423
518
  label: "OpenViking API Key",
424
519
  sensitive: true,
425
520
  placeholder: "${OPENVIKING_API_KEY}",
426
- help: "Optional API key for OpenViking server",
521
+ help: "Optional API key for OpenViking server. Accepts a plain string, " +
522
+ "${ENV_VAR} interpolation, or a SecretRef object ({source: env/file/exec, id}). " +
523
+ "Prefer the SecretRef shapes so the key never sits as plaintext in openclaw.json.",
427
524
  },
428
525
  headers: {
429
526
  label: "Headers",
@@ -579,7 +676,7 @@ export const memoryOpenVikingConfigSchema = {
579
676
  enabledTools: {
580
677
  label: "Enabled Tools",
581
678
  placeholder: "default",
582
- help: "Agent-visible tool allowlist. Accepts tool names or groups: default, all, memory, resource_query, import, recall_trace, archive, tool_result. add_resource also requires enableAddResourceTool=true.",
679
+ help: "Agent-visible tool allowlist. Accepts tool names or groups: default, all, memory, resource_query, experience, import, recall_trace, archive, tool_result. add_resource also requires enableAddResourceTool=true.",
583
680
  advanced: true,
584
681
  },
585
682
  disabledTools: {
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { registerOpenVikingCommands } from "./plugin/command-registration.js";
15
15
  import { registerRecallTraceRoutes as registerRecallTraceRouteAdapters } from "./plugin/recall-trace-routes.js";
16
16
  import { createOpenVikingService } from "./plugin/openviking-services.js";
17
17
  import { registerOpenVikingArchiveTools } from "./plugin/openviking-archive-tools.js";
18
+ import { registerOpenVikingExperienceTools } from "./plugin/openviking-experience-tools.js";
18
19
  import { createOpenVikingImportRuntime } from "./plugin/openviking-import-runtime.js";
19
20
  import { registerOpenVikingImportTools } from "./plugin/openviking-import-tools.js";
20
21
  import { registerOpenVikingLifecycleHooks } from "./plugin/openviking-lifecycle-hooks.js";
@@ -136,6 +137,14 @@ const contextEnginePlugin = {
136
137
  isBypassedSession,
137
138
  makeBypassedToolResult,
138
139
  });
140
+ registerOpenVikingExperienceTools({
141
+ registerTool: registerOpenVikingTool,
142
+ getClient,
143
+ resolvePluginSessionRouting,
144
+ isBypassedSession,
145
+ makeBypassedToolResult,
146
+ userId: cfg.userId,
147
+ });
139
148
  registerOpenVikingMemoryRecallTools({
140
149
  registerTool: registerOpenVikingTool,
141
150
  getClient,
@@ -0,0 +1,105 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ const EXPERIENCE_TARGET_URI = "viking://user/memories/experiences/";
3
+ const DEFAULT_LIMIT = 5;
4
+ const MAX_LIMIT = 20;
5
+ const EXPERIENCE_SIDECAR_FILENAMES = new Set([".abstract.md", ".overview.md", ".relations.json"]);
6
+ function clampLimit(value) {
7
+ const parsed = Number.isFinite(Number(value)) ? Math.trunc(Number(value)) : DEFAULT_LIMIT;
8
+ return Math.max(1, Math.min(MAX_LIMIT, parsed));
9
+ }
10
+ function experienceRelativePath(uri) {
11
+ return uri.match(/^viking:\/\/user\/[^/]+\/memories\/experiences\/(.+)$/)?.[1] ?? "";
12
+ }
13
+ export function isCanonicalExperienceUri(uri, expectedUserId) {
14
+ const value = typeof uri === "string" ? uri.trim() : "";
15
+ if (!value || value.includes("?") || value.includes("#")) {
16
+ return false;
17
+ }
18
+ const owner = value.match(/^viking:\/\/user\/([^/]+)\//)?.[1] ?? "";
19
+ if (expectedUserId?.trim() && owner !== expectedUserId.trim()) {
20
+ return false;
21
+ }
22
+ const relative = experienceRelativePath(value);
23
+ const segments = relative.split("/");
24
+ const basename = segments.at(-1) ?? "";
25
+ return Boolean(relative
26
+ && segments.every((segment) => segment && segment !== "." && segment !== "..")
27
+ && !EXPERIENCE_SIDECAR_FILENAMES.has(basename));
28
+ }
29
+ function titleFromUri(uri) {
30
+ const basename = uri.split("/").at(-1) ?? uri;
31
+ const title = basename.replace(/\.md$/i, "");
32
+ try {
33
+ return decodeURIComponent(title);
34
+ }
35
+ catch {
36
+ return title;
37
+ }
38
+ }
39
+ function jsonToolResult(payload) {
40
+ return {
41
+ content: [{ type: "text", text: JSON.stringify(payload) }],
42
+ };
43
+ }
44
+ export function registerOpenVikingExperienceTools(deps) {
45
+ deps.registerTool((ctx) => ({
46
+ name: "search_experience",
47
+ label: "Search Experience (OpenViking)",
48
+ description: "Search reusable task-execution experiences for the current OpenViking user. " +
49
+ "Use before executing operational or multi-step tasks, then call read_experience for selected results.",
50
+ parameters: Type.Object({
51
+ query: Type.String({ description: "Current task, situation, or execution problem to search for" }),
52
+ limit: Type.Optional(Type.Integer({
53
+ description: `Maximum results to return. Default: ${DEFAULT_LIMIT}; maximum: ${MAX_LIMIT}`,
54
+ minimum: 1,
55
+ maximum: MAX_LIMIT,
56
+ })),
57
+ }, { additionalProperties: false }),
58
+ async execute(_toolCallId, params) {
59
+ if (deps.isBypassedSession(ctx)) {
60
+ return deps.makeBypassedToolResult("search_experience");
61
+ }
62
+ const query = typeof params.query === "string" ? params.query.trim() : "";
63
+ if (!query) {
64
+ throw new Error("query is required");
65
+ }
66
+ const session = deps.resolvePluginSessionRouting(ctx);
67
+ const client = await deps.getClient();
68
+ const result = await client.find(query, {
69
+ targetUri: EXPERIENCE_TARGET_URI,
70
+ limit: clampLimit(params.limit),
71
+ }, session.actorPeerId);
72
+ const results = (result.memories ?? [])
73
+ .filter((item) => isCanonicalExperienceUri(item.uri, deps.userId))
74
+ .map((item) => ({
75
+ uri: item.uri,
76
+ title: titleFromUri(item.uri),
77
+ score: typeof item.score === "number" ? item.score : 0,
78
+ snippet: item.abstract || item.overview || "",
79
+ }));
80
+ return jsonToolResult({ results });
81
+ },
82
+ }), { name: "search_experience" });
83
+ deps.registerTool((ctx) => ({
84
+ name: "read_experience",
85
+ label: "Read Experience (OpenViking)",
86
+ description: "Read the complete Markdown body of one Experience returned by search_experience and use it as execution guidance.",
87
+ parameters: Type.Object({
88
+ uri: Type.String({ description: "Exact canonical Experience URI returned by search_experience" }),
89
+ }, { additionalProperties: false }),
90
+ async execute(_toolCallId, params) {
91
+ if (deps.isBypassedSession(ctx)) {
92
+ return deps.makeBypassedToolResult("read_experience");
93
+ }
94
+ const uri = typeof params.uri === "string" ? params.uri.trim() : "";
95
+ if (!isCanonicalExperienceUri(uri, deps.userId)) {
96
+ throw new Error("uri must be a canonical OpenViking Experience URI returned by search_experience");
97
+ }
98
+ const session = deps.resolvePluginSessionRouting(ctx);
99
+ const client = await deps.getClient();
100
+ const content = await client.read(uri, session.actorPeerId);
101
+ const text = typeof content === "string" ? content : JSON.stringify(content, null, 2);
102
+ return jsonToolResult({ uri, content: text });
103
+ },
104
+ }), { name: "read_experience" });
105
+ }
package/index.ts CHANGED
@@ -19,6 +19,7 @@ import { registerOpenVikingCommands } from "./plugin/command-registration.js";
19
19
  import { registerRecallTraceRoutes as registerRecallTraceRouteAdapters } from "./plugin/recall-trace-routes.js";
20
20
  import { createOpenVikingService } from "./plugin/openviking-services.js";
21
21
  import { registerOpenVikingArchiveTools } from "./plugin/openviking-archive-tools.js";
22
+ import { registerOpenVikingExperienceTools } from "./plugin/openviking-experience-tools.js";
22
23
  import { createOpenVikingImportRuntime } from "./plugin/openviking-import-runtime.js";
23
24
  import { registerOpenVikingImportTools } from "./plugin/openviking-import-tools.js";
24
25
  import { registerOpenVikingLifecycleHooks } from "./plugin/openviking-lifecycle-hooks.js";
@@ -280,6 +281,15 @@ const contextEnginePlugin = {
280
281
  makeBypassedToolResult,
281
282
  });
282
283
 
284
+ registerOpenVikingExperienceTools({
285
+ registerTool: registerOpenVikingTool,
286
+ getClient,
287
+ resolvePluginSessionRouting,
288
+ isBypassedSession,
289
+ makeBypassedToolResult,
290
+ userId: cfg.userId,
291
+ });
292
+
283
293
  registerOpenVikingMemoryRecallTools({
284
294
  registerTool: registerOpenVikingTool,
285
295
  getClient,
@@ -41,6 +41,7 @@
41
41
  ".gitignore",
42
42
  "skills/install-openviking-memory/SKILL.md",
43
43
  "skills/openviking-context-database/SKILL.md",
44
+ "skills/ov-experience-memory/SKILL.md",
44
45
  "INSTALL-AGENT.md"
45
46
  ]
46
47
  },
@@ -3,6 +3,7 @@
3
3
  "name": "OpenViking",
4
4
  "kind": "context-engine",
5
5
  "description": "OpenClaw context-engine plugin for memory management — powered by OpenViking",
6
+ "icon": "https://raw.githubusercontent.com/volcengine/OpenViking/main/docs/images/ov-logo-icon.png",
6
7
  "activation": {
7
8
  "onStartup": true,
8
9
  "onCapabilities": [
@@ -12,11 +13,14 @@
12
13
  },
13
14
  "contracts": {
14
15
  "tools": [
16
+ "add_resource",
15
17
  "add_skill",
16
18
  "ov_search",
17
19
  "ov_read",
18
20
  "ov_multi_read",
19
21
  "ov_list",
22
+ "search_experience",
23
+ "read_experience",
20
24
  "memory_recall",
21
25
  "ov_recall_trace",
22
26
  "memory_store",
@@ -96,7 +100,7 @@
96
100
  "label": "OpenViking API Key",
97
101
  "sensitive": true,
98
102
  "placeholder": "${OPENVIKING_API_KEY}",
99
- "help": "Optional API key for OpenViking server"
103
+ "help": "Optional API key for OpenViking server. Accepts a plain string, ${ENV_VAR} interpolation, or a SecretRef object ({source: env/file/exec, id}). Prefer a SecretRef so the key is never stored as plaintext in openclaw.json."
100
104
  },
101
105
  "headers": {
102
106
  "label": "Headers",
@@ -250,7 +254,7 @@
250
254
  "enabledTools": {
251
255
  "label": "Enabled Tools",
252
256
  "placeholder": "default",
253
- "help": "Agent-visible tool allowlist. Accepts tool names or groups: default, all, memory, resource_query, import, recall_trace, archive, tool_result. add_resource also requires enableAddResourceTool=true.",
257
+ "help": "Agent-visible tool allowlist. Accepts tool names or groups: default, all, memory, resource_query, experience, import, recall_trace, archive, tool_result. add_resource also requires enableAddResourceTool=true.",
254
258
  "advanced": true
255
259
  },
256
260
  "disabledTools": {
@@ -290,7 +294,46 @@
290
294
  "description": "Deprecated and ignored. Tenant identity headers are controlled by explicit accountId/userId."
291
295
  },
292
296
  "apiKey": {
293
- "type": "string"
297
+ "oneOf": [
298
+ {
299
+ "type": "string",
300
+ "description": "Plain API key or ${ENV_VAR} interpolation. For secret-manager-managed keys, prefer the SecretRef object shape below."
301
+ },
302
+ {
303
+ "type": "object",
304
+ "title": "SecretRef (env)",
305
+ "description": "Read the API key from an environment variable.",
306
+ "additionalProperties": false,
307
+ "required": ["source", "id"],
308
+ "properties": {
309
+ "source": { "type": "string", "const": "env", "description": "Look up the key in process.env[id] at plugin load time. Equivalent to ${ENV} interpolation but declarative and visible in the UI." },
310
+ "id": { "type": "string", "description": "Name of the environment variable. Example: OPENVIKING_API_KEY." }
311
+ }
312
+ },
313
+ {
314
+ "type": "object",
315
+ "title": "SecretRef (file)",
316
+ "description": "Read the API key from a file on disk, e.g. a Kubernetes secretKeyRef mount volume or a 0600-permission managed file. ~ is expanded and trailing whitespace is trimmed.",
317
+ "additionalProperties": false,
318
+ "required": ["source", "id"],
319
+ "properties": {
320
+ "source": { "type": "string", "const": "file" },
321
+ "id": { "type": "string", "description": "Absolute file path or ~/relative path. Example: /etc/secrets/openviking.key." }
322
+ }
323
+ },
324
+ {
325
+ "type": "object",
326
+ "title": "SecretRef (exec)",
327
+ "description": "Read the API key by shelling out to a secret-manager CLI. Matches the shape used by OpenClaw's core provider configs so 1Password, HashiCorp Vault, gopass, etc. can manage the OpenViking key alongside LLM/TTS/MCP credentials.",
328
+ "additionalProperties": false,
329
+ "required": ["source", "provider", "id"],
330
+ "properties": {
331
+ "source": { "type": "string", "const": "exec" },
332
+ "provider": { "type": "string", "description": "CLI binary name on PATH. Examples: op, vault, gopass." },
333
+ "id": { "type": "string", "description": "Argument passed to the provider CLI to select the specific secret, e.g. 'vault kv get -field=key secret/openviking' in plain-arg form; this implementation passes id as a single arg." }
334
+ }
335
+ }
336
+ ]
294
337
  },
295
338
  "headers": {
296
339
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/openclaw-plugin",
3
- "version": "2026.7.15",
3
+ "version": "2026.8.6",
4
4
  "displayName": "OpenViking",
5
5
  "description": "OpenClaw context-engine plugin — OpenViking AI memory, long-term memory, knowledge base, semantic search, RAG, context engine. 长期记忆 · 知识库 · 语义检索 · 上下文引擎",
6
6
  "type": "module",
@@ -0,0 +1,160 @@
1
+ import { Type } from "@sinclair/typebox";
2
+
3
+ import type { FindResult } from "../client.js";
4
+
5
+ const EXPERIENCE_TARGET_URI = "viking://user/memories/experiences/";
6
+ const DEFAULT_LIMIT = 5;
7
+ const MAX_LIMIT = 20;
8
+ const EXPERIENCE_SIDECAR_FILENAMES = new Set([".abstract.md", ".overview.md", ".relations.json"]);
9
+
10
+ export type OpenVikingExperienceToolContext = {
11
+ sessionKey?: string;
12
+ sessionId?: string;
13
+ agentId?: string;
14
+ senderId?: string;
15
+ requesterSenderId?: string;
16
+ };
17
+
18
+ export type OpenVikingExperienceSession = {
19
+ sessionId?: string;
20
+ sessionKey?: string;
21
+ ovSessionId?: string;
22
+ agentId: string;
23
+ actorPeerId?: string;
24
+ };
25
+
26
+ type OpenVikingExperienceClient = {
27
+ find: (
28
+ query: string,
29
+ options: { targetUri: string; limit: number; scoreThreshold?: number },
30
+ actorPeerId?: string,
31
+ ) => Promise<FindResult>;
32
+ read: (uri: string, actorPeerId?: string) => Promise<unknown>;
33
+ };
34
+
35
+ export type OpenVikingExperienceToolsDeps = {
36
+ registerTool: (toolOrFactory: unknown, opts: { name: string }) => void;
37
+ getClient: () => Promise<OpenVikingExperienceClient>;
38
+ resolvePluginSessionRouting: (ctx?: OpenVikingExperienceToolContext) => OpenVikingExperienceSession;
39
+ isBypassedSession: (ctx?: OpenVikingExperienceToolContext) => boolean;
40
+ makeBypassedToolResult: (toolName: string) => unknown;
41
+ userId?: string;
42
+ };
43
+
44
+ function clampLimit(value: unknown): number {
45
+ const parsed = Number.isFinite(Number(value)) ? Math.trunc(Number(value)) : DEFAULT_LIMIT;
46
+ return Math.max(1, Math.min(MAX_LIMIT, parsed));
47
+ }
48
+
49
+ function experienceRelativePath(uri: string): string {
50
+ return uri.match(/^viking:\/\/user\/[^/]+\/memories\/experiences\/(.+)$/)?.[1] ?? "";
51
+ }
52
+
53
+ export function isCanonicalExperienceUri(uri: unknown, expectedUserId?: string): uri is string {
54
+ const value = typeof uri === "string" ? uri.trim() : "";
55
+ if (!value || value.includes("?") || value.includes("#")) {
56
+ return false;
57
+ }
58
+ const owner = value.match(/^viking:\/\/user\/([^/]+)\//)?.[1] ?? "";
59
+ if (expectedUserId?.trim() && owner !== expectedUserId.trim()) {
60
+ return false;
61
+ }
62
+ const relative = experienceRelativePath(value);
63
+ const segments = relative.split("/");
64
+ const basename = segments.at(-1) ?? "";
65
+ return Boolean(
66
+ relative
67
+ && segments.every((segment) => segment && segment !== "." && segment !== "..")
68
+ && !EXPERIENCE_SIDECAR_FILENAMES.has(basename)
69
+ );
70
+ }
71
+
72
+ function titleFromUri(uri: string): string {
73
+ const basename = uri.split("/").at(-1) ?? uri;
74
+ const title = basename.replace(/\.md$/i, "");
75
+ try {
76
+ return decodeURIComponent(title);
77
+ } catch {
78
+ return title;
79
+ }
80
+ }
81
+
82
+ function jsonToolResult(payload: Record<string, unknown>): {
83
+ content: Array<{ type: "text"; text: string }>;
84
+ } {
85
+ return {
86
+ content: [{ type: "text", text: JSON.stringify(payload) }],
87
+ };
88
+ }
89
+
90
+ export function registerOpenVikingExperienceTools(deps: OpenVikingExperienceToolsDeps): void {
91
+ deps.registerTool(
92
+ (ctx: OpenVikingExperienceToolContext) => ({
93
+ name: "search_experience",
94
+ label: "Search Experience (OpenViking)",
95
+ description:
96
+ "Search reusable task-execution experiences for the current OpenViking user. " +
97
+ "Use before executing operational or multi-step tasks, then call read_experience for selected results.",
98
+ parameters: Type.Object({
99
+ query: Type.String({ description: "Current task, situation, or execution problem to search for" }),
100
+ limit: Type.Optional(Type.Integer({
101
+ description: `Maximum results to return. Default: ${DEFAULT_LIMIT}; maximum: ${MAX_LIMIT}`,
102
+ minimum: 1,
103
+ maximum: MAX_LIMIT,
104
+ })),
105
+ }, { additionalProperties: false }),
106
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
107
+ if (deps.isBypassedSession(ctx)) {
108
+ return deps.makeBypassedToolResult("search_experience");
109
+ }
110
+ const query = typeof params.query === "string" ? params.query.trim() : "";
111
+ if (!query) {
112
+ throw new Error("query is required");
113
+ }
114
+ const session = deps.resolvePluginSessionRouting(ctx);
115
+ const client = await deps.getClient();
116
+ const result = await client.find(query, {
117
+ targetUri: EXPERIENCE_TARGET_URI,
118
+ limit: clampLimit(params.limit),
119
+ }, session.actorPeerId);
120
+ const results = (result.memories ?? [])
121
+ .filter((item) => isCanonicalExperienceUri(item.uri, deps.userId))
122
+ .map((item) => ({
123
+ uri: item.uri,
124
+ title: titleFromUri(item.uri),
125
+ score: typeof item.score === "number" ? item.score : 0,
126
+ snippet: item.abstract || item.overview || "",
127
+ }));
128
+ return jsonToolResult({ results });
129
+ },
130
+ }),
131
+ { name: "search_experience" },
132
+ );
133
+
134
+ deps.registerTool(
135
+ (ctx: OpenVikingExperienceToolContext) => ({
136
+ name: "read_experience",
137
+ label: "Read Experience (OpenViking)",
138
+ description:
139
+ "Read the complete Markdown body of one Experience returned by search_experience and use it as execution guidance.",
140
+ parameters: Type.Object({
141
+ uri: Type.String({ description: "Exact canonical Experience URI returned by search_experience" }),
142
+ }, { additionalProperties: false }),
143
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
144
+ if (deps.isBypassedSession(ctx)) {
145
+ return deps.makeBypassedToolResult("read_experience");
146
+ }
147
+ const uri = typeof params.uri === "string" ? params.uri.trim() : "";
148
+ if (!isCanonicalExperienceUri(uri, deps.userId)) {
149
+ throw new Error("uri must be a canonical OpenViking Experience URI returned by search_experience");
150
+ }
151
+ const session = deps.resolvePluginSessionRouting(ctx);
152
+ const client = await deps.getClient();
153
+ const content = await client.read(uri, session.actorPeerId);
154
+ const text = typeof content === "string" ? content : JSON.stringify(content, null, 2);
155
+ return jsonToolResult({ uri, content: text });
156
+ },
157
+ }),
158
+ { name: "read_experience" },
159
+ );
160
+ }
@@ -1,6 +1,7 @@
1
1
  export type OpenVikingToolGroup =
2
2
  | "memory"
3
3
  | "resource_query"
4
+ | "experience"
4
5
  | "import"
5
6
  | "recall_trace"
6
7
  | "archive"
@@ -13,6 +14,8 @@ export type OpenVikingToolName =
13
14
  | "ov_read"
14
15
  | "ov_multi_read"
15
16
  | "ov_list"
17
+ | "search_experience"
18
+ | "read_experience"
16
19
  | "memory_recall"
17
20
  | "ov_recall_trace"
18
21
  | "memory_store"
@@ -44,6 +47,8 @@ export const OPENVIKING_TOOL_SPECS = [
44
47
  { name: "ov_read", group: "resource_query", defaultEnabled: true },
45
48
  { name: "ov_multi_read", group: "resource_query", defaultEnabled: true },
46
49
  { name: "ov_list", group: "resource_query", defaultEnabled: true },
50
+ { name: "search_experience", group: "experience", defaultEnabled: true },
51
+ { name: "read_experience", group: "experience", defaultEnabled: true },
47
52
  { name: "memory_recall", group: "memory", defaultEnabled: true },
48
53
  { name: "ov_recall_trace", group: "recall_trace", defaultEnabled: true },
49
54
  { name: "memory_store", group: "memory", defaultEnabled: true },
@@ -64,6 +69,7 @@ export const OPENVIKING_DEFAULT_ENABLED_TOOL_NAMES = OPENVIKING_TOOL_SPECS
64
69
  const OPENVIKING_TOOL_GROUP_ORDER: readonly OpenVikingToolGroup[] = [
65
70
  "memory",
66
71
  "resource_query",
72
+ "experience",
67
73
  "import",
68
74
  "recall_trace",
69
75
  "archive",
@@ -0,0 +1,156 @@
1
+ ---
2
+ name: ov-experience-memory
3
+ description: >
4
+ Use OpenViking experience memories during task execution. Search relevant
5
+ experiences with search_experience, read selected experiences with
6
+ read_experience, and leave standard tool parts in the committed session so
7
+ OpenViking can report recall and injection usage.
8
+ version: 2026.7.9
9
+ tags:
10
+ - openviking
11
+ - experience-memory
12
+ - agent-memory
13
+ - usage-reporting
14
+ ---
15
+
16
+ # OpenViking Experience Memory
17
+
18
+ Use this skill when the current user request starts or continues an executable
19
+ task, especially tasks involving tools, files, code changes, data operations,
20
+ workflow decisions, or multi-step actions.
21
+
22
+ Do not use this skill for casual chat, pure explanation, or one-off factual Q&A
23
+ that does not require operational guidance.
24
+
25
+ ## Runtime Contract
26
+
27
+ The agent runtime must expose two tools with these exact names:
28
+
29
+ - `search_experience`
30
+ - `read_experience`
31
+
32
+ OpenViking usage reporting recognizes only completed tool parts with these exact
33
+ tool names. Calls to generic `find`, `search`, `read`, `ov_search`, or `ov_read`
34
+ do not count as experience recall or injection events.
35
+
36
+ ## Tool: search_experience
37
+
38
+ Purpose: search reusable execution experiences from the OpenViking experience
39
+ library before assembling task context.
40
+
41
+ Input schema:
42
+
43
+ ```json
44
+ {
45
+ "query": "string",
46
+ "limit": 5
47
+ }
48
+ ```
49
+
50
+ Output schema:
51
+
52
+ ```json
53
+ {
54
+ "results": [
55
+ {
56
+ "uri": "viking://user/<current_user_id>/memories/experiences/example.md",
57
+ "title": "example",
58
+ "score": 0.82,
59
+ "snippet": "Short summary or matched situation"
60
+ }
61
+ ]
62
+ }
63
+ ```
64
+
65
+ Implementation:
66
+
67
+ The runtime tool calls OpenViking `POST /api/v1/search/find` with `target_uri`
68
+ fixed to the current-user shorthand `viking://user/memories/experiences/`.
69
+ Callers provide only `query` and optional `limit`; they cannot override or pass
70
+ `target_uri`. OpenViking resolves the fixed shorthand against the authenticated
71
+ request user. Return only canonical experience memory URIs for that user; never
72
+ hardcode `default` or another user ID.
73
+
74
+ Usage reporting:
75
+
76
+ A completed `search_experience` tool part is counted as an experience recall
77
+ event for every `results[].uri` value.
78
+
79
+ ## Tool: read_experience
80
+
81
+ Purpose: read the full Markdown body of a selected experience and inject it into
82
+ the agent prompt as task execution guidance.
83
+
84
+ Input schema:
85
+
86
+ ```json
87
+ {
88
+ "uri": "viking://user/<current_user_id>/memories/experiences/example.md"
89
+ }
90
+ ```
91
+
92
+ Output schema:
93
+
94
+ ```json
95
+ {
96
+ "uri": "viking://user/<current_user_id>/memories/experiences/example.md",
97
+ "content": "Experience Markdown body"
98
+ }
99
+ ```
100
+
101
+ Implementation:
102
+
103
+ Call OpenViking `GET /api/v1/content/read?uri=<encoded_uri>` for the selected
104
+ experience URI. Always pass the canonical URI returned by `search_experience`;
105
+ do not construct a URI with a hardcoded user ID. The returned content should be
106
+ inserted into the prompt as operational guidance, not as user profile facts.
107
+
108
+ Usage reporting:
109
+
110
+ A completed `read_experience` tool part is counted as an experience injection
111
+ event for `tool_input.uri` or `tool_output.uri`. In this design, reading an
112
+ experience through `read_experience` means the experience was injected into the
113
+ prompt.
114
+
115
+ ## Recommended Flow
116
+
117
+ 1. When a task begins, build a short query from the latest user instruction,
118
+ current plan, active skill name, and important tool/environment context.
119
+ 2. Call `search_experience` before final prompt assembly.
120
+ 3. Review returned titles/snippets and select only experiences likely to affect
121
+ execution.
122
+ 4. Call `read_experience` for selected experience URIs.
123
+ 5. Inject the returned Markdown into the prompt under an explicit experience
124
+ section.
125
+ 6. Continue task execution.
126
+ 7. Commit the session normally. The committed session must include the
127
+ `search_experience` and `read_experience` tool parts so OpenViking can report
128
+ usage.
129
+
130
+ ## Prompt Injection Format
131
+
132
+ Use a compact and explicit block:
133
+
134
+ ```text
135
+ <openviking-experience-memory>
136
+ The following guidance was retrieved from prior task execution experience.
137
+ Use it as operational guidance. Do not treat it as user identity or preference.
138
+
139
+ <experience uri="viking://user/<current_user_id>/memories/experiences/example.md">
140
+ ...experience markdown...
141
+ </experience>
142
+ </openviking-experience-memory>
143
+ ```
144
+
145
+ ## Commit Requirements
146
+
147
+ The session committed to OpenViking must preserve tool parts with:
148
+
149
+ - `tool_name`
150
+ - `tool_status`
151
+ - `tool_input`
152
+ - `tool_output`
153
+ - `tool_id`
154
+
155
+ Only `tool_status == "completed"` is counted. Failed, cancelled, or skipped tool
156
+ parts are ignored by usage reporting.