@xmoxmo/bncr 0.6.4 → 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.
@@ -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;
@@ -20,7 +20,7 @@ import {
20
20
  } from '../../openclaw/routing-runtime.ts';
21
21
  import type { BncrInboundApi, BncrInboundConfig, BncrRememberSessionRoute } from './contracts.ts';
22
22
  import { INBOUND_MEDIA_URL_MAX_BYTES, isHttpMediaUrl } from './media-url-download.ts';
23
- import { parseBncrNativeCommand } from './native-command.ts';
23
+ import { parseBncrNativeCommand, resolveBncrNativeCommandParseOptions } from './native-command.ts';
24
24
  import { loadInboundRemoteMedia } from './remote-media.ts';
25
25
 
26
26
  export type ParsedInbound = ReturnType<typeof import('./parse.ts')['parseBncrInboundParams']>;
@@ -460,9 +460,13 @@ export async function prepareBncrInboundDispatch(args: {
460
460
  const senderIdForContext = parsed.userId || parsed.clientId || resolution.canonicalTo;
461
461
  const senderDisplayName = parsed.userName || resolution.canonicalTo;
462
462
  const isBncrNativeCommand =
463
- parseBncrNativeCommand(parsed.extracted.text, {
464
- allowBareWhoami: parsed.isAdmin !== true,
465
- }) !== null;
463
+ parseBncrNativeCommand(
464
+ parsed.extracted.text,
465
+ resolveBncrNativeCommandParseOptions({
466
+ isAdmin: parsed.isAdmin,
467
+ peerKind: parsed.peer.kind as 'direct' | 'group',
468
+ }),
469
+ ) !== null;
466
470
  const isOpenClawNativeCommand =
467
471
  !isBncrNativeCommand &&
468
472
  hasControlCommand(parsed.extracted.text, cfg as Parameters<typeof hasControlCommand>[1]);
@@ -176,6 +176,8 @@ export async function dispatchBncrInbound(params: {
176
176
  // A stop command must not wait behind the history serial chain or mutate
177
177
  // the accumulated conversation window. Deliver it immediately so the host
178
178
  // can abort the active run instead of queuing behind it.
179
+ const stopOwnerAllowFrom =
180
+ parsed.peer.kind === 'direct' && senderIdForContext ? [senderIdForContext] : ownerAllowFrom;
179
181
  const ctxPayload = await buildBncrInboundTurnContext({
180
182
  api,
181
183
  cfg,
@@ -185,7 +187,7 @@ export async function dispatchBncrInbound(params: {
185
187
  peer,
186
188
  senderIdForContext,
187
189
  senderDisplayName,
188
- ownerAllowFrom,
190
+ ownerAllowFrom: stopOwnerAllowFrom,
189
191
  bridgeSenderId,
190
192
  bridgeSenderName,
191
193
  historyLimit: resolvedHistoryLimit,
@@ -81,6 +81,7 @@ export function createNativeCommandTurnContext(args: {
81
81
  peer: ParsedInbound['peer'];
82
82
  resolvedRoute: { sessionKey: string; agentId: string; mainSessionKey?: string };
83
83
  sessionKey: string;
84
+ ownerAllowFrom?: string[];
84
85
  displayTo: string;
85
86
  originatingTo: string;
86
87
  senderIdForContext: string;
@@ -167,6 +168,9 @@ export function createNativeCommandTurnContext(args: {
167
168
  authorized: true,
168
169
  body: args.body,
169
170
  };
171
+ if (args.ownerAllowFrom?.length) {
172
+ ctx.OwnerAllowFrom = args.ownerAllowFrom;
173
+ }
170
174
  return ctx;
171
175
  });
172
176
  }
@@ -50,7 +50,73 @@ export type NativeSessionResetCommand = {
50
50
  text: string;
51
51
  };
52
52
 
53
- const BNCR_NATIVE_COMMANDS = new Set([
53
+ /**
54
+ * Commands that non-admin private-chat users may self-elevate for.
55
+ * When a non-admin direct-chat caller issues one of these commands,
56
+ * the plugin temporarily treats them as admin so the command follows
57
+ * the same code path as an admin caller. This whitelist is shared
58
+ * across permission checks so admin and non-admin logic stay in sync.
59
+ *
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
+ */
64
+ export const BNCR_SELF_SERVICE_COMMANDS: ReadonlySet<string> = new Set([
65
+ 'whoami',
66
+ 'status',
67
+ 'verbose',
68
+ 'model',
69
+ 'new',
70
+ 'reset',
71
+ ]);
72
+
73
+ /**
74
+ * Synthetic command name used when an admin bare command is routed to the
75
+ * OpenClaw native parser. It must never be exposed to the user.
76
+ */
77
+ export const BNCR_OPENCLAW_NATIVE_COMMAND = '__openclaw_native__';
78
+
79
+ /**
80
+ * Detect if the raw text is a bare whitelist command that should be
81
+ * elevated to admin for OpenClaw native parsing (private non-admin)
82
+ * or mapped to /bncr (group non-admin).
83
+ */
84
+ export function isBncrWhitelistBareCommandText(rawBody: string): string | null {
85
+ const raw = String(rawBody || '')
86
+ .trim()
87
+ .toLowerCase();
88
+ if (!raw.startsWith('/')) return null;
89
+ const bare = raw.slice(1);
90
+ if (!bare) return null;
91
+ // Whitelist commands match the exact command name only. @bot suffixes are
92
+ // intentionally unsupported so bare commands stay consistent with /stop.
93
+ const commandName = bare.split(/\s+/, 1)[0];
94
+ if (!commandName) return null;
95
+ return BNCR_SELF_SERVICE_COMMANDS.has(commandName) ? commandName : null;
96
+ }
97
+
98
+ /**
99
+ * Resolve which bare slash commands bncr should treat as native commands.
100
+ *
101
+ * Admin callers never have whitelist restrictions: their bare commands go to
102
+ * the OpenClaw parser. Non-admin group callers map the whitelist onto /bncr
103
+ * native commands; non-admin private callers are elevated and routed to the
104
+ * OpenClaw parser instead (handled before this helper runs).
105
+ */
106
+ export function resolveBncrNativeCommandParseOptions(args: {
107
+ isAdmin: boolean;
108
+ peerKind: 'direct' | 'group';
109
+ }): ParseBncrNativeCommandOptions {
110
+ const isGroup = args.peerKind === 'group';
111
+ const isNonAdmin = args.isAdmin !== true;
112
+ return {
113
+ allowBareWhoami: isNonAdmin && isGroup,
114
+ allowBareStatus: isNonAdmin && isGroup,
115
+ allowBareSessionReset: isNonAdmin && isGroup,
116
+ };
117
+ }
118
+
119
+ export const BNCR_NATIVE_COMMANDS = new Set([
54
120
  'help',
55
121
  'new',
56
122
  'reset',
@@ -69,13 +135,7 @@ const BNCR_NATIVE_COMMANDS = new Set([
69
135
  'download-media',
70
136
  ]);
71
137
 
72
- export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set([
73
- 'whoami',
74
- 'status',
75
- 'new',
76
- 'reset',
77
- 'stop',
78
- ]);
138
+ export const BNCR_DIRECT_ALLOWED_BARE_COMMANDS = new Set(['whoami', 'status', 'new', 'reset']);
79
139
 
80
140
  export const BNCR_DIRECT_ALLOWED_SUBCOMMANDS = new Set([
81
141
  'help',
@@ -128,7 +188,9 @@ export function parseBncrUnsupportedDirectCommand(text: string): {
128
188
  const raw = String(text || '').trim();
129
189
  if (!raw.startsWith('/')) return null;
130
190
 
131
- const bareMatch = raw.match(/^\/([A-Za-z0-9_]+)(?:\s+.*)?$/);
191
+ // Hyphens are allowed in command names (e.g. /history-limit) so bare slash
192
+ // parsing matches the same command vocabulary as the /bncr subcommands.
193
+ const bareMatch = raw.match(/^\/([A-Za-z0-9_-]+)(?:\s+.*)?$/);
132
194
  if (!bareMatch) return null;
133
195
  const bareCommand = String(bareMatch[1] || '')
134
196
  .trim()
@@ -158,7 +220,7 @@ const BNCR_HELP_SECTIONS = [
158
220
  { label: '/bncr status', scopes: ['admin', 'direct', 'group'] },
159
221
  { label: '/bncr new', scopes: ['admin', 'direct'] },
160
222
  { label: '/bncr reset', scopes: ['admin', 'direct'] },
161
- { label: '/bncr verbose on|off|full', scopes: ['admin'] },
223
+ { label: '/bncr verbose on|off|full', scopes: ['admin', 'direct'] },
162
224
  ],
163
225
  },
164
226
  {
@@ -180,20 +242,20 @@ const BNCR_HELP_SECTIONS = [
180
242
  },
181
243
  {
182
244
  title: '📋 Conversation history',
183
- audience: 'admin',
245
+ audience: 'all',
184
246
  commands: [
185
- { label: '/bncr history-help', scopes: ['admin'] },
186
- { label: '/bncr history-limit [<number>|clear] [<SceneId>]', scopes: ['admin'] },
187
- { label: '/bncr history-force on|off|clear [<SceneId>]', scopes: ['admin'] },
247
+ { label: '/bncr history-help', scopes: ['admin', 'direct'] },
248
+ { label: '/bncr history-limit [<number>|clear] [<SceneId>]', scopes: ['admin', 'direct'] },
249
+ { label: '/bncr history-force on|off|clear [<SceneId>]', scopes: ['admin', 'direct'] },
188
250
  ],
189
251
  },
190
252
  {
191
253
  title: '🌐 Remote media',
192
- audience: 'admin',
254
+ audience: 'all',
193
255
  commands: [
194
256
  {
195
257
  label: '/bncr download-media on|off|clear|default on|off [<SceneId>]',
196
- scopes: ['admin'],
258
+ scopes: ['admin', 'direct'],
197
259
  },
198
260
  ],
199
261
  },
@@ -271,27 +333,35 @@ export function resolveBncrNativeSessionResetCommand(args: {
271
333
  peerKind: 'direct' | 'group';
272
334
  }): NativeSessionResetCommand | null {
273
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;
274
339
  const reason = args.command.command === 'new' ? 'new' : 'reset';
275
- const scope = args.peerKind === 'group' ? 'this group chat' : 'this private chat';
276
340
  return {
277
341
  handled: true,
278
342
  reason,
279
- text:
280
- reason === 'new'
281
- ? `Started a new session for ${scope}.`
282
- : `Reset the current session for ${scope}.`,
343
+ text: reason === 'new' ? '✅ New session started.' : '✅ Session reset.',
283
344
  };
284
345
  }
285
346
 
286
347
  export function resolveBncrNativeVerboseCommand(
287
348
  command: NativeCommand,
349
+ currentLevel?: 'on' | 'off' | 'full',
288
350
  ): NativeVerboseCommand | null {
289
351
  if (command.command !== 'verbose') return null;
290
352
  const rawLevel = String(command.argsText || '')
291
353
  .trim()
292
354
  .toLowerCase();
293
355
  if (!rawLevel || rawLevel === 'status') {
294
- return { handled: true, text: 'Current verbose level is unchanged.' };
356
+ const label =
357
+ currentLevel === 'full'
358
+ ? 'full'
359
+ : currentLevel === 'off'
360
+ ? 'off'
361
+ : currentLevel === 'on'
362
+ ? 'on'
363
+ : 'default';
364
+ return { handled: true, text: `Current verbose level: ${label}` };
295
365
  }
296
366
  if (rawLevel === 'on')
297
367
  return { handled: true, verboseLevel: 'on', text: 'Verbose logging enabled.' };
@@ -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')
@@ -6,6 +6,7 @@ import {
6
6
  resolveBncrPinnedMainDmOwnerFromAllowlist,
7
7
  } from '../../openclaw/inbound-session-runtime.ts';
8
8
  import { dispatchOpenClawReplyWithBufferedBlockDispatcher } from '../../openclaw/reply-runtime.ts';
9
+ import { mergeBncrOwnerAllowFromIntoConfig } from './command-owner.ts';
9
10
  import type {
10
11
  BncrEnqueueFromReply,
11
12
  BncrInboundApi,
@@ -18,7 +19,11 @@ import type {
18
19
  ParsedInbound,
19
20
  } from './dispatch-prep.ts';
20
21
  import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
21
- import { isBncrStopCommandText, parseBncrNativeCommand } from './native-command.ts';
22
+ import {
23
+ isBncrStopCommandText,
24
+ parseBncrNativeCommand,
25
+ resolveBncrNativeCommandParseOptions,
26
+ } from './native-command.ts';
22
27
  import { buildBncrReplyConfig } from './reply-config.ts';
23
28
  import { runBncrReplyDispatchSerial } from './reply-dispatch-serial.ts';
24
29
  import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
@@ -29,32 +34,6 @@ import {
29
34
  } from './session-label.ts';
30
35
  import { createBncrSessionMetaTaskBarrier } from './session-meta-task.ts';
31
36
 
32
- function mergeBncrCommandOwnerAllowFrom(args: {
33
- cfg: BncrInboundConfig;
34
- parsed: ParsedInbound;
35
- isBncrNativeCommand: boolean;
36
- senderIdForContext: string;
37
- }) {
38
- const { cfg, parsed, isBncrNativeCommand, senderIdForContext } = args;
39
- if (parsed.isAdmin !== true || isBncrNativeCommand) return cfg;
40
- const senderId = String(senderIdForContext || '').trim();
41
- if (!senderId) return cfg;
42
-
43
- const currentCommands = (cfg.commands || {}) as { ownerAllowFrom?: string[] };
44
- const currentOwnerAllowFrom = Array.isArray(currentCommands.ownerAllowFrom)
45
- ? currentCommands.ownerAllowFrom
46
- : [];
47
- if (currentOwnerAllowFrom.includes(senderId)) return cfg;
48
-
49
- return {
50
- ...cfg,
51
- commands: {
52
- ...currentCommands,
53
- ownerAllowFrom: [...currentOwnerAllowFrom, senderId],
54
- },
55
- } satisfies BncrInboundConfig;
56
- }
57
-
58
37
  function sleep(ms: number) {
59
38
  return new Promise((resolve) => setTimeout(resolve, ms));
60
39
  }
@@ -180,15 +159,17 @@ export async function runBncrInboundReplyDispatch(args: {
180
159
  pinnedMainDmOwner,
181
160
  });
182
161
  const isBncrNativeCommand =
183
- parseBncrNativeCommand(rawBody, {
184
- allowBareWhoami: parsed.isAdmin !== true,
185
- }) !== null;
186
- const commandDispatchCfg = mergeBncrCommandOwnerAllowFrom({
187
- cfg,
188
- parsed,
189
- isBncrNativeCommand,
190
- senderIdForContext,
191
- });
162
+ parseBncrNativeCommand(
163
+ rawBody,
164
+ resolveBncrNativeCommandParseOptions({
165
+ isAdmin: parsed.isAdmin,
166
+ peerKind: parsed.peer.kind as 'direct' | 'group',
167
+ }),
168
+ ) !== null;
169
+ const commandDispatchCfg =
170
+ parsed.isAdmin === true && !isBncrNativeCommand
171
+ ? mergeBncrOwnerAllowFromIntoConfig({ cfg, senderIdForContext })
172
+ : cfg;
192
173
 
193
174
  if (!shouldDispatch) {
194
175
  await wrapBncrInboundRecordSessionLabelCorrection({
@@ -266,6 +247,14 @@ export async function runBncrInboundReplyDispatch(args: {
266
247
  sessionMetaBarrier.track(task);
267
248
  },
268
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
+ },
269
258
  runDispatch: async () => {
270
259
  await sessionMetaBarrier.wait();
271
260
  await correctBncrInboundSessionLabel({
@@ -435,13 +435,41 @@ export function executeSceneAdminCommand(args: {
435
435
  defaultAdminAgentId: string;
436
436
  defaultPublicAgentId: string;
437
437
  now: () => number;
438
+ /**
439
+ * When true, non-admin callers in direct chat are allowed to execute
440
+ * self-admin commands (history-*, download-media) scoped to
441
+ * their own session only. The flag is set by the caller after an
442
+ * explicit allow-list check – it must NOT be granted unconditionally
443
+ * for direct chat.
444
+ */
445
+ allowNonAdminSelfAdmin?: boolean;
438
446
  }): { ok: true; text: string } | { ok: false; text: string } {
439
- const { parsed, command, sceneRegistry, defaultAdminAgentId, defaultPublicAgentId, now } = args;
440
-
441
- if (!parsed.isAdmin) {
447
+ const {
448
+ parsed,
449
+ command,
450
+ sceneRegistry,
451
+ defaultAdminAgentId,
452
+ defaultPublicAgentId,
453
+ now,
454
+ allowNonAdminSelfAdmin,
455
+ } = args;
456
+
457
+ // Admin always passes; non-admin only passes when the caller explicitly
458
+ // opted in via allowNonAdminSelfAdmin (self-admin commands in direct chat).
459
+ if (!parsed.isAdmin && !allowNonAdminSelfAdmin) {
442
460
  return { ok: false, text: 'Admin permission required.' };
443
461
  }
444
462
 
463
+ // Non-admin self-admin: enforce that sceneId targets only the caller's own session.
464
+ if (allowNonAdminSelfAdmin && !parsed.isAdmin) {
465
+ const currentSceneKey = resolveCurrentSceneKey(parsed);
466
+ const cmdSceneKey =
467
+ 'sceneKey' in command ? (command as { sceneKey?: string }).sceneKey : undefined;
468
+ if (cmdSceneKey && currentSceneKey && cmdSceneKey !== currentSceneKey) {
469
+ return { ok: false, text: 'You can only manage your own private chat session.' };
470
+ }
471
+ }
472
+
445
473
  if (command.kind === 'history-help') {
446
474
  return { ok: true, text: HISTORY_HELP_TEXT };
447
475
  }
@@ -1,6 +1,7 @@
1
1
  import { recordInboundSession as sdkRecordInboundSession } from 'openclaw/plugin-sdk/conversation-runtime';
2
2
  import { resolvePinnedMainDmOwnerFromAllowlist as sdkResolvePinnedMainDmOwnerFromAllowlist } from 'openclaw/plugin-sdk/security-runtime';
3
3
  import {
4
+ getSessionEntry as sdkGetSessionEntry,
4
5
  recordSessionMetaFromInbound as sdkRecordSessionMetaFromInbound,
5
6
  resolveStorePath as sdkResolveStorePath,
6
7
  updateSessionStoreEntry as sdkUpdateSessionStoreEntry,
@@ -14,6 +15,7 @@ type ResolveStorePathFn = (
14
15
  type RecordInboundSessionFn = typeof sdkRecordInboundSession;
15
16
  type RecordSessionMetaFromInboundFn = typeof sdkRecordSessionMetaFromInbound;
16
17
  type UpdateSessionStoreEntryFn = typeof sdkUpdateSessionStoreEntry;
18
+ type GetSessionEntryFn = typeof sdkGetSessionEntry;
17
19
  type ReadSessionUpdatedAtFn = (params: { storePath: string; sessionKey: string }) => unknown;
18
20
  type ResolvePinnedMainDmOwnerFromAllowlistFn = typeof sdkResolvePinnedMainDmOwnerFromAllowlist;
19
21
 
@@ -22,6 +24,7 @@ type BncrInboundSessionRuntime = {
22
24
  recordInboundSession: RecordInboundSessionFn;
23
25
  recordSessionMetaFromInbound: RecordSessionMetaFromInboundFn;
24
26
  updateSessionStoreEntry: UpdateSessionStoreEntryFn;
27
+ getSessionEntry: GetSessionEntryFn;
25
28
  readSessionUpdatedAt?: ReadSessionUpdatedAtFn;
26
29
  resolvePinnedMainDmOwnerFromAllowlist: ResolvePinnedMainDmOwnerFromAllowlistFn;
27
30
  };
@@ -36,6 +39,7 @@ function resolveRuntime(): BncrInboundSessionRuntime {
36
39
  testRuntimeOverride?.recordSessionMetaFromInbound ?? sdkRecordSessionMetaFromInbound,
37
40
  updateSessionStoreEntry:
38
41
  testRuntimeOverride?.updateSessionStoreEntry ?? sdkUpdateSessionStoreEntry,
42
+ getSessionEntry: testRuntimeOverride?.getSessionEntry ?? sdkGetSessionEntry,
39
43
  readSessionUpdatedAt: testRuntimeOverride?.readSessionUpdatedAt,
40
44
  resolvePinnedMainDmOwnerFromAllowlist:
41
45
  testRuntimeOverride?.resolvePinnedMainDmOwnerFromAllowlist ??
@@ -81,6 +85,19 @@ export function readBncrSessionUpdatedAt(
81
85
  return readSessionUpdatedAt(params);
82
86
  }
83
87
 
88
+ export async function readBncrSessionEntry(params: {
89
+ storePath: string;
90
+ sessionKey: string;
91
+ }): Promise<Record<string, unknown> | undefined> {
92
+ const runtime = resolveRuntime();
93
+ try {
94
+ const entry = await runtime.getSessionEntry(params);
95
+ return entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : undefined;
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+
84
101
  export function resolveBncrPinnedMainDmOwnerFromAllowlist(
85
102
  params: Parameters<ResolvePinnedMainDmOwnerFromAllowlistFn>[0],
86
103
  ): ReturnType<ResolvePinnedMainDmOwnerFromAllowlistFn> {
@@ -0,0 +1,125 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+
6
+ type SessionResetResult = { ok?: boolean } | undefined;
7
+ type SessionResetParams = {
8
+ key: string;
9
+ reason: 'new' | 'reset';
10
+ agentId?: string;
11
+ commandSource?: string;
12
+ };
13
+ type SessionResetFn = (params: SessionResetParams) => Promise<SessionResetResult>;
14
+
15
+ let testOverride: SessionResetFn | null = null;
16
+ let resolvedSessionResetRuntimeCache: SessionResetFn | null = null;
17
+
18
+ export function setBncrSessionResetRuntimeForTest(fn: SessionResetFn | null): () => void {
19
+ const previous = testOverride;
20
+ testOverride = fn;
21
+ return () => {
22
+ testOverride = previous;
23
+ };
24
+ }
25
+
26
+ function findPackageRoot(startPath: string, packageName: string): string | null {
27
+ let current = dirname(startPath);
28
+ while (true) {
29
+ const pkgPath = join(current, 'package.json');
30
+ try {
31
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string };
32
+ if (pkg.name === packageName) return current;
33
+ } catch {
34
+ // Keep walking toward the package root.
35
+ }
36
+ const parent = dirname(current);
37
+ if (parent === current) return null;
38
+ current = parent;
39
+ }
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.
49
+ }
50
+
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;
64
+ }
65
+ const parent = dirname(current);
66
+ if (parent === current) break;
67
+ current = parent;
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
+ );
77
+ throw new Error(
78
+ `OpenClaw dist directory not found (resolved entry: ${resolvedPath}; plugin fallback: ${pluginPath})`,
79
+ );
80
+ }
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
+
99
+ export async function performBncrGatewaySessionReset(
100
+ params: SessionResetParams,
101
+ ): Promise<SessionResetResult> {
102
+ if (testOverride) return testOverride(params);
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
+ }
123
+ }
124
+ return resolvedSessionResetRuntimeCache(params);
125
+ }