@hybridaione/hybridclaw 0.2.2 → 0.2.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.
Files changed (277) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +85 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +41 -16
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +71 -8
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1303 -0
  12. package/container/src/browser-tools.ts +431 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +34 -13
  15. package/container/src/index.ts +451 -109
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +20 -10
  18. package/container/src/tools.ts +599 -225
  19. package/container/src/types.ts +32 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +10 -2
  23. package/dist/agent.js.map +1 -1
  24. package/dist/audit-cli.d.ts.map +1 -1
  25. package/dist/audit-cli.js +4 -2
  26. package/dist/audit-cli.js.map +1 -1
  27. package/dist/audit-events.d.ts.map +1 -1
  28. package/dist/audit-events.js +53 -3
  29. package/dist/audit-events.js.map +1 -1
  30. package/dist/audit-trail.d.ts.map +1 -1
  31. package/dist/audit-trail.js +17 -8
  32. package/dist/audit-trail.js.map +1 -1
  33. package/dist/channels/discord/attachments.d.ts.map +1 -1
  34. package/dist/channels/discord/attachments.js +14 -7
  35. package/dist/channels/discord/attachments.js.map +1 -1
  36. package/dist/channels/discord/debounce.d.ts +9 -0
  37. package/dist/channels/discord/debounce.d.ts.map +1 -0
  38. package/dist/channels/discord/debounce.js +20 -0
  39. package/dist/channels/discord/debounce.js.map +1 -0
  40. package/dist/channels/discord/delivery.d.ts +4 -1
  41. package/dist/channels/discord/delivery.d.ts.map +1 -1
  42. package/dist/channels/discord/delivery.js +19 -3
  43. package/dist/channels/discord/delivery.js.map +1 -1
  44. package/dist/channels/discord/human-delay.d.ts +16 -0
  45. package/dist/channels/discord/human-delay.d.ts.map +1 -0
  46. package/dist/channels/discord/human-delay.js +29 -0
  47. package/dist/channels/discord/human-delay.js.map +1 -0
  48. package/dist/channels/discord/inbound.d.ts +4 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +45 -4
  51. package/dist/channels/discord/inbound.js.map +1 -1
  52. package/dist/channels/discord/mentions.d.ts.map +1 -1
  53. package/dist/channels/discord/mentions.js +16 -4
  54. package/dist/channels/discord/mentions.js.map +1 -1
  55. package/dist/channels/discord/presence.d.ts +33 -0
  56. package/dist/channels/discord/presence.d.ts.map +1 -0
  57. package/dist/channels/discord/presence.js +111 -0
  58. package/dist/channels/discord/presence.js.map +1 -0
  59. package/dist/channels/discord/rate-limiter.d.ts +14 -0
  60. package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
  61. package/dist/channels/discord/rate-limiter.js +49 -0
  62. package/dist/channels/discord/rate-limiter.js.map +1 -0
  63. package/dist/channels/discord/reactions.d.ts +38 -0
  64. package/dist/channels/discord/reactions.d.ts.map +1 -0
  65. package/dist/channels/discord/reactions.js +151 -0
  66. package/dist/channels/discord/reactions.js.map +1 -0
  67. package/dist/channels/discord/runtime.d.ts +6 -3
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +621 -125
  70. package/dist/channels/discord/runtime.js.map +1 -1
  71. package/dist/channels/discord/stream.d.ts +4 -1
  72. package/dist/channels/discord/stream.d.ts.map +1 -1
  73. package/dist/channels/discord/stream.js +16 -8
  74. package/dist/channels/discord/stream.js.map +1 -1
  75. package/dist/channels/discord/tool-actions.d.ts.map +1 -1
  76. package/dist/channels/discord/tool-actions.js +24 -12
  77. package/dist/channels/discord/tool-actions.js.map +1 -1
  78. package/dist/channels/discord/typing.d.ts +15 -0
  79. package/dist/channels/discord/typing.d.ts.map +1 -0
  80. package/dist/channels/discord/typing.js +106 -0
  81. package/dist/channels/discord/typing.js.map +1 -0
  82. package/dist/chunk.d.ts.map +1 -1
  83. package/dist/chunk.js +4 -2
  84. package/dist/chunk.js.map +1 -1
  85. package/dist/cli.js +47 -22
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +19 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +103 -18
  90. package/dist/config.js.map +1 -1
  91. package/dist/container-runner.d.ts.map +1 -1
  92. package/dist/container-runner.js +58 -26
  93. package/dist/container-runner.js.map +1 -1
  94. package/dist/container-setup.d.ts.map +1 -1
  95. package/dist/container-setup.js +10 -9
  96. package/dist/container-setup.js.map +1 -1
  97. package/dist/conversation.d.ts +2 -2
  98. package/dist/conversation.d.ts.map +1 -1
  99. package/dist/conversation.js +1 -1
  100. package/dist/conversation.js.map +1 -1
  101. package/dist/db.d.ts +118 -2
  102. package/dist/db.d.ts.map +1 -1
  103. package/dist/db.js +1568 -50
  104. package/dist/db.js.map +1 -1
  105. package/dist/delegation-manager.d.ts.map +1 -1
  106. package/dist/delegation-manager.js +3 -2
  107. package/dist/delegation-manager.js.map +1 -1
  108. package/dist/gateway-client.d.ts +2 -2
  109. package/dist/gateway-client.d.ts.map +1 -1
  110. package/dist/gateway-client.js +10 -4
  111. package/dist/gateway-client.js.map +1 -1
  112. package/dist/gateway-service.d.ts +3 -3
  113. package/dist/gateway-service.d.ts.map +1 -1
  114. package/dist/gateway-service.js +563 -73
  115. package/dist/gateway-service.js.map +1 -1
  116. package/dist/gateway-types.d.ts +24 -0
  117. package/dist/gateway-types.d.ts.map +1 -1
  118. package/dist/gateway-types.js.map +1 -1
  119. package/dist/gateway.js +179 -24
  120. package/dist/gateway.js.map +1 -1
  121. package/dist/health.d.ts.map +1 -1
  122. package/dist/health.js +20 -10
  123. package/dist/health.js.map +1 -1
  124. package/dist/heartbeat.d.ts +4 -0
  125. package/dist/heartbeat.d.ts.map +1 -1
  126. package/dist/heartbeat.js +48 -20
  127. package/dist/heartbeat.js.map +1 -1
  128. package/dist/hybridai-bots.d.ts.map +1 -1
  129. package/dist/hybridai-bots.js +4 -2
  130. package/dist/hybridai-bots.js.map +1 -1
  131. package/dist/instruction-approval-audit.d.ts.map +1 -1
  132. package/dist/instruction-approval-audit.js.map +1 -1
  133. package/dist/instruction-integrity.d.ts.map +1 -1
  134. package/dist/instruction-integrity.js +8 -2
  135. package/dist/instruction-integrity.js.map +1 -1
  136. package/dist/ipc.d.ts.map +1 -1
  137. package/dist/ipc.js +6 -1
  138. package/dist/ipc.js.map +1 -1
  139. package/dist/logger.js.map +1 -1
  140. package/dist/memory-consolidation.d.ts +17 -0
  141. package/dist/memory-consolidation.d.ts.map +1 -0
  142. package/dist/memory-consolidation.js +25 -0
  143. package/dist/memory-consolidation.js.map +1 -0
  144. package/dist/memory-service.d.ts +200 -0
  145. package/dist/memory-service.d.ts.map +1 -0
  146. package/dist/memory-service.js +294 -0
  147. package/dist/memory-service.js.map +1 -0
  148. package/dist/mount-security.d.ts.map +1 -1
  149. package/dist/mount-security.js +31 -7
  150. package/dist/mount-security.js.map +1 -1
  151. package/dist/observability-ingest.d.ts.map +1 -1
  152. package/dist/observability-ingest.js +32 -11
  153. package/dist/observability-ingest.js.map +1 -1
  154. package/dist/onboarding.d.ts.map +1 -1
  155. package/dist/onboarding.js +32 -9
  156. package/dist/onboarding.js.map +1 -1
  157. package/dist/proactive-policy.d.ts.map +1 -1
  158. package/dist/proactive-policy.js +2 -1
  159. package/dist/proactive-policy.js.map +1 -1
  160. package/dist/prompt-hooks.d.ts.map +1 -1
  161. package/dist/prompt-hooks.js +9 -7
  162. package/dist/prompt-hooks.js.map +1 -1
  163. package/dist/runtime-config.d.ts +98 -1
  164. package/dist/runtime-config.d.ts.map +1 -1
  165. package/dist/runtime-config.js +477 -23
  166. package/dist/runtime-config.js.map +1 -1
  167. package/dist/scheduled-task-runner.d.ts +1 -0
  168. package/dist/scheduled-task-runner.d.ts.map +1 -1
  169. package/dist/scheduled-task-runner.js +29 -10
  170. package/dist/scheduled-task-runner.js.map +1 -1
  171. package/dist/scheduler.d.ts +43 -4
  172. package/dist/scheduler.d.ts.map +1 -1
  173. package/dist/scheduler.js +530 -56
  174. package/dist/scheduler.js.map +1 -1
  175. package/dist/session-export.d.ts +26 -0
  176. package/dist/session-export.d.ts.map +1 -0
  177. package/dist/session-export.js +149 -0
  178. package/dist/session-export.js.map +1 -0
  179. package/dist/session-maintenance.d.ts.map +1 -1
  180. package/dist/session-maintenance.js +75 -13
  181. package/dist/session-maintenance.js.map +1 -1
  182. package/dist/session-transcripts.d.ts.map +1 -1
  183. package/dist/session-transcripts.js.map +1 -1
  184. package/dist/side-effects.d.ts.map +1 -1
  185. package/dist/side-effects.js +14 -2
  186. package/dist/side-effects.js.map +1 -1
  187. package/dist/skills-guard.d.ts.map +1 -1
  188. package/dist/skills-guard.js +893 -130
  189. package/dist/skills-guard.js.map +1 -1
  190. package/dist/skills.d.ts +5 -0
  191. package/dist/skills.d.ts.map +1 -1
  192. package/dist/skills.js +29 -15
  193. package/dist/skills.js.map +1 -1
  194. package/dist/token-efficiency.d.ts.map +1 -1
  195. package/dist/token-efficiency.js.map +1 -1
  196. package/dist/tui.js +92 -11
  197. package/dist/tui.js.map +1 -1
  198. package/dist/types.d.ts +146 -0
  199. package/dist/types.d.ts.map +1 -1
  200. package/dist/types.js +24 -1
  201. package/dist/types.js.map +1 -1
  202. package/dist/update.d.ts.map +1 -1
  203. package/dist/update.js +42 -14
  204. package/dist/update.js.map +1 -1
  205. package/dist/workspace.d.ts.map +1 -1
  206. package/dist/workspace.js +49 -9
  207. package/dist/workspace.js.map +1 -1
  208. package/docs/chat.html +9 -3
  209. package/docs/index.html +37 -13
  210. package/package.json +8 -2
  211. package/src/agent.ts +16 -3
  212. package/src/audit-cli.ts +44 -16
  213. package/src/audit-events.ts +69 -5
  214. package/src/audit-trail.ts +41 -15
  215. package/src/channels/discord/attachments.ts +81 -27
  216. package/src/channels/discord/debounce.ts +25 -0
  217. package/src/channels/discord/delivery.ts +57 -13
  218. package/src/channels/discord/human-delay.ts +48 -0
  219. package/src/channels/discord/inbound.ts +66 -7
  220. package/src/channels/discord/mentions.ts +42 -18
  221. package/src/channels/discord/presence.ts +148 -0
  222. package/src/channels/discord/rate-limiter.ts +58 -0
  223. package/src/channels/discord/reactions.ts +211 -0
  224. package/src/channels/discord/runtime.ts +1048 -182
  225. package/src/channels/discord/stream.ts +73 -27
  226. package/src/channels/discord/tool-actions.ts +78 -37
  227. package/src/channels/discord/typing.ts +140 -0
  228. package/src/chunk.ts +12 -4
  229. package/src/cli.ts +141 -56
  230. package/src/config.ts +192 -34
  231. package/src/container-runner.ts +132 -42
  232. package/src/container-setup.ts +57 -22
  233. package/src/conversation.ts +9 -7
  234. package/src/db.ts +2217 -84
  235. package/src/delegation-manager.ts +6 -2
  236. package/src/gateway-client.ts +41 -17
  237. package/src/gateway-service.ts +1019 -201
  238. package/src/gateway-types.ts +33 -0
  239. package/src/gateway.ts +321 -48
  240. package/src/health.ts +66 -26
  241. package/src/heartbeat.ts +84 -22
  242. package/src/hybridai-bots.ts +14 -5
  243. package/src/instruction-approval-audit.ts +4 -1
  244. package/src/instruction-integrity.ts +30 -9
  245. package/src/ipc.ts +23 -5
  246. package/src/logger.ts +4 -1
  247. package/src/memory-consolidation.ts +41 -0
  248. package/src/memory-service.ts +606 -0
  249. package/src/mount-security.ts +58 -13
  250. package/src/observability-ingest.ts +134 -35
  251. package/src/onboarding.ts +126 -35
  252. package/src/proactive-policy.ts +3 -1
  253. package/src/prompt-hooks.ts +40 -17
  254. package/src/runtime-config.ts +1114 -99
  255. package/src/scheduled-task-runner.ts +63 -11
  256. package/src/scheduler.ts +683 -60
  257. package/src/session-export.ts +196 -0
  258. package/src/session-maintenance.ts +125 -22
  259. package/src/session-transcripts.ts +12 -3
  260. package/src/side-effects.ts +28 -5
  261. package/src/skills-guard.ts +1067 -219
  262. package/src/skills.ts +163 -65
  263. package/src/token-efficiency.ts +31 -9
  264. package/src/tui.ts +166 -25
  265. package/src/types.ts +195 -2
  266. package/src/update.ts +79 -23
  267. package/src/workspace.ts +63 -11
  268. package/tests/approval-policy.test.ts +224 -0
  269. package/tests/discord.basic.test.ts +82 -2
  270. package/tests/discord.human-presence.test.ts +85 -0
  271. package/tests/gateway-service.media-routing.test.ts +8 -2
  272. package/tests/memory-service.test.ts +1114 -0
  273. package/tests/token-efficiency.basic.test.ts +8 -2
  274. package/vitest.e2e.config.ts +3 -1
  275. package/vitest.integration.config.ts +3 -1
  276. package/vitest.live.config.ts +3 -1
  277. package/vitest.unit.config.ts +9 -0
