@troykelly/openclaw-projects 0.0.38 → 0.0.39

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.
@@ -58,14 +58,14 @@ function toAgentToolResult(result) {
58
58
  /** Namespace property for store/create tools (Issue #1428) */
59
59
  const namespaceProperty = {
60
60
  type: 'string',
61
- description: 'Target namespace for this operation. Defaults to the agent\'s configured namespace.',
61
+ description: 'Target namespace for this operation. When omitted, defaults to the agent\'s own namespace (agent ID or "default").',
62
62
  pattern: '^[a-z0-9][a-z0-9._-]*$',
63
63
  maxLength: 63,
64
64
  };
65
65
  /** Namespaces property for query/list tools (Issue #1428) */
66
66
  const namespacesProperty = {
67
67
  type: 'array',
68
- description: 'Namespaces to search. Defaults to the agent\'s configured recall namespaces.',
68
+ description: 'Namespaces to search. When omitted, searches the agent\'s own namespace and the shared "default" namespace. Pass explicit namespaces to restrict or expand the search scope.',
69
69
  items: {
70
70
  type: 'string',
71
71
  pattern: '^[a-z0-9][a-z0-9._-]*$',
@@ -3465,7 +3465,7 @@ export const registerOpenClaw = (api) => {
3465
3465
  // user_setting.email. The email is needed for FK-constrained operations.
3466
3466
  const user_email = context.user?.email;
3467
3467
  // Store plugin state
3468
- const state = { config, logger, apiClient, agentId: user_id, agentEmail: user_email, resolvedNamespace, hasStaticRecall, lastNamespaceRefreshMs: 0, refreshInFlight: false };
3468
+ const state = { config, logger, apiClient, agentId: user_id, agentEmail: user_email, resolvedNamespace, hasStaticRecall, lastNamespaceRefreshMs: 0, refreshInFlight: false, sessionCapturedKeys: new Set() };
3469
3469
  // Create tool handlers
3470
3470
  const handlers = createToolHandlers(state);
3471
3471
  // Register all 30 tools with correct OpenClaw Gateway execute signature
@@ -4287,6 +4287,13 @@ export const registerOpenClaw = (api) => {
4287
4287
  // into this registration path using the correct OpenClaw hook contract.
4288
4288
  /** Default timeout for hook execution (5 seconds) */
4289
4289
  const HOOK_TIMEOUT_MS = 5000;
4290
+ // Track whether before_prompt_build was successfully registered (#2050 review fix #5).
4291
+ // If it was, we skip registering before_agent_start for auto-recall to avoid
4292
+ // duplicate context injection. Only ONE recall hook should inject context.
4293
+ let beforePromptBuildRegistered = false;
4294
+ // The before_agent_start recall handler is built here but registration is
4295
+ // deferred until we know whether before_prompt_build is available. (#2050 review fix #5)
4296
+ let beforeAgentStartRecallHandler;
4290
4297
  if (config.autoRecall) {
4291
4298
  // Create the graph-aware auto-recall hook which traverses the user's
4292
4299
  // relationship graph for multi-scope context retrieval.
@@ -4302,8 +4309,12 @@ export const registerOpenClaw = (api) => {
4302
4309
  * before_agent_start handler: Extracts the user's prompt from the event,
4303
4310
  * performs semantic memory search, and returns { prependContext } to inject
4304
4311
  * relevant memories into the conversation.
4312
+ *
4313
+ * Only registered when before_prompt_build is NOT available (review fix #5).
4314
+ * The before_prompt_build hook supersedes this one because it has access to
4315
+ * session messages for richer context analysis.
4305
4316
  */
4306
- const beforeAgentStartHandler = async (event, ctx) => {
4317
+ beforeAgentStartRecallHandler = async (event, ctx) => {
4307
4318
  // Issue #1655: Detect concurrent session conflict
4308
4319
  if (state.activeSessionKey && ctx.sessionKey && state.activeSessionKey !== ctx.sessionKey) {
4309
4320
  logger.warn('Concurrent session detected — agent identity may be stale', {
@@ -4345,16 +4356,8 @@ export const registerOpenClaw = (api) => {
4345
4356
  }
4346
4357
  // Return undefined (void) when no context is available
4347
4358
  };
4348
- if (typeof api.on === 'function') {
4349
- // Modern registration: api.on('before_agent_start', handler)
4350
- // Cast needed: our typed handler satisfies the runtime contract but
4351
- // the generic api.on() signature uses (...args: unknown[]) => unknown
4352
- api.on('before_agent_start', beforeAgentStartHandler);
4353
- }
4354
- else {
4355
- // Legacy fallback: api.registerHook('beforeAgentStart', handler)
4356
- api.registerHook('beforeAgentStart', beforeAgentStartHandler);
4357
- }
4359
+ // NOTE: Registration deferred until after before_prompt_build attempt.
4360
+ // See "Register before_agent_start fallback" block below. (#2050 review fix #5)
4358
4361
  }
4359
4362
  if (config.autoCapture) {
4360
4363
  // Create the auto-capture hook using the consolidated hooks.ts implementation
@@ -4384,38 +4387,56 @@ export const registerOpenClaw = (api) => {
4384
4387
  message_count: event.messages?.length ?? 0,
4385
4388
  success: event.success,
4386
4389
  });
4387
- try {
4388
- // Convert the event messages to the format expected by the capture hook
4389
- const messages = (event.messages ?? []).map((msg) => {
4390
- if (typeof msg === 'object' && msg !== null) {
4391
- const msgObj = msg;
4392
- return {
4393
- role: String(msgObj.role ?? 'unknown'),
4394
- content: String(msgObj.content ?? ''),
4395
- };
4396
- }
4397
- return { role: 'unknown', content: String(msg) };
4398
- });
4399
- await autoCaptureHook({ messages });
4390
+ // #2052 review fix #4: Check dedup before capturing (bidirectional dedup)
4391
+ const sessionId = ctx.sessionId ?? ctx.sessionKey;
4392
+ if (sessionId && state.sessionCapturedKeys.has(sessionId)) {
4393
+ logger.debug('agent_end: session already captured by before_reset, skipping', { sessionId });
4400
4394
  }
4401
- catch (error) {
4402
- // Hook errors should never crash the agent
4403
- logger.error('Auto-capture hook failed', {
4404
- error: error instanceof Error ? error.message : String(error),
4405
- });
4395
+ else {
4396
+ try {
4397
+ // Convert the event messages to the format expected by the capture hook
4398
+ const messages = (event.messages ?? []).map((msg) => {
4399
+ if (typeof msg === 'object' && msg !== null) {
4400
+ const msgObj = msg;
4401
+ return {
4402
+ role: String(msgObj.role ?? 'unknown'),
4403
+ content: String(msgObj.content ?? ''),
4404
+ };
4405
+ }
4406
+ return { role: 'unknown', content: String(msg) };
4407
+ });
4408
+ await autoCaptureHook({ messages });
4409
+ // #2052 review fix #2: Only mark as captured on SUCCESS, not on failure
4410
+ if (sessionId) {
4411
+ state.sessionCapturedKeys.add(sessionId);
4412
+ if (state.sessionCapturedKeys.size > 100) {
4413
+ const firstKey = state.sessionCapturedKeys.values().next().value;
4414
+ if (firstKey !== undefined) {
4415
+ state.sessionCapturedKeys.delete(firstKey);
4416
+ }
4417
+ }
4418
+ }
4419
+ }
4420
+ catch (error) {
4421
+ // Hook errors should never crash the agent
4422
+ // #2052 review fix #2: Do NOT mark as captured — allow before_reset to retry
4423
+ logger.error('Auto-capture hook failed', {
4424
+ error: error instanceof Error ? error.message : String(error),
4425
+ });
4426
+ }
4406
4427
  }
4407
4428
  // Issue #1655: Clear session key after agent ends
4408
4429
  state.activeSessionKey = undefined;
4409
4430
  };
4410
4431
  if (typeof api.on === 'function') {
4411
4432
  // Modern registration: api.on('agent_end', handler)
4412
- // Cast needed: our typed handler satisfies the runtime contract but
4413
- // the generic api.on() signature uses (...args: unknown[]) => unknown
4433
+ // Handler signature matches PluginHookHandlerMap['agent_end'] directly (#2032)
4414
4434
  api.on('agent_end', agentEndHandler);
4415
4435
  }
4416
4436
  else {
4417
- // Legacy fallback: api.registerHook('agentEnd', handler)
4418
- api.registerHook('agentEnd', agentEndHandler);
4437
+ // Legacy fallback: api.registerHook('agent_end', handler)
4438
+ // Uses snake_case to match SDK convention (#2044)
4439
+ api.registerHook('agent_end', agentEndHandler);
4419
4440
  }
4420
4441
  }
4421
4442
  // Register auto-linking hook for inbound messages (Issue #1223)
@@ -4427,27 +4448,46 @@ export const registerOpenClaw = (api) => {
4427
4448
  * message_received handler: Extracts sender and content info from the
4428
4449
  * inbound message event and runs auto-linking in the background.
4429
4450
  * Failures are logged but never crash message processing.
4451
+ *
4452
+ * SDK contract (2026.3.1):
4453
+ * event: { from: string; content: string; timestamp?: number; metadata?: Record<string, unknown> }
4454
+ * ctx: PluginHookMessageContext { channelId: string; accountId?: string; conversationId?: string }
4455
+ *
4456
+ * Sender info is extracted from `event.from` and `event.metadata`.
4457
+ * Thread ID is extracted from `ctx.conversationId` or `event.metadata.thread_id`. (#2029)
4430
4458
  */
4431
- const messageReceivedHandler = async (event, _ctx) => {
4459
+ const messageReceivedHandler = async (event, ctx) => {
4460
+ // Defensive: ctx may be undefined when invoked via legacy registerHook (single-arg)
4461
+ const safeCtx = ctx ?? {};
4462
+ // Derive thread_id from context conversationId or event metadata
4463
+ const threadId = safeCtx.conversationId
4464
+ ?? (typeof event.metadata?.thread_id === 'string' ? event.metadata.thread_id : undefined);
4432
4465
  // Skip if no thread ID (nothing to link to)
4433
- if (!event.thread_id) {
4466
+ if (!threadId) {
4434
4467
  logger.debug('Auto-link skipped: no thread_id in message_received event');
4435
4468
  return;
4436
4469
  }
4437
4470
  // Skip if no content and no sender info (nothing to match on)
4438
- if (!event.content && !event.senderEmail && !event.senderPhone && !event.sender) {
4471
+ if (!event.content && !event.from) {
4439
4472
  logger.debug('Auto-link skipped: no content or sender info in event');
4440
4473
  return;
4441
4474
  }
4475
+ // Extract sender email/phone from event.from and metadata.
4476
+ // event.from is a channel-scoped sender identifier (could be email, phone, or username).
4477
+ // Metadata may contain explicit senderEmail/senderPhone fields from the channel plugin.
4478
+ const senderEmail = (typeof event.metadata?.senderEmail === 'string' ? event.metadata.senderEmail : undefined)
4479
+ ?? (event.from.includes('@') ? event.from : undefined);
4480
+ const senderPhone = (typeof event.metadata?.senderPhone === 'string' ? event.metadata.senderPhone : undefined)
4481
+ ?? (!event.from.includes('@') && /^\+?\d[\d\s-]{5,}$/.test(event.from) ? event.from : undefined);
4442
4482
  try {
4443
4483
  await autoLinkInboundMessage({
4444
4484
  client: apiClient,
4445
4485
  logger,
4446
4486
  getAgentId: () => state.agentId,
4447
4487
  message: {
4448
- thread_id: event.thread_id,
4449
- senderEmail: event.senderEmail ?? (event.sender?.includes('@') ? event.sender : undefined),
4450
- senderPhone: event.senderPhone ?? (event.sender && !event.sender.includes('@') ? event.sender : undefined),
4488
+ thread_id: threadId,
4489
+ senderEmail,
4490
+ senderPhone,
4451
4491
  content: event.content ?? '',
4452
4492
  },
4453
4493
  });
@@ -4460,12 +4500,411 @@ export const registerOpenClaw = (api) => {
4460
4500
  }
4461
4501
  };
4462
4502
  if (typeof api.on === 'function') {
4503
+ // Handler signature matches PluginHookHandlerMap['message_received'] directly (#2032)
4463
4504
  api.on('message_received', messageReceivedHandler);
4464
4505
  }
4465
4506
  else {
4466
- api.registerHook('messageReceived', messageReceivedHandler);
4507
+ // Legacy fallback: uses snake_case to match SDK convention (#2044)
4508
+ api.registerHook('message_received', messageReceivedHandler);
4509
+ }
4510
+ }
4511
+ // ── #2050: Register before_prompt_build hook for enhanced auto-recall ──
4512
+ // Migrates auto-recall from before_agent_start to before_prompt_build
4513
+ // which has session messages available for better context analysis.
4514
+ // Gracefully falls back to before_agent_start if before_prompt_build
4515
+ // is not available (older SDK).
4516
+ if (config.autoRecall && typeof api.on === 'function') {
4517
+ const autoRecallHookForPromptBuild = createGraphAwareRecallHook({
4518
+ client: apiClient,
4519
+ logger,
4520
+ config,
4521
+ getAgentId: () => state.agentId,
4522
+ timeoutMs: HOOK_TIMEOUT_MS,
4523
+ });
4524
+ const beforePromptBuildHandler = async (event, ctx) => {
4525
+ // Resolve agent ID from context if needed
4526
+ const resolvedId = resolveAgentId(ctx, config.agentId, state.agentId);
4527
+ if (resolvedId !== state.agentId) {
4528
+ const previousId = state.agentId;
4529
+ state.agentId = resolvedId;
4530
+ state.resolvedNamespace = resolveNamespaceConfig(config.namespace, resolvedId);
4531
+ logger.info('Agent ID resolved from before_prompt_build context', {
4532
+ previousId,
4533
+ resolvedId,
4534
+ });
4535
+ }
4536
+ // Build a richer query from messages + prompt when available (#2050)
4537
+ let searchQuery = event.prompt ?? '';
4538
+ if (Array.isArray(event.messages) && event.messages.length > 0) {
4539
+ // Extract recent user messages for context-aware recall
4540
+ const recentUserMessages = event.messages
4541
+ .filter((msg) => typeof msg === 'object' && msg !== null && msg.role === 'user')
4542
+ .slice(-3) // Last 3 user messages for context
4543
+ .map((msg) => {
4544
+ const content = msg.content;
4545
+ if (typeof content === 'string')
4546
+ return content;
4547
+ if (Array.isArray(content)) {
4548
+ return content
4549
+ .filter((b) => typeof b === 'object' && b !== null && b.type === 'text' && typeof b.text === 'string')
4550
+ .map((b) => b.text)
4551
+ .join(' ');
4552
+ }
4553
+ return '';
4554
+ })
4555
+ .filter(Boolean);
4556
+ if (recentUserMessages.length > 0) {
4557
+ // Combine prompt with recent messages for better semantic matching
4558
+ searchQuery = [...recentUserMessages, searchQuery].join(' ').substring(0, 500);
4559
+ }
4560
+ }
4561
+ logger.debug('before_prompt_build auto-recall triggered', {
4562
+ promptLength: event.prompt?.length ?? 0,
4563
+ messageCount: event.messages?.length ?? 0,
4564
+ searchQueryLength: searchQuery.length,
4565
+ });
4566
+ try {
4567
+ const result = await autoRecallHookForPromptBuild({ prompt: searchQuery });
4568
+ if (result?.prependContext) {
4569
+ return { prependContext: result.prependContext };
4570
+ }
4571
+ }
4572
+ catch (error) {
4573
+ logger.error('before_prompt_build auto-recall failed', {
4574
+ error: error instanceof Error ? error.message : String(error),
4575
+ });
4576
+ }
4577
+ };
4578
+ // Register before_prompt_build — if the SDK supports it, this supersedes
4579
+ // before_agent_start for recall (review fix #5: only ONE injects context)
4580
+ try {
4581
+ api.on('before_prompt_build', beforePromptBuildHandler);
4582
+ beforePromptBuildRegistered = true;
4583
+ logger.debug('Registered before_prompt_build hook for enhanced auto-recall (#2050)');
4584
+ }
4585
+ catch {
4586
+ // Graceful fallback: older SDK may not support this hook name
4587
+ logger.debug('before_prompt_build hook not available — auto-recall uses before_agent_start only');
4588
+ }
4589
+ }
4590
+ // Register before_agent_start ONLY if before_prompt_build was NOT registered (#2050 review fix #5).
4591
+ // This prevents duplicate context injection — only one recall hook should run.
4592
+ if (config.autoRecall && !beforePromptBuildRegistered && beforeAgentStartRecallHandler) {
4593
+ if (typeof api.on === 'function') {
4594
+ api.on('before_agent_start', beforeAgentStartRecallHandler);
4595
+ }
4596
+ else {
4597
+ api.registerHook('before_agent_start', beforeAgentStartRecallHandler);
4598
+ }
4599
+ logger.debug('Registered before_agent_start hook for auto-recall (before_prompt_build not available)');
4600
+ }
4601
+ // ── #2051: Register llm_input/llm_output hooks for token usage analytics ──
4602
+ if (typeof api.on === 'function') {
4603
+ // llm_input: audit logging of prompt metadata (not content)
4604
+ const llmInputHandler = async (event, ctx) => {
4605
+ try {
4606
+ logger.debug('llm_input audit', {
4607
+ runId: event.runId,
4608
+ sessionId: event.sessionId ?? ctx.sessionId,
4609
+ provider: event.provider,
4610
+ model: event.model,
4611
+ messageCount: event.messageCount,
4612
+ });
4613
+ }
4614
+ catch (error) {
4615
+ // Non-fatal: never crash the agent for analytics
4616
+ logger.warn('llm_input hook error', {
4617
+ error: error instanceof Error ? error.message : String(error),
4618
+ });
4619
+ }
4620
+ };
4621
+ // llm_output: capture token usage data
4622
+ const llmOutputHandler = async (event, ctx) => {
4623
+ try {
4624
+ const usage = event.usage;
4625
+ if (!usage) {
4626
+ logger.debug('llm_output: no usage data available');
4627
+ return;
4628
+ }
4629
+ const usageData = {
4630
+ runId: event.runId,
4631
+ sessionId: event.sessionId ?? ctx.sessionId,
4632
+ provider: event.provider,
4633
+ model: event.model,
4634
+ promptTokens: usage.promptTokens ?? 0,
4635
+ completionTokens: usage.completionTokens ?? 0,
4636
+ totalTokens: usage.totalTokens ?? 0,
4637
+ durationMs: event.durationMs,
4638
+ timestamp: event.timestamp ?? Date.now(),
4639
+ };
4640
+ logger.debug('llm_output token usage', usageData);
4641
+ // Post usage data to backend (fire-and-forget)
4642
+ apiClient.post('/analytics/token-usage', usageData, { user_id: state.agentId }).catch((err) => {
4643
+ logger.warn('Failed to post token usage analytics', {
4644
+ error: err instanceof Error ? err.message : String(err),
4645
+ });
4646
+ });
4647
+ }
4648
+ catch (error) {
4649
+ // Non-fatal: never crash the agent for analytics
4650
+ logger.warn('llm_output hook error', {
4651
+ error: error instanceof Error ? error.message : String(error),
4652
+ });
4653
+ }
4654
+ };
4655
+ try {
4656
+ api.on('llm_input', llmInputHandler);
4657
+ api.on('llm_output', llmOutputHandler);
4658
+ logger.debug('Registered llm_input/llm_output hooks for token analytics (#2051)');
4659
+ }
4660
+ catch {
4661
+ logger.debug('llm_input/llm_output hooks not available in this SDK version');
4467
4662
  }
4468
4663
  }
4664
+ // ── #2052: Register before_reset hook for session data archival ──
4665
+ if (config.autoCapture && typeof api.on === 'function') {
4666
+ const autoCaptureHookForReset = createAutoCaptureHook({
4667
+ client: apiClient,
4668
+ logger,
4669
+ config,
4670
+ getAgentId: () => state.agentId,
4671
+ timeoutMs: HOOK_TIMEOUT_MS * 2,
4672
+ });
4673
+ const beforeResetHandler = async (event, ctx) => {
4674
+ try {
4675
+ const sessionId = event.sessionId ?? ctx.sessionId ?? ctx.sessionKey;
4676
+ // Deduplication: avoid double-capture if agent_end also fires after reset
4677
+ if (sessionId && state.sessionCapturedKeys.has(sessionId)) {
4678
+ logger.debug('before_reset: session already captured, skipping', { sessionId });
4679
+ return;
4680
+ }
4681
+ // Resolve agent ID from context
4682
+ const resolvedId = resolveAgentId(ctx, config.agentId, state.agentId);
4683
+ if (resolvedId !== state.agentId) {
4684
+ state.agentId = resolvedId;
4685
+ state.resolvedNamespace = resolveNamespaceConfig(config.namespace, resolvedId);
4686
+ }
4687
+ // Try to capture from messages in the event
4688
+ const messages = Array.isArray(event.messages) ? event.messages : [];
4689
+ if (messages.length === 0) {
4690
+ logger.debug('before_reset: no messages to archive');
4691
+ return;
4692
+ }
4693
+ logger.debug('before_reset: archiving session data', {
4694
+ sessionId,
4695
+ messageCount: messages.length,
4696
+ hasSessionFile: !!event.sessionFile,
4697
+ });
4698
+ // Convert messages to the format expected by the capture hook
4699
+ const formattedMessages = messages.map((msg) => {
4700
+ if (typeof msg === 'object' && msg !== null) {
4701
+ const msgObj = msg;
4702
+ return {
4703
+ role: String(msgObj.role ?? 'unknown'),
4704
+ content: String(msgObj.content ?? ''),
4705
+ };
4706
+ }
4707
+ return { role: 'unknown', content: String(msg) };
4708
+ });
4709
+ await autoCaptureHookForReset({ messages: formattedMessages });
4710
+ // Mark session as captured for deduplication
4711
+ if (sessionId) {
4712
+ state.sessionCapturedKeys.add(sessionId);
4713
+ // Limit set size to prevent unbounded growth
4714
+ if (state.sessionCapturedKeys.size > 100) {
4715
+ const firstKey = state.sessionCapturedKeys.values().next().value;
4716
+ if (firstKey !== undefined) {
4717
+ state.sessionCapturedKeys.delete(firstKey);
4718
+ }
4719
+ }
4720
+ }
4721
+ }
4722
+ catch (error) {
4723
+ // Non-fatal: never crash the agent during reset
4724
+ logger.warn('before_reset hook error', {
4725
+ error: error instanceof Error ? error.message : String(error),
4726
+ });
4727
+ }
4728
+ };
4729
+ try {
4730
+ api.on('before_reset', beforeResetHandler);
4731
+ logger.debug('Registered before_reset hook for session archival (#2052)');
4732
+ }
4733
+ catch {
4734
+ logger.debug('before_reset hook not available in this SDK version');
4735
+ }
4736
+ }
4737
+ // ── #2053: Owner-gated tool access via before_tool_call hook ──
4738
+ // Gate destructive tools to senderIsOwner === true.
4739
+ // Non-owner callers get a clear error. Backwards compatible: undefined senderIsOwner treated as owner.
4740
+ {
4741
+ /** Tools that require owner-level access */
4742
+ const OWNER_GATED_TOOLS = new Set(['memory_forget', 'api_remove', 'api_restore', 'api_credential_manage']);
4743
+ const beforeToolCallHandler = async (event, ctx) => {
4744
+ const toolName = typeof event.toolName === 'string' ? event.toolName : (typeof event.name === 'string' ? event.name : '');
4745
+ if (!OWNER_GATED_TOOLS.has(toolName))
4746
+ return; // Not a gated tool
4747
+ // Guard ctx — legacy registerHook callers may pass only the event arg
4748
+ if (!ctx || typeof ctx !== 'object') {
4749
+ logger.warn('Owner-gated tool invoked without context — defaulting to allow (legacy SDK path)', { toolName });
4750
+ return;
4751
+ }
4752
+ // Extract trust fields from context
4753
+ const senderIsOwner = ctx.senderIsOwner;
4754
+ const requesterSenderId = typeof ctx.requesterSenderId === 'string' ? ctx.requesterSenderId : undefined;
4755
+ // Log the invocation with sender info
4756
+ logger.debug('Owner-gated tool invocation', {
4757
+ toolName,
4758
+ requesterSenderId,
4759
+ senderIsOwner,
4760
+ });
4761
+ // Backwards compatible: undefined senderIsOwner is treated as owner
4762
+ // Review fix #3: log warning when trust signal is missing so operators know
4763
+ if (senderIsOwner === undefined) {
4764
+ logger.warn('Owner-gated tool invoked without senderIsOwner trust signal — defaulting to allow', {
4765
+ toolName,
4766
+ requesterSenderId,
4767
+ });
4768
+ return;
4769
+ }
4770
+ if (senderIsOwner === true)
4771
+ return;
4772
+ // Non-owner: block with clear error message
4773
+ logger.warn('Owner-gated tool blocked for non-owner', {
4774
+ toolName,
4775
+ requesterSenderId,
4776
+ senderIsOwner,
4777
+ });
4778
+ return {
4779
+ blocked: true,
4780
+ error: `Access denied: /${toolName} requires owner-level access. Current sender (${requesterSenderId ?? 'unknown'}) is not the owner.`,
4781
+ };
4782
+ };
4783
+ if (typeof api.on === 'function') {
4784
+ try {
4785
+ api.on('before_tool_call', beforeToolCallHandler);
4786
+ logger.debug('Registered before_tool_call hook for owner-gated access (#2053)');
4787
+ }
4788
+ catch {
4789
+ logger.debug('before_tool_call hook not available in this SDK version');
4790
+ }
4791
+ }
4792
+ else if (typeof api.registerHook === 'function') {
4793
+ // Review fix #6: Legacy fallback for older SDKs without api.on
4794
+ try {
4795
+ api.registerHook('before_tool_call', beforeToolCallHandler);
4796
+ logger.debug('Registered before_tool_call hook via legacy registerHook for owner-gated access (#2053)');
4797
+ }
4798
+ catch {
4799
+ logger.warn('Owner-gated tool access not available — destructive tools are ungated on this SDK version (#2053)');
4800
+ }
4801
+ }
4802
+ else {
4803
+ // Review fix #6: No hook mechanism available at all
4804
+ logger.warn('Neither api.on nor api.registerHook available — destructive tools are ungated (#2053)');
4805
+ }
4806
+ }
4807
+ // ── #2054: Register slash commands via api.registerCommand() ──
4808
+ if (typeof api.registerCommand === 'function') {
4809
+ const commandDefs = [
4810
+ {
4811
+ name: 'remember',
4812
+ description: 'Store a memory for future reference',
4813
+ requireAuth: false,
4814
+ handler: async (args) => {
4815
+ try {
4816
+ const result = await handlers.memory_store({ text: args.input });
4817
+ return {
4818
+ text: result.success ? (result.data?.content ?? 'Memory stored.') : `Error: ${result.error ?? 'Failed to store memory'}`,
4819
+ success: result.success,
4820
+ data: result.data?.details,
4821
+ };
4822
+ }
4823
+ catch (error) {
4824
+ return {
4825
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
4826
+ success: false,
4827
+ };
4828
+ }
4829
+ },
4830
+ },
4831
+ {
4832
+ name: 'forget',
4833
+ description: 'Remove a memory by ID or search query',
4834
+ requireAuth: true,
4835
+ handler: async (args) => {
4836
+ try {
4837
+ // Review fix #1: Enforce owner-gating parity with tool gating.
4838
+ // The before_tool_call hook only gates tool invocations, not commands.
4839
+ // We must check senderIsOwner here as well.
4840
+ const ctx = args.context ?? {};
4841
+ const senderIsOwner = ctx.senderIsOwner;
4842
+ if (senderIsOwner === false) {
4843
+ const senderId = typeof ctx.requesterSenderId === 'string' ? ctx.requesterSenderId : 'unknown';
4844
+ logger.warn('Owner-gated /forget command blocked for non-owner', { requesterSenderId: senderId });
4845
+ return {
4846
+ text: `Access denied: /forget requires owner-level access. Current sender (${senderId}) is not the owner.`,
4847
+ success: false,
4848
+ };
4849
+ }
4850
+ if (senderIsOwner === undefined) {
4851
+ logger.warn('/forget command invoked without senderIsOwner trust signal — defaulting to allow');
4852
+ }
4853
+ // Determine if input is a UUID (memory_id) or a search query
4854
+ const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(args.input.trim());
4855
+ const params = isUuid ? { memory_id: args.input.trim() } : { query: args.input };
4856
+ const result = await handlers.memory_forget(params);
4857
+ return {
4858
+ text: result.success ? (result.data?.content ?? 'Memory forgotten.') : `Error: ${result.error ?? 'Failed to forget memory'}`,
4859
+ success: result.success,
4860
+ data: result.data?.details,
4861
+ };
4862
+ }
4863
+ catch (error) {
4864
+ return {
4865
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
4866
+ success: false,
4867
+ };
4868
+ }
4869
+ },
4870
+ },
4871
+ {
4872
+ name: 'recall',
4873
+ description: 'Search memories by semantic similarity',
4874
+ requireAuth: false,
4875
+ handler: async (args) => {
4876
+ try {
4877
+ const result = await handlers.memory_recall({ query: args.input, limit: 5 });
4878
+ return {
4879
+ text: result.success ? (result.data?.content ?? 'No memories found.') : `Error: ${result.error ?? 'Failed to recall memories'}`,
4880
+ success: result.success,
4881
+ data: result.data?.details,
4882
+ };
4883
+ }
4884
+ catch (error) {
4885
+ return {
4886
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
4887
+ success: false,
4888
+ };
4889
+ }
4890
+ },
4891
+ },
4892
+ ];
4893
+ for (const cmd of commandDefs) {
4894
+ try {
4895
+ api.registerCommand(cmd);
4896
+ }
4897
+ catch {
4898
+ // Graceful degradation: older SDK may not support registerCommand
4899
+ logger.debug(`registerCommand not available for /${cmd.name} — skipping`);
4900
+ break; // If one fails, they'll all fail
4901
+ }
4902
+ }
4903
+ logger.debug('Registered slash commands: /remember, /forget, /recall (#2054)');
4904
+ }
4905
+ else {
4906
+ logger.debug('api.registerCommand not available — slash commands not registered (#2054)');
4907
+ }
4469
4908
  // Register Gateway RPC methods (Issue #324)
4470
4909
  const gatewayMethods = createGatewayMethods({
4471
4910
  logger,