@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.
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -2
- package/dist/config.js.map +1 -1
- package/dist/gateway/oauth-rpc-methods.d.ts +6 -1
- package/dist/gateway/oauth-rpc-methods.d.ts.map +1 -1
- package/dist/gateway/oauth-rpc-methods.js +37 -10
- package/dist/gateway/oauth-rpc-methods.js.map +1 -1
- package/dist/gateway/rpc-methods.d.ts +6 -1
- package/dist/gateway/rpc-methods.d.ts.map +1 -1
- package/dist/gateway/rpc-methods.js +30 -3
- package/dist/gateway/rpc-methods.js.map +1 -1
- package/dist/register-openclaw.d.ts +2 -0
- package/dist/register-openclaw.d.ts.map +1 -1
- package/dist/register-openclaw.js +482 -43
- package/dist/register-openclaw.js.map +1 -1
- package/dist/types/openclaw-api.d.ts +324 -41
- package/dist/types/openclaw-api.d.ts.map +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
4349
|
-
|
|
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
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
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
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
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
|
-
//
|
|
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('
|
|
4418
|
-
|
|
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,
|
|
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 (!
|
|
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.
|
|
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:
|
|
4449
|
-
senderEmail
|
|
4450
|
-
senderPhone
|
|
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
|
-
|
|
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,
|