@@ -1,9 +1,17 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import { CronExpressionParser } from 'cron-parser';
2
- import { spawnSync } from 'child_process';
3
-
3
+ import { runAgent } from './agent.js';
4
+ import {
5
+ emitToolExecutionAuditEvents,
6
+ makeAuditRunId,
7
+ recordAuditEvent,
8
+ } from './audit-events.js';
4
9
  import {
5
10
  APP_VERSION,
6
11
  DISCORD_COMMANDS_ONLY,
12
+ DISCORD_FREE_RESPONSE_CHANNELS,
13
+ DISCORD_GROUP_POLICY,
14
+ DISCORD_GUILDS,
7
15
  DISCORD_RESPOND_TO_ALL_MESSAGES,
8
16
  HYBRIDAI_CHATBOT_ID,
9
17
  HYBRIDAI_ENABLE_RAG,
@@ -17,61 +25,72 @@ import {
17
25
  PROACTIVE_DELEGATION_MAX_PER_TURN,
18
26
  PROACTIVE_RALPH_MAX_ITERATIONS,
19
27
  } from './config.js';
20
- import { runAgent } from './agent.js';
21
- import { getActiveContainerCount, stopSessionContainer } from './container-runner.js';
22
28
  import {
23
- clearSessionHistory,
29
+ getActiveContainerCount,
30
+ stopSessionContainer,
31
+ } from './container-runner.js';
32
+ import { buildConversationContext } from './conversation.js';
33
+ import {
24
34
  createTask,
25
35
  deleteTask,
26
36
  getAllSessions,
27
- getConversationHistory,
28
- getOrCreateSession,
29
37
  getQueuedProactiveMessageCount,
30
38
  getRecentStructuredAuditForSession,
31
39
  getSessionCount,
32
40
  getTasksForSession,
41
+ getUsageTotals,
42
+ listUsageByAgent,
43
+ listUsageByModel,
33
44
  logAudit,
34
- storeMessage,
35
- toggleTask,
45
+ pauseTask,
46
+ recordUsageEvent,
47
+ resumeTask,
36
48
  updateSessionChatbot,
37
49
  updateSessionModel,
38
50
  updateSessionRag,
39
51
  } from './db.js';
40
- import { emitToolExecutionAuditEvents, makeAuditRunId, recordAuditEvent } from './audit-events.js';
52
+ import {
53
+ delegationQueueStatus,
54
+ enqueueDelegation,
55
+ } from './delegation-manager.js';
56
+ import {
57
+ type GatewayChatRequestBody,
58
+ type GatewayChatResult,
59
+ type GatewayCommandRequest,
60
+ type GatewayCommandResult,
61
+ type GatewayStatus,
62
+ renderGatewayCommand,
63
+ } from './gateway-types.js';
41
64
  import { fetchHybridAIBots } from './hybridai-bots.js';
42
65
  import { logger } from './logger.js';
66
+ import { memoryService } from './memory-service.js';
43
67
  import { getObservabilityIngestState } from './observability-ingest.js';
44
- import { rearmScheduler } from './scheduler.js';
68
+ import { updateRuntimeConfig } from './runtime-config.js';
69
+ import { runIsolatedScheduledTask } from './scheduled-task-runner.js';
70
+ import { getSchedulerStatus, rearmScheduler } from './scheduler.js';
71
+ import { exportSessionSnapshotJsonl } from './session-export.js';
45
72
  import { maybeCompactSession } from './session-maintenance.js';
46
73
  import { appendSessionTranscript } from './session-transcripts.js';
47
74
  import { processSideEffects } from './side-effects.js';
48
75
  import { expandSkillInvocation } from './skills.js';
49
76
  import {
50
- renderGatewayCommand,
51
- type GatewayChatRequestBody,
52
- type GatewayChatResult,
53
- type GatewayCommandRequest,
54
- type GatewayCommandResult,
55
- type GatewayStatus,
56
- } from './gateway-types.js';
77
+ estimateTokenCountFromMessages,
78
+ estimateTokenCountFromText,
79
+ } from './token-efficiency.js';
57
80
  import type {
58
81
  ArtifactMetadata,
82
+ CanonicalSessionContext,
59
83
  ChatMessage,
60
84
  DelegationSideEffect,
61
85
  DelegationTaskSpec,
62
86
  MediaContextItem,
63
87
  ScheduledTask,
64
- StructuredAuditEntry,
65
88
  StoredMessage,
89
+ StructuredAuditEntry,
66
90
  TokenUsageStats,
67
91
  ToolProgressEvent,
68
92
  } from './types.js';
69
93
  import { ensureBootstrapFiles } from './workspace.js';
70
- import { buildConversationContext } from './conversation.js';
71
- import { runIsolatedScheduledTask } from './scheduled-task-runner.js';
72
- import { delegationQueueStatus, enqueueDelegation } from './delegation-manager.js';
73
- import { estimateTokenCountFromMessages, estimateTokenCountFromText } from './token-efficiency.js';
74
- import { updateRuntimeConfig } from './runtime-config.js';
75
94
 
76
95
  const BOT_CACHE_TTL = 300_000; // 5 minutes
77
96
  const MAX_HISTORY_MESSAGES = 40;
@@ -103,10 +122,15 @@ const BASE_SUBAGENT_ALLOWED_TOOLS = [
103
122
  'browser_network',
104
123
  'browser_close',
105
124
  ];
106
- const ORCHESTRATOR_SUBAGENT_ALLOWED_TOOLS = [...BASE_SUBAGENT_ALLOWED_TOOLS, 'delegate'];
125
+ const ORCHESTRATOR_SUBAGENT_ALLOWED_TOOLS = [
126
+ ...BASE_SUBAGENT_ALLOWED_TOOLS,
127
+ 'delegate',
128
+ ];
107
129
  const MAX_DELEGATION_TASKS = 6;
108
130
  const MAX_DELEGATION_USER_CHARS = 500;
109
131
  const MAX_RALPH_ITERATIONS = 64;
132
+ const DISCORD_CHANNEL_MODE_VALUES = new Set(['off', 'mention', 'free']);
133
+ const DISCORD_GROUP_POLICY_VALUES = new Set(['open', 'allowlist', 'disabled']);
110
134
  const IMAGE_QUESTION_RE =
