@openviking/openclaw-plugin 2026.9.8 → 2026.9.9

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/commands/setup.ts CHANGED
@@ -6,9 +6,13 @@ import { fileURLToPath } from "node:url";
6
6
  import { getEnv } from "../runtime-utils.js";
7
7
  import {
8
8
  cleanOpenVikingRequestHeaders,
9
- resolveOpenVikingRequestHeaders,
10
9
  type OpenVikingRequestHeaders,
11
10
  } from "../request-headers.js";
11
+ import {
12
+ createSetupNetworkProbes,
13
+ type SetupApiKeyProbeResult,
14
+ type SetupHealthResult,
15
+ } from "../services/setup/probe-service.js";
12
16
 
13
17
  const HOME = os.homedir();
14
18
  const OPENCLAW_DIR = getEnv("OPENCLAW_STATE_DIR") || path.join(HOME, ".openclaw");
@@ -269,14 +273,7 @@ function ask(rl: readline.Interface, prompt: string, defaultValue = ""): Promise
269
273
 
270
274
  type VersionCompatibility = "compatible" | "server_too_old" | "server_too_new" | "unknown";
271
275
 
272
- type HealthResult = {
273
- ok: boolean;
274
- version: string;
275
- error: string;
276
- compatibility: VersionCompatibility;
277
- pluginVersion: string;
278
- compatRange: string;
279
- };
276
+ type HealthResult = SetupHealthResult;
280
277
 
