@openviking/openclaw-plugin 2026.9.9 → 2026.9.10

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 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
- This automatic path is best-effort and commit-dependent. Short but important facts can stay only in the live session until a threshold commit, `/compact`, or an explicit store happens.
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/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: {
@@ -388,20 +414,56 @@ export function createMemoryOpenVikingContextEngine(params: {
388
414
  });
389
415
  },
390
416
 
391
- // Capture still happens in afterTurn (host calls it per LLM call + on finalize);
392
- // commitTurn only acknowledges the accepted turn so OpenClaw drains its outbox.
393
- // ponytail: in-memory key set, not durable across restarts — the host outbox is.
394
- async commitTurn({ advancementKey, sessionId }): Promise<{ status: "committed" | "duplicate" }> {
417
+ async commitTurn(commitParams): Promise<{ status: "committed" | "duplicate" }> {
418
+ const { advancementKey, sessionId } = commitParams;
419
+ if (deferredTurnCapture === undefined) {
420
+ // ACKing with an unknown capture owner can discard the only copy in the
421
+ // host outbox. Reject instead; legacy afterTurn remains available.
422
+ throw new Error(`openviking: cannot select turn capture for OpenClaw version ${params.hostVersion ?? "(missing)"}`);
423
+ }
395
424
  if (committedTurnKeys.has(advancementKey)) {
396
425
  diag("commitTurn_duplicate", sessionId, { advancementKey });
397
426
  return { status: "duplicate" };
398
427
  }
399
- committedTurnKeys.add(advancementKey);
400
- if (committedTurnKeys.size > 1024) {
401
- committedTurnKeys.delete(committedTurnKeys.values().next().value as string);
428
+ const pending = inFlightTurns.get(advancementKey);
429
+ if (pending) {
430
+ await pending;
431
+ return { status: "duplicate" };
432
+ }
433
+ // Publish the promise before capture begins so concurrent deliveries share
434
+ // both success and failure. Completion keys are intentionally process-local.
435
+ const capture = Promise.resolve().then(async () => {
436
+ if (deferredTurnCapture) {
437
+ await afterTurnOpenVikingSession({
438
+ sessionId,
439
+ sessionKey: resolveSessionKey(commitParams),
440
+ messages: commitParams.messages,
441
+ prePromptMessageCount: 0, // Host already supplies the closed-turn range.
442
+ isHeartbeat: commitParams.isHeartbeat,
443
+ tokenBudget: 128_000, // Durable delivery does not carry a token budget.
444
+ throwOnError: true,
445
+ cfg,
446
+ getClient,
447
+ logger,
448
+ resolveAgentId,
449
+ rememberSessionAgentId,
450
+ isBypassedSession,
451
+ diag,
452
+ });
453
+ }
454
+ committedTurnKeys.add(advancementKey);
455
+ if (committedTurnKeys.size > 1024) {
456
+ committedTurnKeys.delete(committedTurnKeys.values().next().value as string);
457
+ }
458
+ diag("commitTurn", sessionId, { advancementKey, deferredTurnCapture });
459
+ });
460
+ inFlightTurns.set(advancementKey, capture);
461
+ try {
462
+ await capture;
463
+ return { status: "committed" };
464
+ } finally {
465
+ inFlightTurns.delete(advancementKey);
402
466
  }
403
- diag("commitTurn", sessionId, { advancementKey });
404
- return { status: "committed" };
405
467
  },
406
468
 
407
469
  async afterTurn(afterTurnParams): Promise<void> {
@@ -121,6 +121,29 @@ function validTokenBudget(raw) {
121
121
  }
122
122
  return undefined;
123
123
  }
124
+ // OpenClaw 9.1/9.2 still capture via afterTurn even when commitTurn exists.
125
+ // 9.3 (#140024) defers admitted turns to commitTurn; standalone runners keep
126
+ // afterTurn. The registration API's runtime.version is the HOST version.
127
+ function usesDeferredTurnCapture(version) {
128
+ const match = version?.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
129
+ if (!match)
130
+ return undefined;
131
+ const parts = match.slice(1, 4).map(Number);
132
+ if (!parts.every(Number.isSafeInteger))
133
+ return undefined;
134
+ const [year, month, day] = parts;
135
+ // OpenClaw uses 0.0.0 when version metadata cannot be resolved. More generally,
136
+ // versions outside our supported range cannot prove who owns capture.
137
+ if (year < 2026 || (year === 2026 && (month < 5 || (month === 5 && day < 27)))) {
138
+ return undefined;
139
+ }
140
+ const boundary = year === 2026 && month === 9 && day === 3;
141
+ // The 9.3 prerelease train can straddle the host change. Numeric packaging
142
+ // revisions (-1, -2, ...) refer to the stable release; do not guess for betas.
143
+ if (boundary && match[4] && !/^\d+$/.test(match[4]))
144
+ return undefined;
145
+ return year > 2026 || (year === 2026 && (month > 9 || (month === 9 && day >= 3)));
146
+ }
124
147
  export function createMemoryOpenVikingContextEngine(params) {
125
148
  const { id, name, version, cfg, logger, getClient, resolveAgentId, rememberSessionAgentId, queryConfigStore, traceRecorder, } = params;
126
149
  const diagEnabled = cfg.emitStandardDiagnostics;
@@ -161,6 +184,8 @@ export function createMemoryOpenVikingContextEngine(params) {
161
184
  };
162
185
  }
163
186
  const committedTurnKeys = new Set();
187
+ const inFlightTurns = new Map();
188
+ const deferredTurnCapture = usesDeferredTurnCapture(params.hostVersion);
164
189
  return {
165
190
  info: {
166
191
  id,
@@ -208,20 +233,57 @@ export function createMemoryOpenVikingContextEngine(params) {
208
233
  prependRecallToLatestUserMessage,
209
234
  });
210
235
  },
211
- // Capture still happens in afterTurn (host calls it per LLM call + on finalize);
212
- // commitTurn only acknowledges the accepted turn so OpenClaw drains its outbox.
213
- // ponytail: in-memory key set, not durable across restarts — the host outbox is.
214
- async commitTurn({ advancementKey, sessionId }) {
236
+ async commitTurn(commitParams) {
237
+ const { advancementKey, sessionId } = commitParams;
238
+ if (deferredTurnCapture === undefined) {
239
+ // ACKing with an unknown capture owner can discard the only copy in the
240
+ // host outbox. Reject instead; legacy afterTurn remains available.
241
+ throw new Error(`openviking: cannot select turn capture for OpenClaw version ${params.hostVersion ?? "(missing)"}`);
242
+ }
215
243
  if (committedTurnKeys.has(advancementKey)) {
216
244
  diag("commitTurn_duplicate", sessionId, { advancementKey });
217
245
  return { status: "duplicate" };
218
246
  }
219
- committedTurnKeys.add(advancementKey);
220
- if (committedTurnKeys.size > 1024) {
221
- committedTurnKeys.delete(committedTurnKeys.values().next().value);
247
+ const pending = inFlightTurns.get(advancementKey);
248
+ if (pending) {
249
+ await pending;
250
+ return { status: "duplicate" };
251
+ }
252
+ // Publish the promise before capture begins so concurrent deliveries share
253
+ // both success and failure. Completion keys are intentionally process-local.
254
+ const capture = Promise.resolve().then(async () => {
255
+ if (deferredTurnCapture) {
256
+ await afterTurnOpenVikingSession({
257
+ sessionId,
258
+ sessionKey: resolveSessionKey(commitParams),
259
+ messages: commitParams.messages,
260
+ prePromptMessageCount: 0, // Host already supplies the closed-turn range.
261
+ isHeartbeat: commitParams.isHeartbeat,
262
+ tokenBudget: 128_000, // Durable delivery does not carry a token budget.
263
+ throwOnError: true,
264
+ cfg,
265
+ getClient,
266
+ logger,
267
+ resolveAgentId,
268
+ rememberSessionAgentId,
269
+ isBypassedSession,
270
+ diag,
271
+ });
272
+ }
273
+ committedTurnKeys.add(advancementKey);
274
+ if (committedTurnKeys.size > 1024) {
275
+ committedTurnKeys.delete(committedTurnKeys.values().next().value);
276
+ }
277
+ diag("commitTurn", sessionId, { advancementKey, deferredTurnCapture });
278
+ });
279
+ inFlightTurns.set(advancementKey, capture);
280
+ try {
281
+ await capture;
282
+ return { status: "committed" };
283
+ }
284
+ finally {
285
+ inFlightTurns.delete(advancementKey);
222
286
  }
223
- diag("commitTurn", sessionId, { advancementKey });
224
- return { status: "committed" };
225
287
  },
226
288
  async afterTurn(afterTurnParams) {
227
289
  const tokenBudget = validTokenBudget(afterTurnParams.tokenBudget) ?? 128_000;
@@ -8,6 +8,7 @@ export function registerOpenVikingContextEngine(deps) {
8
8
  id: deps.plugin.id,
9
9
  name: deps.plugin.name,
10
10
  version: deps.version,
11
+ hostVersion: deps.api.runtime?.version,
11
12
  cfg: deps.cfg,
12
13
  logger: deps.logger,
13
14
  getClient: deps.getClient,
@@ -501,7 +501,7 @@ function messageDigest(messages, maxCharsPerMsg = 2000) {
501
501
  };
502
502
  });
503
503
  }
504
- export async function afterTurnOpenVikingSession({ sessionId, sessionKey, messages: rawMessages, prePromptMessageCount, isHeartbeat, tokenBudget, runtimeContext, cfg, getClient, logger, resolveAgentId, rememberSessionAgentId, isBypassedSession, diag, }) {
504
+ export async function afterTurnOpenVikingSession({ throwOnError = false, sessionId, sessionKey, messages: rawMessages, prePromptMessageCount, isHeartbeat, tokenBudget, runtimeContext, cfg, getClient, logger, resolveAgentId, rememberSessionAgentId, isBypassedSession, diag, }) {
505
505
  if (!cfg.autoCapture) {
506
506
  return;
507
507
  }
@@ -650,6 +650,9 @@ export async function afterTurnOpenVikingSession({ sessionId, sessionKey, messag
650
650
  senderIdFound: sender.found,
651
651
  senderId: sender.senderId ?? null,
652
652
  });
653
+ if (throwOnError) {
654
+ throw err;
655
+ }
653
656
  }
654
657
  }
655
658
  function validTokenCount(value) {
package/index.ts CHANGED
@@ -95,6 +95,7 @@ type ToolContext = {
95
95
  };
96
96
 
97
97
  type OpenClawPluginApi = {
98
+ runtime?: { version?: string };
98
99
  pluginConfig?: unknown;
99
100
  openVikingTransport?: HttpTransport;
100
101
  logger: PluginLogger;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/openclaw-plugin",
3
- "version": "2026.9.9",
3
+ "version": "2026.9.10",
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",
@@ -1,4 +1,5 @@
1
1
  export type OpenVikingContextEngineRegistrationApi = {
2
+ runtime?: { version?: string };
2
3
  registerContextEngine?: (id: string, factory: () => unknown) => void;
3
4
  };
4
5
 
@@ -22,6 +23,7 @@ export type OpenVikingContextEngineCreateParams<
22
23
  id: string;
23
24
  name: string;
24
25
  version: string;
26
+ hostVersion?: string;
25
27
  cfg: TCfg;
26
28
  logger: TLogger;
27
29
  getClient: () => Promise<TClient>;
@@ -80,6 +82,7 @@ export function registerOpenVikingContextEngine<
80
82
  id: deps.plugin.id,
81
83
  name: deps.plugin.name,
82
84
  version: deps.version,
85
+ hostVersion: deps.api.runtime?.version,
83
86
  cfg: deps.cfg,
84
87
  logger: deps.logger,
85
88
  getClient: deps.getClient,
@@ -125,6 +125,8 @@ export type CompactOpenVikingSessionParams = {
125
125
  type AfterTurnClient = Pick<OpenVikingClient, "addSessionMessage" | "getSession" | "commitSession" | "getTask">;
126
126
 
127
127
  export type AfterTurnOpenVikingSessionParams = {
128
+ /** Durable delivery must reject failed writes so the host retains its outbox row. */
129
+ throwOnError?: boolean;
128
130
  sessionId: string;
129
131
  sessionKey?: string;
130
132
  messages?: AgentMessage[];
@@ -786,6 +788,7 @@ function messageDigest(messages: AgentMessage[], maxCharsPerMsg = 2000): Array<{
786
788
  }
787
789
 
788
790
  export async function afterTurnOpenVikingSession({
791
+ throwOnError = false,
789
792
  sessionId,
790
793
  sessionKey,
791
794
  messages: rawMessages,
@@ -976,6 +979,9 @@ export async function afterTurnOpenVikingSession({
976
979
  senderIdFound: sender.found,
977
980
  senderId: sender.senderId ?? null,
978
981
  });
982
+ if (throwOnError) {
983
+ throw err;
984
+ }
979
985
  }
980
986
  }
981
987