@openviking/openclaw-plugin 2026.9.8 → 2026.9.10-1
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 +13 -2
- package/auto-recall.ts +2 -1
- package/client.ts +1 -0
- package/commands/setup.ts +19 -142
- package/context-engine.ts +72 -9
- package/dist/auto-recall.js +1 -1
- package/dist/client.js +1 -0
- package/dist/commands/setup.js +14 -121
- package/dist/context-engine.js +72 -9
- package/dist/plugin/openviking-context-engine-registration.js +1 -0
- package/dist/recall-trace.js +0 -58
- package/dist/services/context-lifecycle-service.js +72 -32
- package/dist/services/setup/probe-service.js +112 -0
- package/dist/shared/recall-compress-core.mjs +23 -9
- package/index.ts +1 -0
- package/package.json +1 -1
- package/plugin/openviking-context-engine-registration.ts +3 -0
- package/recall-trace.ts +5 -75
- package/services/context-lifecycle-service.ts +100 -52
- package/shared/recall-compress-core.mjs +17 -6
- package/skills/ov-experience-memory/SKILL.md +22 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ The agent runs install → setup → restart → verify automatically. See [INST
|
|
|
23
23
|
|
|
24
24
|
| Stage | What happens |
|
|
25
25
|
|-------|-------------|
|
|
26
|
-
| **Every turn** (`afterTurn`) | New messages are appended to an OpenViking session; commit/extraction is threshold-triggered |
|
|
26
|
+
| **Every turn** (`afterTurn` or `commitTurn`, depending on host/runner) | New messages are appended to an OpenViking session; commit/extraction is threshold-triggered |
|
|
27
27
|
| **Explicit remember** (`memory_store`) | Important long-term facts can be written and committed immediately |
|
|
28
28
|
| **On `/compact`** (`compact`) | Pending session messages are committed and extracted into long-term memories |
|
|
29
29
|
| **Before each reply** (`assemble`) | Relevant memories are auto-retrieved and injected into context |
|
|
@@ -202,6 +202,17 @@ That means OpenClaw sees "compressed history summary + archive index + active me
|
|
|
202
202
|
|
|
203
203
|
`afterTurn()` has a narrower job: append only the new turn into the OpenViking session.
|
|
204
204
|
|
|
205
|
+
Capture ownership depends on the **host** version from `api.runtime.version`:
|
|
206
|
+
|
|
207
|
+
- Before OpenClaw 2026.9.3, `afterTurn` captures messages; `commitTurn` only acknowledges the turn to avoid writing those messages twice.
|
|
208
|
+
- Starting with 2026.9.3, admitted/deferred turns are captured inside `commitTurn` before acknowledgment. Standalone runners still capture via `afterTurn`.
|
|
209
|
+
- The minimum supported OpenClaw version remains 2026.5.27. Missing/unparseable versions and ambiguous 2026.9.3 prereleases reject `commitTurn` rather than silently discarding queued messages; `afterTurn` remains available. Numeric packaging revisions such as `2026.9.3-1` use the 9.3 behavior.
|
|
210
|
+
|
|
211
|
+
Durable capture propagates client/write/commit failures to the host for retry and shares one in-flight write between concurrent deliveries of the same advancement key. Disabled capture, heartbeats, bypassed sessions, and empty turns are intentional skips. The legacy `afterTurn` callback retains best-effort error handling.
|
|
212
|
+
|
|
213
|
+
The host's durable delivery does not supply the model token budget or sender runtime context: this path uses the 128,000-token fallback and cannot attach the runtime sender ID to user messages. Message writes remain non-transactional: replay after partial writes or a process crash can duplicate messages. The in-memory advancement-key cache does not provide server-side exactly-once delivery.
|
|
214
|
+
|
|
215
|
+
|
|
205
216
|
- it slices only the newly added messages
|
|
206
217
|
- it keeps only `user` / `assistant` capture text
|
|
207
218
|
- it preserves `toolCall` / `toolResult` content in the serialized turn text
|
|
@@ -214,7 +225,7 @@ After that, the plugin checks `pending_tokens`. Once it reaches `commitTokenThre
|
|
|
214
225
|
- the current turn is not blocked waiting for extraction
|
|
215
226
|
- if `logFindRequests` is enabled, the logs include the task id and follow-up extraction detail
|
|
216
227
|
|
|
217
|
-
|
|
228
|
+
Memory extraction on this automatic path is commit-dependent. Short but important facts can stay only in the live session until a threshold commit, `/compact`, or an explicit store happens.
|
|
218
229
|
|
|
219
230
|
### Explicit long-term memory writes
|
|
220
231
|
|
package/auto-recall.ts
CHANGED
|
@@ -331,6 +331,7 @@ export async function buildAutoRecallContext(params: {
|
|
|
331
331
|
rawUserTextPreview?: string;
|
|
332
332
|
queryTruncated?: boolean;
|
|
333
333
|
resourceTypes?: RecallResourceType[];
|
|
334
|
+
dedupTurns?: number;
|
|
334
335
|
}): Promise<{ block?: string; memoryCount: number; estimatedTokens: number }> {
|
|
335
336
|
const { cfg, client, agentId, actorPeerId, queryText, logger, verbose } = params;
|
|
336
337
|
const queryConfig = params.queryConfig;
|
|
@@ -373,7 +374,7 @@ export async function buildAutoRecallContext(params: {
|
|
|
373
374
|
queryExpansion: "auto",
|
|
374
375
|
maxTokens,
|
|
375
376
|
detail: recallPreferAbstract ? "abstract" : undefined,
|
|
376
|
-
dedupTurns: params.ovSessionId ? AUTO_RECALL_DEDUP_TURNS : undefined,
|
|
377
|
+
dedupTurns: params.dedupTurns ?? (params.ovSessionId ? AUTO_RECALL_DEDUP_TURNS : undefined),
|
|
377
378
|
peerScope: "actor",
|
|
378
379
|
actorPeerId,
|
|
379
380
|
requestTimeoutMs: cfg.autoRecallTimeoutMs,
|
package/client.ts
CHANGED
|
@@ -508,6 +508,7 @@ export class OpenVikingClient {
|
|
|
508
508
|
};
|
|
509
509
|
const body = {
|
|
510
510
|
...buildContextSearchBody(contractConfig, { sessionId: options.sessionId }),
|
|
511
|
+
...(options.dedupTurns === 0 ? { dedup_turns: 0 } : {}),
|
|
511
512
|
query,
|
|
512
513
|
...(options.contextType !== undefined ? { context_type: options.contextType } : {}),
|
|
513
514
|
...(options.detail !== undefined ? { detail: options.detail } : {}),
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
)
|
|
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,
|
package/context-engine.ts
CHANGED
|
@@ -245,10 +245,34 @@ function validTokenBudget(raw: unknown): number | undefined {
|
|
|
245
245
|
return undefined;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// OpenClaw 9.1/9.2 still capture via afterTurn even when commitTurn exists.
|
|
249
|
+
// 9.3 (#140024) defers admitted turns to commitTurn; standalone runners keep
|
|
250
|
+
// afterTurn. The registration API's runtime.version is the HOST version.
|
|
251
|
+
function usesDeferredTurnCapture(version: string | undefined): boolean | undefined {
|
|
252
|
+
const match = version?.trim().match(
|
|
253
|
+
/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/,
|
|
254
|
+
);
|
|
255
|
+
if (!match) return undefined;
|
|
256
|
+
const parts = match.slice(1, 4).map(Number);
|
|
257
|
+
if (!parts.every(Number.isSafeInteger)) return undefined;
|
|
258
|
+
const [year, month, day] = parts;
|
|
259
|
+
// OpenClaw uses 0.0.0 when version metadata cannot be resolved. More generally,
|
|
260
|
+
// versions outside our supported range cannot prove who owns capture.
|
|
261
|
+
if (year < 2026 || (year === 2026 && (month < 5 || (month === 5 && day < 27)))) {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
const boundary = year === 2026 && month === 9 && day === 3;
|
|
265
|
+
// The 9.3 prerelease train can straddle the host change. Numeric packaging
|
|
266
|
+
// revisions (-1, -2, ...) refer to the stable release; do not guess for betas.
|
|
267
|
+
if (boundary && match[4] && !/^\d+$/.test(match[4])) return undefined;
|
|
268
|
+
return year > 2026 || (year === 2026 && (month > 9 || (month === 9 && day >= 3)));
|
|
269
|
+
}
|
|
270
|
+
|
|
248
271
|
export function createMemoryOpenVikingContextEngine(params: {
|
|
249
272
|
id: string;
|
|
250
273
|
name: string;
|
|
251
274
|
version?: string;
|
|
275
|
+
hostVersion?: string;
|
|
252
276
|
cfg: ParsedMemoryOpenVikingConfig;
|
|
253
277
|
logger: Logger;
|
|
254
278
|
getClient: () => Promise<OpenVikingClient>;
|
|
@@ -333,6 +357,8 @@ export function createMemoryOpenVikingContextEngine(params: {
|
|
|
333
357
|
}
|
|
334
358
|
|
|
335
359
|
const committedTurnKeys = new Set<string>();
|
|
360
|
+
const inFlightTurns = new Map<string, Promise<void>>();
|
|
361
|
+
const deferredTurnCapture = usesDeferredTurnCapture(params.hostVersion);
|
|
336
362
|
|
|
337
363
|
return {
|
|
338
364
|
info: {
|
|
@@ -368,6 +394,7 @@ export function createMemoryOpenVikingContextEngine(params: {
|
|
|
368
394
|
sessionId: assembleParams.sessionId,
|
|
369
395
|
sessionKey: resolveSessionKey(assembleParams),
|
|
370
396
|
messages: assembleParams.messages,
|
|
397
|
+
prompt: assembleParams.prompt,
|
|
371
398
|
tokenBudget,
|
|
372
399
|
runtimeContext: assembleParams.runtimeContext,
|
|
373
400
|
isMainAssemble,
|
|
@@ -388,20 +415,56 @@ export function createMemoryOpenVikingContextEngine(params: {
|
|
|
388
415
|
});
|
|
389
416
|
},
|
|
390
417
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
418
|
+
async commitTurn(commitParams): Promise<{ status: "committed" | "duplicate" }> {
|
|
419
|
+
const { advancementKey, sessionId } = commitParams;
|
|
420
|
+
if (deferredTurnCapture === undefined) {
|
|
421
|
+
// ACKing with an unknown capture owner can discard the only copy in the
|
|
422
|
+
// host outbox. Reject instead; legacy afterTurn remains available.
|
|
423
|
+
throw new Error(`openviking: cannot select turn capture for OpenClaw version ${params.hostVersion ?? "(missing)"}`);
|
|
424
|
+
}
|
|
395
425
|
if (committedTurnKeys.has(advancementKey)) {
|
|
396
426
|
diag("commitTurn_duplicate", sessionId, { advancementKey });
|
|
397
427
|
return { status: "duplicate" };
|
|
398
428
|
}
|
|
399
|
-
|
|
400
|
-
if (
|
|
401
|
-
|
|
429
|
+
const pending = inFlightTurns.get(advancementKey);
|
|
430
|
+
if (pending) {
|
|
431
|
+
await pending;
|
|
432
|
+
return { status: "duplicate" };
|
|
433
|
+
}
|
|
434
|
+
// Publish the promise before capture begins so concurrent deliveries share
|
|
435
|
+
// both success and failure. Completion keys are intentionally process-local.
|
|
436
|
+
const capture = Promise.resolve().then(async () => {
|
|
437
|
+
if (deferredTurnCapture) {
|
|
438
|
+
await afterTurnOpenVikingSession({
|
|
439
|
+
sessionId,
|
|
440
|
+
sessionKey: resolveSessionKey(commitParams),
|
|
441
|
+
messages: commitParams.messages,
|
|
442
|
+
prePromptMessageCount: 0, // Host already supplies the closed-turn range.
|
|
443
|
+
isHeartbeat: commitParams.isHeartbeat,
|
|
444
|
+
tokenBudget: 128_000, // Durable delivery does not carry a token budget.
|
|
445
|
+
throwOnError: true,
|
|
446
|
+
cfg,
|
|
447
|
+
getClient,
|
|
448
|
+
logger,
|
|
449
|
+
resolveAgentId,
|
|
450
|
+
rememberSessionAgentId,
|
|
451
|
+
isBypassedSession,
|
|
452
|
+
diag,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
committedTurnKeys.add(advancementKey);
|
|
456
|
+
if (committedTurnKeys.size > 1024) {
|
|
457
|
+
committedTurnKeys.delete(committedTurnKeys.values().next().value as string);
|
|
458
|
+
}
|
|
459
|
+
diag("commitTurn", sessionId, { advancementKey, deferredTurnCapture });
|
|
460
|
+
});
|
|
461
|
+
inFlightTurns.set(advancementKey, capture);
|
|
462
|
+
try {
|
|
463
|
+
await capture;
|
|
464
|
+
return { status: "committed" };
|
|
465
|
+
} finally {
|
|
466
|
+
inFlightTurns.delete(advancementKey);
|
|
402
467
|
}
|
|
403
|
-
diag("commitTurn", sessionId, { advancementKey });
|
|
404
|
-
return { status: "committed" };
|
|
405
468
|
},
|
|
406
469
|
|
|
407
470
|
async afterTurn(afterTurnParams): Promise<void> {
|
package/dist/auto-recall.js
CHANGED
|
@@ -244,7 +244,7 @@ export async function buildAutoRecallContext(params) {
|
|
|
244
244
|
queryExpansion: "auto",
|
|
245
245
|
maxTokens,
|
|
246
246
|
detail: recallPreferAbstract ? "abstract" : undefined,
|
|
247
|
-
dedupTurns: params.ovSessionId ? AUTO_RECALL_DEDUP_TURNS : undefined,
|
|
247
|
+
dedupTurns: params.dedupTurns ?? (params.ovSessionId ? AUTO_RECALL_DEDUP_TURNS : undefined),
|
|
248
248
|
peerScope: "actor",
|
|
249
249
|
actorPeerId,
|
|
250
250
|
requestTimeoutMs: cfg.autoRecallTimeoutMs,
|
package/dist/client.js
CHANGED
|
@@ -205,6 +205,7 @@ export class OpenVikingClient {
|
|
|
205
205
|
};
|
|
206
206
|
const body = {
|
|
207
207
|
...buildContextSearchBody(contractConfig, { sessionId: options.sessionId }),
|
|
208
|
+
...(options.dedupTurns === 0 ? { dedup_turns: 0 } : {}),
|
|
208
209
|
query,
|
|
209
210
|
...(options.contextType !== undefined ? { context_type: options.contextType } : {}),
|
|
210
211
|
...(options.detail !== undefined ? { detail: options.detail } : {}),
|
package/dist/commands/setup.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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,
|