281
278
  function parseVersionTuple(v: string): number[] | null {
282
279
  const cleaned = v.replace(/^v/i, "").split("-")[0];
@@ -317,132 +314,13 @@ function formatCompatRange(): string {
317
314
  return "any";
318
315
  }
319
316
 
320
- type ApiKeyProbeResult = {
321
- keyType: "user_key" | "root_key" | "no_key" | "unknown";
322
- needsAccountId: boolean;
323
- needsUserId: boolean;
324
- detail: string;
325
- };
326
-
327
- async function probeApiKeyType(
328
- baseUrl: string,
329
- apiKey?: string,
330
- configuredHeaders?: OpenVikingRequestHeaders,
331
- ): Promise<ApiKeyProbeResult> {
332
- if (!apiKey) return { keyType: "no_key", needsAccountId: false, needsUserId: false, detail: "No API key configured" };
333
-
334
- const controller = new AbortController();
335
- const timeoutId = setTimeout(() => controller.abort(), 10_000);
336
- const sessionsUrl = `${baseUrl.replace(/\/+$/, "")}/api/v1/sessions?limit=1`;
337
- try {
338
- const configuredRequestHeaders = resolveOpenVikingRequestHeaders({ headers: configuredHeaders });
339
- const headers: Record<string, string> = {};
340
- headers["X-API-Key"] = apiKey;
341
- Object.assign(headers, configuredRequestHeaders);
342
- const response = await fetch(sessionsUrl, {
343
- headers,
344
- signal: controller.signal,
345
- });
346
-
347
- if (response.ok) {
348
- return { keyType: "user_key", needsAccountId: false, needsUserId: false, detail: "API key has full user context" };
349
- }
350
-
351
- // Server raises InvalidArgumentError (-> HTTP 400) when a ROOT key calls
352
- // tenant-scoped endpoints without X-OpenViking-Account / X-OpenViking-User.
353
- // 401/403 may also be returned by older versions, FastAPI validation can
354
- // surface as 422. Treat all four as candidates for tenant-context errors.
355
- if ([400, 401, 403, 422].includes(response.status)) {
356
- let body = "";
357
- try {
358
- body = await response.text();
359
- } catch { /* ignore parse errors */ }
360
- const lower = body.toLowerCase();
361
- const needsAccount = /x-openviking-account|account[_ ]?id|account context|tenant/.test(lower);
362
- const needsUser = /x-openviking-user|user[_ ]?id|user context|user key/.test(lower);
363
- if (needsAccount || needsUser) {
364
- return {
365
- keyType: "root_key",
366
- needsAccountId: needsAccount,
367
- needsUserId: needsUser,
368
- detail: body.slice(0, 200),
369
- };
370
- }
371
-
372
- // Body did not name account/user/tenant explicitly (custom auth middleware,
373
- // localized message, etc.). Re-probe with placeholder tenant headers; if
374
- // the failure was due to missing tenant headers the response will change.
375
- try {
376
- const probeHeaders: Record<string, string> = {
377
- "X-API-Key": apiKey,
378
- "X-OpenViking-Account": "__probe__",
379
- "X-OpenViking-User": "__probe__",
380
- ...configuredRequestHeaders,
381
- };
382
- const probe2 = await fetch(sessionsUrl, {
383
- headers: probeHeaders,
384
- signal: controller.signal,
385
- });
386
- if (probe2.status !== response.status) {
387
- return {
388
- keyType: "root_key",
389
- needsAccountId: true,
390
- needsUserId: true,
391
- detail: body.slice(0, 200) || `HTTP ${response.status} -> ${probe2.status} after adding tenant headers`,
392
- };
393
- }
394
- } catch { /* ignore probe errors, fall through to unknown */ }
395
-
396
- if (response.status === 401 || response.status === 403) {
397
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status} - authentication failed, verify your API key` };
398
- }
399
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}${body ? ` - ${body.slice(0, 160)}` : ""}` };
400
- }
401
-
402
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}` };
403
- } catch (err) {
404
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: String(err instanceof Error ? err.message : err) };
405
- } finally {
406
- clearTimeout(timeoutId);
407
- }
408
- }
317
+ type ApiKeyProbeResult = SetupApiKeyProbeResult;
409
318
 
410
- async function checkServiceHealth(
411
- baseUrl: string,
412
- apiKey?: string,
413
- configuredHeaders?: OpenVikingRequestHeaders,
414
- ): Promise<HealthResult> {
415
- const controller = new AbortController();
416
- const timeoutId = setTimeout(() => controller.abort(), 10_000);
417
- try {
418
- const configuredRequestHeaders = resolveOpenVikingRequestHeaders({ headers: configuredHeaders });
419
- const headers: Record<string, string> = {};
420
- if (apiKey) {
421
- headers["X-API-Key"] = apiKey;
422
- }
423
- Object.assign(headers, configuredRequestHeaders);
424
- const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/health`, {
425
- headers,
426
- signal: controller.signal,
427
- });
428
- if (response.ok) {
429
- try {
430
- const data = await response.json() as Record<string, unknown>;
431
- const result = (data.result ?? data) as Record<string, unknown>;
432
- const version = String(result.version ?? data.version ?? "");
433
- const compatibility = checkVersionCompatibility(version);
434
- return { ok: true, version, error: "", compatibility, pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
435
- } catch {
436
- return { ok: true, version: "", error: "", compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
437
- }
438
- }
439
- return { ok: false, version: "", error: `HTTP ${response.status}`, compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
440
- } catch (err) {
441
- return { ok: false, version: "", error: String(err instanceof Error ? err.message : err), compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
442
- } finally {
443
- clearTimeout(timeoutId);
444
- }
445
- }
319
+ const setupProbes = createSetupNetworkProbes({
320
+ pluginVersion: PLUGIN_VERSION,
321
+ compatRange: formatCompatRange(),
322
+ checkVersionCompatibility,
323
+ });
446
324
 
447
325
  function readOpenClawConfig(configPath: string): Record<string, unknown> {
448
326
  if (!fs.existsSync(configPath)) return {};
@@ -813,7 +691,7 @@ async function runRemoteCheck(
813
691
  const apiKey = existing.apiKey ? String(existing.apiKey) : undefined;
814
692
  const headers = nonEmptyOpenVikingRequestHeaders(existing.headers);
815
693
  console.log(tr(zh, `Testing connectivity to ${baseUrl}...`, `正在测试连接 ${baseUrl}...`));
816
- const health = await checkServiceHealth(baseUrl, apiKey, headers);
694
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey, headers);
817
695
  if (health.ok) {
818
696
  const ver = health.version ? ` (version: ${health.version})` : "";
819
697
  console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
@@ -847,7 +725,7 @@ async function setupNonInteractive(
847
725
  }
848
726
 
849
727
  // Phase 1: validate connectivity and key type BEFORE writing config
850
- const health = await checkServiceHealth(baseUrl, apiKey);
728
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey);
851
729
 
852
730
  if (!health.ok && !allowOffline) {
853
731
  return {
@@ -860,7 +738,7 @@ async function setupNonInteractive(
860
738
  };
861
739
  }
862
740
 
863
- const keyProbe = health.ok ? await probeApiKeyType(baseUrl, apiKey) : undefined;
741
+ const keyProbe = health.ok ? await setupProbes.probeApiKeyType(baseUrl, apiKey) : undefined;
864
742
 
865
743
  if (keyProbe?.keyType === "root_key" && (!accountId || !userId)) {
866
744
  const missing: string[] = [];
@@ -996,8 +874,8 @@ async function getStatus(configPath: string): Promise<StatusResult> {
996
874
  const baseUrl = String(existing.baseUrl ?? DEFAULT_REMOTE_URL);
997
875
  const apiKey = existing.apiKey ? String(existing.apiKey) : undefined;
998
876
  const headers = nonEmptyOpenVikingRequestHeaders(existing.headers);
999
- const health = await checkServiceHealth(baseUrl, apiKey, headers);
1000
- const keyProbe = health.ok ? await probeApiKeyType(baseUrl, apiKey, headers) : undefined;
877
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey, headers);
878
+ const keyProbe = health.ok ? await setupProbes.probeApiKeyType(baseUrl, apiKey, headers) : undefined;
1001
879
 
1002
880
  const peerPrefix = resolveExistingPeerPrefix(existing);
1003
881
  return {
@@ -1119,7 +997,7 @@ async function setupRemote(
1119
997
 
1120
998
  if (apiKey) {
1121
999
  console.log(tr(zh, " Detecting API key type...", " 正在检测 API Key 类型..."));
1122
- const probe = await probeApiKeyType(baseUrl, apiKey, headers);
1000
+ const probe = await setupProbes.probeApiKeyType(baseUrl, apiKey, headers);
1123
1001
  if (probe.keyType === "root_key") {
1124
1002
  console.log(tr(zh,
1125
1003
  " ⚠ Root API key detected. accountId and userId are required.",
@@ -1140,7 +1018,7 @@ async function setupRemote(
1140
1018
  console.log("");
1141
1019
 
1142
1020
  console.log(tr(zh, `Testing connectivity to ${baseUrl}...`, `正在测试连接 ${baseUrl}...`));
1143
- const health = await checkServiceHealth(baseUrl, apiKey || undefined, headers);
1021
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey || undefined, headers);
1144
1022
  if (health.ok) {
1145
1023
  const ver = health.version ? ` (version: ${health.version})` : "";
1146
1024
  console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
@@ -1204,7 +1082,6 @@ export const __test__ = {
1204
1082
  checkVersionCompatibility,
1205
1083
  parseVersionTuple,
1206
1084
  compareVersions,
1207
- probeApiKeyType,
1208
1085
  ensureInstallRecord,
1209
1086
  findPluginPackageRoot,
1210
1087
  readCompatRangeFromManifest,
@@ -4,7 +4,8 @@ import * as path from "node:path";
4
4
  import * as readline from "node:readline";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { getEnv } from "../runtime-utils.js";
7
- import { cleanOpenVikingRequestHeaders, resolveOpenVikingRequestHeaders, } from "../request-headers.js";
7
+ import { cleanOpenVikingRequestHeaders, } from "../request-headers.js";
8
+ import { createSetupNetworkProbes, } from "../services/setup/probe-service.js";
8
9
  const HOME = os.homedir();
9
10
  const OPENCLAW_DIR = getEnv("OPENCLAW_STATE_DIR") || path.join(HOME, ".openclaw");
10
11
  const DEFAULT_REMOTE_URL = "http://127.0.0.1:1933";
@@ -247,118 +248,11 @@ function formatCompatRange() {
247
248
  return `<= ${COMPATIBLE_SERVER_MAX}`;
248
249
  return "any";
249
250
  }
250
- async function probeApiKeyType(baseUrl, apiKey, configuredHeaders) {
251
- if (!apiKey)
252
- return { keyType: "no_key", needsAccountId: false, needsUserId: false, detail: "No API key configured" };
253
- const controller = new AbortController();
254
- const timeoutId = setTimeout(() => controller.abort(), 10_000);
255
- const sessionsUrl = `${baseUrl.replace(/\/+$/, "")}/api/v1/sessions?limit=1`;
256
- try {
257
- const configuredRequestHeaders = resolveOpenVikingRequestHeaders({ headers: configuredHeaders });
258
- const headers = {};
259
- headers["X-API-Key"] = apiKey;
260
- Object.assign(headers, configuredRequestHeaders);
261
- const response = await fetch(sessionsUrl, {
262
- headers,
263
- signal: controller.signal,
264
- });
265
- if (response.ok) {
266
- return { keyType: "user_key", needsAccountId: false, needsUserId: false, detail: "API key has full user context" };
267
- }
268
- // Server raises InvalidArgumentError (-> HTTP 400) when a ROOT key calls
269
- // tenant-scoped endpoints without X-OpenViking-Account / X-OpenViking-User.
270
- // 401/403 may also be returned by older versions, FastAPI validation can
271
- // surface as 422. Treat all four as candidates for tenant-context errors.
272
- if ([400, 401, 403, 422].includes(response.status)) {
273
- let body = "";
274
- try {
275
- body = await response.text();
276
- }
277
- catch { /* ignore parse errors */ }
278
- const lower = body.toLowerCase();
279
- const needsAccount = /x-openviking-account|account[_ ]?id|account context|tenant/.test(lower);
280
- const needsUser = /x-openviking-user|user[_ ]?id|user context|user key/.test(lower);
281
- if (needsAccount || needsUser) {
282
- return {
283
- keyType: "root_key",
284
- needsAccountId: needsAccount,
285
- needsUserId: needsUser,
286
- detail: body.slice(0, 200),
287
- };
288
- }
289
- // Body did not name account/user/tenant explicitly (custom auth middleware,
290
- // localized message, etc.). Re-probe with placeholder tenant headers; if
291
- // the failure was due to missing tenant headers the response will change.
292
- try {
293
- const probeHeaders = {
294
- "X-API-Key": apiKey,
295
- "X-OpenViking-Account": "__probe__",
296
- "X-OpenViking-User": "__probe__",
297
- ...configuredRequestHeaders,
298
- };
299
- const probe2 = await fetch(sessionsUrl, {
300
- headers: probeHeaders,
301
- signal: controller.signal,
302
- });
303
- if (probe2.status !== response.status) {
304
- return {
305
- keyType: "root_key",
306
- needsAccountId: true,
307
- needsUserId: true,
308
- detail: body.slice(0, 200) || `HTTP ${response.status} -> ${probe2.status} after adding tenant headers`,
309
- };
310
- }
311
- }
312
- catch { /* ignore probe errors, fall through to unknown */ }
313
- if (response.status === 401 || response.status === 403) {
314
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status} - authentication failed, verify your API key` };
315
- }
316
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}${body ? ` - ${body.slice(0, 160)}` : ""}` };
317
- }
318
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}` };
319
- }
320
- catch (err) {
321
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: String(err instanceof Error ? err.message : err) };
322
- }
323
- finally {
324
- clearTimeout(timeoutId);
325
- }
326
- }
327
- async function checkServiceHealth(baseUrl, apiKey, configuredHeaders) {
328
- const controller = new AbortController();
329
- const timeoutId = setTimeout(() => controller.abort(), 10_000);
330
- try {
331
- const configuredRequestHeaders = resolveOpenVikingRequestHeaders({ headers: configuredHeaders });
332
- const headers = {};
333
- if (apiKey) {
334
- headers["X-API-Key"] = apiKey;
335
- }
336
- Object.assign(headers, configuredRequestHeaders);
337
- const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/health`, {
338
- headers,
339
- signal: controller.signal,
340
- });
341
- if (response.ok) {
342
- try {
343
- const data = await response.json();
344
- const result = (data.result ?? data);
345
- const version = String(result.version ?? data.version ?? "");
346
- const compatibility = checkVersionCompatibility(version);
347
- return { ok: true, version, error: "", compatibility, pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
348
- }
349
- catch {
350
- return { ok: true, version: "", error: "", compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
351
- }
352
- }
353
- return { ok: false, version: "", error: `HTTP ${response.status}`, compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
354
- }
355
- catch (err) {
356
- return { ok: false, version: "", error: String(err instanceof Error ? err.message : err), compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
357
- }
358
- finally {
359
- clearTimeout(timeoutId);
360
- }
361
- }
251
+ const setupProbes = createSetupNetworkProbes({
252
+ pluginVersion: PLUGIN_VERSION,
253
+ compatRange: formatCompatRange(),
254
+ checkVersionCompatibility,
255
+ });
362
256
  function readOpenClawConfig(configPath) {
363
257
  if (!fs.existsSync(configPath))
364
258
  return {};
@@ -624,7 +518,7 @@ async function runRemoteCheck(zh, existing) {
624
518
  const apiKey = existing.apiKey ? String(existing.apiKey) : undefined;
625
519
  const headers = nonEmptyOpenVikingRequestHeaders(existing.headers);
626
520
  console.log(tr(zh, `Testing connectivity to ${baseUrl}...`, `正在测试连接 ${baseUrl}...`));
627
- const health = await checkServiceHealth(baseUrl, apiKey, headers);
521
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey, headers);
628
522
  if (health.ok) {
629
523
  const ver = health.version ? ` (version: ${health.version})` : "";
630
524
  console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
@@ -644,7 +538,7 @@ async function setupNonInteractive(configPath, params) {
644
538
  throw new Error("peer_prefix may only contain letters, digits, underscores, and hyphens, or be empty");
645
539
  }
646
540
  // Phase 1: validate connectivity and key type BEFORE writing config
647
- const health = await checkServiceHealth(baseUrl, apiKey);
541
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey);
648
542
  if (!health.ok && !allowOffline) {
649
543
  return {
650
544
  success: false,
@@ -655,7 +549,7 @@ async function setupNonInteractive(configPath, params) {
655
549
  error: `Server unreachable: ${health.error}. Use --allow-offline to save config anyway.`,
656
550
  };
657
551
  }
658
- const keyProbe = health.ok ? await probeApiKeyType(baseUrl, apiKey) : undefined;
552
+ const keyProbe = health.ok ? await setupProbes.probeApiKeyType(baseUrl, apiKey) : undefined;
659
553
  if (keyProbe?.keyType === "root_key" && (!accountId || !userId)) {
660
554
  const missing = [];
661
555
  if (!accountId)
@@ -793,8 +687,8 @@ async function getStatus(configPath) {
793
687
  const baseUrl = String(existing.baseUrl ?? DEFAULT_REMOTE_URL);
794
688
  const apiKey = existing.apiKey ? String(existing.apiKey) : undefined;
795
689
  const headers = nonEmptyOpenVikingRequestHeaders(existing.headers);
796
- const health = await checkServiceHealth(baseUrl, apiKey, headers);
797
- const keyProbe = health.ok ? await probeApiKeyType(baseUrl, apiKey, headers) : undefined;
690
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey, headers);
691
+ const keyProbe = health.ok ? await setupProbes.probeApiKeyType(baseUrl, apiKey, headers) : undefined;
798
692
  const peerPrefix = resolveExistingPeerPrefix(existing);
799
693
  return {
800
694
  configured: true,
@@ -887,7 +781,7 @@ async function setupRemote(zh, configPath, existing, q) {
887
781
  let userId = existing?.userId ? String(existing.userId) : "";
888
782
  if (apiKey) {
889
783
  console.log(tr(zh, " Detecting API key type...", " 正在检测 API Key 类型..."));
890
- const probe = await probeApiKeyType(baseUrl, apiKey, headers);
784
+ const probe = await setupProbes.probeApiKeyType(baseUrl, apiKey, headers);
891
785
  if (probe.keyType === "root_key") {
892
786
  console.log(tr(zh, " ⚠ Root API key detected. accountId and userId are required.", " ⚠ 检测到 Root API Key,需要提供 accountId 和 userId。"));
893
787
  accountId = await q(tr(zh, "Account ID (required for root key)", "Account ID(root key 必填)"), accountId);
@@ -903,7 +797,7 @@ async function setupRemote(zh, configPath, existing, q) {
903
797
  : "";
904
798
  console.log("");
905
799
  console.log(tr(zh, `Testing connectivity to ${baseUrl}...`, `正在测试连接 ${baseUrl}...`));
906
- const health = await checkServiceHealth(baseUrl, apiKey || undefined, headers);
800
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey || undefined, headers);
907
801
  if (health.ok) {
908
802
  const ver = health.version ? ` (version: ${health.version})` : "";
909
803
  console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
@@ -969,7 +863,6 @@ export const __test__ = {
969
863
  checkVersionCompatibility,
970
864
  parseVersionTuple,
971
865
  compareVersions,
972
- probeApiKeyType,
973
866
  ensureInstallRecord,
974
867
  findPluginPackageRoot,
975
868
  readCompatRangeFromManifest,
@@ -1,63 +1,5 @@
1
1
  import { appendFile, mkdir, readFile, readdir, unlink } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- const ALLOWED_RESOURCE_TYPES = ["resource", "user", "agent"];
4
- const DEFAULT_RESOURCE_TYPES = ["user", "agent"];
5
- function toResourceTypeEntries(value) {
6
- if (Array.isArray(value)) {
7
- return value
8
- .filter((entry) => typeof entry === "string")
9
- .map((entry) => entry.trim())
10
- .filter(Boolean);
11
- }
12
- if (typeof value === "string") {
13
- return value
14
- .split(/[,\n]/)
15
- .map((entry) => entry.trim())
16
- .filter(Boolean);
17
- }
18
- return [];
19
- }
20
- export function normalizeResourceTypes(value) {
21
- const entries = toResourceTypeEntries(value);
22
- if (entries.length === 0) {
23
- return [...DEFAULT_RESOURCE_TYPES];
24
- }
25
- const seen = new Set();
26
- const normalized = [];
27
- const invalid = [];
28
- for (const entry of entries) {
29
- if (ALLOWED_RESOURCE_TYPES.includes(entry)) {
30
- const typed = entry;
31
- if (!seen.has(typed)) {
32
- seen.add(typed);
33
- normalized.push(typed);
34
- }
35
- }
36
- else {
37
- invalid.push(entry);
38
- }
39
- }
40
- if (invalid.length > 0) {
41
- throw new Error(`invalid resourceTypes: ${invalid.join(", ")}`);
42
- }
43
- return normalized.length > 0 ? normalized : [...DEFAULT_RESOURCE_TYPES];
44
- }
45
- export function resolveRecallSearchPlan(resourceTypes, _ctx) {
46
- const normalized = normalizeResourceTypes(resourceTypes);
47
- const searches = [];
48
- const skipped = [];
49
- let addedMemorySearch = false;
50
- for (const resourceType of normalized) {
51
- if (resourceType === "resource") {
52
- searches.push({ resourceType, contextType: "resource" });
53
- }
54
- else if ((resourceType === "user" || resourceType === "agent") && !addedMemorySearch) {
55
- searches.push({ resourceType: "user", contextType: "memory" });
56
- addedMemorySearch = true;
57
- }
58
- }
59
- return { resourceTypes: normalized, searches, skipped };
60
- }
61
3
  export class RecallTraceMemoryStore {
62
4
  maxEntries;
63
5
  entries = [];
@@ -0,0 +1,112 @@
1
+ import { resolveOpenVikingRequestHeaders, } from "../../request-headers.js";
2
+ export const defaultSetupNetworkTransport = (url, init) => fetch(url, init);
3
+ export function createSetupNetworkProbes({ pluginVersion, compatRange, checkVersionCompatibility, transport = defaultSetupNetworkTransport, timeoutMs = 10_000, }) {
4
+ return {
5
+ async probeApiKeyType(baseUrl, apiKey, configuredHeaders) {
6
+ if (!apiKey) {
7
+ return { keyType: "no_key", needsAccountId: false, needsUserId: false, detail: "No API key configured" };
8
+ }
9
+ const controller = new AbortController();
10
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
11
+ const sessionsUrl = `${baseUrl.replace(/\/+$/, "")}/api/v1/sessions?limit=1`;
12
+ try {
13
+ const configuredRequestHeaders = resolveOpenVikingRequestHeaders({ headers: configuredHeaders });
14
+ const headers = {};
15
+ headers["X-API-Key"] = apiKey;
16
+ Object.assign(headers, configuredRequestHeaders);
17
+ const response = await transport(sessionsUrl, {
18
+ headers,
19
+ signal: controller.signal,
20
+ });
21
+ if (response.ok) {
22
+ return { keyType: "user_key", needsAccountId: false, needsUserId: false, detail: "API key has full user context" };
23
+ }
24
+ if ([400, 401, 403, 422].includes(response.status)) {
25
+ let body = "";
26
+ try {
27
+ body = await response.text();
28
+ }
29
+ catch { /* ignore parse errors */ }
30
+ const lower = body.toLowerCase();
31
+ const needsAccount = /x-openviking-account|account[_ ]?id|account context|tenant/.test(lower);
32
+ const needsUser = /x-openviking-user|user[_ ]?id|user context|user key/.test(lower);
33
+ if (needsAccount || needsUser) {
34
+ return {
35
+ keyType: "root_key",
36
+ needsAccountId: needsAccount,
37
+ needsUserId: needsUser,
38
+ detail: body.slice(0, 200),
39
+ };
40
+ }
41
+ try {
42
+ const probeHeaders = {
43
+ "X-API-Key": apiKey,
44
+ "X-OpenViking-Account": "__probe__",
45
+ "X-OpenViking-User": "__probe__",
46
+ ...configuredRequestHeaders,
47
+ };
48
+ const probe2 = await transport(sessionsUrl, {
49
+ headers: probeHeaders,
50
+ signal: controller.signal,
51
+ });
52
+ if (probe2.status !== response.status) {
53
+ return {
54
+ keyType: "root_key",
55
+ needsAccountId: true,
56
+ needsUserId: true,
57
+ detail: body.slice(0, 200) || `HTTP ${response.status} -> ${probe2.status} after adding tenant headers`,
58
+ };
59
+ }
60
+ }
61
+ catch { /* ignore probe errors, fall through to unknown */ }
62
+ if (response.status === 401 || response.status === 403) {
63
+ return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status} - authentication failed, verify your API key` };
64
+ }
65
+ return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}${body ? ` - ${body.slice(0, 160)}` : ""}` };
66
+ }
67
+ return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}` };
68
+ }
69
+ catch (err) {
70
+ return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: String(err instanceof Error ? err.message : err) };
71
+ }
72
+ finally {
73
+ clearTimeout(timeoutId);
74
+ }
75
+ },
76
+ async checkServiceHealth(baseUrl, apiKey, configuredHeaders) {
77
+ const controller = new AbortController();
78
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
79
+ try {
80
+ const configuredRequestHeaders = resolveOpenVikingRequestHeaders({ headers: configuredHeaders });
81
+ const headers = {};
82
+ if (apiKey) {
83
+ headers["X-API-Key"] = apiKey;
84
+ }
85
+ Object.assign(headers, configuredRequestHeaders);
86
+ const response = await transport(`${baseUrl.replace(/\/+$/, "")}/health`, {
87
+ headers,
88
+ signal: controller.signal,
89
+ });
90
+ if (response.ok) {
91
+ try {
92
+ const data = await response.json();
93
+ const result = (data.result ?? data);
94
+ const version = String(result.version ?? data.version ?? "");
95
+ const compatibility = checkVersionCompatibility(version);
96
+ return { ok: true, version, error: "", compatibility, pluginVersion, compatRange };
97
+ }
98
+ catch {
99
+ return { ok: true, version: "", error: "", compatibility: "unknown", pluginVersion, compatRange };
100
+ }
101
+ }
102
+ return { ok: false, version: "", error: `HTTP ${response.status}`, compatibility: "unknown", pluginVersion, compatRange };
103
+ }
104
+ catch (err) {
105
+ return { ok: false, version: "", error: String(err instanceof Error ? err.message : err), compatibility: "unknown", pluginVersion, compatRange };
106
+ }
107
+ finally {
108
+ clearTimeout(timeoutId);
109
+ }
110
+ },
111
+ };
112
+ }
@@ -83,11 +83,21 @@ export function repairDigestUris(digest, validUris = []) {
83
83
  lines.push(line);
84
84
  continue;
85
85
  }
86
+ let tokenized = line;
87
+ for (const uri of [...valid].sort((a, b) => b.length - a.length)) {
88
+ if (/\s/.test(uri))
89
+ tokenized = tokenized.split(uri).join(encodeURI(uri));
90
+ }
86
91
  let dropped = false;
87
- const repaired = line.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
88
- if (validSet.has(uri))
89
- return uri;
90
- const nearest = nearestUri(uri, valid);
92
+ const repaired = tokenized.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
93
+ let decoded = uri;
94
+ try {
95
+ decoded = decodeURI(uri);
96
+ }
97
+ catch { /* keep the original candidate */ }
98
+ if (validSet.has(decoded))
99
+ return decoded;
100
+ const nearest = nearestUri(decoded, valid);
91
101
  if (nearest)
92
102
  return nearest;
93
103
  dropped = true;
@@ -146,13 +156,14 @@ async function writeCache(path, value) {
146
156
  }
147
157
  catch { /* best effort */ }
148
158
  }
149
- export async function compressRecallContext({ query, rendered, entries = [], cfg = {}, runCompressor, cachePath = "", now = 0, }) {
159
+ export async function compressRecallContext({ query, rendered, shortContext = rendered, entries = [], cfg = {}, runCompressor, cachePath = "", now = 0, }) {
150
160
  const input = String(rendered || "").trim();
151
161
  if (!input)
152
162
  return { status: COMPRESS_EMPTY, context: "" };
153
163
  const minChars = Math.max(0, Number(cfg.recallCompressMinInputChars ?? 1500));
154
- if (input.length < minChars)
155
- return { status: COMPRESS_OK, context: input };
164
+ if (input.length < minChars) {
165
+ return { status: COMPRESS_OK, context: String(shortContext ?? input).trim() };
166
+ }
156
167
  const maxInputChars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
157
168
  const maxBullets = Math.max(1, Number(cfg.recallCompressMaxBullets || 6));
158
169
  const key = recallDigestCacheKey({
@@ -164,7 +175,9 @@ export async function compressRecallContext({ query, rendered, entries = [], cfg
164
175
  });
165
176
  const cached = await readCache(cachePath);
166
177
  if (cached?.key === key && typeof cached.digest === "string") {
167
- return { status: COMPRESS_OK, context: cached.digest };
178
+ const digest = normalizeCompressedContext(cached.digest, 4000, maxBullets);
179
+ if (digest)
180
+ return { status: COMPRESS_OK, context: digest };
168
181
  }
169
182
  const prompt = buildRecallCompressionPrompt({
170
183
  query,
@@ -178,9 +191,10 @@ export async function compressRecallContext({ query, rendered, entries = [], cfg
178
191
  if (!normalized)
179
192
  return { status: COMPRESS_EMPTY, context: "" };
180
193
  const validUris = entries.map((entry) => entry?.uri).filter(Boolean);
181
- const digest = repairDigestUris(normalized, validUris.length
194
+ const repaired = repairDigestUris(normalized, validUris.length
182
195
  ? validUris
183
196
  : (input.match(/viking:\/\/[^\s<>"']+/g) || []));
197
+ const digest = normalizeCompressedContext(repaired, 4000, maxBullets);
184
198
  if (!digest)
185
199
  return { status: COMPRESS_FAILED, context: "" };
186
200
  await writeCache(cachePath, { key, digest, updatedAt: now || 0 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/openclaw-plugin",
3
- "version": "2026.9.8",
3
+ "version": "2026.9.9",
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",
package/recall-trace.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { appendFile, mkdir, readFile, readdir, unlink } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
 
4
- export type RecallResourceType = "resource" | "session" | "user" | "agent";
4
+ import type { RecallResourceType } from "./registries/recall-resource-types.js";
5
5
 
6
6
  export type RecallTraceSource = "auto_recall" | "memory_recall" | "ov_search" | "ov_archive_search";
7
7
 
@@ -27,7 +27,9 @@ export type RecallTraceEntry = {
27
27
  agentId?: string;
28
28
  source: RecallTraceSource;
29
29
  operationType: RecallTraceOperationType;
30
- resourceTypes: RecallResourceType[];
30
+ // "session" labels archive-grep traces over session history; recall targets
31
+ // stay in the registry's narrower union and never include it.
32
+ resourceTypes: Array<RecallResourceType | "session">;
31
33
  trigger: {
32
34
  rawUserTextPreview?: string;
33
35
  query: string;
@@ -92,78 +94,6 @@ export type RecallTraceFlushResult = {
92
94
  warnings: string[];
93
95
  };
94
96
 
95
- const ALLOWED_RESOURCE_TYPES: RecallResourceType[] = ["resource", "user", "agent"];
96
- const DEFAULT_RESOURCE_TYPES: RecallResourceType[] = ["user", "agent"];
97
-
98
- function toResourceTypeEntries(value: unknown): string[] {
99
- if (Array.isArray(value)) {
100
- return value
101
- .filter((entry): entry is string => typeof entry === "string")
102
- .map((entry) => entry.trim())
103
- .filter(Boolean);
104
- }
105
- if (typeof value === "string") {
106
- return value
107
- .split(/[,\n]/)
108
- .map((entry) => entry.trim())
109
- .filter(Boolean);
110
- }
111
- return [];
112
- }
113
-
114
- export function normalizeResourceTypes(value: unknown): RecallResourceType[] {
115
- const entries = toResourceTypeEntries(value);
116
- if (entries.length === 0) {
117
- return [...DEFAULT_RESOURCE_TYPES];
118
- }
119
-
120
- const seen = new Set<RecallResourceType>();
121
- const normalized: RecallResourceType[] = [];
122
- const invalid: string[] = [];
123
- for (const entry of entries) {
124
- if ((ALLOWED_RESOURCE_TYPES as string[]).includes(entry)) {
125
- const typed = entry as RecallResourceType;
126
- if (!seen.has(typed)) {
127
- seen.add(typed);
128
- normalized.push(typed);
129
- }
130
- } else {
131
- invalid.push(entry);
132
- }
133
- }
134
-
135
- if (invalid.length > 0) {
136
- throw new Error(`invalid resourceTypes: ${invalid.join(", ")}`);
137
- }
138
-
139
- return normalized.length > 0 ? normalized : [...DEFAULT_RESOURCE_TYPES];
140
- }
141
-
142
- export function resolveRecallSearchPlan(
143
- resourceTypes: unknown,
144
- _ctx: { ovSessionId?: string; agentId?: string },
145
- ): {
146
- resourceTypes: RecallResourceType[];
147
- searches: Array<{ resourceType: RecallResourceType; targetUri?: string; contextType: "memory" | "resource" }>;
148
- skipped: Array<{ resourceType: RecallResourceType; reason: "missing_session" }>;
149
- } {
150
- const normalized = normalizeResourceTypes(resourceTypes);
151
- const searches: Array<{ resourceType: RecallResourceType; targetUri?: string; contextType: "memory" | "resource" }> = [];
152
- const skipped: Array<{ resourceType: RecallResourceType; reason: "missing_session" }> = [];
153
- let addedMemorySearch = false;
154
-
155
- for (const resourceType of normalized) {
156
- if (resourceType === "resource") {
157
- searches.push({ resourceType, contextType: "resource" });
158
- } else if ((resourceType === "user" || resourceType === "agent") && !addedMemorySearch) {
159
- searches.push({ resourceType: "user", contextType: "memory" });
160
- addedMemorySearch = true;
161
- }
162
- }
163
-
164
- return { resourceTypes: normalized, searches, skipped };
165
- }
166
-
167
97
  export class RecallTraceMemoryStore {
168
98
  private readonly maxEntries: number;
169
99
  private readonly entries: RecallTraceEntry[] = [];
@@ -183,7 +113,7 @@ export class RecallTraceMemoryStore {
183
113
  const limit = Math.max(1, Math.floor(query.limit ?? 20));
184
114
  const turn = query.turn ?? "latest";
185
115
  const resourceTypes = query.resourceTypes && query.resourceTypes.length > 0
186
- ? new Set(query.resourceTypes)
116
+ ? new Set<string>(query.resourceTypes)
187
117
  : undefined;
188
118
 
189
119
  const filtered = this.entries
@@ -86,10 +86,16 @@ export function repairDigestUris(digest, validUris = []) {
86
86
  lines.push(line);
87
87
  continue;
88
88
  }
89
+ let tokenized = line;
90
+ for (const uri of [...valid].sort((a, b) => b.length - a.length)) {
91
+ if (/\s/.test(uri)) tokenized = tokenized.split(uri).join(encodeURI(uri));
92
+ }
89
93
  let dropped = false;
90
- const repaired = line.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
91
- if (validSet.has(uri)) return uri;
92
- const nearest = nearestUri(uri, valid);
94
+ const repaired = tokenized.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
95
+ let decoded = uri;
96
+ try { decoded = decodeURI(uri); } catch { /* keep the original candidate */ }
97
+ if (validSet.has(decoded)) return decoded;
98
+ const nearest = nearestUri(decoded, valid);
93
99
  if (nearest) return nearest;
94
100
  dropped = true;
95
101
  return uri;
@@ -149,6 +155,7 @@ async function writeCache(path, value) {
149
155
  export async function compressRecallContext({
150
156
  query,
151
157
  rendered,
158
+ shortContext = rendered,
152
159
  entries = [],
153
160
  cfg = {},
154
161
  runCompressor,
@@ -158,7 +165,9 @@ export async function compressRecallContext({
158
165
  const input = String(rendered || "").trim();
159
166
  if (!input) return { status: COMPRESS_EMPTY, context: "" };
160
167
  const minChars = Math.max(0, Number(cfg.recallCompressMinInputChars ?? 1500));
161
- if (input.length < minChars) return { status: COMPRESS_OK, context: input };
168
+ if (input.length < minChars) {
169
+ return { status: COMPRESS_OK, context: String(shortContext ?? input).trim() };
170
+ }
162
171
 
163
172
  const maxInputChars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
164
173
  const maxBullets = Math.max(1, Number(cfg.recallCompressMaxBullets || 6));
@@ -171,7 +180,8 @@ export async function compressRecallContext({
171
180
  });
172
181
  const cached = await readCache(cachePath);
173
182
  if (cached?.key === key && typeof cached.digest === "string") {
174
- return { status: COMPRESS_OK, context: cached.digest };
183
+ const digest = normalizeCompressedContext(cached.digest, 4000, maxBullets);
184
+ if (digest) return { status: COMPRESS_OK, context: digest };
175
185
  }
176
186
 
177
187
  const prompt = buildRecallCompressionPrompt({
@@ -185,9 +195,10 @@ export async function compressRecallContext({
185
195
  if (!normalized) return { status: COMPRESS_EMPTY, context: "" };
186
196
 
187
197
  const validUris = entries.map((entry) => entry?.uri).filter(Boolean);
188
- const digest = repairDigestUris(normalized, validUris.length
198
+ const repaired = repairDigestUris(normalized, validUris.length
189
199
  ? validUris
190
200
  : (input.match(/viking:\/\/[^\s<>"']+/g) || []));
201
+ const digest = normalizeCompressedContext(repaired, 4000, maxBullets);
191
202
  if (!digest) return { status: COMPRESS_FAILED, context: "" };
192
203
 
193
204
  await writeCache(cachePath, { key, digest, updatedAt: now || 0 });
@@ -55,7 +55,8 @@ context or deeper intent analysis is useful.
55
55
 
56
56
  For tools using MCP-style parameters, set `target_uri` to this root. For
57
57
  OpenClaw `ov_search`, set `uri` to this root. Never hardcode `default`,
58
- `test`, or another user ID.
58
+ `test`, or another user ID. If the server rejects the URI, resolve the root
59
+ as described in *Servers Without the Home Alias* and retry once.
59
60
  4. Start with `limit=5` and the tool's normal score threshold. Judge results by
60
61
  task, environment, preconditions, and likely effect; title similarity alone
61
62
  is insufficient. If no result is relevant, continue without Experience and
@@ -73,6 +74,26 @@ context or deeper intent analysis is useful.
73
74
  follow-up search using the failure evidence, then read only newly relevant
74
75
  Experience files.
75
76
 
77
+ ## Servers Without the Home Alias
78
+
79
+ `viking://~` is the home alias for the caller's own space. A server released
80
+ before the alias (OpenViking v0.4.16) does not resolve it and rejects the
81
+ request with `INVALID_URI` (HTTP 400) instead of searching. Retry once against
82
+ the caller's explicit root, resolved from evidence rather than guessed:
83
+
84
+ - If a canonical `viking://user/<user_id>/...` URI is already visible in this
85
+ session — injected memory context, or an earlier OpenViking tool result —
86
+ reuse that `<user_id>` and search
87
+ `viking://user/<user_id>/memories/experiences`.
88
+ - Otherwise repeat the same query with no target scope, narrowed to memories
89
+ with `context_type` when the tool accepts it, and keep only hits whose URI
90
+ contains `/memories/experiences/`. Those hits carry the canonical user ID;
91
+ reuse it for the reads and for any later Experience search in this task.
92
+
93
+ Do not fall back to the uid-less `viking://user/memories/experiences`: only
94
+ some older servers and roles expand it, and current servers reject it. If no
95
+ explicit root can be resolved, continue without Experience.
96
+
76
97
  ## Applying Retrieved Experience
77
98
 
78
99
  - Treat Experience as reusable procedure, not as a user profile, user intent,