111
135
  /(what(?:'s| is)? on (?:the )?(?:image|picture|photo|screenshot)|describe (?:this|the) (?:image|picture|photo)|image|picture|photo|screenshot|ocr|diagram|chart|grafik|bild|foto|was steht|was ist auf dem bild)/i;
112
136
  const BROWSER_TAB_RE =
@@ -190,7 +214,9 @@ export interface GatewayChatRequest {
190
214
  enableRag?: GatewayChatRequestBody['enableRag'];
191
215
  onTextDelta?: (delta: string) => void;
192
216
  onToolProgress?: (event: ToolProgressEvent) => void;
193
- onProactiveMessage?: (message: ProactiveMessagePayload) => void | Promise<void>;
217
+ onProactiveMessage?: (
218
+ message: ProactiveMessagePayload,
219
+ ) => void | Promise<void>;
194
220
  abortSignal?: AbortSignal;
195
221
  }
196
222
 
@@ -199,7 +225,12 @@ export interface ProactiveMessagePayload {
199
225
  artifacts?: ArtifactMetadata[];
200
226
  }
201
227
 
202
- export type { GatewayChatResult, GatewayCommandRequest, GatewayCommandResult, GatewayStatus };
228
+ export type {
229
+ GatewayChatResult,
230
+ GatewayCommandRequest,
231
+ GatewayCommandResult,
232
+ GatewayStatus,
233
+ };
203
234
  export { renderGatewayCommand };
204
235
 
205
236
  function formatUptime(seconds: number): string {
@@ -221,22 +252,31 @@ function parseIntOrNull(raw: string | undefined): number | null {
221
252
  return Number.isNaN(parsed) ? null : parsed;
222
253
  }
223
254
 
224
- function normalizeMediaContextItems(raw: GatewayChatRequestBody['media']): MediaContextItem[] {
255
+ function normalizeMediaContextItems(
256
+ raw: GatewayChatRequestBody['media'],
257
+ ): MediaContextItem[] {
225
258
  if (!Array.isArray(raw) || raw.length === 0) return [];
226
259
  const normalized: MediaContextItem[] = [];
227
260
  for (const item of raw) {
228
261
  if (!item || typeof item !== 'object') continue;
229
- const path = typeof item.path === 'string' && item.path.trim() ? item.path.trim() : null;
262
+ const path =
263
+ typeof item.path === 'string' && item.path.trim()
264
+ ? item.path.trim()
265
+ : null;
230
266
  const url = typeof item.url === 'string' ? item.url.trim() : '';
231
- const originalUrl = typeof item.originalUrl === 'string' ? item.originalUrl.trim() : '';
232
- const filename = typeof item.filename === 'string' ? item.filename.trim() : '';
267
+ const originalUrl =
268
+ typeof item.originalUrl === 'string' ? item.originalUrl.trim() : '';
269
+ const filename =
270
+ typeof item.filename === 'string' ? item.filename.trim() : '';
233
271
  if (!url || !originalUrl || !filename) continue;
234
- const sizeBytes = typeof item.sizeBytes === 'number' && Number.isFinite(item.sizeBytes)
235
- ? Math.max(0, Math.floor(item.sizeBytes))
236
- : 0;
237
- const mimeType = typeof item.mimeType === 'string' && item.mimeType.trim()
238
- ? item.mimeType.trim().toLowerCase()
239
- : null;
272
+ const sizeBytes =
273
+ typeof item.sizeBytes === 'number' && Number.isFinite(item.sizeBytes)
274
+ ? Math.max(0, Math.floor(item.sizeBytes))
275
+ : 0;
276
+ const mimeType =
277
+ typeof item.mimeType === 'string' && item.mimeType.trim()
278
+ ? item.mimeType.trim().toLowerCase()
279
+ : null;
240
280
  normalized.push({
241
281
  path,
242
282
  url,
@@ -251,7 +291,9 @@ function normalizeMediaContextItems(raw: GatewayChatRequestBody['media']): Media
251
291
 
252
292
  function buildMediaPromptContext(media: MediaContextItem[]): string {
253
293
  if (media.length === 0) return '';
254
- const mediaPaths = media.map((item) => item.path).filter((path): path is string => Boolean(path));
294
+ const mediaPaths = media
295
+ .map((item) => item.path)
296
+ .filter((path): path is string => Boolean(path));
255
297
  const mediaUrls = media.map((item) => item.url);
256
298
  const mediaTypes = media.map((item) => item.mimeType || 'unknown');
257
299
  const payload = media.map((item, index) => ({
@@ -294,7 +336,10 @@ export interface MediaToolPolicy {
294
336
  prioritizeVisionTool: boolean;
295
337
  }
296
338
 
297
- export function resolveMediaToolPolicy(content: string, media: MediaContextItem[]): MediaToolPolicy {
339
+ export function resolveMediaToolPolicy(
340
+ content: string,
341
+ media: MediaContextItem[],
342
+ ): MediaToolPolicy {
298
343
  if (media.length === 0) {
299
344
  return {
300
345
  blockedTools: undefined,
@@ -353,14 +398,22 @@ function formatRelativeTime(raw: string | null | undefined): string {
353
398
  if (!at) return 'unknown';
354
399
  const deltaMs = Date.now() - at.getTime();
355
400
  if (deltaMs < 15_000) return 'just now';
356
- if (deltaMs < 60_000) return `${Math.max(1, Math.floor(deltaMs / 1_000))}s ago`;
357
- if (deltaMs < 3_600_000) return `${Math.max(1, Math.floor(deltaMs / 60_000))}m ago`;
358
- if (deltaMs < 86_400_000) return `${Math.max(1, Math.floor(deltaMs / 3_600_000))}h ago`;
401
+ if (deltaMs < 60_000)
402
+ return `${Math.max(1, Math.floor(deltaMs / 1_000))}s ago`;
403
+ if (deltaMs < 3_600_000)
404
+ return `${Math.max(1, Math.floor(deltaMs / 60_000))}m ago`;
405
+ if (deltaMs < 86_400_000)
406
+ return `${Math.max(1, Math.floor(deltaMs / 3_600_000))}h ago`;
359
407
  return `${Math.max(1, Math.floor(deltaMs / 86_400_000))}d ago`;
360
408
  }
361
409
 
362
410
  function numberFromUnknown(value: unknown): number | null {
363
- if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return null;
411
+ if (
412
+ typeof value !== 'number' ||
413
+ Number.isNaN(value) ||
414
+ !Number.isFinite(value)
415
+ )
416
+ return null;
364
417
  return value;
365
418
  }
366
419
 
@@ -372,7 +425,9 @@ function firstNumber(values: unknown[]): number | null {
372
425
  return null;
373
426
  }
374
427
 
375
- function parseAuditPayload(entry: StructuredAuditEntry): Record<string, unknown> | null {
428
+ function parseAuditPayload(
429
+ entry: StructuredAuditEntry,
430
+ ): Record<string, unknown> | null {
376
431
  try {
377
432
  const parsed = JSON.parse(entry.payload) as unknown;
378
433
  if (!parsed || typeof parsed !== 'object') return null;
@@ -386,27 +441,140 @@ function formatCompactNumber(value: number | null): string {
386
441
  if (value == null) return 'n/a';
387
442
  const abs = Math.abs(value);
388
443
  if (abs >= 1_000_000) {
389
- const scaled = abs >= 10_000_000 ? (value / 1_000_000).toFixed(0) : (value / 1_000_000).toFixed(1);
444
+ const scaled =
445
+ abs >= 10_000_000
446
+ ? (value / 1_000_000).toFixed(0)
447
+ : (value / 1_000_000).toFixed(1);
390
448
  return `${scaled.replace(/\.0$/, '')}M`;
391
449
  }
392
450
  if (abs >= 1_000) {
393
- const scaled = abs >= 10_000 ? (value / 1_000).toFixed(0) : (value / 1_000).toFixed(1);
451
+ const scaled =
452
+ abs >= 10_000 ? (value / 1_000).toFixed(0) : (value / 1_000).toFixed(1);
394
453
  return `${scaled.replace(/\.0$/, '')}k`;
395
454
  }
396
455
  return String(Math.round(value));
397
456
  }
398
457
 
399
458
  function formatPercent(value: number | null): string {
400
- if (value == null || Number.isNaN(value) || !Number.isFinite(value)) return 'n/a';
459
+ if (value == null || Number.isNaN(value) || !Number.isFinite(value))
460
+ return 'n/a';
401
461
  return `${Math.max(0, Math.min(100, Math.round(value)))}%`;
402
462
  }
403
463
 
464
+ function formatUsd(value: number | null): string {
465
+ if (value == null || Number.isNaN(value) || !Number.isFinite(value)) {
466
+ return 'n/a';
467
+ }
468
+ if (value <= 0) return '$0.0000';
469
+ if (value >= 1) return `$${value.toFixed(2)}`;
470
+ if (value >= 0.01) return `$${value.toFixed(4)}`;
471
+ return `$${value.toFixed(6)}`;
472
+ }
473
+
474
+ function resolveSessionAgentId(session: {
475
+ chatbot_id: string | null;
476
+ }): string | null {
477
+ const sessionAgent = session.chatbot_id?.trim();
478
+ if (sessionAgent) return sessionAgent;
479
+ const defaultAgent = HYBRIDAI_CHATBOT_ID?.trim();
480
+ if (defaultAgent) return defaultAgent;
481
+ return null;
482
+ }
483
+
484
+ function extractUsageCostUsd(tokenUsage?: TokenUsageStats): number {
485
+ if (!tokenUsage) return 0;
486
+ const costCarrier = tokenUsage as unknown as Record<string, unknown>;
487
+ const value = firstNumber([
488
+ costCarrier.costUsd,
489
+ costCarrier.costUSD,
490
+ costCarrier.cost_usd,
491
+ costCarrier.estimatedCostUsd,
492
+ costCarrier.estimated_cost_usd,
493
+ ]);
494
+ if (value == null) return 0;
495
+ return Math.max(0, value);
496
+ }
497
+
498
+ function formatCanonicalContextPrompt(params: {
499
+ summary: string | null;
500
+ recentMessages: Array<{
501
+ role: string;
502
+ content: string;
503
+ session_id: string;
504
+ channel_id: string | null;
505
+ }>;
506
+ }): string | null {
507
+ const sections: string[] = [];
508
+ const summary = (params.summary || '').trim();
509
+ if (summary) {
510
+ sections.push(['### Canonical Session Summary', summary].join('\n'));
511
+ }
512
+
513
+ if (params.recentMessages.length > 0) {
514
+ const lines = params.recentMessages.slice(-6).map((entry) => {
515
+ const role = (entry.role || 'user').trim().toLowerCase();
516
+ const who = role === 'assistant' ? 'Assistant' : 'User';
517
+ const from =
518
+ entry.channel_id && entry.channel_id.trim()
519
+ ? `${entry.channel_id.trim()} (${entry.session_id})`
520
+ : entry.session_id;
521
+ const compact = entry.content.replace(/\s+/g, ' ').trim();
522
+ const short =
523
+ compact.length > 180 ? `${compact.slice(0, 180)}...` : compact;
524
+ return `- ${who} [${from}]: ${short}`;
525
+ });
526
+ sections.push(
527
+ [
528
+ '### Cross-Channel Recall',
529
+ 'Recent context from other sessions/channels for this user:',
530
+ ...lines,
531
+ ].join('\n'),
532
+ );
533
+ }
534
+
535
+ const merged = sections.join('\n\n').trim();
536
+ return merged || null;
537
+ }
538
+
404
539
  function resolveActivationModeLabel(): string {
405
540
  if (DISCORD_COMMANDS_ONLY) return 'commands-only';
541
+ if (DISCORD_GROUP_POLICY === 'disabled') return 'disabled';
542
+ if (DISCORD_GROUP_POLICY === 'allowlist') return 'allowlist';
543
+ if (DISCORD_FREE_RESPONSE_CHANNELS.length > 0)
544
+ return `mention + ${DISCORD_FREE_RESPONSE_CHANNELS.length} free channel(s)`;
406
545
  if (DISCORD_RESPOND_TO_ALL_MESSAGES) return 'all messages';
407
546
  return 'mention';
408
547
  }
409
548
 
549
+ function resolveGuildChannelMode(
550
+ guildId: string | null,
551
+ channelId: string,
552
+ ): 'off' | 'mention' | 'free' {
553
+ if (!guildId) return 'free';
554
+ if (DISCORD_GROUP_POLICY === 'disabled') return 'off';
555
+ const guild = DISCORD_GUILDS[guildId];
556
+ const explicit = guild?.channels[channelId]?.mode;
557
+ if (DISCORD_GROUP_POLICY === 'allowlist') {
558
+ return explicit ?? 'off';
559
+ }
560
+ if (explicit === 'off' || explicit === 'mention' || explicit === 'free') {
561
+ return explicit;
562
+ }
563
+ if (DISCORD_FREE_RESPONSE_CHANNELS.includes(channelId)) return 'free';
564
+ if (guild) {
565
+ const defaultMode = guild.defaultMode;
566
+ if (
567
+ defaultMode === 'off' ||
568
+ defaultMode === 'mention' ||
569
+ defaultMode === 'free'
570
+ ) {
571
+ return defaultMode;
572
+ }
573
+ }
574
+ if (DISCORD_RESPOND_TO_ALL_MESSAGES) return 'free';
575
+ return 'mention';
576
+ }
577
+
410
578
  interface SessionStatusSnapshot {
411
579
  promptTokens: number | null;
412
580
  completionTokens: number | null;
@@ -426,7 +594,8 @@ function readSessionStatusSnapshot(sessionId: string): SessionStatusSnapshot {
426
594
  for (const entry of entries) {
427
595
  const payload = parseAuditPayload(entry);
428
596
  if (!payload) continue;
429
- const payloadType = typeof payload.type === 'string' ? payload.type : entry.event_type;
597
+ const payloadType =
598
+ typeof payload.type === 'string' ? payload.type : entry.event_type;
430
599
  if (!usagePayload && payloadType === 'model.usage') {
431
600
  usagePayload = payload;
432
601
  } else if (!contextPayload && payloadType === 'context.optimization') {
@@ -457,21 +626,23 @@ function readSessionStatusSnapshot(sessionId: string): SessionStatusSnapshot {
457
626
  const cacheRead = Math.max(0, cacheReadTokens || 0);
458
627
  const cacheWrite = Math.max(0, cacheWriteTokens || 0);
459
628
  const cacheTotal = cacheRead + cacheWrite;
460
- const cacheHitPercent = cacheTotal > 0 ? (cacheRead / cacheTotal) * 100 : null;
629
+ const cacheHitPercent =
630
+ cacheTotal > 0 ? (cacheRead / cacheTotal) * 100 : null;
461
631
 
462
- const contextUsedTokens = firstNumber([contextPayload?.historyEstimatedTokens]);
632
+ const contextUsedTokens = firstNumber([
633
+ contextPayload?.historyEstimatedTokens,
634
+ ]);
463
635
  const contextBudgetTokens = (() => {
464
636
  const maxChars = firstNumber([contextPayload?.historyMaxChars]);
465
637
  if (maxChars == null || maxChars <= 0) return null;
466
638
  return Math.max(1, Math.round(maxChars / 4));
467
639
  })();
468
- const contextUsagePercent = (
469
- contextUsedTokens != null
470
- && contextBudgetTokens != null
471
- && contextBudgetTokens > 0
472
- )
473
- ? (contextUsedTokens / contextBudgetTokens) * 100
474
- : null;
640
+ const contextUsagePercent =
641
+ contextUsedTokens != null &&
642
+ contextBudgetTokens != null &&
643
+ contextBudgetTokens > 0
644
+ ? (contextUsedTokens / contextBudgetTokens) * 100
645
+ : null;
475
646
 
476
647
  return {
477
648
  promptTokens,
@@ -522,10 +693,12 @@ function isVersionOnlyQuestion(raw: string): boolean {
522
693
  if (detailedRuntimeTokens.some((token) => text.includes(token))) return false;
523
694
 
524
695
  const words = text.split(' ').filter(Boolean);
525
- if (words.length > 8
526
- && !text.includes('welche version')
527
- && !text.includes('what version')
528
- && !text.includes('which version')) {
696
+ if (
697
+ words.length > 8 &&
698
+ !text.includes('welche version') &&
699
+ !text.includes('what version') &&
700
+ !text.includes('which version')
701
+ ) {
529
702
  return false;
530
703
  }
531
704
 
@@ -548,8 +721,46 @@ function recordSuccessfulTurn(opts: {
548
721
  toolCallCount: number;
549
722
  startedAt: number;
550
723
  }): void {
551
- storeMessage(opts.sessionId, opts.userId, opts.username, 'user', opts.userContent);
552
- storeMessage(opts.sessionId, 'assistant', null, 'assistant', opts.resultText);
724
+ memoryService.storeTurn({
725
+ sessionId: opts.sessionId,
726
+ user: {
727
+ userId: opts.userId,
728
+ username: opts.username,
729
+ content: opts.userContent,
730
+ },
731
+ assistant: {
732
+ userId: 'assistant',
733
+ username: null,
734
+ content: opts.resultText,
735
+ },
736
+ });
737
+ try {
738
+ if (opts.userId.trim()) {
739
+ memoryService.appendCanonicalMessages({
740
+ agentId: opts.agentId,
741
+ userId: opts.userId,
742
+ newMessages: [
743
+ {
744
+ role: 'user',
745
+ content: opts.userContent,
746
+ sessionId: opts.sessionId,
747
+ channelId: opts.channelId,
748
+ },
749
+ {
750
+ role: 'assistant',
751
+ content: opts.resultText,
752
+ sessionId: opts.sessionId,
753
+ channelId: opts.channelId,
754
+ },
755
+ ],
756
+ });
757
+ }
758
+ } catch (err) {
759
+ logger.debug(
760
+ { sessionId: opts.sessionId, userId: opts.userId, err },
761
+ 'Failed to append canonical session memory',
762
+ );
763
+ }
553
764
  appendSessionTranscript(opts.agentId, {
554
765
  sessionId: opts.sessionId,
555
766
  channelId: opts.channelId,
@@ -575,7 +786,10 @@ function recordSuccessfulTurn(opts: {
575
786
  model: opts.model,
576
787
  channelId: opts.channelId,
577
788
  }).catch((err) => {
578
- logger.warn({ sessionId: opts.sessionId, err }, 'Background session compaction failed');
789
+ logger.warn(
790
+ { sessionId: opts.sessionId, err },
791
+ 'Background session compaction failed',
792
+ );
579
793
  });
580
794
 
581
795
  recordAuditEvent({
@@ -640,19 +854,30 @@ function buildTokenUsageAuditPayload(
640
854
  }, 0);
641
855
  const completionChars = (resultText || '').length;
642
856
 
643
- const fallbackEstimatedPromptTokens = estimateTokenCountFromMessages(messages);
644
- const fallbackEstimatedCompletionTokens = estimateTokenCountFromText(resultText || '');
645
- const estimatedPromptTokens = tokenUsage?.estimatedPromptTokens || fallbackEstimatedPromptTokens;
646
- const estimatedCompletionTokens = tokenUsage?.estimatedCompletionTokens || fallbackEstimatedCompletionTokens;
857
+ const fallbackEstimatedPromptTokens =
858
+ estimateTokenCountFromMessages(messages);
859
+ const fallbackEstimatedCompletionTokens = estimateTokenCountFromText(
860
+ resultText || '',
861
+ );
862
+ const estimatedPromptTokens =
863
+ tokenUsage?.estimatedPromptTokens || fallbackEstimatedPromptTokens;
864
+ const estimatedCompletionTokens =
865
+ tokenUsage?.estimatedCompletionTokens || fallbackEstimatedCompletionTokens;
647
866
  const estimatedTotalTokens =
648
- tokenUsage?.estimatedTotalTokens || (estimatedPromptTokens + estimatedCompletionTokens);
867
+ tokenUsage?.estimatedTotalTokens ||
868
+ estimatedPromptTokens + estimatedCompletionTokens;
649
869
 
650
870
  const apiUsageAvailable = tokenUsage?.apiUsageAvailable === true;
651
871
  const apiPromptTokens = tokenUsage?.apiPromptTokens || 0;
652
872
  const apiCompletionTokens = tokenUsage?.apiCompletionTokens || 0;
653
- const apiTotalTokens = tokenUsage?.apiTotalTokens || (apiPromptTokens + apiCompletionTokens);
654
- const promptTokens = apiUsageAvailable ? apiPromptTokens : estimatedPromptTokens;
655
- const completionTokens = apiUsageAvailable ? apiCompletionTokens : estimatedCompletionTokens;
873
+ const apiTotalTokens =
874
+ tokenUsage?.apiTotalTokens || apiPromptTokens + apiCompletionTokens;
875
+ const promptTokens = apiUsageAvailable
876
+ ? apiPromptTokens
877
+ : estimatedPromptTokens;
878
+ const completionTokens = apiUsageAvailable
879
+ ? apiCompletionTokens
880
+ : estimatedCompletionTokens;
656
881
  const totalTokens = apiUsageAvailable ? apiTotalTokens : estimatedTotalTokens;
657
882
 
658
883
  return {
@@ -684,11 +909,19 @@ export function getGatewayStatus(): GatewayStatus {
684
909
  ragDefault: HYBRIDAI_ENABLE_RAG,
685
910
  timestamp: new Date().toISOString(),
686
911
  observability: getObservabilityIngestState(),
912
+ scheduler: {
913
+ jobs: getSchedulerStatus(),
914
+ },
687
915
  };
688
916
  }
689
917
 
690
- export function getGatewayHistory(sessionId: string, limit = MAX_HISTORY_MESSAGES): StoredMessage[] {
691
- return getConversationHistory(sessionId, Math.max(1, Math.min(limit, 200))).reverse();
918
+ export function getGatewayHistory(
919
+ sessionId: string,
920
+ limit = MAX_HISTORY_MESSAGES,
921
+ ): StoredMessage[] {
922
+ return memoryService
923
+ .getConversationHistory(sessionId, Math.max(1, Math.min(limit, 200)))
924
+ .reverse();
692
925
  }
693
926
 
694
927
  function extractDelegationDepth(sessionId: string): number {
@@ -698,18 +931,28 @@ function extractDelegationDepth(sessionId: string): number {
698
931
  return Number.isFinite(parsed) ? parsed : 0;
699
932
  }
700
933
 
701
- function nextDelegationSessionId(parentSessionId: string, nextDepth: number): string {
702
- const safeParent = parentSessionId.replace(/[^a-zA-Z0-9:_-]/g, '-').slice(0, 48);
934
+ function nextDelegationSessionId(
935
+ parentSessionId: string,
936
+ nextDepth: number,
937
+ ): string {
938
+ const safeParent = parentSessionId
939
+ .replace(/[^a-zA-Z0-9:_-]/g, '-')
940
+ .slice(0, 48);
703
941
  const nonce = Math.random().toString(36).slice(2, 8);
704
942
  return `delegate:d${nextDepth}:${safeParent}:${Date.now()}:${nonce}`;
705
943
  }
706
944
 
707
945
  function resolveSubagentAllowedTools(depth: number): string[] {
708
- if (depth < PROACTIVE_DELEGATION_MAX_DEPTH) return ORCHESTRATOR_SUBAGENT_ALLOWED_TOOLS;
946
+ if (depth < PROACTIVE_DELEGATION_MAX_DEPTH)
947
+ return ORCHESTRATOR_SUBAGENT_ALLOWED_TOOLS;
709
948
  return BASE_SUBAGENT_ALLOWED_TOOLS;
710
949
  }
711
950
 
712
- function buildSubagentSystemPrompt(params: { depth: number; canDelegate: boolean; mode: DelegationMode }): string {
951
+ function buildSubagentSystemPrompt(params: {
952
+ depth: number;
953
+ canDelegate: boolean;
954
+ mode: DelegationMode;
955
+ }): string {
713
956
  const { depth, canDelegate, mode } = params;
714
957
  const delegationLine = canDelegate
715
958
  ? 'You may delegate further only if absolutely necessary and still within depth/turn limits.'
@@ -757,31 +1000,50 @@ function formatDurationMs(ms: number): string {
757
1000
  return `${(ms / 1_000).toFixed(1)}s`;
758
1001
  }
759
1002
 
760
- function abbreviateForUser(text: string, maxChars = MAX_DELEGATION_USER_CHARS): string {
1003
+ function abbreviateForUser(
1004
+ text: string,
1005
+ maxChars = MAX_DELEGATION_USER_CHARS,
1006
+ ): string {
761
1007
  const normalized = text.replace(/\s+/g, ' ').trim();
762
1008
  if (normalized.length <= maxChars) return normalized;
763
1009
  return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
764
1010
  }
765
1011
 
766
1012
  function classifyDelegationError(errorText: string): DelegationErrorClass {
767
- if (PERMANENT_DELEGATION_ERROR_PATTERNS.some((pattern) => pattern.test(errorText))) return 'permanent';
768
- if (TRANSIENT_DELEGATION_ERROR_PATTERNS.some((pattern) => pattern.test(errorText))) return 'transient';
1013
+ if (
1014
+ PERMANENT_DELEGATION_ERROR_PATTERNS.some((pattern) =>
1015
+ pattern.test(errorText),
1016
+ )
1017
+ )
1018
+ return 'permanent';
1019
+ if (
1020
+ TRANSIENT_DELEGATION_ERROR_PATTERNS.some((pattern) =>
1021
+ pattern.test(errorText),
1022
+ )
1023
+ )
1024
+ return 'transient';
769
1025
  return 'unknown';
770
1026
  }
771
1027
 
772
1028
  function inferDelegationStatus(errorText: string): DelegationRunStatus {
773
- return /timeout|timed out|deadline exceeded/i.test(errorText) ? 'timeout' : 'failed';
1029
+ return /timeout|timed out|deadline exceeded/i.test(errorText)
1030
+ ? 'timeout'
1031
+ : 'failed';
774
1032
  }
775
1033
 
776
- function normalizeDelegationTask(raw: unknown, fallbackModel: string): NormalizedDelegationTask | null {
1034
+ function normalizeDelegationTask(
1035
+ raw: unknown,
1036
+ fallbackModel: string,
1037
+ ): NormalizedDelegationTask | null {
777
1038
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
778
1039
  const task = raw as DelegationTaskSpec;
779
1040
  const prompt = typeof task.prompt === 'string' ? task.prompt.trim() : '';
780
1041
  if (!prompt) return null;
781
1042
  const label = typeof task.label === 'string' ? task.label.trim() : '';
782
- const model = typeof task.model === 'string' && task.model.trim()
783
- ? task.model.trim()
784
- : fallbackModel;
1043
+ const model =
1044
+ typeof task.model === 'string' && task.model.trim()
1045
+ ? task.model.trim()
1046
+ : fallbackModel;
785
1047
  return {
786
1048
  prompt,
787
1049
  label: label || undefined,
@@ -789,11 +1051,15 @@ function normalizeDelegationTask(raw: unknown, fallbackModel: string): Normalize
789
1051
  };
790
1052
  }
791
1053
 
792
- function normalizeDelegationEffect(effect: DelegationSideEffect, fallbackModel: string): {
1054
+ function normalizeDelegationEffect(
1055
+ effect: DelegationSideEffect,
1056
+ fallbackModel: string,
1057
+ ): {
793
1058
  plan?: NormalizedDelegationPlan;
794
1059
  error?: string;
795
1060
  } {
796
- const rawMode = typeof effect.mode === 'string' ? effect.mode.trim().toLowerCase() : '';
1061
+ const rawMode =
1062
+ typeof effect.mode === 'string' ? effect.mode.trim().toLowerCase() : '';
797
1063
  const modeRaw: DelegationMode | '' =
798
1064
  rawMode === 'single' || rawMode === 'parallel' || rawMode === 'chain'
799
1065
  ? rawMode
@@ -803,9 +1069,10 @@ function normalizeDelegationEffect(effect: DelegationSideEffect, fallbackModel:
803
1069
  }
804
1070
 
805
1071
  const label = typeof effect.label === 'string' ? effect.label.trim() : '';
806
- const baseModel = typeof effect.model === 'string' && effect.model.trim()
807
- ? effect.model.trim()
808
- : fallbackModel;
1072
+ const baseModel =
1073
+ typeof effect.model === 'string' && effect.model.trim()
1074
+ ? effect.model.trim()
1075
+ : fallbackModel;
809
1076
  const prompt = typeof effect.prompt === 'string' ? effect.prompt.trim() : '';
810
1077
  const rawTasks = Array.isArray(effect.tasks) ? effect.tasks : [];
811
1078
  const rawChain = Array.isArray(effect.chain) ? effect.chain : [];
@@ -832,12 +1099,15 @@ function normalizeDelegationEffect(effect: DelegationSideEffect, fallbackModel:
832
1099
  return { error: `${mode} delegation requires at least one task` };
833
1100
  }
834
1101
  if (sourceTasks.length > MAX_DELEGATION_TASKS) {
835
- return { error: `${mode} delegation exceeds max tasks (${MAX_DELEGATION_TASKS})` };
1102
+ return {
1103
+ error: `${mode} delegation exceeds max tasks (${MAX_DELEGATION_TASKS})`,
1104
+ };
836
1105
  }
837
1106
  const tasks: NormalizedDelegationTask[] = [];
838
1107
  for (let i = 0; i < sourceTasks.length; i++) {
839
1108
  const normalized = normalizeDelegationTask(sourceTasks[i], baseModel);
840
- if (!normalized) return { error: `${mode} delegation task #${i + 1} is invalid` };
1109
+ if (!normalized)
1110
+ return { error: `${mode} delegation task #${i + 1} is invalid` };
841
1111
  tasks.push(normalized);
842
1112
  }
843
1113
  return {
@@ -849,14 +1119,22 @@ function normalizeDelegationEffect(effect: DelegationSideEffect, fallbackModel:
849
1119
  };
850
1120
  }
851
1121
 
852
- function renderDelegationTaskTitle(mode: DelegationMode, task: NormalizedDelegationTask, index: number, total: number): string {
1122
+ function renderDelegationTaskTitle(
1123
+ mode: DelegationMode,
1124
+ task: NormalizedDelegationTask,
1125
+ index: number,
1126
+ total: number,
1127
+ ): string {
853
1128
  if (task.label) return task.label;
854
1129
  if (mode === 'chain') return `step ${index + 1}/${total}`;
855
1130
  if (mode === 'parallel') return `task ${index + 1}/${total}`;
856
1131
  return 'task';
857
1132
  }
858
1133
 
859
- function interpolateChainPrompt(prompt: string, previousResult: string): string {
1134
+ function interpolateChainPrompt(
1135
+ prompt: string,
1136
+ previousResult: string,
1137
+ ): string {
860
1138
  if (!prompt.includes('{previous}')) return prompt;
861
1139
  const replacement = previousResult.trim() || '(no previous output)';
862
1140
  return prompt.replace(/\{previous\}/g, replacement);
@@ -866,7 +1144,9 @@ function sleep(ms: number): Promise<void> {
866
1144
  return new Promise((resolve) => setTimeout(resolve, ms));
867
1145
  }
868
1146
 
869
- async function runDelegationTaskWithRetry(input: DelegationTaskRunInput): Promise<DelegationRunResult> {
1147
+ async function runDelegationTaskWithRetry(
1148
+ input: DelegationTaskRunInput,
1149
+ ): Promise<DelegationRunResult> {
870
1150
  const {
871
1151
  parentSessionId,
872
1152
  childDepth,
@@ -879,7 +1159,9 @@ async function runDelegationTaskWithRetry(input: DelegationTaskRunInput): Promis
879
1159
  } = input;
880
1160
  const allowedTools = resolveSubagentAllowedTools(childDepth);
881
1161
  const canDelegate = allowedTools.includes('delegate');
882
- const maxAttempts = PROACTIVE_AUTO_RETRY_ENABLED ? PROACTIVE_AUTO_RETRY_MAX_ATTEMPTS : 1;
1162
+ const maxAttempts = PROACTIVE_AUTO_RETRY_ENABLED
1163
+ ? PROACTIVE_AUTO_RETRY_MAX_ATTEMPTS
1164
+ : 1;
883
1165
  let attempt = 0;
884
1166
  let delayMs = PROACTIVE_AUTO_RETRY_BASE_DELAY_MS;
885
1167
  let lastError = 'Delegation failed with unknown error';
@@ -898,7 +1180,14 @@ async function runDelegationTaskWithRetry(input: DelegationTaskRunInput): Promis
898
1180
  const output = await runAgent(
899
1181
  sessionId,
900
1182
  [
901
- { role: 'system', content: buildSubagentSystemPrompt({ depth: childDepth, canDelegate, mode }) },
1183
+ {
1184
+ role: 'system',
1185
+ content: buildSubagentSystemPrompt({
1186
+ depth: childDepth,
1187
+ canDelegate,
1188
+ mode,
1189
+ }),
1190
+ },
902
1191
  { role: 'user', content: task.prompt },
903
1192
  ],
904
1193
  chatbotId,
@@ -931,11 +1220,19 @@ async function runDelegationTaskWithRetry(input: DelegationTaskRunInput): Promis
931
1220
  lastError = errorText;
932
1221
  lastStatus = inferDelegationStatus(errorText);
933
1222
  const classification = classifyDelegationError(errorText);
934
- const shouldRetry = classification === 'transient' && attempt < maxAttempts;
1223
+ const shouldRetry =
1224
+ classification === 'transient' && attempt < maxAttempts;
935
1225
  if (!shouldRetry) break;
936
1226
 
937
1227
  logger.warn(
938
- { parentSessionId, sessionId, attempt, maxAttempts, delayMs, errorText },
1228
+ {
1229
+ parentSessionId,
1230
+ sessionId,
1231
+ attempt,
1232
+ maxAttempts,
1233
+ delayMs,
1234
+ errorText,
1235
+ },
939
1236
  'Delegation retry scheduled after transient error',
940
1237
  );
941
1238
  await sleep(delayMs);
@@ -947,10 +1244,18 @@ async function runDelegationTaskWithRetry(input: DelegationTaskRunInput): Promis
947
1244
  lastError = errorText;
948
1245
  lastStatus = inferDelegationStatus(errorText);
949
1246
  const classification = classifyDelegationError(errorText);
950
- const shouldRetry = classification === 'transient' && attempt < maxAttempts;
1247
+ const shouldRetry =
1248
+ classification === 'transient' && attempt < maxAttempts;
951
1249
  if (!shouldRetry) break;
952
1250
  logger.warn(
953
- { parentSessionId, sessionId, attempt, maxAttempts, delayMs, errorText },
1251
+ {
1252
+ parentSessionId,
1253
+ sessionId,
1254
+ attempt,
1255
+ maxAttempts,
1256
+ delayMs,
1257
+ errorText,
1258
+ },
954
1259
  'Delegation retry scheduled after transient exception',
955
1260
  );
956
1261
  await sleep(delayMs);
@@ -977,19 +1282,32 @@ function formatDelegationCompletion(params: {
977
1282
  totalDurationMs: number;
978
1283
  }): { forUser: string; forLLM: string; artifacts?: ArtifactMetadata[] } {
979
1284
  const { mode, label, entries, totalDurationMs } = params;
980
- const completedCount = entries.filter((entry) => entry.run.status === 'completed').length;
1285
+ const completedCount = entries.filter(
1286
+ (entry) => entry.run.status === 'completed',
1287
+ ).length;
981
1288
  const failedCount = entries.length - completedCount;
982
- const overallStatus = failedCount === 0 ? 'completed' : completedCount === 0 ? 'failed' : 'partial';
983
- const heading = label?.trim() ? `[Delegate: ${label.trim()}]` : `[Delegate ${mode}]`;
1289
+ const overallStatus =
1290
+ failedCount === 0
1291
+ ? 'completed'
1292
+ : completedCount === 0
1293
+ ? 'failed'
1294
+ : 'partial';
1295
+ const heading = label?.trim()
1296
+ ? `[Delegate: ${label.trim()}]`
1297
+ : `[Delegate ${mode}]`;
984
1298
 
985
1299
  const userLines = [
986
1300
  `${heading} ${overallStatus} (${completedCount}/${entries.length} completed, ${formatDurationMs(totalDurationMs)}).`,
987
1301
  ];
988
1302
  for (const entry of entries) {
989
1303
  if (entry.run.status === 'completed') {
990
- userLines.push(`- ${entry.title}: ${abbreviateForUser(entry.run.result || '')}`);
1304
+ userLines.push(
1305
+ `- ${entry.title}: ${abbreviateForUser(entry.run.result || '')}`,
1306
+ );
991
1307
  } else {
992
- userLines.push(`- ${entry.title}: ${entry.run.status} (${abbreviateForUser(entry.run.error || 'Unknown error')})`);
1308
+ userLines.push(
1309
+ `- ${entry.title}: ${entry.run.status} (${abbreviateForUser(entry.run.error || 'Unknown error')})`,
1310
+ );
993
1311
  }
994
1312
  }
995
1313
 
@@ -1045,7 +1363,9 @@ async function publishDelegationCompletion(params: {
1045
1363
  forLLM: string;
1046
1364
  forUser: string;
1047
1365
  artifacts?: ArtifactMetadata[];
1048
- onProactiveMessage?: (message: ProactiveMessagePayload) => void | Promise<void>;
1366
+ onProactiveMessage?: (
1367
+ message: ProactiveMessagePayload,
1368
+ ) => void | Promise<void>;
1049
1369
  }): Promise<void> {
1050
1370
  const {
1051
1371
  parentSessionId,
@@ -1057,7 +1377,13 @@ async function publishDelegationCompletion(params: {
1057
1377
  onProactiveMessage,
1058
1378
  } = params;
1059
1379
 
1060
- storeMessage(parentSessionId, 'assistant', null, 'assistant', forLLM);
1380
+ memoryService.storeMessage({
1381
+ sessionId: parentSessionId,
1382
+ userId: 'assistant',
1383
+ username: null,
1384
+ role: 'assistant',
1385
+ content: forLLM,
1386
+ });
1061
1387
  appendSessionTranscript(agentId, {
1062
1388
  sessionId: parentSessionId,
1063
1389
  channelId,
@@ -1072,7 +1398,11 @@ async function publishDelegationCompletion(params: {
1072
1398
  return;
1073
1399
  }
1074
1400
  logger.info(
1075
- { parentSessionId, message: forUser, artifactCount: artifacts?.length || 0 },
1401
+ {
1402
+ parentSessionId,
1403
+ message: forUser,
1404
+ artifactCount: artifacts?.length || 0,
1405
+ },
1076
1406
  'Delegation completion (no proactive channel callback)',
1077
1407
  );
1078
1408
  }
@@ -1084,7 +1414,9 @@ function enqueueDelegationFromSideEffect(params: {
1084
1414
  chatbotId: string;
1085
1415
  enableRag: boolean;
1086
1416
  agentId: string;
1087
- onProactiveMessage?: (message: ProactiveMessagePayload) => void | Promise<void>;
1417
+ onProactiveMessage?: (
1418
+ message: ProactiveMessagePayload,
1419
+ ) => void | Promise<void>;
1088
1420
  parentDepth: number;
1089
1421
  }): void {
1090
1422
  const {
@@ -1099,7 +1431,10 @@ function enqueueDelegationFromSideEffect(params: {
1099
1431
  } = params;
1100
1432
  const childDepth = parentDepth + 1;
1101
1433
  if (childDepth > PROACTIVE_DELEGATION_MAX_DEPTH) {
1102
- logger.info({ parentSessionId, childDepth, maxDepth: PROACTIVE_DELEGATION_MAX_DEPTH }, 'Delegation skipped — depth limit reached');
1434
+ logger.info(
1435
+ { parentSessionId, childDepth, maxDepth: PROACTIVE_DELEGATION_MAX_DEPTH },
1436
+ 'Delegation skipped — depth limit reached',
1437
+ );
1103
1438
  return;
1104
1439
  }
1105
1440
 
@@ -1111,22 +1446,29 @@ function enqueueDelegationFromSideEffect(params: {
1111
1446
  const entries: DelegationCompletionEntry[] = [];
1112
1447
 
1113
1448
  if (plan.mode === 'parallel') {
1114
- const runs = await Promise.all(plan.tasks.map(async (task, index) => {
1115
- const run = await runDelegationTaskWithRetry({
1116
- parentSessionId,
1117
- childDepth,
1118
- channelId,
1119
- chatbotId,
1120
- enableRag,
1121
- agentId,
1122
- mode: plan.mode,
1123
- task,
1124
- });
1125
- return {
1126
- title: renderDelegationTaskTitle(plan.mode, task, index, plan.tasks.length),
1127
- run,
1128
- } as DelegationCompletionEntry;
1129
- }));
1449
+ const runs = await Promise.all(
1450
+ plan.tasks.map(async (task, index) => {
1451
+ const run = await runDelegationTaskWithRetry({
1452
+ parentSessionId,
1453
+ childDepth,
1454
+ channelId,
1455
+ chatbotId,
1456
+ enableRag,
1457
+ agentId,
1458
+ mode: plan.mode,
1459
+ task,
1460
+ });
1461
+ return {
1462
+ title: renderDelegationTaskTitle(
1463
+ plan.mode,
1464
+ task,
1465
+ index,
1466
+ plan.tasks.length,
1467
+ ),
1468
+ run,
1469
+ } as DelegationCompletionEntry;
1470
+ }),
1471
+ );
1130
1472
  entries.push(...runs);
1131
1473
  } else if (plan.mode === 'chain') {
1132
1474
  let previousResult = '';
@@ -1146,7 +1488,12 @@ function enqueueDelegationFromSideEffect(params: {
1146
1488
  },
1147
1489
  });
1148
1490
  entries.push({
1149
- title: renderDelegationTaskTitle(plan.mode, task, i, plan.tasks.length),
1491
+ title: renderDelegationTaskTitle(
1492
+ plan.mode,
1493
+ task,
1494
+ i,
1495
+ plan.tasks.length,
1496
+ ),
1150
1497
  run,
1151
1498
  });
1152
1499
  if (run.status !== 'completed') break;
@@ -1171,7 +1518,10 @@ function enqueueDelegationFromSideEffect(params: {
1171
1518
  }
1172
1519
 
1173
1520
  if (entries.length === 0) {
1174
- logger.warn({ parentSessionId, mode: plan.mode }, 'Delegation produced no entries');
1521
+ logger.warn(
1522
+ { parentSessionId, mode: plan.mode },
1523
+ 'Delegation produced no entries',
1524
+ );
1175
1525
  return;
1176
1526
  }
1177
1527
 
@@ -1194,10 +1544,16 @@ function enqueueDelegationFromSideEffect(params: {
1194
1544
  });
1195
1545
  }
1196
1546
 
1197
- export async function handleGatewayMessage(req: GatewayChatRequest): Promise<GatewayChatResult> {
1547
+ export async function handleGatewayMessage(
1548
+ req: GatewayChatRequest,
1549
+ ): Promise<GatewayChatResult> {
1198
1550
  const startedAt = Date.now();
1199
1551
  const runId = makeAuditRunId('turn');
1200
- const session = getOrCreateSession(req.sessionId, req.guildId, req.channelId);
1552
+ const session = memoryService.getOrCreateSession(
1553
+ req.sessionId,
1554
+ req.guildId,
1555
+ req.channelId,
1556
+ );
1201
1557
  const chatbotId = req.chatbotId ?? session.chatbot_id ?? HYBRIDAI_CHATBOT_ID;
1202
1558
  const enableRag = req.enableRag ?? session.enable_rag === 1;
1203
1559
  const model = req.model ?? session.model ?? HYBRIDAI_MODEL;
@@ -1229,7 +1585,8 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1229
1585
  });
1230
1586
 
1231
1587
  if (!chatbotId) {
1232
- const error = 'No chatbot configured. Set `hybridai.defaultChatbotId` in config.json or select a bot for this session.';
1588
+ const error =
1589
+ 'No chatbot configured. Set `hybridai.defaultChatbotId` in config.json or select a bot for this session.';
1233
1590
  recordAuditEvent({
1234
1591
  sessionId: req.sessionId,
1235
1592
  runId,
@@ -1277,10 +1634,20 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1277
1634
  if (isVersionOnlyQuestion(req.content)) {
1278
1635
  const resultText = `HybridClaw v${APP_VERSION}`;
1279
1636
  recordSuccessfulTurn({
1280
- sessionId: req.sessionId, agentId, chatbotId, enableRag, model,
1281
- channelId: req.channelId, runId, turnIndex,
1282
- userId: req.userId, username: req.username,
1283
- userContent: req.content, resultText, toolCallCount: 0, startedAt,
1637
+ sessionId: req.sessionId,
1638
+ agentId,
1639
+ chatbotId,
1640
+ enableRag,
1641
+ model,
1642
+ channelId: req.channelId,
1643
+ runId,
1644
+ turnIndex,
1645
+ userId: req.userId,
1646
+ username: req.username,
1647
+ userContent: req.content,
1648
+ resultText,
1649
+ toolCallCount: 0,
1650
+ startedAt,
1284
1651
  });
1285
1652
  return {
1286
1653
  status: 'success',
@@ -1289,10 +1656,48 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1289
1656
  };
1290
1657
  }
1291
1658
 
1292
- const history = getConversationHistory(req.sessionId, MAX_HISTORY_MESSAGES);
1659
+ const history = memoryService.getConversationHistory(
1660
+ req.sessionId,
1661
+ MAX_HISTORY_MESSAGES,
1662
+ );
1663
+ let canonicalContext: CanonicalSessionContext = {
1664
+ summary: null,
1665
+ recent_messages: [],
1666
+ };
1667
+ if (req.userId.trim()) {
1668
+ try {
1669
+ canonicalContext = memoryService.getCanonicalContext({
1670
+ agentId,
1671
+ userId: req.userId,
1672
+ windowSize: 12,
1673
+ excludeSessionId: req.sessionId,
1674
+ });
1675
+ } catch (err) {
1676
+ logger.debug(
1677
+ { sessionId: req.sessionId, userId: req.userId, err },
1678
+ 'Failed to load canonical session context',
1679
+ );
1680
+ }
1681
+ }
1682
+ const canonicalPromptSummary = formatCanonicalContextPrompt({
1683
+ summary: canonicalContext.summary,
1684
+ recentMessages: canonicalContext.recent_messages,
1685
+ });
1686
+ const memoryContext = memoryService.buildPromptMemoryContext({
1687
+ session,
1688
+ query: req.content,
1689
+ });
1690
+ const mergedSessionSummary =
1691
+ [canonicalPromptSummary, memoryContext.promptSummary]
1692
+ .filter(
1693
+ (value): value is string =>
1694
+ typeof value === 'string' && value.trim().length > 0,
1695
+ )
1696
+ .join('\n\n')
1697
+ .trim() || null;
1293
1698
  const { messages, skills, historyStats } = buildConversationContext({
1294
1699
  agentId,
1295
- sessionSummary: session.session_summary,
1700
+ sessionSummary: mergedSessionSummary,
1296
1701
  history,
1297
1702
  runtimeInfo: {
1298
1703
  chatbotId,
@@ -1302,7 +1707,8 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1302
1707
  guildId: req.guildId,
1303
1708
  },
1304
1709
  });
1305
- const historyStart = messages.length > 0 && messages[0].role === 'system' ? 1 : 0;
1710
+ const historyStart =
1711
+ messages.length > 0 && messages[0].role === 'system' ? 1 : 0;
1306
1712
  recordAuditEvent({
1307
1713
  sessionId: req.sessionId,
1308
1714
  runId,
@@ -1319,7 +1725,11 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1319
1725
  historyMaxMessageChars: historyStats.maxMessageChars,
1320
1726
  perMessageTruncatedCount: historyStats.perMessageTruncatedCount,
1321
1727
  middleCompressionApplied: historyStats.middleCompressionApplied,
1322
- historyEstimatedTokens: estimateTokenCountFromMessages(messages.slice(historyStart)),
1728
+ historyEstimatedTokens: estimateTokenCountFromMessages(
1729
+ messages.slice(historyStart),
1730
+ ),
1731
+ canonicalSummaryIncluded: Boolean(canonicalPromptSummary),
1732
+ canonicalRecentMessagesIncluded: canonicalContext.recent_messages.length,
1323
1733
  },
1324
1734
  });
1325
1735
  const mediaPolicy = resolveMediaToolPolicy(req.content, media);
@@ -1361,12 +1771,22 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1361
1771
  req.abortSignal,
1362
1772
  media,
1363
1773
  );
1774
+ const effectiveUserContent =
1775
+ typeof output.effectiveUserPrompt === 'string' &&
1776
+ output.effectiveUserPrompt.trim()
1777
+ ? output.effectiveUserPrompt.trim()
1778
+ : req.content;
1364
1779
  const toolExecutions = output.toolExecutions || [];
1365
1780
  emitToolExecutionAuditEvents({
1366
1781
  sessionId: req.sessionId,
1367
1782
  runId,
1368
1783
  toolExecutions,
1369
1784
  });
1785
+ const usagePayload = buildTokenUsageAuditPayload(
1786
+ messages,
1787
+ output.result,
1788
+ output.tokenUsage,
1789
+ );
1370
1790
  recordAuditEvent({
1371
1791
  sessionId: req.sessionId,
1372
1792
  runId,
@@ -1376,9 +1796,19 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1376
1796
  model,
1377
1797
  durationMs: Date.now() - startedAt,
1378
1798
  toolCallCount: toolExecutions.length,
1379
- ...buildTokenUsageAuditPayload(messages, output.result, output.tokenUsage),
1799
+ ...usagePayload,
1380
1800
  },
1381
1801
  });
1802
+ recordUsageEvent({
1803
+ sessionId: req.sessionId,
1804
+ agentId,
1805
+ model,
1806
+ inputTokens: firstNumber([usagePayload.promptTokens]) || 0,
1807
+ outputTokens: firstNumber([usagePayload.completionTokens]) || 0,
1808
+ totalTokens: firstNumber([usagePayload.totalTokens]) || 0,
1809
+ toolCalls: toolExecutions.length,
1810
+ costUsd: extractUsageCostUsd(output.tokenUsage),
1811
+ });
1382
1812
 
1383
1813
  const parentDepth = extractDelegationDepth(req.sessionId);
1384
1814
  let acceptedDelegations = 0;
@@ -1387,7 +1817,11 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1387
1817
  const normalized = normalizeDelegationEffect(effect, model);
1388
1818
  if (!normalized.plan) {
1389
1819
  logger.warn(
1390
- { sessionId: req.sessionId, error: normalized.error || 'unknown', effect },
1820
+ {
1821
+ sessionId: req.sessionId,
1822
+ error: normalized.error || 'unknown',
1823
+ effect,
1824
+ },
1391
1825
  'Delegation skipped — invalid payload',
1392
1826
  );
1393
1827
  return;
@@ -1396,14 +1830,21 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1396
1830
  const childDepth = parentDepth + 1;
1397
1831
  if (childDepth > PROACTIVE_DELEGATION_MAX_DEPTH) {
1398
1832
  logger.info(
1399
- { sessionId: req.sessionId, childDepth, maxDepth: PROACTIVE_DELEGATION_MAX_DEPTH },
1833
+ {
1834
+ sessionId: req.sessionId,
1835
+ childDepth,
1836
+ maxDepth: PROACTIVE_DELEGATION_MAX_DEPTH,
1837
+ },
1400
1838
  'Delegation skipped — depth limit reached',
1401
1839
  );
1402
1840
  return;
1403
1841
  }
1404
1842
 
1405
1843
  const requestedRuns = normalized.plan.tasks.length;
1406
- if (acceptedDelegations + requestedRuns > PROACTIVE_DELEGATION_MAX_PER_TURN) {
1844
+ if (
1845
+ acceptedDelegations + requestedRuns >
1846
+ PROACTIVE_DELEGATION_MAX_PER_TURN
1847
+ ) {
1407
1848
  logger.info(
1408
1849
  {
1409
1850
  sessionId: req.sessionId,
@@ -1477,10 +1918,20 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1477
1918
 
1478
1919
  const resultText = output.result || 'No response from agent.';
1479
1920
  recordSuccessfulTurn({
1480
- sessionId: req.sessionId, agentId, chatbotId, enableRag, model,
1481
- channelId: req.channelId, runId, turnIndex,
1482
- userId: req.userId, username: req.username,
1483
- userContent: req.content, resultText, toolCallCount: toolExecutions.length, startedAt,
1921
+ sessionId: req.sessionId,
1922
+ agentId,
1923
+ chatbotId,
1924
+ enableRag,
1925
+ model,
1926
+ channelId: req.channelId,
1927
+ runId,
1928
+ turnIndex,
1929
+ userId: req.userId,
1930
+ username: req.username,
1931
+ userContent: effectiveUserContent,
1932
+ resultText,
1933
+ toolCallCount: toolExecutions.length,
1934
+ startedAt,
1484
1935
  });
1485
1936
 
1486
1937
  return {
@@ -1490,11 +1941,20 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1490
1941
  artifacts: output.artifacts,
1491
1942
  toolExecutions,
1492
1943
  tokenUsage: output.tokenUsage,
1944
+ effectiveUserPrompt: output.effectiveUserPrompt,
1493
1945
  };
1494
1946
  } catch (err) {
1495
1947
  const errorMsg = err instanceof Error ? err.message : String(err);
1496
- logAudit('error', req.sessionId, { error: errorMsg }, Date.now() - startedAt);
1497
- logger.error({ sessionId: req.sessionId, err }, 'Gateway message handling failed');
1948
+ logAudit(
1949
+ 'error',
1950
+ req.sessionId,
1951
+ { error: errorMsg },
1952
+ Date.now() - startedAt,
1953
+ );
1954
+ logger.error(
1955
+ { sessionId: req.sessionId, err },
1956
+ 'Gateway message handling failed',
1957
+ );
1498
1958
  recordAuditEvent({
1499
1959
  sessionId: req.sessionId,
1500
1960
  runId,
@@ -1545,8 +2005,13 @@ export async function runGatewayScheduledTask(
1545
2005
  taskId: number,
1546
2006
  onResult: (result: ProactiveMessagePayload) => Promise<void>,
1547
2007
  onError: (error: unknown) => void,
2008
+ runKey?: string,
1548
2009
  ): Promise<void> {
1549
- const session = getOrCreateSession(origSessionId, null, channelId);
2010
+ const session = memoryService.getOrCreateSession(
2011
+ origSessionId,
2012
+ null,
2013
+ channelId,
2014
+ );
1550
2015
  const chatbotId = session.chatbot_id || HYBRIDAI_CHATBOT_ID;
1551
2016
  if (!chatbotId) return;
1552
2017
  const model = session.model || HYBRIDAI_MODEL;
@@ -1559,14 +2024,21 @@ export async function runGatewayScheduledTask(
1559
2024
  chatbotId,
1560
2025
  model,
1561
2026
  agentId,
2027
+ sessionKey: runKey,
1562
2028
  onResult,
1563
2029
  onError,
1564
2030
  });
1565
2031
  }
1566
2032
 
1567
- export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<GatewayCommandResult> {
2033
+ export async function handleGatewayCommand(
2034
+ req: GatewayCommandRequest,
2035
+ ): Promise<GatewayCommandResult> {
1568
2036
  const cmd = (req.args[0] || '').toLowerCase();
1569
- const session = getOrCreateSession(req.sessionId, req.guildId, req.channelId);
2037
+ const session = memoryService.getOrCreateSession(
2038
+ req.sessionId,
2039
+ req.guildId,
2040
+ req.channelId,
2041
+ );
1570
2042
 
1571
2043
  switch (cmd) {
1572
2044
  case 'help': {
@@ -1578,11 +2050,19 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1578
2050
  '`model set <name>` — Set model for this session',
1579
2051
  '`model info` — Show current model',
1580
2052
  '`rag [on|off]` — Toggle or set RAG mode',
2053
+ '`channel mode [off|mention|free]` — Set or inspect this Discord channel response mode',
2054
+ '`channel policy [open|allowlist|disabled]` — Set or inspect guild channel policy',
1581
2055
  '`ralph [on|off|set <n>|info]` — Configure Ralph loop (0 off, -1 unlimited)',
1582
2056
  '`clear` — Clear session history',
1583
2057
  '`/status` — Show runtime status (Discord slash command, private to caller)',
2058
+ '`/channel-mode <off|mention|free>` — Set this Discord channel response mode',
2059
+ '`/channel-policy <open|allowlist|disabled>` — Set Discord guild channel policy',
1584
2060
  '`sessions` — List active sessions',
1585
- '`schedule add "<cron>" <prompt>`Add scheduled task',
2061
+ '`usage [summary|daily|monthly|model [daily|monthly] [agentId]]`Usage/cost aggregates',
2062
+ '`export session [sessionId]` — Export session JSONL snapshot for debugging',
2063
+ '`schedule add "<cron>" <prompt>` — Add cron scheduled task',
2064
+ '`schedule add at "<ISO time>" <prompt>` — Add one-shot task',
2065
+ '`schedule add every <ms> <prompt>` — Add interval task',
1586
2066
  '`schedule list` — List scheduled tasks',
1587
2067
  '`schedule remove <id>` — Remove a task',
1588
2068
  '`schedule toggle <id>` — Enable/disable a task',
@@ -1596,30 +2076,41 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1596
2076
  try {
1597
2077
  const bots = await fetchHybridAIBots({ cacheTtlMs: BOT_CACHE_TTL });
1598
2078
  if (bots.length === 0) return plainCommand('No bots available.');
1599
- const list = bots.map((b) =>
1600
- `• ${b.name} (${b.id})${b.description ? ` — ${b.description}` : ''}`
1601
- ).join('\n');
2079
+ const list = bots
2080
+ .map(
2081
+ (b) =>
2082
+ `• ${b.name} (${b.id})${b.description ? ` — ${b.description}` : ''}`,
2083
+ )
2084
+ .join('\n');
1602
2085
  return infoCommand('Available Bots', list);
1603
2086
  } catch (err) {
1604
- return badCommand('Error', `Failed to fetch bots: ${err instanceof Error ? err.message : String(err)}`);
2087
+ return badCommand(
2088
+ 'Error',
2089
+ `Failed to fetch bots: ${err instanceof Error ? err.message : String(err)}`,
2090
+ );
1605
2091
  }
1606
2092
  }
1607
2093
 
1608
2094
  if (sub === 'set') {
1609
2095
  const requested = req.args.slice(2).join(' ').trim();
1610
- if (!requested) return badCommand('Usage', 'Usage: `bot set <id|name>`');
2096
+ if (!requested)
2097
+ return badCommand('Usage', 'Usage: `bot set <id|name>`');
1611
2098
  let resolvedBotId = requested;
1612
2099
  try {
1613
2100
  const bots = await fetchHybridAIBots({ cacheTtlMs: BOT_CACHE_TTL });
1614
- const matched = bots.find((b) =>
1615
- b.id === requested || b.name.toLowerCase() === requested.toLowerCase()
2101
+ const matched = bots.find(
2102
+ (b) =>
2103
+ b.id === requested ||
2104
+ b.name.toLowerCase() === requested.toLowerCase(),
1616
2105
  );
1617
2106
  if (matched) resolvedBotId = matched.id;
1618
2107
  } catch {
1619
2108
  // keep user-supplied value when lookup fails
1620
2109
  }
1621
2110
  updateSessionChatbot(session.id, resolvedBotId);
1622
- return plainCommand(`Chatbot set to \`${resolvedBotId}\` for this session.`);
2111
+ return plainCommand(
2112
+ `Chatbot set to \`${resolvedBotId}\` for this session.`,
2113
+ );
1623
2114
  }
1624
2115
 
1625
2116
  if (sub === 'info') {
@@ -1634,7 +2125,10 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1634
2125
  }
1635
2126
  const model = session.model || HYBRIDAI_MODEL;
1636
2127
  const ragStatus = session.enable_rag ? 'Enabled' : 'Disabled';
1637
- return infoCommand('Bot Info', `Chatbot: ${botLabel}\nModel: ${model}\nRAG: ${ragStatus}`);
2128
+ return infoCommand(
2129
+ 'Bot Info',
2130
+ `Chatbot: ${botLabel}\nModel: ${model}\nRAG: ${ragStatus}`,
2131
+ );
1638
2132
  }
1639
2133
 
1640
2134
  return badCommand('Usage', 'Usage: `bot list|set <id|name>|info`');
@@ -1645,7 +2139,7 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1645
2139
  if (sub === 'list') {
1646
2140
  const current = session.model || HYBRIDAI_MODEL;
1647
2141
  const list = HYBRIDAI_MODELS.map((m) =>
1648
- m === current ? `${m} (current)` : m
2142
+ m === current ? `${m} (current)` : m,
1649
2143
  ).join('\n');
1650
2144
  return infoCommand('Available Models', list);
1651
2145
  }
@@ -1653,8 +2147,14 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1653
2147
  if (sub === 'set') {
1654
2148
  const modelName = req.args[2];
1655
2149
  if (!modelName) return badCommand('Usage', 'Usage: `model set <name>`');
1656
- if (HYBRIDAI_MODELS.length > 0 && !HYBRIDAI_MODELS.includes(modelName)) {
1657
- return badCommand('Unknown Model', `\`${modelName}\` is not in the available models list.`);
2150
+ if (
2151
+ HYBRIDAI_MODELS.length > 0 &&
2152
+ !HYBRIDAI_MODELS.includes(modelName)
2153
+ ) {
2154
+ return badCommand(
2155
+ 'Unknown Model',
2156
+ `\`${modelName}\` is not in the available models list.`,
2157
+ );
1658
2158
  }
1659
2159
  updateSessionModel(session.id, modelName);
1660
2160
  return plainCommand(`Model set to \`${modelName}\` for this session.`);
@@ -1662,7 +2162,10 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1662
2162
 
1663
2163
  if (sub === 'info') {
1664
2164
  const current = session.model || HYBRIDAI_MODEL;
1665
- return infoCommand('Model Info', `Current model: ${current}\nDefault model: ${HYBRIDAI_MODEL}`);
2165
+ return infoCommand(
2166
+ 'Model Info',
2167
+ `Current model: ${current}\nDefault model: ${HYBRIDAI_MODEL}`,
2168
+ );
1666
2169
  }
1667
2170
 
1668
2171
  return badCommand('Usage', 'Usage: `model list|set <name>|info`');
@@ -1672,20 +2175,100 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1672
2175
  const sub = req.args[1]?.toLowerCase();
1673
2176
  if (sub === 'on' || sub === 'off') {
1674
2177
  updateSessionRag(session.id, sub === 'on');
1675
- return plainCommand(`RAG ${sub === 'on' ? 'enabled' : 'disabled'} for this session.`);
2178
+ return plainCommand(
2179
+ `RAG ${sub === 'on' ? 'enabled' : 'disabled'} for this session.`,
2180
+ );
1676
2181
  }
1677
2182
  if (!sub) {
1678
2183
  const nextEnabled = session.enable_rag === 0;
1679
2184
  updateSessionRag(session.id, nextEnabled);
1680
- return plainCommand(`RAG ${nextEnabled ? 'enabled' : 'disabled'} for this session.`);
2185
+ return plainCommand(
2186
+ `RAG ${nextEnabled ? 'enabled' : 'disabled'} for this session.`,
2187
+ );
1681
2188
  }
1682
2189
  return badCommand('Usage', 'Usage: `rag [on|off]`');
1683
2190
  }
1684
2191
 
2192
+ case 'channel': {
2193
+ const sub = (req.args[1] || '').toLowerCase();
2194
+ if (sub === 'mode' || !sub) {
2195
+ const guildId = req.guildId;
2196
+ if (!guildId) {
2197
+ return badCommand(
2198
+ 'Guild Only',
2199
+ '`channel mode` is only available in Discord guild channels.',
2200
+ );
2201
+ }
2202
+ const requestedMode = (req.args[sub ? 2 : 1] || '').toLowerCase();
2203
+ if (!requestedMode) {
2204
+ const currentMode = resolveGuildChannelMode(guildId, req.channelId);
2205
+ return infoCommand(
2206
+ 'Channel Mode',
2207
+ [
2208
+ `Current mode: \`${currentMode}\``,
2209
+ `Group policy: \`${DISCORD_GROUP_POLICY}\``,
2210
+ `Config path: \`discord.guilds.${guildId}.channels.${req.channelId}.mode\``,
2211
+ 'Usage: `channel mode off|mention|free`',
2212
+ ].join('\n'),
2213
+ );
2214
+ }
2215
+ if (!DISCORD_CHANNEL_MODE_VALUES.has(requestedMode)) {
2216
+ return badCommand('Usage', 'Usage: `channel mode off|mention|free`');
2217
+ }
2218
+ const mode = requestedMode as 'off' | 'mention' | 'free';
2219
+ updateRuntimeConfig((draft) => {
2220
+ const guild = draft.discord.guilds[guildId] ?? {
2221
+ defaultMode: 'mention',
2222
+ channels: {},
2223
+ };
2224
+ guild.channels[req.channelId] = { mode };
2225
+ draft.discord.guilds[guildId] = guild;
2226
+ });
2227
+ return plainCommand(
2228
+ `Set channel mode to \`${mode}\` for this channel. (Policy: \`${DISCORD_GROUP_POLICY}\`)`,
2229
+ );
2230
+ }
2231
+
2232
+ if (sub === 'policy') {
2233
+ const requestedPolicy = (req.args[2] || '').toLowerCase();
2234
+ if (!requestedPolicy) {
2235
+ return infoCommand(
2236
+ 'Channel Policy',
2237
+ [
2238
+ `Current policy: \`${DISCORD_GROUP_POLICY}\``,
2239
+ 'Policies:',
2240
+ '• `open` — all guild channels are active unless a per-channel mode overrides',
2241
+ '• `allowlist` — only channels listed under `discord.guilds.<guild>.channels` are active',
2242
+ '• `disabled` — all guild channels are disabled',
2243
+ 'Usage: `channel policy open|allowlist|disabled`',
2244
+ ].join('\n'),
2245
+ );
2246
+ }
2247
+ if (!DISCORD_GROUP_POLICY_VALUES.has(requestedPolicy)) {
2248
+ return badCommand(
2249
+ 'Usage',
2250
+ 'Usage: `channel policy open|allowlist|disabled`',
2251
+ );
2252
+ }
2253
+ const policy = requestedPolicy as 'open' | 'allowlist' | 'disabled';
2254
+ updateRuntimeConfig((draft) => {
2255
+ draft.discord.groupPolicy = policy;
2256
+ });
2257
+ return plainCommand(`Discord group policy set to \`${policy}\`.`);
2258
+ }
2259
+
2260
+ return badCommand(
2261
+ 'Usage',
2262
+ 'Usage: `channel mode [off|mention|free]` or `channel policy [open|allowlist|disabled]`',
2263
+ );
2264
+ }
2265
+
1685
2266
  case 'ralph': {
1686
2267
  const sub = (req.args[1] || '').toLowerCase();
1687
2268
  if (!sub || sub === 'info' || sub === 'status') {
1688
- const current = normalizeRalphIterations(PROACTIVE_RALPH_MAX_ITERATIONS);
2269
+ const current = normalizeRalphIterations(
2270
+ PROACTIVE_RALPH_MAX_ITERATIONS,
2271
+ );
1689
2272
  return infoCommand(
1690
2273
  'Ralph Loop',
1691
2274
  [
@@ -1698,19 +2281,31 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1698
2281
 
1699
2282
  let nextValue: number | null = null;
1700
2283
  if (sub === 'on') {
1701
- nextValue = PROACTIVE_RALPH_MAX_ITERATIONS === 0 ? 3 : PROACTIVE_RALPH_MAX_ITERATIONS;
2284
+ nextValue =
2285
+ PROACTIVE_RALPH_MAX_ITERATIONS === 0
2286
+ ? 3
2287
+ : PROACTIVE_RALPH_MAX_ITERATIONS;
1702
2288
  } else if (sub === 'off') {
1703
2289
  nextValue = 0;
1704
2290
  } else if (sub === 'set') {
1705
2291
  if (req.args[2] == null) {
1706
- return badCommand('Usage', 'Usage: `ralph set <n>` (0=off, -1=unlimited, 1-64=extra iterations)');
2292
+ return badCommand(
2293
+ 'Usage',
2294
+ 'Usage: `ralph set <n>` (0=off, -1=unlimited, 1-64=extra iterations)',
2295
+ );
1707
2296
  }
1708
2297
  const parsed = Number.parseInt(req.args[2], 10);
1709
2298
  if (Number.isNaN(parsed)) {
1710
- return badCommand('Usage', 'Usage: `ralph set <n>` where n is an integer');
2299
+ return badCommand(
2300
+ 'Usage',
2301
+ 'Usage: `ralph set <n>` where n is an integer',
2302
+ );
1711
2303
  }
1712
2304
  if (parsed < -1 || parsed > MAX_RALPH_ITERATIONS) {
1713
- return badCommand('Range', `Ralph iterations must be between -1 and ${MAX_RALPH_ITERATIONS}.`);
2305
+ return badCommand(
2306
+ 'Range',
2307
+ `Ralph iterations must be between -1 and ${MAX_RALPH_ITERATIONS}.`,
2308
+ );
1714
2309
  }
1715
2310
  nextValue = parsed;
1716
2311
  } else {
@@ -1719,7 +2314,10 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1719
2314
  return badCommand('Usage', 'Usage: `ralph on|off|set <n>|info`');
1720
2315
  }
1721
2316
  if (parsed < -1 || parsed > MAX_RALPH_ITERATIONS) {
1722
- return badCommand('Range', `Ralph iterations must be between -1 and ${MAX_RALPH_ITERATIONS}.`);
2317
+ return badCommand(
2318
+ 'Range',
2319
+ `Ralph iterations must be between -1 and ${MAX_RALPH_ITERATIONS}.`,
2320
+ );
1723
2321
  }
1724
2322
  nextValue = parsed;
1725
2323
  }
@@ -1732,12 +2330,17 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1732
2330
  const restartNote = restarted
1733
2331
  ? ' Current session container restarted to apply immediately.'
1734
2332
  : '';
1735
- return plainCommand(`Ralph loop set to ${formatRalphIterations(normalized)}.${restartNote}`);
2333
+ return plainCommand(
2334
+ `Ralph loop set to ${formatRalphIterations(normalized)}.${restartNote}`,
2335
+ );
1736
2336
  }
1737
2337
 
1738
2338
  case 'clear': {
1739
- const deleted = clearSessionHistory(session.id);
1740
- return infoCommand('Session Cleared', `Deleted ${deleted} messages. Workspace files preserved.`);
2339
+ const deleted = memoryService.clearSessionHistory(session.id);
2340
+ return infoCommand(
2341
+ 'Session Cleared',
2342
+ `Deleted ${deleted} messages. Workspace files preserved.`,
2343
+ );
1741
2344
  }
1742
2345
 
1743
2346
  case 'status': {
@@ -1748,14 +2351,14 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1748
2351
  const sessionModel = session.model || HYBRIDAI_MODEL;
1749
2352
  const queueLabel = `${delegationStatus.active} active / ${delegationStatus.queued} queued`;
1750
2353
  const proactiveQueued = getQueuedProactiveMessageCount();
1751
- const cacheKnown = metrics.cacheReadTokens != null || metrics.cacheWriteTokens != null;
1752
- const contextLabel = (
2354
+ const cacheKnown =
2355
+ metrics.cacheReadTokens != null || metrics.cacheWriteTokens != null;
2356
+ const contextLabel =
1753
2357
  metrics.contextUsedTokens != null && metrics.contextBudgetTokens != null
1754
- )
1755
- ? `${formatCompactNumber(metrics.contextUsedTokens)}/${formatCompactNumber(metrics.contextBudgetTokens)} (${formatPercent(metrics.contextUsagePercent)})`
1756
- : metrics.contextUsedTokens != null
1757
- ? `${formatCompactNumber(metrics.contextUsedTokens)} est`
1758
- : 'n/a';
2358
+ ? `${formatCompactNumber(metrics.contextUsedTokens)}/${formatCompactNumber(metrics.contextBudgetTokens)} (${formatPercent(metrics.contextUsagePercent)})`
2359
+ : metrics.contextUsedTokens != null
2360
+ ? `${formatCompactNumber(metrics.contextUsedTokens)} est`
2361
+ : 'n/a';
1759
2362
  const lines = [
1760
2363
  `🦞 HybridClaw v${status.version}${commitShort ? ` (${commitShort})` : ''}`,
1761
2364
  `🧠 Model: ${sessionModel}`,
@@ -1775,43 +2378,244 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1775
2378
  case 'sessions': {
1776
2379
  const sessions = getAllSessions();
1777
2380
  if (sessions.length === 0) return plainCommand('No active sessions.');
1778
- const list = sessions.slice(0, 20).map((s) =>
1779
- `${s.id} — ${s.message_count} msgs, last active ${s.last_active}`
1780
- ).join('\n');
2381
+ const list = sessions
2382
+ .slice(0, 20)
2383
+ .map(
2384
+ (s) =>
2385
+ `${s.id} — ${s.message_count} msgs, last active ${s.last_active}`,
2386
+ )
2387
+ .join('\n');
1781
2388
  return infoCommand('Sessions', list);
1782
2389
  }
1783
2390
 
2391
+ case 'usage': {
2392
+ const sub = (req.args[1] || 'summary').toLowerCase();
2393
+ if (sub === 'daily' || sub === 'monthly') {
2394
+ const rows = listUsageByAgent({ window: sub });
2395
+ if (rows.length === 0) {
2396
+ return plainCommand(`No usage events recorded for ${sub} window.`);
2397
+ }
2398
+ const lines = rows.slice(0, 20).map((row) => {
2399
+ return `${row.agent_id} — ${formatCompactNumber(row.total_tokens)} tokens (${formatCompactNumber(row.total_input_tokens)} in / ${formatCompactNumber(row.total_output_tokens)} out) · ${row.call_count} calls · ${formatUsd(row.total_cost_usd)}`;
2400
+ });
2401
+ return infoCommand(`Usage (${sub} · by agent)`, lines.join('\n'));
2402
+ }
2403
+
2404
+ if (sub === 'model') {
2405
+ const maybeWindow = (req.args[2] || '').toLowerCase();
2406
+ const window =
2407
+ maybeWindow === 'daily' || maybeWindow === 'monthly'
2408
+ ? maybeWindow
2409
+ : 'monthly';
2410
+ const modelAgentId =
2411
+ maybeWindow === 'daily' || maybeWindow === 'monthly'
2412
+ ? (req.args[3] || '').trim()
2413
+ : (req.args[2] || '').trim();
2414
+ const rows = listUsageByModel({
2415
+ window,
2416
+ agentId: modelAgentId || undefined,
2417
+ });
2418
+ if (rows.length === 0) {
2419
+ return plainCommand('No usage events recorded for model breakdown.');
2420
+ }
2421
+ const lines = rows.slice(0, 20).map((row) => {
2422
+ return `${row.model} — ${formatCompactNumber(row.total_tokens)} tokens · ${row.call_count} calls · ${formatUsd(row.total_cost_usd)}`;
2423
+ });
2424
+ const scope = modelAgentId ? `agent ${modelAgentId}` : 'all agents';
2425
+ return infoCommand(
2426
+ `Usage (${window} · by model · ${scope})`,
2427
+ lines.join('\n'),
2428
+ );
2429
+ }
2430
+
2431
+ if (sub !== 'summary') {
2432
+ return badCommand(
2433
+ 'Usage',
2434
+ 'Usage: `usage [summary|daily|monthly|model [daily|monthly] [agentId]]`',
2435
+ );
2436
+ }
2437
+
2438
+ const currentAgentId = resolveSessionAgentId(session);
2439
+ const daily = getUsageTotals({
2440
+ agentId: currentAgentId || undefined,
2441
+ window: 'daily',
2442
+ });
2443
+ const monthly = getUsageTotals({
2444
+ agentId: currentAgentId || undefined,
2445
+ window: 'monthly',
2446
+ });
2447
+ const topModels = listUsageByModel({
2448
+ agentId: currentAgentId || undefined,
2449
+ window: 'monthly',
2450
+ }).slice(0, 5);
2451
+ const scopeLabel = currentAgentId || 'all agents';
2452
+ const lines = [
2453
+ `Scope: ${scopeLabel}`,
2454
+ `Today: ${formatCompactNumber(daily.total_tokens)} tokens · ${daily.call_count} calls · ${formatUsd(daily.total_cost_usd)}`,
2455
+ `Month: ${formatCompactNumber(monthly.total_tokens)} tokens · ${monthly.call_count} calls · ${formatUsd(monthly.total_cost_usd)}`,
2456
+ ];
2457
+ if (topModels.length > 0) {
2458
+ lines.push('Top models (monthly):');
2459
+ lines.push(
2460
+ ...topModels.map(
2461
+ (row) =>
2462
+ `- ${row.model}: ${formatCompactNumber(row.total_tokens)} tokens · ${formatUsd(row.total_cost_usd)}`,
2463
+ ),
2464
+ );
2465
+ }
2466
+ return infoCommand('Usage Summary', lines.join('\n'));
2467
+ }
2468
+
2469
+ case 'export': {
2470
+ const sub = (req.args[1] || 'session').toLowerCase();
2471
+ if (sub !== 'session') {
2472
+ return badCommand('Usage', 'Usage: `export session [sessionId]`');
2473
+ }
2474
+ const targetSessionId = (req.args[2] || session.id || '').trim();
2475
+ if (!targetSessionId) {
2476
+ return badCommand('Usage', 'Usage: `export session [sessionId]`');
2477
+ }
2478
+ const targetSession = memoryService.getSessionById(targetSessionId);
2479
+ if (!targetSession) {
2480
+ return badCommand(
2481
+ 'Not Found',
2482
+ `Session \`${targetSessionId}\` was not found.`,
2483
+ );
2484
+ }
2485
+ const exportAgentId =
2486
+ resolveSessionAgentId(targetSession) || resolveSessionAgentId(session);
2487
+ if (!exportAgentId) {
2488
+ return badCommand(
2489
+ 'Missing Agent',
2490
+ 'Cannot export session: no agent/chatbot is configured for the target session.',
2491
+ );
2492
+ }
2493
+ const messages = memoryService.getRecentMessages(targetSessionId);
2494
+ const exported = exportSessionSnapshotJsonl({
2495
+ agentId: exportAgentId,
2496
+ sessionId: targetSessionId,
2497
+ channelId: targetSession.channel_id,
2498
+ summary: targetSession.session_summary,
2499
+ messages,
2500
+ reason: 'manual',
2501
+ });
2502
+ if (!exported) {
2503
+ return badCommand(
2504
+ 'Export Failed',
2505
+ 'Failed to write session export JSONL file. Check gateway logs for details.',
2506
+ );
2507
+ }
2508
+ return infoCommand(
2509
+ 'Session Exported',
2510
+ [
2511
+ `File: ${exported.path}`,
2512
+ `Messages: ${messages.length}`,
2513
+ `Summary: ${targetSession.session_summary ? 'yes' : 'no'}`,
2514
+ ].join('\n'),
2515
+ );
2516
+ }
2517
+
1784
2518
  case 'schedule': {
1785
2519
  const sub = req.args[1]?.toLowerCase();
1786
2520
  if (sub === 'add') {
1787
2521
  const rest = req.args.slice(2).join(' ');
2522
+ const atMatch = rest.match(/^at\s+"([^"]+)"\s+(.+)$/i);
2523
+ if (atMatch) {
2524
+ const [, runAtRaw, prompt] = atMatch;
2525
+ const parsedDate = new Date(runAtRaw);
2526
+ if (Number.isNaN(parsedDate.getTime())) {
2527
+ return badCommand(
2528
+ 'Invalid Time',
2529
+ `\`${runAtRaw}\` is not a valid ISO timestamp.`,
2530
+ );
2531
+ }
2532
+ const taskId = createTask(
2533
+ session.id,
2534
+ req.channelId,
2535
+ '',
2536
+ prompt,
2537
+ parsedDate.toISOString(),
2538
+ );
2539
+ rearmScheduler();
2540
+ return plainCommand(
2541
+ `Task #${taskId} created: one-shot at \`${parsedDate.toISOString()}\` — ${prompt}`,
2542
+ );
2543
+ }
2544
+
2545
+ const everyMatch = rest.match(/^every\s+(\d+)\s+(.+)$/i);
2546
+ if (everyMatch) {
2547
+ const [, everyRaw, prompt] = everyMatch;
2548
+ const everyMs = Number.parseInt(everyRaw, 10);
2549
+ if (!Number.isFinite(everyMs) || everyMs < 10_000) {
2550
+ return badCommand(
2551
+ 'Invalid Interval',
2552
+ 'Interval must be at least 10000ms.',
2553
+ );
2554
+ }
2555
+ const taskId = createTask(
2556
+ session.id,
2557
+ req.channelId,
2558
+ '',
2559
+ prompt,
2560
+ undefined,
2561
+ everyMs,
2562
+ );
2563
+ rearmScheduler();
2564
+ return plainCommand(
2565
+ `Task #${taskId} created: every \`${everyMs}ms\` — ${prompt}`,
2566
+ );
2567
+ }
2568
+
1788
2569
  const cronMatch = rest.match(/^"([^"]+)"\s+(.+)$/);
1789
2570
  if (!cronMatch) {
1790
- return badCommand('Usage', 'Usage: `schedule add "<cron>" <prompt>`');
2571
+ return badCommand(
2572
+ 'Usage',
2573
+ 'Usage: `schedule add "<cron>" <prompt>` or `schedule add at "<ISO time>" <prompt>` or `schedule add every <ms> <prompt>`',
2574
+ );
1791
2575
  }
1792
2576
  const [, cronExpr, prompt] = cronMatch;
1793
2577
  try {
1794
2578
  CronExpressionParser.parse(cronExpr);
1795
2579
  } catch {
1796
- return badCommand('Invalid Cron', `\`${cronExpr}\` is not a valid cron expression.`);
2580
+ return badCommand(
2581
+ 'Invalid Cron',
2582
+ `\`${cronExpr}\` is not a valid cron expression.`,
2583
+ );
1797
2584
  }
1798
2585
  const taskId = createTask(session.id, req.channelId, cronExpr, prompt);
1799
2586
  rearmScheduler();
1800
- return plainCommand(`Task #${taskId} created: \`${cronExpr}\` — ${prompt}`);
2587
+ return plainCommand(
2588
+ `Task #${taskId} created: cron \`${cronExpr}\` — ${prompt}`,
2589
+ );
1801
2590
  }
1802
2591
 
1803
2592
  if (sub === 'list') {
1804
2593
  const tasks = getTasksForSession(session.id);
1805
2594
  if (tasks.length === 0) return plainCommand('No scheduled tasks.');
1806
- const list = tasks.map((task) =>
1807
- `#${task.id} ${task.enabled ? 'enabled' : 'disabled'} \`${task.cron_expr}\` — ${task.prompt.slice(0, 60)}`
1808
- ).join('\n');
2595
+ const list = tasks
2596
+ .map((task) => {
2597
+ const scheduleLabel = task.run_at
2598
+ ? `at ${task.run_at}`
2599
+ : task.every_ms
2600
+ ? `every ${task.every_ms}ms`
2601
+ : task.cron_expr
2602
+ ? `cron ${task.cron_expr}`
2603
+ : 'unspecified';
2604
+ const statusLabel = task.last_status || 'n/a';
2605
+ const errorSuffix =
2606
+ task.consecutive_errors > 0
2607
+ ? ` · errors ${task.consecutive_errors}`
2608
+ : '';
2609
+ return `#${task.id} ${task.enabled ? 'enabled' : 'disabled'} (${scheduleLabel}) [${statusLabel}${errorSuffix}] — ${task.prompt.slice(0, 60)}`;
2610
+ })
2611
+ .join('\n');
1809
2612
  return infoCommand('Scheduled Tasks', list);
1810
2613
  }
1811
2614
 
1812
2615
  if (sub === 'remove') {
1813
2616
  const taskId = parseIntOrNull(req.args[2]);
1814
- if (!taskId) return badCommand('Usage', 'Usage: `schedule remove <id>`');
2617
+ if (!taskId)
2618
+ return badCommand('Usage', 'Usage: `schedule remove <id>`');
1815
2619
  deleteTask(taskId);
1816
2620
  rearmScheduler();
1817
2621
  return plainCommand(`Task #${taskId} removed.`);
@@ -1819,19 +2623,33 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1819
2623
 
1820
2624
  if (sub === 'toggle') {
1821
2625
  const taskId = parseIntOrNull(req.args[2]);
1822
- if (!taskId) return badCommand('Usage', 'Usage: `schedule toggle <id>`');
2626
+ if (!taskId)
2627
+ return badCommand('Usage', 'Usage: `schedule toggle <id>`');
1823
2628
  const tasks = getTasksForSession(session.id);
1824
2629
  const task = tasks.find((t) => t.id === taskId);
1825
- if (!task) return badCommand('Not Found', `Task #${taskId} was not found in this session.`);
1826
- toggleTask(taskId, !Boolean(task.enabled));
2630
+ if (!task)
2631
+ return badCommand(
2632
+ 'Not Found',
2633
+ `Task #${taskId} was not found in this session.`,
2634
+ );
2635
+ if (task.enabled) {
2636
+ pauseTask(taskId);
2637
+ } else {
2638
+ resumeTask(taskId);
2639
+ }
1827
2640
  rearmScheduler();
1828
- return plainCommand(`Task #${taskId} ${task.enabled ? 'disabled' : 'enabled'}.`);
2641
+ return plainCommand(
2642
+ `Task #${taskId} ${task.enabled ? 'disabled' : 'enabled'}.`,
2643
+ );
1829
2644
  }
1830
2645
 
1831
2646
  return badCommand('Usage', 'Usage: `schedule add|list|remove|toggle`');
1832
2647
  }
1833
2648
 
1834
2649
  default:
1835
- return badCommand('Unknown Command', `Unknown command: \`${cmd || '(empty)'}\`.`);
2650
+ return badCommand(
2651
+ 'Unknown Command',
2652
+ `Unknown command: \`${cmd || '(empty)'}\`.`,
2653
+ );
1836
2654
  }
1837
2655
  }