@xmoxmo/bncr 0.6.5 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -120,8 +120,49 @@ export async function handleBncrNativeCommand(params: {
120
120
 
121
121
  // Stop always goes through the unified stop fast path in dispatch.ts.
122
122
  // It is not a bncr native command and must never be processed here.
123
- if (isBncrStopCommandText(extracted.text)) {
124
- return { handled: false };
123
+ // Only exact `/stop` is accepted; any other `/stop*` form is rejected.
124
+ const rawStopText = String(extracted.text || '')
125
+ .trim()
126
+ .toLowerCase();
127
+ if (rawStopText.startsWith('/stop')) {
128
+ if (isBncrStopCommandText(extracted.text)) {
129
+ return { handled: false };
130
+ }
131
+ // Non-exact `/stop` forms (extra args, @bot suffix, or other extension)
132
+ // are rejected immediately so they never reach the agent.
133
+ const rejectedRoute = buildBncrNativeCommandResolvedRoute({
134
+ api,
135
+ cfg,
136
+ channelId,
137
+ accountId,
138
+ peer,
139
+ resolvedAgentId,
140
+ });
141
+ const { baseSessionKey, sessionKey } = buildBncrNativeCommandSessionState({
142
+ parsed,
143
+ sessionAgentId: rejectedRoute.agentId || canonicalAgentId,
144
+ resolvedRoute: rejectedRoute,
145
+ });
146
+ logBncrNativeCommandSummary(
147
+ buildBncrNativeCommandSummary({
148
+ kind: 'stop',
149
+ command: rawStopText,
150
+ accountId,
151
+ to: formatDisplayScope(route),
152
+ msgId: msgId || null,
153
+ result: 'rejected',
154
+ }),
155
+ );
156
+ await enqueueFromReply({
157
+ accountId,
158
+ sessionKey: baseSessionKey,
159
+ route,
160
+ payload: {
161
+ text: 'Only exact /stop is supported. Send /stop to stop.',
162
+ replyToId: msgId || undefined,
163
+ },
164
+ });
165
+ return { handled: true, command: 'stop', sessionKey };
125
166
  }
126
167
 
127
168
  const whitelistBareCommand =
@@ -5,6 +5,7 @@ import {
5
5
  type HistoryEntry,
6
6
  type HistoryMediaEntry,
7
7
  } from 'openclaw/plugin-sdk/reply-history';
8
+ import { normalizeAccountId } from '../../core/accounts.ts';
8
9
  import { formatOpenClawAgentEnvelope } from '../../openclaw/reply-runtime.ts';
9
10
  import type { BncrInboundApi } from './contracts.ts';
10
11
  import type { ParsedInbound } from './dispatch-prep.ts';
@@ -87,33 +88,53 @@ export function resolveBncrHistoryLimit(historyLimit?: number): number {
87
88
  return resolved >= 2 ? resolved : DEFAULT_HISTORY_LIMIT;
88
89
  }
89
90
 
90
- export function buildBncrConversationHistoryKey(parsed: ParsedInbound): string | null {
91
+ export function buildBncrConversationHistoryKey(
92
+ parsed: ParsedInbound,
93
+ options?: { accountId?: string | null },
94
+ ): string | null {
91
95
  const platform = String(parsed.platform || '').trim();
92
96
  if (!platform) return null;
97
+ const accountId = normalizeAccountId(options?.accountId ?? parsed.accountId);
93
98
  if (parsed.peer.kind === 'group') {
94
99
  const groupId = String(parsed.groupId || '').trim();
95
100
  if (!groupId || groupId === '0') return null;
96
- return `${platform}:${groupId}`;
101
+ return `${accountId}:${platform}:${groupId}`;
97
102
  }
98
103
  const userId = String(parsed.userId || '').trim();
99
104
  if (!userId || userId === '0') return null;
100
- return `${platform}:${userId}`;
105
+ return `${accountId}:${platform}:${userId}`;
101
106
  }
102
107
 
103
108
  export function buildBncrConversationHistoryKeyFromRoute(args: {
109
+ accountId?: string | null;
104
110
  platform?: string;
105
111
  groupId?: string;
106
112
  userId?: string;
107
113
  }): string | null {
108
114
  const platform = String(args.platform || '').trim();
109
115
  if (!platform) return null;
116
+ const accountId = normalizeAccountId(args.accountId);
110
117
  const groupId = String(args.groupId || '0').trim() || '0';
111
118
  const userId = String(args.userId || '0').trim() || '0';
112
- if (groupId !== '0') return `${platform}:${groupId}`;
113
- if (userId !== '0') return `${platform}:${userId}`;
119
+ if (groupId !== '0') return `${accountId}:${platform}:${groupId}`;
120
+ if (userId !== '0') return `${accountId}:${platform}:${userId}`;
114
121
  return null;
115
122
  }
116
123
 
124
+ /**
125
+ * Extract the scene key (platform:userId or platform:groupId) from a history
126
+ * key that may contain an accountId prefix. Legacy scene-only keys pass
127
+ * through unchanged.
128
+ */
129
+ export function resolveBncrSceneKeyFromHistoryKey(historyKey: string): string {
130
+ const key = String(historyKey || '').trim();
131
+ const firstColon = key.indexOf(':');
132
+ if (firstColon > 0 && key.indexOf(':', firstColon + 1) !== -1) {
133
+ return key.slice(firstColon + 1);
134
+ }
135
+ return key;
136
+ }
137
+
117
138
  export function resolveBncrConversationHistoryMessageId(parsed: ParsedInbound): string | undefined {
118
139
  const msgId = String(parsed.msgId || '').trim();
119
140
  return msgId || undefined;
@@ -57,9 +57,9 @@ export type NativeSessionResetCommand = {
57
57
  * the same code path as an admin caller. This whitelist is shared
58
58
  * across permission checks so admin and non-admin logic stay in sync.
59
59
  *
60
- * `stop` stays in this whitelist for semantic consistency, but it is
61
- * routed through the unified stop fast path in dispatch.ts rather than
62
- * the native command handler, so it is never processed here.
60
+ * `stop` is intentionally absent: it is handled before this set by the
61
+ * unified stop fast path in dispatch.ts, and `isBncrStopCommandText` only
62
+ * accepts the exact `/stop` form.
63
63
  */
64
64
  export const BNCR_SELF_SERVICE_COMMANDS: ReadonlySet<string> = new Set([
65
65
  'whoami',
@@ -68,7 +68,6 @@ export const BNCR_SELF_SERVICE_COMMANDS: ReadonlySet<string> = new Set([
68
68
  'model',
69
69
  'new',
70
70
  'reset',
71
- 'stop',
72
71
  ]);
73
72
 
74
73
  /**
@@ -136,13 +135,7 @@ export const BNCR_NATIVE_COMMANDS = new Set([
136
135
  'download-media',
137
136
  ]);
138
137
 
139
- export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set([
140
- 'whoami',
141
- 'status',
142
- 'new',
143
- 'reset',
144
- 'stop',
145
- ]);
138
+ export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set(['whoami', 'status', 'new', 'reset']);
146
139
 
147
140
  export const BNCR_DIRECT_ALLOWED_SUBCOMMANDS = new Set([
148
141
  'help',
@@ -340,6 +333,9 @@ export function resolveBncrNativeSessionResetCommand(args: {
340
333
  peerKind: 'direct' | 'group';
341
334
  }): NativeSessionResetCommand | null {
342
335
  if (args.command.command !== 'new' && args.command.command !== 'reset') return null;
336
+ // new/reset are exact session-reset commands. Extra arguments are not
337
+ // valid and must not silently reset or create a session.
338
+ if (String(args.command.argsText || '').trim()) return null;
343
339
  const reason = args.command.command === 'new' ? 'new' : 'reset';
344
340
  return {
345
341
  handled: true,
@@ -1,3 +1,4 @@
1
+ import { normalizeAccountId } from '../../core/accounts.ts';
1
2
  import type {
2
3
  BncrOutboundReplayEntry,
3
4
  BncrOutboundReplayMediaEntry,
@@ -66,7 +67,7 @@ export function buildBncrOutboundReplayKeyFromRoute(args: {
66
67
  groupId?: string;
67
68
  userId?: string;
68
69
  }): string | null {
69
- const accountId = String(args.accountId || '').trim() || 'default';
70
+ const accountId = normalizeAccountId(args.accountId);
70
71
  const platform = String(args.platform || '').trim();
71
72
  const groupId = String(args.groupId || '0').trim() || '0';
72
73
  const userId = String(args.userId || '0').trim() || '0';
@@ -167,6 +168,18 @@ export type BncrOutboundReplayRecordResult = {
167
168
  historyVersion?: number;
168
169
  };
169
170
 
171
+ /**
172
+ * Record an outbound bot reply into both the conversation-history window and
173
+ * the outbound-replay cache.
174
+ *
175
+ * Dual-write strategy: the conversation-history window is the canonical
176
+ * ordered source for structured context (conversation_context). The
177
+ * outbound-replay cache is a lighter, indexed copy used by bridge-facing
178
+ * queries such as `listRecentOutbound`. Both are kept in sync for the
179
+ * duration of the runtime session. This is a compatibility design; the
180
+ * cache may be retired once all read paths are migrated to the history
181
+ * window.
182
+ */
170
183
  export function recordBncrOutboundReplay(args: {
171
184
  cache: BncrOutboundReplayCache;
172
185
  conversationHistories?: BncrConversationHistoryMap;
@@ -200,6 +213,7 @@ export function recordBncrOutboundReplay(args: {
200
213
  if (!body) return { recorded: false, historyOverflow: false };
201
214
  const recordTimestamp = args.entry.lastPushAt ?? Date.now();
202
215
  const historyKey = buildBncrConversationHistoryKeyFromRoute({
216
+ accountId: args.entry.accountId,
203
217
  platform: args.entry.route?.platform,
204
218
  groupId: args.entry.route?.groupId,
205
219
  userId: args.entry.route?.userId,
@@ -347,7 +361,9 @@ export function readBncrOutboundReplaySnapshot(args: {
347
361
  : [];
348
362
  if (!args.conversationHistories) return legacyEntries;
349
363
 
350
- const historyKey = buildBncrConversationHistoryKey(args.parsed);
364
+ const historyKey = buildBncrConversationHistoryKey(args.parsed, {
365
+ accountId: args.accountId,
366
+ });
351
367
  const historyEntries = historyKey
352
368
  ? (args.conversationHistories.get(historyKey) || [])
353
369
  .filter((entry) => entry.role === 'assistant')
@@ -247,6 +247,14 @@ export async function runBncrInboundReplyDispatch(args: {
247
247
  sessionMetaBarrier.track(task);
248
248
  },
249
249
  },
250
+ // OpenClaw ≥8.1 requires prepared turns to carry a runDispatchLifecycle
251
+ // shim. Bncr dispatches directly via runDispatch and does not participate
252
+ // in the adoption lifecycle, so turnAdoptionLifecycle is intentionally
253
+ // left as undefined; onDispatchSkipped is a safe no-op here.
254
+ runDispatchLifecycle: {
255
+ turnAdoptionLifecycle: undefined,
256
+ onDispatchSkipped: async () => {},
257
+ },
250
258
  runDispatch: async () => {
251
259
  await sessionMetaBarrier.wait();
252
260
  await correctBncrInboundSessionLabel({
@@ -1,4 +1,5 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
5
 
@@ -12,7 +13,7 @@ type SessionResetParams = {
12
13
  type SessionResetFn = (params: SessionResetParams) => Promise<SessionResetResult>;
13
14
 
14
15
  let testOverride: SessionResetFn | null = null;
15
- let resolvedSessionRuntimeUrlCache: string | null = null;
16
+ let resolvedSessionResetRuntimeCache: SessionResetFn | null = null;
16
17
 
17
18
  export function setBncrSessionResetRuntimeForTest(fn: SessionResetFn | null): () => void {
18
19
  const previous = testOverride;
@@ -22,56 +23,103 @@ export function setBncrSessionResetRuntimeForTest(fn: SessionResetFn | null): ()
22
23
  };
23
24
  }
24
25
 
25
- function resolveOpenClawSessionsRuntimeUrl(): string {
26
- if (resolvedSessionRuntimeUrlCache) return resolvedSessionRuntimeUrlCache;
27
- let current = dirname(fileURLToPath(import.meta.url));
28
- let pluginRoot: string | null = null;
26
+ function findPackageRoot(startPath: string, packageName: string): string | null {
27
+ let current = dirname(startPath);
29
28
  while (true) {
30
29
  const pkgPath = join(current, 'package.json');
31
30
  try {
32
31
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string };
33
- if (pkg.name === '@xmoxmo/bncr') {
34
- pluginRoot = current;
35
- break;
36
- }
32
+ if (pkg.name === packageName) return current;
37
33
  } catch {
38
- // Keep walking up toward the plugin root.
34
+ // Keep walking toward the package root.
39
35
  }
40
36
  const parent = dirname(current);
41
- if (parent === current) break;
37
+ if (parent === current) return null;
42
38
  current = parent;
43
39
  }
44
- if (!pluginRoot) {
45
- throw new Error('bncr plugin root not found for session reset runtime');
40
+ }
41
+
42
+ function resolveOpenClawDistDir(): string {
43
+ let current = dirname(fileURLToPath(import.meta.url));
44
+ let resolvedOpenClawEntry: string | null = null;
45
+ try {
46
+ resolvedOpenClawEntry = createRequire(import.meta.url).resolve('openclaw');
47
+ } catch {
48
+ // Fall back to the plugin-local dependency path below.
46
49
  }
47
50
 
48
- // Try candidate paths for the sessions runtime re-export file.
49
- // The hash-stamped bundle name may change across OpenClaw releases, so we
50
- // first try the stable re-export entry and fall back to the known hash.
51
- const candidates = [
52
- join(pluginRoot, 'node_modules', 'openclaw', 'dist', 'sessions.runtime.js'),
53
- join(pluginRoot, 'node_modules', 'openclaw', 'dist', 'sessions.runtime-BQwwkuFq.js'),
54
- ];
55
- for (const target of candidates) {
56
- if (existsSync(target)) {
57
- resolvedSessionRuntimeUrlCache = pathToFileURL(target).href;
58
- return resolvedSessionRuntimeUrlCache;
51
+ const openClawRoot = resolvedOpenClawEntry
52
+ ? findPackageRoot(resolvedOpenClawEntry, 'openclaw')
53
+ : null;
54
+ if (openClawRoot) {
55
+ const distDir = join(openClawRoot, 'dist');
56
+ if (existsSync(distDir)) return distDir;
57
+ }
58
+
59
+ while (true) {
60
+ const pluginRoot = findPackageRoot(current, '@xmoxmo/bncr');
61
+ if (pluginRoot) {
62
+ const distDir = join(pluginRoot, 'node_modules', 'openclaw', 'dist');
63
+ if (existsSync(distDir)) return distDir;
59
64
  }
65
+ const parent = dirname(current);
66
+ if (parent === current) break;
67
+ current = parent;
60
68
  }
69
+
70
+ const resolvedPath = resolvedOpenClawEntry || '(unresolved)';
71
+ const pluginPath = join(
72
+ dirname(fileURLToPath(import.meta.url)),
73
+ 'node_modules',
74
+ 'openclaw',
75
+ 'dist',
76
+ );
61
77
  throw new Error(
62
- `OpenClaw sessions runtime not found under ${join(pluginRoot, 'node_modules', 'openclaw', 'dist')}`,
78
+ `OpenClaw dist directory not found (resolved entry: ${resolvedPath}; plugin fallback: ${pluginPath})`,
63
79
  );
64
80
  }
65
81
 
82
+ function resolveOpenClawSessionsRuntimeUrls(): string[] {
83
+ const distDir = resolveOpenClawDistDir();
84
+ if (!existsSync(distDir)) {
85
+ throw new Error(`OpenClaw dist directory not found: ${distDir}`);
86
+ }
87
+
88
+ // Scan dist for sessions.runtime*.js, preferring the stable re-export.
89
+ const candidates = readdirSync(distDir)
90
+ .filter((name) => /^sessions\.runtime(?:-[A-Za-z0-9_-]+)?\.js$/.test(name))
91
+ .sort(
92
+ (a, b) =>
93
+ Number(b === 'sessions.runtime.js') - Number(a === 'sessions.runtime.js') ||
94
+ a.localeCompare(b),
95
+ );
96
+ return candidates.map((name) => pathToFileURL(join(distDir, name)).href);
97
+ }
98
+
66
99
  export async function performBncrGatewaySessionReset(
67
100
  params: SessionResetParams,
68
101
  ): Promise<SessionResetResult> {
69
102
  if (testOverride) return testOverride(params);
70
- const mod = (await import(resolveOpenClawSessionsRuntimeUrl())) as {
71
- performGatewaySessionReset?: SessionResetFn;
72
- };
73
- if (typeof mod.performGatewaySessionReset !== 'function') {
74
- throw new Error('OpenClaw performGatewaySessionReset is unavailable');
103
+ if (!resolvedSessionResetRuntimeCache) {
104
+ const candidates = resolveOpenClawSessionsRuntimeUrls();
105
+ const importErrors: string[] = [];
106
+ for (const url of candidates) {
107
+ try {
108
+ const mod = (await import(url)) as { performGatewaySessionReset?: SessionResetFn };
109
+ if (typeof mod.performGatewaySessionReset === 'function') {
110
+ resolvedSessionResetRuntimeCache = mod.performGatewaySessionReset;
111
+ break;
112
+ }
113
+ importErrors.push(`${url}: export unavailable`);
114
+ } catch (error) {
115
+ importErrors.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
116
+ }
117
+ }
118
+ if (!resolvedSessionResetRuntimeCache) {
119
+ throw new Error(
120
+ `OpenClaw performGatewaySessionReset is unavailable; candidates: ${importErrors.join('; ') || '(none)'}`,
121
+ );
122
+ }
75
123
  }
76
- return mod.performGatewaySessionReset(params);
124
+ return resolvedSessionResetRuntimeCache(params);
77
125
  }
@@ -86,7 +86,7 @@ export type BncrPersistedConversationHistoryEntry = {
86
86
  export type BncrPersistedConversationHistoryMediaEntry = {
87
87
  path?: string;
88
88
  contentType?: string;
89
- kind?: 'image' | 'video' | 'audio' | 'document' | 'unknown';
89
+ kind?: 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'unknown';
90
90
  messageId?: string;
91
91
  };
92
92
 
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { DatabaseSync } from 'node:sqlite';
3
+ import { BNCR_DEFAULT_ACCOUNT_ID, normalizeAccountId } from '../core/accounts.ts';
3
4
  import {
4
5
  dumpRegisterDriftSnapshot,
5
6
  normalizeRegisterDriftSnapshot,
@@ -776,19 +777,43 @@ function loadControlState(db: DatabaseSync): BncrSqliteControlState {
776
777
  }
777
778
 
778
779
  function deriveHistoryKeyFromRoute(args: {
780
+ accountId?: string | null;
779
781
  platform?: string;
780
782
  groupId?: string;
781
783
  userId?: string;
782
784
  }): string | null {
785
+ const accountId = normalizeAccountId(args.accountId);
783
786
  const platform = String(args.platform || '').trim();
784
787
  const groupId = String(args.groupId || '0').trim() || '0';
785
788
  const userId = String(args.userId || '0').trim() || '0';
786
789
  if (!platform) return null;
787
- if (groupId !== '0') return `${platform}:${groupId}`;
788
- if (userId !== '0') return `${platform}:${userId}`;
790
+ if (groupId !== '0') return `${accountId}:${platform}:${groupId}`;
791
+ if (userId !== '0') return `${accountId}:${platform}:${userId}`;
789
792
  return null;
790
793
  }
791
794
 
795
+ function normalizePersistedHistoryKey(key: string): string {
796
+ const raw = String(key || '').trim();
797
+ if (!raw) return raw;
798
+ const firstColon = raw.indexOf(':');
799
+ const secondColon = raw.indexOf(':', firstColon + 1);
800
+ if (firstColon <= 0 || secondColon < 0) return `${BNCR_DEFAULT_ACCOUNT_ID}:${raw}`;
801
+ return `${normalizeAccountId(raw.slice(0, firstColon))}:${raw.slice(firstColon + 1)}`;
802
+ }
803
+
804
+ function resolvePersistedHistoryAccountId(key: string): string {
805
+ const normalized = normalizePersistedHistoryKey(key);
806
+ const separator = normalized.indexOf(':');
807
+ return normalizeAccountId(separator > 0 ? normalized.slice(0, separator) : undefined);
808
+ }
809
+
810
+ function resolvePersistedHistoryKey(key: string, accountId: string): string {
811
+ const normalized = normalizePersistedHistoryKey(key);
812
+ const separator = normalized.indexOf(':');
813
+ const sceneKey = separator > 0 ? normalized.slice(separator + 1) : normalized;
814
+ return `${normalizeAccountId(accountId)}:${sceneKey}`;
815
+ }
816
+
792
817
  function parseMediaJson(value: string) {
793
818
  try {
794
819
  const parsed = JSON.parse(value ?? '[]');
@@ -861,7 +886,7 @@ function replaceHistoryMessages(
861
886
  const seenReplayMessageIds = new Map<string, Set<string>>();
862
887
 
863
888
  for (const bucket of historyBuckets) {
864
- const historyKey = String(bucket.key || '').trim();
889
+ const historyKey = normalizePersistedHistoryKey(String(bucket.key || '').trim());
865
890
  if (!historyKey) continue;
866
891
  for (const entry of Array.isArray(bucket.entries) ? bucket.entries : []) {
867
892
  const sender = String(entry?.sender || '').trim();
@@ -909,12 +934,22 @@ function replaceHistoryMessages(
909
934
  }
910
935
 
911
936
  for (const bucket of replayBuckets) {
912
- const bufferKey = String(bucket.key || '').trim();
913
- if (!bufferKey) continue;
937
+ const bucketKey = normalizePersistedHistoryKey(String(bucket.key || '').trim());
938
+ if (!bucketKey) continue;
914
939
  for (const entry of Array.isArray(bucket.entries) ? bucket.entries : []) {
915
940
  const sender = String(entry?.sender || '').trim();
916
941
  const body = String(entry?.body || '').trim();
917
942
  if (!sender || !body) continue;
943
+ const entryAccountId = entry.accountId
944
+ ? normalizeAccountId(entry.accountId)
945
+ : resolvePersistedHistoryAccountId(bucketKey);
946
+ const routeHistoryKey = deriveHistoryKeyFromRoute({
947
+ accountId: entryAccountId,
948
+ platform: entry.route?.platform,
949
+ groupId: entry.route?.groupId,
950
+ userId: entry.route?.userId,
951
+ });
952
+ const bufferKey = routeHistoryKey || resolvePersistedHistoryKey(bucketKey, entryAccountId);
918
953
  if (entry.messageId) {
919
954
  const seen = seenReplayMessageIds.get(bufferKey) ?? new Set<string>();
920
955
  if (seen.has(entry.messageId)) continue;
@@ -930,16 +965,12 @@ function replaceHistoryMessages(
930
965
  if (entry.messageId && isConsumedReplayMessage.get(bufferKey, entry.messageId)) {
931
966
  continue;
932
967
  }
933
- const historyKey = deriveHistoryKeyFromRoute({
934
- platform: entry.route?.platform,
935
- groupId: entry.route?.groupId,
936
- userId: entry.route?.userId,
937
- });
968
+ const historyKey = routeHistoryKey || bufferKey;
938
969
  insert.run(
939
970
  'replay',
940
971
  historyKey ?? '',
941
972
  bufferKey,
942
- entry.accountId ?? null,
973
+ entryAccountId,
943
974
  sender,
944
975
  entry.senderId ?? null,
945
976
  'assistant',
@@ -1807,13 +1838,23 @@ export function createBncrSqliteStateDatabase(
1807
1838
  }
1808
1839
 
1809
1840
  function normalizeHistoryKeySkipList(skipHistoryKeys: readonly string[] | undefined): string[] {
1810
- return Array.from(
1811
- new Set(
1812
- (skipHistoryKeys ?? [])
1813
- .map((key) => String(key || '').trim())
1814
- .filter((key) => Boolean(key)),
1815
- ),
1816
- );
1841
+ const normalized: string[] = [];
1842
+ for (const rawKey of skipHistoryKeys ?? []) {
1843
+ const key = String(rawKey || '').trim();
1844
+ if (!key) continue;
1845
+ normalized.push(key);
1846
+ const legacyKey = toLegacyHistoryKey(key);
1847
+ if (legacyKey && legacyKey !== key) normalized.push(legacyKey);
1848
+ }
1849
+ return Array.from(new Set(normalized));
1850
+ }
1851
+
1852
+ function toLegacyHistoryKey(historyKey: string): string {
1853
+ const firstColon = historyKey.indexOf(':');
1854
+ if (firstColon <= 0) return '';
1855
+ const secondColon = historyKey.indexOf(':', firstColon + 1);
1856
+ if (secondColon <= firstColon + 1) return '';
1857
+ return historyKey.slice(firstColon + 1);
1817
1858
  }
1818
1859
 
1819
1860
  function buildHistoryKeySkipCondition(skipped: readonly string[]): string {