@hybridaione/hybridclaw 0.2.3 → 0.2.7

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 (285) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +95 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +57 -19
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +72 -7
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1405 -0
  12. package/container/src/browser-tools.ts +672 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +65 -16
  15. package/container/src/index.ts +463 -110
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +107 -18
  18. package/container/src/tools.ts +600 -225
  19. package/container/src/types.ts +49 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +15 -4
  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 +24 -4
  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 +31 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +156 -6
  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 +7 -4
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +755 -150
  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 +19 -12
  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 +77 -23
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +24 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +113 -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 +59 -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 +624 -87
  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 +303 -26
  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 +62 -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/hybridai-models.d.ts +8 -0
  132. package/dist/hybridai-models.d.ts.map +1 -0
  133. package/dist/hybridai-models.js +94 -0
  134. package/dist/hybridai-models.js.map +1 -0
  135. package/dist/instruction-approval-audit.d.ts.map +1 -1
  136. package/dist/instruction-approval-audit.js.map +1 -1
  137. package/dist/instruction-integrity.d.ts.map +1 -1
  138. package/dist/instruction-integrity.js +8 -2
  139. package/dist/instruction-integrity.js.map +1 -1
  140. package/dist/ipc.d.ts.map +1 -1
  141. package/dist/ipc.js +6 -1
  142. package/dist/ipc.js.map +1 -1
  143. package/dist/logger.js.map +1 -1
  144. package/dist/memory-consolidation.d.ts +17 -0
  145. package/dist/memory-consolidation.d.ts.map +1 -0
  146. package/dist/memory-consolidation.js +25 -0
  147. package/dist/memory-consolidation.js.map +1 -0
  148. package/dist/memory-service.d.ts +200 -0
  149. package/dist/memory-service.d.ts.map +1 -0
  150. package/dist/memory-service.js +294 -0
  151. package/dist/memory-service.js.map +1 -0
  152. package/dist/mount-security.d.ts.map +1 -1
  153. package/dist/mount-security.js +31 -7
  154. package/dist/mount-security.js.map +1 -1
  155. package/dist/observability-ingest.d.ts.map +1 -1
  156. package/dist/observability-ingest.js +32 -11
  157. package/dist/observability-ingest.js.map +1 -1
  158. package/dist/onboarding.d.ts.map +1 -1
  159. package/dist/onboarding.js +32 -9
  160. package/dist/onboarding.js.map +1 -1
  161. package/dist/proactive-policy.d.ts.map +1 -1
  162. package/dist/proactive-policy.js +2 -1
  163. package/dist/proactive-policy.js.map +1 -1
  164. package/dist/prompt-hooks.d.ts.map +1 -1
  165. package/dist/prompt-hooks.js +11 -7
  166. package/dist/prompt-hooks.js.map +1 -1
  167. package/dist/runtime-config.d.ts +104 -1
  168. package/dist/runtime-config.d.ts.map +1 -1
  169. package/dist/runtime-config.js +503 -24
  170. package/dist/runtime-config.js.map +1 -1
  171. package/dist/scheduled-task-runner.d.ts +1 -0
  172. package/dist/scheduled-task-runner.d.ts.map +1 -1
  173. package/dist/scheduled-task-runner.js +43 -10
  174. package/dist/scheduled-task-runner.js.map +1 -1
  175. package/dist/scheduler.d.ts +43 -4
  176. package/dist/scheduler.d.ts.map +1 -1
  177. package/dist/scheduler.js +530 -56
  178. package/dist/scheduler.js.map +1 -1
  179. package/dist/session-export.d.ts +26 -0
  180. package/dist/session-export.d.ts.map +1 -0
  181. package/dist/session-export.js +149 -0
  182. package/dist/session-export.js.map +1 -0
  183. package/dist/session-maintenance.d.ts.map +1 -1
  184. package/dist/session-maintenance.js +75 -13
  185. package/dist/session-maintenance.js.map +1 -1
  186. package/dist/session-transcripts.d.ts.map +1 -1
  187. package/dist/session-transcripts.js.map +1 -1
  188. package/dist/side-effects.d.ts.map +1 -1
  189. package/dist/side-effects.js +14 -2
  190. package/dist/side-effects.js.map +1 -1
  191. package/dist/skills-guard.d.ts.map +1 -1
  192. package/dist/skills-guard.js +893 -130
  193. package/dist/skills-guard.js.map +1 -1
  194. package/dist/skills.d.ts +5 -0
  195. package/dist/skills.d.ts.map +1 -1
  196. package/dist/skills.js +29 -15
  197. package/dist/skills.js.map +1 -1
  198. package/dist/token-efficiency.d.ts.map +1 -1
  199. package/dist/token-efficiency.js.map +1 -1
  200. package/dist/tui.js +92 -11
  201. package/dist/tui.js.map +1 -1
  202. package/dist/types.d.ts +150 -0
  203. package/dist/types.d.ts.map +1 -1
  204. package/dist/types.js +24 -1
  205. package/dist/types.js.map +1 -1
  206. package/dist/update.d.ts.map +1 -1
  207. package/dist/update.js +42 -14
  208. package/dist/update.js.map +1 -1
  209. package/dist/workspace.d.ts.map +1 -1
  210. package/dist/workspace.js +49 -9
  211. package/dist/workspace.js.map +1 -1
  212. package/docs/chat.html +9 -3
  213. package/docs/index.html +72 -13
  214. package/package.json +8 -2
  215. package/src/agent.ts +31 -4
  216. package/src/audit-cli.ts +44 -16
  217. package/src/audit-events.ts +69 -5
  218. package/src/audit-trail.ts +41 -15
  219. package/src/channels/discord/attachments.ts +81 -27
  220. package/src/channels/discord/debounce.ts +25 -0
  221. package/src/channels/discord/delivery.ts +65 -14
  222. package/src/channels/discord/human-delay.ts +48 -0
  223. package/src/channels/discord/inbound.ts +190 -8
  224. package/src/channels/discord/mentions.ts +42 -18
  225. package/src/channels/discord/presence.ts +148 -0
  226. package/src/channels/discord/rate-limiter.ts +58 -0
  227. package/src/channels/discord/reactions.ts +211 -0
  228. package/src/channels/discord/runtime.ts +1161 -205
  229. package/src/channels/discord/stream.ts +85 -31
  230. package/src/channels/discord/tool-actions.ts +78 -37
  231. package/src/channels/discord/typing.ts +140 -0
  232. package/src/chunk.ts +12 -4
  233. package/src/cli.ts +174 -57
  234. package/src/config.ts +208 -36
  235. package/src/container-runner.ts +134 -42
  236. package/src/container-setup.ts +57 -22
  237. package/src/conversation.ts +9 -7
  238. package/src/db.ts +2217 -84
  239. package/src/delegation-manager.ts +6 -2
  240. package/src/gateway-client.ts +41 -17
  241. package/src/gateway-service.ts +996 -220
  242. package/src/gateway-types.ts +33 -0
  243. package/src/gateway.ts +487 -52
  244. package/src/health.ts +66 -26
  245. package/src/heartbeat.ts +99 -22
  246. package/src/hybridai-bots.ts +14 -5
  247. package/src/hybridai-models.ts +158 -0
  248. package/src/instruction-approval-audit.ts +4 -1
  249. package/src/instruction-integrity.ts +30 -9
  250. package/src/ipc.ts +23 -5
  251. package/src/logger.ts +4 -1
  252. package/src/memory-consolidation.ts +41 -0
  253. package/src/memory-service.ts +606 -0
  254. package/src/mount-security.ts +58 -13
  255. package/src/observability-ingest.ts +134 -35
  256. package/src/onboarding.ts +126 -35
  257. package/src/proactive-policy.ts +3 -1
  258. package/src/prompt-hooks.ts +42 -17
  259. package/src/runtime-config.ts +1112 -122
  260. package/src/scheduled-task-runner.ts +77 -11
  261. package/src/scheduler.ts +683 -60
  262. package/src/session-export.ts +196 -0
  263. package/src/session-maintenance.ts +125 -22
  264. package/src/session-transcripts.ts +12 -3
  265. package/src/side-effects.ts +28 -5
  266. package/src/skills-guard.ts +1067 -219
  267. package/src/skills.ts +163 -65
  268. package/src/token-efficiency.ts +31 -9
  269. package/src/tui.ts +166 -25
  270. package/src/types.ts +199 -2
  271. package/src/update.ts +79 -23
  272. package/src/workspace.ts +63 -11
  273. package/tests/approval-policy.test.ts +335 -0
  274. package/tests/discord.basic.test.ts +213 -4
  275. package/tests/discord.human-presence.test.ts +85 -0
  276. package/tests/gateway-service.media-routing.test.ts +8 -2
  277. package/tests/hybridai-client.test.ts +112 -0
  278. package/tests/hybridai-models.test.ts +46 -0
  279. package/tests/memory-service.test.ts +1114 -0
  280. package/tests/token-efficiency.basic.test.ts +8 -2
  281. package/tests/token-usage.cache.test.ts +128 -0
  282. package/vitest.e2e.config.ts +3 -1
  283. package/vitest.integration.config.ts +3 -1
  284. package/vitest.live.config.ts +3 -1
  285. package/vitest.unit.config.ts +9 -0
@@ -1,6 +1,11 @@
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,
@@ -20,61 +25,73 @@ import {
20
25
  PROACTIVE_DELEGATION_MAX_PER_TURN,
21
26
  PROACTIVE_RALPH_MAX_ITERATIONS,
22
27
  } from './config.js';
23
- import { runAgent } from './agent.js';
24
- import { getActiveContainerCount, stopSessionContainer } from './container-runner.js';
25
28
  import {
26
- clearSessionHistory,
29
+ getActiveContainerCount,
30
+ stopSessionContainer,
31
+ } from './container-runner.js';
32
+ import { buildConversationContext } from './conversation.js';
33
+ import {
27
34
  createTask,
28
35
  deleteTask,
29
36
  getAllSessions,
30
- getConversationHistory,
31
- getOrCreateSession,
32
37
  getQueuedProactiveMessageCount,
33
38
  getRecentStructuredAuditForSession,
34
39
  getSessionCount,
35
40
  getTasksForSession,
41
+ getUsageTotals,
42
+ listUsageByAgent,
43
+ listUsageByModel,
36
44
  logAudit,
37
- storeMessage,
38
- toggleTask,
45
+ pauseTask,
46
+ recordUsageEvent,
47
+ resumeTask,
39
48
  updateSessionChatbot,
40
49
  updateSessionModel,
41
50
  updateSessionRag,
42
51
  } from './db.js';
43
- 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';
44
64
  import { fetchHybridAIBots } from './hybridai-bots.js';
65
+ import { resolveModelContextWindowFallback } from './hybridai-models.js';
45
66
  import { logger } from './logger.js';
67
+ import { memoryService } from './memory-service.js';
46
68
  import { getObservabilityIngestState } from './observability-ingest.js';
47
- import { rearmScheduler } from './scheduler.js';
69
+ import { updateRuntimeConfig } from './runtime-config.js';
70
+ import { runIsolatedScheduledTask } from './scheduled-task-runner.js';
71
+ import { getSchedulerStatus, rearmScheduler } from './scheduler.js';
72
+ import { exportSessionSnapshotJsonl } from './session-export.js';
48
73
  import { maybeCompactSession } from './session-maintenance.js';
49
74
  import { appendSessionTranscript } from './session-transcripts.js';
50
75
  import { processSideEffects } from './side-effects.js';
51
76
  import { expandSkillInvocation } from './skills.js';
52
77
  import {
53
- renderGatewayCommand,
54
- type GatewayChatRequestBody,
55
- type GatewayChatResult,
56
- type GatewayCommandRequest,
57
- type GatewayCommandResult,
58
- type GatewayStatus,
59
- } from './gateway-types.js';
78
+ estimateTokenCountFromMessages,
79
+ estimateTokenCountFromText,
80
+ } from './token-efficiency.js';
60
81
  import type {
61
82
  ArtifactMetadata,
83
+ CanonicalSessionContext,
62
84
  ChatMessage,
63
85
  DelegationSideEffect,
64
86
  DelegationTaskSpec,
65
87
  MediaContextItem,
66
88
  ScheduledTask,
67
- StructuredAuditEntry,
68
89
  StoredMessage,
90
+ StructuredAuditEntry,
69
91
  TokenUsageStats,
70
92
  ToolProgressEvent,
71
93
  } from './types.js';
72
94
  import { ensureBootstrapFiles } from './workspace.js';
73
- import { buildConversationContext } from './conversation.js';
74
- import { runIsolatedScheduledTask } from './scheduled-task-runner.js';
75
- import { delegationQueueStatus, enqueueDelegation } from './delegation-manager.js';
76
- import { estimateTokenCountFromMessages, estimateTokenCountFromText } from './token-efficiency.js';
77
- import { updateRuntimeConfig } from './runtime-config.js';
78
95
 
79
96
  const BOT_CACHE_TTL = 300_000; // 5 minutes
80
97
  const MAX_HISTORY_MESSAGES = 40;
@@ -93,6 +110,7 @@ const BASE_SUBAGENT_ALLOWED_TOOLS = [
93
110
  'browser_snapshot',
94
111
  'browser_click',
95
112
  'browser_type',
113
+ 'browser_upload',
96
114
  'browser_press',
97
115
  'browser_scroll',
98
116
  'browser_back',
@@ -106,7 +124,10 @@ const BASE_SUBAGENT_ALLOWED_TOOLS = [
106
124
  'browser_network',
107
125
  'browser_close',
108
126
  ];
109
- const ORCHESTRATOR_SUBAGENT_ALLOWED_TOOLS = [...BASE_SUBAGENT_ALLOWED_TOOLS, 'delegate'];
127
+ const ORCHESTRATOR_SUBAGENT_ALLOWED_TOOLS = [
128
+ ...BASE_SUBAGENT_ALLOWED_TOOLS,
129
+ 'delegate',
130
+ ];
110
131
  const MAX_DELEGATION_TASKS = 6;
111
132
  const MAX_DELEGATION_USER_CHARS = 500;
112
133
  const MAX_RALPH_ITERATIONS = 64;
@@ -195,7 +216,9 @@ export interface GatewayChatRequest {
195
216
  enableRag?: GatewayChatRequestBody['enableRag'];
196
217
  onTextDelta?: (delta: string) => void;
197
218
  onToolProgress?: (event: ToolProgressEvent) => void;
198
- onProactiveMessage?: (message: ProactiveMessagePayload) => void | Promise<void>;
219
+ onProactiveMessage?: (
220
+ message: ProactiveMessagePayload,
221
+ ) => void | Promise<void>;
199
222
  abortSignal?: AbortSignal;
200
223
  }
201
224
 
@@ -204,7 +227,12 @@ export interface ProactiveMessagePayload {
204
227
  artifacts?: ArtifactMetadata[];
205
228
  }
206
229
 
207
- export type { GatewayChatResult, GatewayCommandRequest, GatewayCommandResult, GatewayStatus };
230
+ export type {
231
+ GatewayChatResult,
232
+ GatewayCommandRequest,
233
+ GatewayCommandResult,
234
+ GatewayStatus,
235
+ };
208
236
  export { renderGatewayCommand };
209
237
 
210
238
  function formatUptime(seconds: number): string {
@@ -226,22 +254,31 @@ function parseIntOrNull(raw: string | undefined): number | null {
226
254
  return Number.isNaN(parsed) ? null : parsed;
227
255
  }
228
256
 
229
- function normalizeMediaContextItems(raw: GatewayChatRequestBody['media']): MediaContextItem[] {
257
+ function normalizeMediaContextItems(
258
+ raw: GatewayChatRequestBody['media'],
259
+ ): MediaContextItem[] {
230
260
  if (!Array.isArray(raw) || raw.length === 0) return [];
231
261
  const normalized: MediaContextItem[] = [];
232
262
  for (const item of raw) {
233
263
  if (!item || typeof item !== 'object') continue;
234
- const path = typeof item.path === 'string' && item.path.trim() ? item.path.trim() : null;
264
+ const path =
265
+ typeof item.path === 'string' && item.path.trim()
266
+ ? item.path.trim()
267
+ : null;
235
268
  const url = typeof item.url === 'string' ? item.url.trim() : '';
236
- const originalUrl = typeof item.originalUrl === 'string' ? item.originalUrl.trim() : '';
237
- const filename = typeof item.filename === 'string' ? item.filename.trim() : '';
269
+ const originalUrl =
270
+ typeof item.originalUrl === 'string' ? item.originalUrl.trim() : '';
271
+ const filename =
272
+ typeof item.filename === 'string' ? item.filename.trim() : '';
238
273
  if (!url || !originalUrl || !filename) continue;
239
- const sizeBytes = typeof item.sizeBytes === 'number' && Number.isFinite(item.sizeBytes)
240
- ? Math.max(0, Math.floor(item.sizeBytes))
241
- : 0;
242
- const mimeType = typeof item.mimeType === 'string' && item.mimeType.trim()
243
- ? item.mimeType.trim().toLowerCase()
244
- : null;
274
+ const sizeBytes =
275
+ typeof item.sizeBytes === 'number' && Number.isFinite(item.sizeBytes)
276
+ ? Math.max(0, Math.floor(item.sizeBytes))
277
+ : 0;
278
+ const mimeType =
279
+ typeof item.mimeType === 'string' && item.mimeType.trim()
280
+ ? item.mimeType.trim().toLowerCase()
281
+ : null;
245
282
  normalized.push({
246
283
  path,
247
284
  url,
@@ -256,7 +293,9 @@ function normalizeMediaContextItems(raw: GatewayChatRequestBody['media']): Media
256
293
 
257
294
  function buildMediaPromptContext(media: MediaContextItem[]): string {
258
295
  if (media.length === 0) return '';
259
- const mediaPaths = media.map((item) => item.path).filter((path): path is string => Boolean(path));
296
+ const mediaPaths = media
297
+ .map((item) => item.path)
298
+ .filter((path): path is string => Boolean(path));
260
299
  const mediaUrls = media.map((item) => item.url);
261
300
  const mediaTypes = media.map((item) => item.mimeType || 'unknown');
262
301
  const payload = media.map((item, index) => ({
@@ -299,7 +338,10 @@ export interface MediaToolPolicy {
299
338
  prioritizeVisionTool: boolean;
300
339
  }
301
340
 
302
- export function resolveMediaToolPolicy(content: string, media: MediaContextItem[]): MediaToolPolicy {
341
+ export function resolveMediaToolPolicy(
342
+ content: string,
343
+ media: MediaContextItem[],
344
+ ): MediaToolPolicy {
303
345
  if (media.length === 0) {
304
346
  return {
305
347
  blockedTools: undefined,
@@ -358,14 +400,22 @@ function formatRelativeTime(raw: string | null | undefined): string {
358
400
  if (!at) return 'unknown';
359
401
  const deltaMs = Date.now() - at.getTime();
360
402
  if (deltaMs < 15_000) return 'just now';
361
- if (deltaMs < 60_000) return `${Math.max(1, Math.floor(deltaMs / 1_000))}s ago`;
362
- if (deltaMs < 3_600_000) return `${Math.max(1, Math.floor(deltaMs / 60_000))}m ago`;
363
- if (deltaMs < 86_400_000) return `${Math.max(1, Math.floor(deltaMs / 3_600_000))}h ago`;
403
+ if (deltaMs < 60_000)
404
+ return `${Math.max(1, Math.floor(deltaMs / 1_000))}s ago`;
405
+ if (deltaMs < 3_600_000)
406
+ return `${Math.max(1, Math.floor(deltaMs / 60_000))}m ago`;
407
+ if (deltaMs < 86_400_000)
408
+ return `${Math.max(1, Math.floor(deltaMs / 3_600_000))}h ago`;
364
409
  return `${Math.max(1, Math.floor(deltaMs / 86_400_000))}d ago`;
365
410
  }
366
411
 
367
412
  function numberFromUnknown(value: unknown): number | null {
368
- if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return null;
413
+ if (
414
+ typeof value !== 'number' ||
415
+ Number.isNaN(value) ||
416
+ !Number.isFinite(value)
417
+ )
418
+ return null;
369
419
  return value;
370
420
  }
371
421
 
@@ -377,7 +427,9 @@ function firstNumber(values: unknown[]): number | null {
377
427
  return null;
378
428
  }
379
429
 
380
- function parseAuditPayload(entry: StructuredAuditEntry): Record<string, unknown> | null {
430
+ function parseAuditPayload(
431
+ entry: StructuredAuditEntry,
432
+ ): Record<string, unknown> | null {
381
433
  try {
382
434
  const parsed = JSON.parse(entry.payload) as unknown;
383
435
  if (!parsed || typeof parsed !== 'object') return null;
@@ -391,31 +443,115 @@ function formatCompactNumber(value: number | null): string {
391
443
  if (value == null) return 'n/a';
392
444
  const abs = Math.abs(value);
393
445
  if (abs >= 1_000_000) {
394
- const scaled = abs >= 10_000_000 ? (value / 1_000_000).toFixed(0) : (value / 1_000_000).toFixed(1);
446
+ const scaled =
447
+ abs >= 10_000_000
448
+ ? (value / 1_000_000).toFixed(0)
449
+ : (value / 1_000_000).toFixed(1);
395
450
  return `${scaled.replace(/\.0$/, '')}M`;
396
451
  }
397
452
  if (abs >= 1_000) {
398
- const scaled = abs >= 10_000 ? (value / 1_000).toFixed(0) : (value / 1_000).toFixed(1);
453
+ const scaled =
454
+ abs >= 10_000 ? (value / 1_000).toFixed(0) : (value / 1_000).toFixed(1);
399
455
  return `${scaled.replace(/\.0$/, '')}k`;
400
456
  }
401
457
  return String(Math.round(value));
402
458
  }
403
459
 
404
460
  function formatPercent(value: number | null): string {
405
- if (value == null || Number.isNaN(value) || !Number.isFinite(value)) return 'n/a';
461
+ if (value == null || Number.isNaN(value) || !Number.isFinite(value))
462
+ return 'n/a';
406
463
  return `${Math.max(0, Math.min(100, Math.round(value)))}%`;
407
464
  }
408
465
 
466
+ function formatUsd(value: number | null): string {
467
+ if (value == null || Number.isNaN(value) || !Number.isFinite(value)) {
468
+ return 'n/a';
469
+ }
470
+ if (value <= 0) return '$0.0000';
471
+ if (value >= 1) return `$${value.toFixed(2)}`;
472
+ if (value >= 0.01) return `$${value.toFixed(4)}`;
473
+ return `$${value.toFixed(6)}`;
474
+ }
475
+
476
+ function resolveSessionAgentId(session: {
477
+ chatbot_id: string | null;
478
+ }): string | null {
479
+ const sessionAgent = session.chatbot_id?.trim();
480
+ if (sessionAgent) return sessionAgent;
481
+ const defaultAgent = HYBRIDAI_CHATBOT_ID?.trim();
482
+ if (defaultAgent) return defaultAgent;
483
+ return null;
484
+ }
485
+
486
+ function extractUsageCostUsd(tokenUsage?: TokenUsageStats): number {
487
+ if (!tokenUsage) return 0;
488
+ const costCarrier = tokenUsage as unknown as Record<string, unknown>;
489
+ const value = firstNumber([
490
+ costCarrier.costUsd,
491
+ costCarrier.costUSD,
492
+ costCarrier.cost_usd,
493
+ costCarrier.estimatedCostUsd,
494
+ costCarrier.estimated_cost_usd,
495
+ ]);
496
+ if (value == null) return 0;
497
+ return Math.max(0, value);
498
+ }
499
+
500
+ function formatCanonicalContextPrompt(params: {
501
+ summary: string | null;
502
+ recentMessages: Array<{
503
+ role: string;
504
+ content: string;
505
+ session_id: string;
506
+ channel_id: string | null;
507
+ }>;
508
+ }): string | null {
509
+ const sections: string[] = [];
510
+ const summary = (params.summary || '').trim();
511
+ if (summary) {
512
+ sections.push(['### Canonical Session Summary', summary].join('\n'));
513
+ }
514
+
515
+ if (params.recentMessages.length > 0) {
516
+ const lines = params.recentMessages.slice(-6).map((entry) => {
517
+ const role = (entry.role || 'user').trim().toLowerCase();
518
+ const who = role === 'assistant' ? 'Assistant' : 'User';
519
+ const from =
520
+ entry.channel_id && entry.channel_id.trim()
521
+ ? `${entry.channel_id.trim()} (${entry.session_id})`
522
+ : entry.session_id;
523
+ const compact = entry.content.replace(/\s+/g, ' ').trim();
524
+ const short =
525
+ compact.length > 180 ? `${compact.slice(0, 180)}...` : compact;
526
+ return `- ${who} [${from}]: ${short}`;
527
+ });
528
+ sections.push(
529
+ [
530
+ '### Cross-Channel Recall',
531
+ 'Recent context from other sessions/channels for this user:',
532
+ ...lines,
533
+ ].join('\n'),
534
+ );
535
+ }
536
+
537
+ const merged = sections.join('\n\n').trim();
538
+ return merged || null;
539
+ }
540
+
409
541
  function resolveActivationModeLabel(): string {
410
542
  if (DISCORD_COMMANDS_ONLY) return 'commands-only';
411
543
  if (DISCORD_GROUP_POLICY === 'disabled') return 'disabled';
412
544
  if (DISCORD_GROUP_POLICY === 'allowlist') return 'allowlist';
413
- if (DISCORD_FREE_RESPONSE_CHANNELS.length > 0) return `mention + ${DISCORD_FREE_RESPONSE_CHANNELS.length} free channel(s)`;
545
+ if (DISCORD_FREE_RESPONSE_CHANNELS.length > 0)
546
+ return `mention + ${DISCORD_FREE_RESPONSE_CHANNELS.length} free channel(s)`;
414
547
  if (DISCORD_RESPOND_TO_ALL_MESSAGES) return 'all messages';
415
548
  return 'mention';
416
549
  }
417
550
 
418
- function resolveGuildChannelMode(guildId: string | null, channelId: string): 'off' | 'mention' | 'free' {
551
+ function resolveGuildChannelMode(
552
+ guildId: string | null,
553
+ channelId: string,
554
+ ): 'off' | 'mention' | 'free' {
419
555
  if (!guildId) return 'free';
420
556
  if (DISCORD_GROUP_POLICY === 'disabled') return 'off';
421
557
  const guild = DISCORD_GUILDS[guildId];
@@ -429,7 +565,11 @@ function resolveGuildChannelMode(guildId: string | null, channelId: string): 'of
429
565
  if (DISCORD_FREE_RESPONSE_CHANNELS.includes(channelId)) return 'free';
430
566
  if (guild) {
431
567
  const defaultMode = guild.defaultMode;
432
- if (defaultMode === 'off' || defaultMode === 'mention' || defaultMode === 'free') {
568
+ if (
569
+ defaultMode === 'off' ||
570
+ defaultMode === 'mention' ||
571
+ defaultMode === 'free'
572
+ ) {
433
573
  return defaultMode;
434
574
  }
435
575
  }
@@ -448,21 +588,22 @@ interface SessionStatusSnapshot {
448
588
  contextUsagePercent: number | null;
449
589
  }
450
590
 
451
- function readSessionStatusSnapshot(sessionId: string): SessionStatusSnapshot {
591
+ function readSessionStatusSnapshot(
592
+ sessionId: string,
593
+ options?: { modelContextWindowTokens?: number | null },
594
+ ): SessionStatusSnapshot {
452
595
  const entries = getRecentStructuredAuditForSession(sessionId, 160);
453
596
  let usagePayload: Record<string, unknown> | null = null;
454
- let contextPayload: Record<string, unknown> | null = null;
455
597
 
456
598
  for (const entry of entries) {
457
599
  const payload = parseAuditPayload(entry);
458
600
  if (!payload) continue;
459
- const payloadType = typeof payload.type === 'string' ? payload.type : entry.event_type;
601
+ const payloadType =
602
+ typeof payload.type === 'string' ? payload.type : entry.event_type;
460
603
  if (!usagePayload && payloadType === 'model.usage') {
461
604
  usagePayload = payload;
462
- } else if (!contextPayload && payloadType === 'context.optimization') {
463
- contextPayload = payload;
464
605
  }
465
- if (usagePayload && contextPayload) break;
606
+ if (usagePayload) break;
466
607
  }
467
608
 
468
609
  const promptTokens = firstNumber([
@@ -479,29 +620,63 @@ function readSessionStatusSnapshot(sessionId: string): SessionStatusSnapshot {
479
620
  const cacheReadTokens = firstNumber([
480
621
  usagePayload?.cacheReadTokens,
481
622
  usagePayload?.cacheReadInputTokens,
623
+ usagePayload?.apiCacheReadTokens,
624
+ usagePayload?.cacheRead,
625
+ usagePayload?.cache_read,
626
+ usagePayload?.cache_read_tokens,
627
+ usagePayload?.cache_read_input_tokens,
628
+ usagePayload?.cached_tokens,
629
+ (usagePayload?.prompt_tokens_details as Record<string, unknown> | undefined)
630
+ ?.cached_tokens,
482
631
  ]);
483
632
  const cacheWriteTokens = firstNumber([
484
633
  usagePayload?.cacheWriteTokens,
485
634
  usagePayload?.cacheWriteInputTokens,
635
+ usagePayload?.apiCacheWriteTokens,
636
+ usagePayload?.cacheWrite,
637
+ usagePayload?.cache_write,
638
+ usagePayload?.cache_write_tokens,
639
+ usagePayload?.cache_write_input_tokens,
640
+ usagePayload?.cache_creation_input_tokens,
486
641
  ]);
487
642
  const cacheRead = Math.max(0, cacheReadTokens || 0);
488
643
  const cacheWrite = Math.max(0, cacheWriteTokens || 0);
489
644
  const cacheTotal = cacheRead + cacheWrite;
490
- const cacheHitPercent = cacheTotal > 0 ? (cacheRead / cacheTotal) * 100 : null;
491
-
492
- const contextUsedTokens = firstNumber([contextPayload?.historyEstimatedTokens]);
493
- const contextBudgetTokens = (() => {
494
- const maxChars = firstNumber([contextPayload?.historyMaxChars]);
495
- if (maxChars == null || maxChars <= 0) return null;
496
- return Math.max(1, Math.round(maxChars / 4));
497
- })();
498
- const contextUsagePercent = (
499
- contextUsedTokens != null
500
- && contextBudgetTokens != null
501
- && contextBudgetTokens > 0
502
- )
503
- ? (contextUsedTokens / contextBudgetTokens) * 100
504
- : null;
645
+ const cacheHitPercent =
646
+ cacheTotal > 0 ? (cacheRead / cacheTotal) * 100 : null;
647
+
648
+ const contextUsedTokens = firstNumber([
649
+ usagePayload?.contextTokens,
650
+ usagePayload?.context_tokens,
651
+ usagePayload?.tokensInContext,
652
+ usagePayload?.tokens_in_context,
653
+ usagePayload?.promptTokens,
654
+ usagePayload?.apiPromptTokens,
655
+ usagePayload?.estimatedPromptTokens,
656
+ ]);
657
+ const contextBudgetTokens = firstNumber([
658
+ usagePayload?.contextWindowTokens,
659
+ usagePayload?.context_window_tokens,
660
+ usagePayload?.modelContextWindowTokens,
661
+ usagePayload?.model_context_window_tokens,
662
+ usagePayload?.modelContextWindow,
663
+ usagePayload?.model_context_window,
664
+ usagePayload?.maxContextTokens,
665
+ usagePayload?.max_context_tokens,
666
+ usagePayload?.contextWindow,
667
+ usagePayload?.context_window,
668
+ usagePayload?.contextLength,
669
+ usagePayload?.context_length,
670
+ usagePayload?.maxContextSize,
671
+ usagePayload?.max_context_size,
672
+ options?.modelContextWindowTokens,
673
+ ]);
674
+ const contextUsagePercent =
675
+ contextUsedTokens != null &&
676
+ contextBudgetTokens != null &&
677
+ contextBudgetTokens > 0
678
+ ? (contextUsedTokens / contextBudgetTokens) * 100
679
+ : null;
505
680
 
506
681
  return {
507
682
  promptTokens,
@@ -552,10 +727,12 @@ function isVersionOnlyQuestion(raw: string): boolean {
552
727
  if (detailedRuntimeTokens.some((token) => text.includes(token))) return false;
553
728
 
554
729
  const words = text.split(' ').filter(Boolean);
555
- if (words.length > 8
556
- && !text.includes('welche version')
557
- && !text.includes('what version')
558
- && !text.includes('which version')) {
730
+ if (
731
+ words.length > 8 &&
732
+ !text.includes('welche version') &&
733
+ !text.includes('what version') &&
734
+ !text.includes('which version')
735
+ ) {
559
736
  return false;
560
737
  }
561
738
 
@@ -578,8 +755,46 @@ function recordSuccessfulTurn(opts: {
578
755
  toolCallCount: number;
579
756
  startedAt: number;
580
757
  }): void {
581
- storeMessage(opts.sessionId, opts.userId, opts.username, 'user', opts.userContent);
582
- storeMessage(opts.sessionId, 'assistant', null, 'assistant', opts.resultText);
758
+ memoryService.storeTurn({
759
+ sessionId: opts.sessionId,
760
+ user: {
761
+ userId: opts.userId,
762
+ username: opts.username,
763
+ content: opts.userContent,
764
+ },
765
+ assistant: {
766
+ userId: 'assistant',
767
+ username: null,
768
+ content: opts.resultText,
769
+ },
770
+ });
771
+ try {
772
+ if (opts.userId.trim()) {
773
+ memoryService.appendCanonicalMessages({
774
+ agentId: opts.agentId,
775
+ userId: opts.userId,
776
+ newMessages: [
777
+ {
778
+ role: 'user',
779
+ content: opts.userContent,
780
+ sessionId: opts.sessionId,
781
+ channelId: opts.channelId,
782
+ },
783
+ {
784
+ role: 'assistant',
785
+ content: opts.resultText,
786
+ sessionId: opts.sessionId,
787
+ channelId: opts.channelId,
788
+ },
789
+ ],
790
+ });
791
+ }
792
+ } catch (err) {
793
+ logger.debug(
794
+ { sessionId: opts.sessionId, userId: opts.userId, err },
795
+ 'Failed to append canonical session memory',
796
+ );
797
+ }
583
798
  appendSessionTranscript(opts.agentId, {
584
799
  sessionId: opts.sessionId,
585
800
  channelId: opts.channelId,
@@ -605,7 +820,10 @@ function recordSuccessfulTurn(opts: {
605
820
  model: opts.model,
606
821
  channelId: opts.channelId,
607
822
  }).catch((err) => {
608
- logger.warn({ sessionId: opts.sessionId, err }, 'Background session compaction failed');
823
+ logger.warn(
824
+ { sessionId: opts.sessionId, err },
825
+ 'Background session compaction failed',
826
+ );
609
827
  });
610
828
 
611
829
  recordAuditEvent({
@@ -670,19 +888,33 @@ function buildTokenUsageAuditPayload(
670
888
  }, 0);
671
889
  const completionChars = (resultText || '').length;
672
890
 
673
- const fallbackEstimatedPromptTokens = estimateTokenCountFromMessages(messages);
674
- const fallbackEstimatedCompletionTokens = estimateTokenCountFromText(resultText || '');
675
- const estimatedPromptTokens = tokenUsage?.estimatedPromptTokens || fallbackEstimatedPromptTokens;
676
- const estimatedCompletionTokens = tokenUsage?.estimatedCompletionTokens || fallbackEstimatedCompletionTokens;
891
+ const fallbackEstimatedPromptTokens =
892
+ estimateTokenCountFromMessages(messages);
893
+ const fallbackEstimatedCompletionTokens = estimateTokenCountFromText(
894
+ resultText || '',
895
+ );
896
+ const estimatedPromptTokens =
897
+ tokenUsage?.estimatedPromptTokens || fallbackEstimatedPromptTokens;
898
+ const estimatedCompletionTokens =
899
+ tokenUsage?.estimatedCompletionTokens || fallbackEstimatedCompletionTokens;
677
900
  const estimatedTotalTokens =
678
- tokenUsage?.estimatedTotalTokens || (estimatedPromptTokens + estimatedCompletionTokens);
901
+ tokenUsage?.estimatedTotalTokens ||
902
+ estimatedPromptTokens + estimatedCompletionTokens;
679
903
 
680
904
  const apiUsageAvailable = tokenUsage?.apiUsageAvailable === true;
681
905
  const apiPromptTokens = tokenUsage?.apiPromptTokens || 0;
682
906
  const apiCompletionTokens = tokenUsage?.apiCompletionTokens || 0;
683
- const apiTotalTokens = tokenUsage?.apiTotalTokens || (apiPromptTokens + apiCompletionTokens);
684
- const promptTokens = apiUsageAvailable ? apiPromptTokens : estimatedPromptTokens;
685
- const completionTokens = apiUsageAvailable ? apiCompletionTokens : estimatedCompletionTokens;
907
+ const apiTotalTokens =
908
+ tokenUsage?.apiTotalTokens || apiPromptTokens + apiCompletionTokens;
909
+ const apiCacheUsageAvailable = tokenUsage?.apiCacheUsageAvailable === true;
910
+ const apiCacheReadTokens = tokenUsage?.apiCacheReadTokens || 0;
911
+ const apiCacheWriteTokens = tokenUsage?.apiCacheWriteTokens || 0;
912
+ const promptTokens = apiUsageAvailable
913
+ ? apiPromptTokens
914
+ : estimatedPromptTokens;
915
+ const completionTokens = apiUsageAvailable
916
+ ? apiCompletionTokens
917
+ : estimatedCompletionTokens;
686
918
  const totalTokens = apiUsageAvailable ? apiTotalTokens : estimatedTotalTokens;
687
919
 
688
920
  return {
@@ -699,6 +931,17 @@ function buildTokenUsageAuditPayload(
699
931
  apiPromptTokens,
700
932
  apiCompletionTokens,
701
933
  apiTotalTokens,
934
+ ...(apiCacheUsageAvailable
935
+ ? {
936
+ apiCacheUsageAvailable,
937
+ apiCacheReadTokens,
938
+ apiCacheWriteTokens,
939
+ cacheReadTokens: apiCacheReadTokens,
940
+ cacheReadInputTokens: apiCacheReadTokens,
941
+ cacheWriteTokens: apiCacheWriteTokens,
942
+ cacheWriteInputTokens: apiCacheWriteTokens,
943
+ }
944
+ : {}),
702
945
  };
703
946
  }
704
947
 
@@ -714,11 +957,19 @@ export function getGatewayStatus(): GatewayStatus {
714
957
  ragDefault: HYBRIDAI_ENABLE_RAG,
715
958
  timestamp: new Date().toISOString(),
716
959
  observability: getObservabilityIngestState(),
960
+ scheduler: {
961
+ jobs: getSchedulerStatus(),
962
+ },
717
963
  };
718
964
  }
719
965
 
720
- export function getGatewayHistory(sessionId: string, limit = MAX_HISTORY_MESSAGES): StoredMessage[] {
721
- return getConversationHistory(sessionId, Math.max(1, Math.min(limit, 200))).reverse();
966
+ export function getGatewayHistory(
967
+ sessionId: string,
968
+ limit = MAX_HISTORY_MESSAGES,
969
+ ): StoredMessage[] {
970
+ return memoryService
971
+ .getConversationHistory(sessionId, Math.max(1, Math.min(limit, 200)))
972
+ .reverse();
722
973
  }
723
974
 
724
975
  function extractDelegationDepth(sessionId: string): number {
@@ -728,18 +979,28 @@ function extractDelegationDepth(sessionId: string): number {
728
979
  return Number.isFinite(parsed) ? parsed : 0;
729
980
  }
730
981
 
731
- function nextDelegationSessionId(parentSessionId: string, nextDepth: number): string {
732
- const safeParent = parentSessionId.replace(/[^a-zA-Z0-9:_-]/g, '-').slice(0, 48);
982
+ function nextDelegationSessionId(
983
+ parentSessionId: string,
984
+ nextDepth: number,
985
+ ): string {
986
+ const safeParent = parentSessionId
987
+ .replace(/[^a-zA-Z0-9:_-]/g, '-')
988
+ .slice(0, 48);
733
989
  const nonce = Math.random().toString(36).slice(2, 8);
734
990
  return `delegate:d${nextDepth}:${safeParent}:${Date.now()}:${nonce}`;
735
991
  }
736
992
 
737
993
  function resolveSubagentAllowedTools(depth: number): string[] {
738
- if (depth < PROACTIVE_DELEGATION_MAX_DEPTH) return ORCHESTRATOR_SUBAGENT_ALLOWED_TOOLS;
994
+ if (depth < PROACTIVE_DELEGATION_MAX_DEPTH)
995
+ return ORCHESTRATOR_SUBAGENT_ALLOWED_TOOLS;
739
996
  return BASE_SUBAGENT_ALLOWED_TOOLS;
740
997
  }
741
998
 
742
- function buildSubagentSystemPrompt(params: { depth: number; canDelegate: boolean; mode: DelegationMode }): string {
999
+ function buildSubagentSystemPrompt(params: {
1000
+ depth: number;
1001
+ canDelegate: boolean;
1002
+ mode: DelegationMode;
1003
+ }): string {
743
1004
  const { depth, canDelegate, mode } = params;
744
1005
  const delegationLine = canDelegate
745
1006
  ? 'You may delegate further only if absolutely necessary and still within depth/turn limits.'
@@ -787,31 +1048,50 @@ function formatDurationMs(ms: number): string {
787
1048
  return `${(ms / 1_000).toFixed(1)}s`;
788
1049
  }
789
1050
 
790
- function abbreviateForUser(text: string, maxChars = MAX_DELEGATION_USER_CHARS): string {
1051
+ function abbreviateForUser(
1052
+ text: string,
1053
+ maxChars = MAX_DELEGATION_USER_CHARS,
1054
+ ): string {
791
1055
  const normalized = text.replace(/\s+/g, ' ').trim();
792
1056
  if (normalized.length <= maxChars) return normalized;
793
1057
  return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
794
1058
  }
795
1059
 
796
1060
  function classifyDelegationError(errorText: string): DelegationErrorClass {
797
- if (PERMANENT_DELEGATION_ERROR_PATTERNS.some((pattern) => pattern.test(errorText))) return 'permanent';
798
- if (TRANSIENT_DELEGATION_ERROR_PATTERNS.some((pattern) => pattern.test(errorText))) return 'transient';
1061
+ if (
1062
+ PERMANENT_DELEGATION_ERROR_PATTERNS.some((pattern) =>
1063
+ pattern.test(errorText),
1064
+ )
1065
+ )
1066
+ return 'permanent';
1067
+ if (
1068
+ TRANSIENT_DELEGATION_ERROR_PATTERNS.some((pattern) =>
1069
+ pattern.test(errorText),
1070
+ )
1071
+ )
1072
+ return 'transient';
799
1073
  return 'unknown';
800
1074
  }
801
1075
 
802
1076
  function inferDelegationStatus(errorText: string): DelegationRunStatus {
803
- return /timeout|timed out|deadline exceeded/i.test(errorText) ? 'timeout' : 'failed';
1077
+ return /timeout|timed out|deadline exceeded/i.test(errorText)
1078
+ ? 'timeout'
1079
+ : 'failed';
804
1080
  }
805
1081
 
806
- function normalizeDelegationTask(raw: unknown, fallbackModel: string): NormalizedDelegationTask | null {
1082
+ function normalizeDelegationTask(
1083
+ raw: unknown,
1084
+ fallbackModel: string,
1085
+ ): NormalizedDelegationTask | null {
807
1086
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
808
1087
  const task = raw as DelegationTaskSpec;
809
1088
  const prompt = typeof task.prompt === 'string' ? task.prompt.trim() : '';
810
1089
  if (!prompt) return null;
811
1090
  const label = typeof task.label === 'string' ? task.label.trim() : '';
812
- const model = typeof task.model === 'string' && task.model.trim()
813
- ? task.model.trim()
814
- : fallbackModel;
1091
+ const model =
1092
+ typeof task.model === 'string' && task.model.trim()
1093
+ ? task.model.trim()
1094
+ : fallbackModel;
815
1095
  return {
816
1096
  prompt,
817
1097
  label: label || undefined,
@@ -819,11 +1099,15 @@ function normalizeDelegationTask(raw: unknown, fallbackModel: string): Normalize
819
1099
  };
820
1100
  }
821
1101
 
822
- function normalizeDelegationEffect(effect: DelegationSideEffect, fallbackModel: string): {
1102
+ function normalizeDelegationEffect(
1103
+ effect: DelegationSideEffect,
1104
+ fallbackModel: string,
1105
+ ): {
823
1106
  plan?: NormalizedDelegationPlan;
824
1107
  error?: string;
825
1108
  } {
826
- const rawMode = typeof effect.mode === 'string' ? effect.mode.trim().toLowerCase() : '';
1109
+ const rawMode =
1110
+ typeof effect.mode === 'string' ? effect.mode.trim().toLowerCase() : '';
827
1111
  const modeRaw: DelegationMode | '' =
828
1112
  rawMode === 'single' || rawMode === 'parallel' || rawMode === 'chain'
829
1113
  ? rawMode
@@ -833,9 +1117,10 @@ function normalizeDelegationEffect(effect: DelegationSideEffect, fallbackModel:
833
1117
  }
834
1118
 
835
1119
  const label = typeof effect.label === 'string' ? effect.label.trim() : '';
836
- const baseModel = typeof effect.model === 'string' && effect.model.trim()
837
- ? effect.model.trim()
838
- : fallbackModel;
1120
+ const baseModel =
1121
+ typeof effect.model === 'string' && effect.model.trim()
1122
+ ? effect.model.trim()
1123
+ : fallbackModel;
839
1124
  const prompt = typeof effect.prompt === 'string' ? effect.prompt.trim() : '';
840
1125
  const rawTasks = Array.isArray(effect.tasks) ? effect.tasks : [];
841
1126
  const rawChain = Array.isArray(effect.chain) ? effect.chain : [];
@@ -862,12 +1147,15 @@ function normalizeDelegationEffect(effect: DelegationSideEffect, fallbackModel:
862
1147
  return { error: `${mode} delegation requires at least one task` };
863
1148
  }
864
1149
  if (sourceTasks.length > MAX_DELEGATION_TASKS) {
865
- return { error: `${mode} delegation exceeds max tasks (${MAX_DELEGATION_TASKS})` };
1150
+ return {
1151
+ error: `${mode} delegation exceeds max tasks (${MAX_DELEGATION_TASKS})`,
1152
+ };
866
1153
  }
867
1154
  const tasks: NormalizedDelegationTask[] = [];
868
1155
  for (let i = 0; i < sourceTasks.length; i++) {
869
1156
  const normalized = normalizeDelegationTask(sourceTasks[i], baseModel);
870
- if (!normalized) return { error: `${mode} delegation task #${i + 1} is invalid` };
1157
+ if (!normalized)
1158
+ return { error: `${mode} delegation task #${i + 1} is invalid` };
871
1159
  tasks.push(normalized);
872
1160
  }
873
1161
  return {
@@ -879,14 +1167,22 @@ function normalizeDelegationEffect(effect: DelegationSideEffect, fallbackModel:
879
1167
  };
880
1168
  }
881
1169
 
882
- function renderDelegationTaskTitle(mode: DelegationMode, task: NormalizedDelegationTask, index: number, total: number): string {
1170
+ function renderDelegationTaskTitle(
1171
+ mode: DelegationMode,
1172
+ task: NormalizedDelegationTask,
1173
+ index: number,
1174
+ total: number,
1175
+ ): string {
883
1176
  if (task.label) return task.label;
884
1177
  if (mode === 'chain') return `step ${index + 1}/${total}`;
885
1178
  if (mode === 'parallel') return `task ${index + 1}/${total}`;
886
1179
  return 'task';
887
1180
  }
888
1181
 
889
- function interpolateChainPrompt(prompt: string, previousResult: string): string {
1182
+ function interpolateChainPrompt(
1183
+ prompt: string,
1184
+ previousResult: string,
1185
+ ): string {
890
1186
  if (!prompt.includes('{previous}')) return prompt;
891
1187
  const replacement = previousResult.trim() || '(no previous output)';
892
1188
  return prompt.replace(/\{previous\}/g, replacement);
@@ -896,7 +1192,9 @@ function sleep(ms: number): Promise<void> {
896
1192
  return new Promise((resolve) => setTimeout(resolve, ms));
897
1193
  }
898
1194
 
899
- async function runDelegationTaskWithRetry(input: DelegationTaskRunInput): Promise<DelegationRunResult> {
1195
+ async function runDelegationTaskWithRetry(
1196
+ input: DelegationTaskRunInput,
1197
+ ): Promise<DelegationRunResult> {
900
1198
  const {
901
1199
  parentSessionId,
902
1200
  childDepth,
@@ -909,7 +1207,9 @@ async function runDelegationTaskWithRetry(input: DelegationTaskRunInput): Promis
909
1207
  } = input;
910
1208
  const allowedTools = resolveSubagentAllowedTools(childDepth);
911
1209
  const canDelegate = allowedTools.includes('delegate');
912
- const maxAttempts = PROACTIVE_AUTO_RETRY_ENABLED ? PROACTIVE_AUTO_RETRY_MAX_ATTEMPTS : 1;
1210
+ const maxAttempts = PROACTIVE_AUTO_RETRY_ENABLED
1211
+ ? PROACTIVE_AUTO_RETRY_MAX_ATTEMPTS
1212
+ : 1;
913
1213
  let attempt = 0;
914
1214
  let delayMs = PROACTIVE_AUTO_RETRY_BASE_DELAY_MS;
915
1215
  let lastError = 'Delegation failed with unknown error';
@@ -928,7 +1228,14 @@ async function runDelegationTaskWithRetry(input: DelegationTaskRunInput): Promis
928
1228
  const output = await runAgent(
929
1229
  sessionId,
930
1230
  [
931
- { role: 'system', content: buildSubagentSystemPrompt({ depth: childDepth, canDelegate, mode }) },
1231
+ {
1232
+ role: 'system',
1233
+ content: buildSubagentSystemPrompt({
1234
+ depth: childDepth,
1235
+ canDelegate,
1236
+ mode,
1237
+ }),
1238
+ },
932
1239
  { role: 'user', content: task.prompt },
933
1240
  ],
934
1241
  chatbotId,
@@ -961,11 +1268,19 @@ async function runDelegationTaskWithRetry(input: DelegationTaskRunInput): Promis
961
1268
  lastError = errorText;
962
1269
  lastStatus = inferDelegationStatus(errorText);
963
1270
  const classification = classifyDelegationError(errorText);
964
- const shouldRetry = classification === 'transient' && attempt < maxAttempts;
1271
+ const shouldRetry =
1272
+ classification === 'transient' && attempt < maxAttempts;
965
1273
  if (!shouldRetry) break;
966
1274
 
967
1275
  logger.warn(
968
- { parentSessionId, sessionId, attempt, maxAttempts, delayMs, errorText },
1276
+ {
1277
+ parentSessionId,
1278
+ sessionId,
1279
+ attempt,
1280
+ maxAttempts,
1281
+ delayMs,
1282
+ errorText,
1283
+ },
969
1284
  'Delegation retry scheduled after transient error',
970
1285
  );
971
1286
  await sleep(delayMs);
@@ -977,10 +1292,18 @@ async function runDelegationTaskWithRetry(input: DelegationTaskRunInput): Promis
977
1292
  lastError = errorText;
978
1293
  lastStatus = inferDelegationStatus(errorText);
979
1294
  const classification = classifyDelegationError(errorText);
980
- const shouldRetry = classification === 'transient' && attempt < maxAttempts;
1295
+ const shouldRetry =
1296
+ classification === 'transient' && attempt < maxAttempts;
981
1297
  if (!shouldRetry) break;
982
1298
  logger.warn(
983
- { parentSessionId, sessionId, attempt, maxAttempts, delayMs, errorText },
1299
+ {
1300
+ parentSessionId,
1301
+ sessionId,
1302
+ attempt,
1303
+ maxAttempts,
1304
+ delayMs,
1305
+ errorText,
1306
+ },
984
1307
  'Delegation retry scheduled after transient exception',
985
1308
  );
986
1309
  await sleep(delayMs);
@@ -1007,19 +1330,32 @@ function formatDelegationCompletion(params: {
1007
1330
  totalDurationMs: number;
1008
1331
  }): { forUser: string; forLLM: string; artifacts?: ArtifactMetadata[] } {
1009
1332
  const { mode, label, entries, totalDurationMs } = params;
1010
- const completedCount = entries.filter((entry) => entry.run.status === 'completed').length;
1333
+ const completedCount = entries.filter(
1334
+ (entry) => entry.run.status === 'completed',
1335
+ ).length;
1011
1336
  const failedCount = entries.length - completedCount;
1012
- const overallStatus = failedCount === 0 ? 'completed' : completedCount === 0 ? 'failed' : 'partial';
1013
- const heading = label?.trim() ? `[Delegate: ${label.trim()}]` : `[Delegate ${mode}]`;
1337
+ const overallStatus =
1338
+ failedCount === 0
1339
+ ? 'completed'
1340
+ : completedCount === 0
1341
+ ? 'failed'
1342
+ : 'partial';
1343
+ const heading = label?.trim()
1344
+ ? `[Delegate: ${label.trim()}]`
1345
+ : `[Delegate ${mode}]`;
1014
1346
 
1015
1347
  const userLines = [
1016
1348
  `${heading} ${overallStatus} (${completedCount}/${entries.length} completed, ${formatDurationMs(totalDurationMs)}).`,
1017
1349
  ];
1018
1350
  for (const entry of entries) {
1019
1351
  if (entry.run.status === 'completed') {
1020
- userLines.push(`- ${entry.title}: ${abbreviateForUser(entry.run.result || '')}`);
1352
+ userLines.push(
1353
+ `- ${entry.title}: ${abbreviateForUser(entry.run.result || '')}`,
1354
+ );
1021
1355
  } else {
1022
- userLines.push(`- ${entry.title}: ${entry.run.status} (${abbreviateForUser(entry.run.error || 'Unknown error')})`);
1356
+ userLines.push(
1357
+ `- ${entry.title}: ${entry.run.status} (${abbreviateForUser(entry.run.error || 'Unknown error')})`,
1358
+ );
1023
1359
  }
1024
1360
  }
1025
1361
 
@@ -1075,7 +1411,9 @@ async function publishDelegationCompletion(params: {
1075
1411
  forLLM: string;
1076
1412
  forUser: string;
1077
1413
  artifacts?: ArtifactMetadata[];
1078
- onProactiveMessage?: (message: ProactiveMessagePayload) => void | Promise<void>;
1414
+ onProactiveMessage?: (
1415
+ message: ProactiveMessagePayload,
1416
+ ) => void | Promise<void>;
1079
1417
  }): Promise<void> {
1080
1418
  const {
1081
1419
  parentSessionId,
@@ -1087,7 +1425,13 @@ async function publishDelegationCompletion(params: {
1087
1425
  onProactiveMessage,
1088
1426
  } = params;
1089
1427
 
1090
- storeMessage(parentSessionId, 'assistant', null, 'assistant', forLLM);
1428
+ memoryService.storeMessage({
1429
+ sessionId: parentSessionId,
1430
+ userId: 'assistant',
1431
+ username: null,
1432
+ role: 'assistant',
1433
+ content: forLLM,
1434
+ });
1091
1435
  appendSessionTranscript(agentId, {
1092
1436
  sessionId: parentSessionId,
1093
1437
  channelId,
@@ -1102,7 +1446,11 @@ async function publishDelegationCompletion(params: {
1102
1446
  return;
1103
1447
  }
1104
1448
  logger.info(
1105
- { parentSessionId, message: forUser, artifactCount: artifacts?.length || 0 },
1449
+ {
1450
+ parentSessionId,
1451
+ message: forUser,
1452
+ artifactCount: artifacts?.length || 0,
1453
+ },
1106
1454
  'Delegation completion (no proactive channel callback)',
1107
1455
  );
1108
1456
  }
@@ -1114,7 +1462,9 @@ function enqueueDelegationFromSideEffect(params: {
1114
1462
  chatbotId: string;
1115
1463
  enableRag: boolean;
1116
1464
  agentId: string;
1117
- onProactiveMessage?: (message: ProactiveMessagePayload) => void | Promise<void>;
1465
+ onProactiveMessage?: (
1466
+ message: ProactiveMessagePayload,
1467
+ ) => void | Promise<void>;
1118
1468
  parentDepth: number;
1119
1469
  }): void {
1120
1470
  const {
@@ -1129,7 +1479,10 @@ function enqueueDelegationFromSideEffect(params: {
1129
1479
  } = params;
1130
1480
  const childDepth = parentDepth + 1;
1131
1481
  if (childDepth > PROACTIVE_DELEGATION_MAX_DEPTH) {
1132
- logger.info({ parentSessionId, childDepth, maxDepth: PROACTIVE_DELEGATION_MAX_DEPTH }, 'Delegation skipped — depth limit reached');
1482
+ logger.info(
1483
+ { parentSessionId, childDepth, maxDepth: PROACTIVE_DELEGATION_MAX_DEPTH },
1484
+ 'Delegation skipped — depth limit reached',
1485
+ );
1133
1486
  return;
1134
1487
  }
1135
1488
 
@@ -1141,22 +1494,29 @@ function enqueueDelegationFromSideEffect(params: {
1141
1494
  const entries: DelegationCompletionEntry[] = [];
1142
1495
 
1143
1496
  if (plan.mode === 'parallel') {
1144
- const runs = await Promise.all(plan.tasks.map(async (task, index) => {
1145
- const run = await runDelegationTaskWithRetry({
1146
- parentSessionId,
1147
- childDepth,
1148
- channelId,
1149
- chatbotId,
1150
- enableRag,
1151
- agentId,
1152
- mode: plan.mode,
1153
- task,
1154
- });
1155
- return {
1156
- title: renderDelegationTaskTitle(plan.mode, task, index, plan.tasks.length),
1157
- run,
1158
- } as DelegationCompletionEntry;
1159
- }));
1497
+ const runs = await Promise.all(
1498
+ plan.tasks.map(async (task, index) => {
1499
+ const run = await runDelegationTaskWithRetry({
1500
+ parentSessionId,
1501
+ childDepth,
1502
+ channelId,
1503
+ chatbotId,
1504
+ enableRag,
1505
+ agentId,
1506
+ mode: plan.mode,
1507
+ task,
1508
+ });
1509
+ return {
1510
+ title: renderDelegationTaskTitle(
1511
+ plan.mode,
1512
+ task,
1513
+ index,
1514
+ plan.tasks.length,
1515
+ ),
1516
+ run,
1517
+ } as DelegationCompletionEntry;
1518
+ }),
1519
+ );
1160
1520
  entries.push(...runs);
1161
1521
  } else if (plan.mode === 'chain') {
1162
1522
  let previousResult = '';
@@ -1176,7 +1536,12 @@ function enqueueDelegationFromSideEffect(params: {
1176
1536
  },
1177
1537
  });
1178
1538
  entries.push({
1179
- title: renderDelegationTaskTitle(plan.mode, task, i, plan.tasks.length),
1539
+ title: renderDelegationTaskTitle(
1540
+ plan.mode,
1541
+ task,
1542
+ i,
1543
+ plan.tasks.length,
1544
+ ),
1180
1545
  run,
1181
1546
  });
1182
1547
  if (run.status !== 'completed') break;
@@ -1201,7 +1566,10 @@ function enqueueDelegationFromSideEffect(params: {
1201
1566
  }
1202
1567
 
1203
1568
  if (entries.length === 0) {
1204
- logger.warn({ parentSessionId, mode: plan.mode }, 'Delegation produced no entries');
1569
+ logger.warn(
1570
+ { parentSessionId, mode: plan.mode },
1571
+ 'Delegation produced no entries',
1572
+ );
1205
1573
  return;
1206
1574
  }
1207
1575
 
@@ -1224,10 +1592,16 @@ function enqueueDelegationFromSideEffect(params: {
1224
1592
  });
1225
1593
  }
1226
1594
 
1227
- export async function handleGatewayMessage(req: GatewayChatRequest): Promise<GatewayChatResult> {
1595
+ export async function handleGatewayMessage(
1596
+ req: GatewayChatRequest,
1597
+ ): Promise<GatewayChatResult> {
1228
1598
  const startedAt = Date.now();
1229
1599
  const runId = makeAuditRunId('turn');
1230
- const session = getOrCreateSession(req.sessionId, req.guildId, req.channelId);
1600
+ const session = memoryService.getOrCreateSession(
1601
+ req.sessionId,
1602
+ req.guildId,
1603
+ req.channelId,
1604
+ );
1231
1605
  const chatbotId = req.chatbotId ?? session.chatbot_id ?? HYBRIDAI_CHATBOT_ID;
1232
1606
  const enableRag = req.enableRag ?? session.enable_rag === 1;
1233
1607
  const model = req.model ?? session.model ?? HYBRIDAI_MODEL;
@@ -1259,7 +1633,8 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1259
1633
  });
1260
1634
 
1261
1635
  if (!chatbotId) {
1262
- const error = 'No chatbot configured. Set `hybridai.defaultChatbotId` in config.json or select a bot for this session.';
1636
+ const error =
1637
+ 'No chatbot configured. Set `hybridai.defaultChatbotId` in config.json or select a bot for this session.';
1263
1638
  recordAuditEvent({
1264
1639
  sessionId: req.sessionId,
1265
1640
  runId,
@@ -1307,10 +1682,20 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1307
1682
  if (isVersionOnlyQuestion(req.content)) {
1308
1683
  const resultText = `HybridClaw v${APP_VERSION}`;
1309
1684
  recordSuccessfulTurn({
1310
- sessionId: req.sessionId, agentId, chatbotId, enableRag, model,
1311
- channelId: req.channelId, runId, turnIndex,
1312
- userId: req.userId, username: req.username,
1313
- userContent: req.content, resultText, toolCallCount: 0, startedAt,
1685
+ sessionId: req.sessionId,
1686
+ agentId,
1687
+ chatbotId,
1688
+ enableRag,
1689
+ model,
1690
+ channelId: req.channelId,
1691
+ runId,
1692
+ turnIndex,
1693
+ userId: req.userId,
1694
+ username: req.username,
1695
+ userContent: req.content,
1696
+ resultText,
1697
+ toolCallCount: 0,
1698
+ startedAt,
1314
1699
  });
1315
1700
  return {
1316
1701
  status: 'success',
@@ -1319,10 +1704,48 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1319
1704
  };
1320
1705
  }
1321
1706
 
1322
- const history = getConversationHistory(req.sessionId, MAX_HISTORY_MESSAGES);
1707
+ const history = memoryService.getConversationHistory(
1708
+ req.sessionId,
1709
+ MAX_HISTORY_MESSAGES,
1710
+ );
1711
+ let canonicalContext: CanonicalSessionContext = {
1712
+ summary: null,
1713
+ recent_messages: [],
1714
+ };
1715
+ if (req.userId.trim()) {
1716
+ try {
1717
+ canonicalContext = memoryService.getCanonicalContext({
1718
+ agentId,
1719
+ userId: req.userId,
1720
+ windowSize: 12,
1721
+ excludeSessionId: req.sessionId,
1722
+ });
1723
+ } catch (err) {
1724
+ logger.debug(
1725
+ { sessionId: req.sessionId, userId: req.userId, err },
1726
+ 'Failed to load canonical session context',
1727
+ );
1728
+ }
1729
+ }
1730
+ const canonicalPromptSummary = formatCanonicalContextPrompt({
1731
+ summary: canonicalContext.summary,
1732
+ recentMessages: canonicalContext.recent_messages,
1733
+ });
1734
+ const memoryContext = memoryService.buildPromptMemoryContext({
1735
+ session,
1736
+ query: req.content,
1737
+ });
1738
+ const mergedSessionSummary =
1739
+ [canonicalPromptSummary, memoryContext.promptSummary]
1740
+ .filter(
1741
+ (value): value is string =>
1742
+ typeof value === 'string' && value.trim().length > 0,
1743
+ )
1744
+ .join('\n\n')
1745
+ .trim() || null;
1323
1746
  const { messages, skills, historyStats } = buildConversationContext({
1324
1747
  agentId,
1325
- sessionSummary: session.session_summary,
1748
+ sessionSummary: mergedSessionSummary,
1326
1749
  history,
1327
1750
  runtimeInfo: {
1328
1751
  chatbotId,
@@ -1332,7 +1755,8 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1332
1755
  guildId: req.guildId,
1333
1756
  },
1334
1757
  });
1335
- const historyStart = messages.length > 0 && messages[0].role === 'system' ? 1 : 0;
1758
+ const historyStart =
1759
+ messages.length > 0 && messages[0].role === 'system' ? 1 : 0;
1336
1760
  recordAuditEvent({
1337
1761
  sessionId: req.sessionId,
1338
1762
  runId,
@@ -1349,7 +1773,11 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1349
1773
  historyMaxMessageChars: historyStats.maxMessageChars,
1350
1774
  perMessageTruncatedCount: historyStats.perMessageTruncatedCount,
1351
1775
  middleCompressionApplied: historyStats.middleCompressionApplied,
1352
- historyEstimatedTokens: estimateTokenCountFromMessages(messages.slice(historyStart)),
1776
+ historyEstimatedTokens: estimateTokenCountFromMessages(
1777
+ messages.slice(historyStart),
1778
+ ),
1779
+ canonicalSummaryIncluded: Boolean(canonicalPromptSummary),
1780
+ canonicalRecentMessagesIncluded: canonicalContext.recent_messages.length,
1353
1781
  },
1354
1782
  });
1355
1783
  const mediaPolicy = resolveMediaToolPolicy(req.content, media);
@@ -1391,12 +1819,22 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1391
1819
  req.abortSignal,
1392
1820
  media,
1393
1821
  );
1822
+ const effectiveUserContent =
1823
+ typeof output.effectiveUserPrompt === 'string' &&
1824
+ output.effectiveUserPrompt.trim()
1825
+ ? output.effectiveUserPrompt.trim()
1826
+ : req.content;
1394
1827
  const toolExecutions = output.toolExecutions || [];
1395
1828
  emitToolExecutionAuditEvents({
1396
1829
  sessionId: req.sessionId,
1397
1830
  runId,
1398
1831
  toolExecutions,
1399
1832
  });
1833
+ const usagePayload = buildTokenUsageAuditPayload(
1834
+ messages,
1835
+ output.result,
1836
+ output.tokenUsage,
1837
+ );
1400
1838
  recordAuditEvent({
1401
1839
  sessionId: req.sessionId,
1402
1840
  runId,
@@ -1406,9 +1844,19 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1406
1844
  model,
1407
1845
  durationMs: Date.now() - startedAt,
1408
1846
  toolCallCount: toolExecutions.length,
1409
- ...buildTokenUsageAuditPayload(messages, output.result, output.tokenUsage),
1847
+ ...usagePayload,
1410
1848
  },
1411
1849
  });
1850
+ recordUsageEvent({
1851
+ sessionId: req.sessionId,
1852
+ agentId,
1853
+ model,
1854
+ inputTokens: firstNumber([usagePayload.promptTokens]) || 0,
1855
+ outputTokens: firstNumber([usagePayload.completionTokens]) || 0,
1856
+ totalTokens: firstNumber([usagePayload.totalTokens]) || 0,
1857
+ toolCalls: toolExecutions.length,
1858
+ costUsd: extractUsageCostUsd(output.tokenUsage),
1859
+ });
1412
1860
 
1413
1861
  const parentDepth = extractDelegationDepth(req.sessionId);
1414
1862
  let acceptedDelegations = 0;
@@ -1417,7 +1865,11 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1417
1865
  const normalized = normalizeDelegationEffect(effect, model);
1418
1866
  if (!normalized.plan) {
1419
1867
  logger.warn(
1420
- { sessionId: req.sessionId, error: normalized.error || 'unknown', effect },
1868
+ {
1869
+ sessionId: req.sessionId,
1870
+ error: normalized.error || 'unknown',
1871
+ effect,
1872
+ },
1421
1873
  'Delegation skipped — invalid payload',
1422
1874
  );
1423
1875
  return;
@@ -1426,14 +1878,21 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1426
1878
  const childDepth = parentDepth + 1;
1427
1879
  if (childDepth > PROACTIVE_DELEGATION_MAX_DEPTH) {
1428
1880
  logger.info(
1429
- { sessionId: req.sessionId, childDepth, maxDepth: PROACTIVE_DELEGATION_MAX_DEPTH },
1881
+ {
1882
+ sessionId: req.sessionId,
1883
+ childDepth,
1884
+ maxDepth: PROACTIVE_DELEGATION_MAX_DEPTH,
1885
+ },
1430
1886
  'Delegation skipped — depth limit reached',
1431
1887
  );
1432
1888
  return;
1433
1889
  }
1434
1890
 
1435
1891
  const requestedRuns = normalized.plan.tasks.length;
1436
- if (acceptedDelegations + requestedRuns > PROACTIVE_DELEGATION_MAX_PER_TURN) {
1892
+ if (
1893
+ acceptedDelegations + requestedRuns >
1894
+ PROACTIVE_DELEGATION_MAX_PER_TURN
1895
+ ) {
1437
1896
  logger.info(
1438
1897
  {
1439
1898
  sessionId: req.sessionId,
@@ -1507,10 +1966,20 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1507
1966
 
1508
1967
  const resultText = output.result || 'No response from agent.';
1509
1968
  recordSuccessfulTurn({
1510
- sessionId: req.sessionId, agentId, chatbotId, enableRag, model,
1511
- channelId: req.channelId, runId, turnIndex,
1512
- userId: req.userId, username: req.username,
1513
- userContent: req.content, resultText, toolCallCount: toolExecutions.length, startedAt,
1969
+ sessionId: req.sessionId,
1970
+ agentId,
1971
+ chatbotId,
1972
+ enableRag,
1973
+ model,
1974
+ channelId: req.channelId,
1975
+ runId,
1976
+ turnIndex,
1977
+ userId: req.userId,
1978
+ username: req.username,
1979
+ userContent: effectiveUserContent,
1980
+ resultText,
1981
+ toolCallCount: toolExecutions.length,
1982
+ startedAt,
1514
1983
  });
1515
1984
 
1516
1985
  return {
@@ -1520,11 +1989,20 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1520
1989
  artifacts: output.artifacts,
1521
1990
  toolExecutions,
1522
1991
  tokenUsage: output.tokenUsage,
1992
+ effectiveUserPrompt: output.effectiveUserPrompt,
1523
1993
  };
1524
1994
  } catch (err) {
1525
1995
  const errorMsg = err instanceof Error ? err.message : String(err);
1526
- logAudit('error', req.sessionId, { error: errorMsg }, Date.now() - startedAt);
1527
- logger.error({ sessionId: req.sessionId, err }, 'Gateway message handling failed');
1996
+ logAudit(
1997
+ 'error',
1998
+ req.sessionId,
1999
+ { error: errorMsg },
2000
+ Date.now() - startedAt,
2001
+ );
2002
+ logger.error(
2003
+ { sessionId: req.sessionId, err },
2004
+ 'Gateway message handling failed',
2005
+ );
1528
2006
  recordAuditEvent({
1529
2007
  sessionId: req.sessionId,
1530
2008
  runId,
@@ -1575,8 +2053,13 @@ export async function runGatewayScheduledTask(
1575
2053
  taskId: number,
1576
2054
  onResult: (result: ProactiveMessagePayload) => Promise<void>,
1577
2055
  onError: (error: unknown) => void,
2056
+ runKey?: string,
1578
2057
  ): Promise<void> {
1579
- const session = getOrCreateSession(origSessionId, null, channelId);
2058
+ const session = memoryService.getOrCreateSession(
2059
+ origSessionId,
2060
+ null,
2061
+ channelId,
2062
+ );
1580
2063
  const chatbotId = session.chatbot_id || HYBRIDAI_CHATBOT_ID;
1581
2064
  if (!chatbotId) return;
1582
2065
  const model = session.model || HYBRIDAI_MODEL;
@@ -1589,14 +2072,21 @@ export async function runGatewayScheduledTask(
1589
2072
  chatbotId,
1590
2073
  model,
1591
2074
  agentId,
2075
+ sessionKey: runKey,
1592
2076
  onResult,
1593
2077
  onError,
1594
2078
  });
1595
2079
  }
1596
2080
 
1597
- export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<GatewayCommandResult> {
2081
+ export async function handleGatewayCommand(
2082
+ req: GatewayCommandRequest,
2083
+ ): Promise<GatewayCommandResult> {
1598
2084
  const cmd = (req.args[0] || '').toLowerCase();
1599
- const session = getOrCreateSession(req.sessionId, req.guildId, req.channelId);
2085
+ const session = memoryService.getOrCreateSession(
2086
+ req.sessionId,
2087
+ req.guildId,
2088
+ req.channelId,
2089
+ );
1600
2090
 
1601
2091
  switch (cmd) {
1602
2092
  case 'help': {
@@ -1613,9 +2103,15 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1613
2103
  '`ralph [on|off|set <n>|info]` — Configure Ralph loop (0 off, -1 unlimited)',
1614
2104
  '`clear` — Clear session history',
1615
2105
  '`/status` — Show runtime status (Discord slash command, private to caller)',
2106
+ '`/approve [view|yes|session|agent|no] [approval_id]` — View/respond to pending approvals privately',
1616
2107
  '`/channel-mode <off|mention|free>` — Set this Discord channel response mode',
2108
+ '`/channel-policy <open|allowlist|disabled>` — Set Discord guild channel policy',
1617
2109
  '`sessions` — List active sessions',
1618
- '`schedule add "<cron>" <prompt>`Add scheduled task',
2110
+ '`usage [summary|daily|monthly|model [daily|monthly] [agentId]]`Usage/cost aggregates',
2111
+ '`export session [sessionId]` — Export session JSONL snapshot for debugging',
2112
+ '`schedule add "<cron>" <prompt>` — Add cron scheduled task',
2113
+ '`schedule add at "<ISO time>" <prompt>` — Add one-shot task',
2114
+ '`schedule add every <ms> <prompt>` — Add interval task',
1619
2115
  '`schedule list` — List scheduled tasks',
1620
2116
  '`schedule remove <id>` — Remove a task',
1621
2117
  '`schedule toggle <id>` — Enable/disable a task',
@@ -1629,30 +2125,41 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1629
2125
  try {
1630
2126
  const bots = await fetchHybridAIBots({ cacheTtlMs: BOT_CACHE_TTL });
1631
2127
  if (bots.length === 0) return plainCommand('No bots available.');
1632
- const list = bots.map((b) =>
1633
- `• ${b.name} (${b.id})${b.description ? ` — ${b.description}` : ''}`
1634
- ).join('\n');
2128
+ const list = bots
2129
+ .map(
2130
+ (b) =>
2131
+ `• ${b.name} (${b.id})${b.description ? ` — ${b.description}` : ''}`,
2132
+ )
2133
+ .join('\n');
1635
2134
  return infoCommand('Available Bots', list);
1636
2135
  } catch (err) {
1637
- return badCommand('Error', `Failed to fetch bots: ${err instanceof Error ? err.message : String(err)}`);
2136
+ return badCommand(
2137
+ 'Error',
2138
+ `Failed to fetch bots: ${err instanceof Error ? err.message : String(err)}`,
2139
+ );
1638
2140
  }
1639
2141
  }
1640
2142
 
1641
2143
  if (sub === 'set') {
1642
2144
  const requested = req.args.slice(2).join(' ').trim();
1643
- if (!requested) return badCommand('Usage', 'Usage: `bot set <id|name>`');
2145
+ if (!requested)
2146
+ return badCommand('Usage', 'Usage: `bot set <id|name>`');
1644
2147
  let resolvedBotId = requested;
1645
2148
  try {
1646
2149
  const bots = await fetchHybridAIBots({ cacheTtlMs: BOT_CACHE_TTL });
1647
- const matched = bots.find((b) =>
1648
- b.id === requested || b.name.toLowerCase() === requested.toLowerCase()
2150
+ const matched = bots.find(
2151
+ (b) =>
2152
+ b.id === requested ||
2153
+ b.name.toLowerCase() === requested.toLowerCase(),
1649
2154
  );
1650
2155
  if (matched) resolvedBotId = matched.id;
1651
2156
  } catch {
1652
2157
  // keep user-supplied value when lookup fails
1653
2158
  }
1654
2159
  updateSessionChatbot(session.id, resolvedBotId);
1655
- return plainCommand(`Chatbot set to \`${resolvedBotId}\` for this session.`);
2160
+ return plainCommand(
2161
+ `Chatbot set to \`${resolvedBotId}\` for this session.`,
2162
+ );
1656
2163
  }
1657
2164
 
1658
2165
  if (sub === 'info') {
@@ -1667,7 +2174,10 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1667
2174
  }
1668
2175
  const model = session.model || HYBRIDAI_MODEL;
1669
2176
  const ragStatus = session.enable_rag ? 'Enabled' : 'Disabled';
1670
- return infoCommand('Bot Info', `Chatbot: ${botLabel}\nModel: ${model}\nRAG: ${ragStatus}`);
2177
+ return infoCommand(
2178
+ 'Bot Info',
2179
+ `Chatbot: ${botLabel}\nModel: ${model}\nRAG: ${ragStatus}`,
2180
+ );
1671
2181
  }
1672
2182
 
1673
2183
  return badCommand('Usage', 'Usage: `bot list|set <id|name>|info`');
@@ -1678,7 +2188,7 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1678
2188
  if (sub === 'list') {
1679
2189
  const current = session.model || HYBRIDAI_MODEL;
1680
2190
  const list = HYBRIDAI_MODELS.map((m) =>
1681
- m === current ? `${m} (current)` : m
2191
+ m === current ? `${m} (current)` : m,
1682
2192
  ).join('\n');
1683
2193
  return infoCommand('Available Models', list);
1684
2194
  }
@@ -1686,8 +2196,14 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1686
2196
  if (sub === 'set') {
1687
2197
  const modelName = req.args[2];
1688
2198
  if (!modelName) return badCommand('Usage', 'Usage: `model set <name>`');
1689
- if (HYBRIDAI_MODELS.length > 0 && !HYBRIDAI_MODELS.includes(modelName)) {
1690
- return badCommand('Unknown Model', `\`${modelName}\` is not in the available models list.`);
2199
+ if (
2200
+ HYBRIDAI_MODELS.length > 0 &&
2201
+ !HYBRIDAI_MODELS.includes(modelName)
2202
+ ) {
2203
+ return badCommand(
2204
+ 'Unknown Model',
2205
+ `\`${modelName}\` is not in the available models list.`,
2206
+ );
1691
2207
  }
1692
2208
  updateSessionModel(session.id, modelName);
1693
2209
  return plainCommand(`Model set to \`${modelName}\` for this session.`);
@@ -1695,7 +2211,10 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1695
2211
 
1696
2212
  if (sub === 'info') {
1697
2213
  const current = session.model || HYBRIDAI_MODEL;
1698
- return infoCommand('Model Info', `Current model: ${current}\nDefault model: ${HYBRIDAI_MODEL}`);
2214
+ return infoCommand(
2215
+ 'Model Info',
2216
+ `Current model: ${current}\nDefault model: ${HYBRIDAI_MODEL}`,
2217
+ );
1699
2218
  }
1700
2219
 
1701
2220
  return badCommand('Usage', 'Usage: `model list|set <name>|info`');
@@ -1705,12 +2224,16 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1705
2224
  const sub = req.args[1]?.toLowerCase();
1706
2225
  if (sub === 'on' || sub === 'off') {
1707
2226
  updateSessionRag(session.id, sub === 'on');
1708
- return plainCommand(`RAG ${sub === 'on' ? 'enabled' : 'disabled'} for this session.`);
2227
+ return plainCommand(
2228
+ `RAG ${sub === 'on' ? 'enabled' : 'disabled'} for this session.`,
2229
+ );
1709
2230
  }
1710
2231
  if (!sub) {
1711
2232
  const nextEnabled = session.enable_rag === 0;
1712
2233
  updateSessionRag(session.id, nextEnabled);
1713
- return plainCommand(`RAG ${nextEnabled ? 'enabled' : 'disabled'} for this session.`);
2234
+ return plainCommand(
2235
+ `RAG ${nextEnabled ? 'enabled' : 'disabled'} for this session.`,
2236
+ );
1714
2237
  }
1715
2238
  return badCommand('Usage', 'Usage: `rag [on|off]`');
1716
2239
  }
@@ -1720,7 +2243,10 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1720
2243
  if (sub === 'mode' || !sub) {
1721
2244
  const guildId = req.guildId;
1722
2245
  if (!guildId) {
1723
- return badCommand('Guild Only', '`channel mode` is only available in Discord guild channels.');
2246
+ return badCommand(
2247
+ 'Guild Only',
2248
+ '`channel mode` is only available in Discord guild channels.',
2249
+ );
1724
2250
  }
1725
2251
  const requestedMode = (req.args[sub ? 2 : 1] || '').toLowerCase();
1726
2252
  if (!requestedMode) {
@@ -1740,7 +2266,10 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1740
2266
  }
1741
2267
  const mode = requestedMode as 'off' | 'mention' | 'free';
1742
2268
  updateRuntimeConfig((draft) => {
1743
- const guild = draft.discord.guilds[guildId] ?? { defaultMode: 'mention', channels: {} };
2269
+ const guild = draft.discord.guilds[guildId] ?? {
2270
+ defaultMode: 'mention',
2271
+ channels: {},
2272
+ };
1744
2273
  guild.channels[req.channelId] = { mode };
1745
2274
  draft.discord.guilds[guildId] = guild;
1746
2275
  });
@@ -1765,7 +2294,10 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1765
2294
  );
1766
2295
  }
1767
2296
  if (!DISCORD_GROUP_POLICY_VALUES.has(requestedPolicy)) {
1768
- return badCommand('Usage', 'Usage: `channel policy open|allowlist|disabled`');
2297
+ return badCommand(
2298
+ 'Usage',
2299
+ 'Usage: `channel policy open|allowlist|disabled`',
2300
+ );
1769
2301
  }
1770
2302
  const policy = requestedPolicy as 'open' | 'allowlist' | 'disabled';
1771
2303
  updateRuntimeConfig((draft) => {
@@ -1774,13 +2306,18 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1774
2306
  return plainCommand(`Discord group policy set to \`${policy}\`.`);
1775
2307
  }
1776
2308
 
1777
- return badCommand('Usage', 'Usage: `channel mode [off|mention|free]` or `channel policy [open|allowlist|disabled]`');
2309
+ return badCommand(
2310
+ 'Usage',
2311
+ 'Usage: `channel mode [off|mention|free]` or `channel policy [open|allowlist|disabled]`',
2312
+ );
1778
2313
  }
1779
2314
 
1780
2315
  case 'ralph': {
1781
2316
  const sub = (req.args[1] || '').toLowerCase();
1782
2317
  if (!sub || sub === 'info' || sub === 'status') {
1783
- const current = normalizeRalphIterations(PROACTIVE_RALPH_MAX_ITERATIONS);
2318
+ const current = normalizeRalphIterations(
2319
+ PROACTIVE_RALPH_MAX_ITERATIONS,
2320
+ );
1784
2321
  return infoCommand(
1785
2322
  'Ralph Loop',
1786
2323
  [
@@ -1793,19 +2330,31 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1793
2330
 
1794
2331
  let nextValue: number | null = null;
1795
2332
  if (sub === 'on') {
1796
- nextValue = PROACTIVE_RALPH_MAX_ITERATIONS === 0 ? 3 : PROACTIVE_RALPH_MAX_ITERATIONS;
2333
+ nextValue =
2334
+ PROACTIVE_RALPH_MAX_ITERATIONS === 0
2335
+ ? 3
2336
+ : PROACTIVE_RALPH_MAX_ITERATIONS;
1797
2337
  } else if (sub === 'off') {
1798
2338
  nextValue = 0;
1799
2339
  } else if (sub === 'set') {
1800
2340
  if (req.args[2] == null) {
1801
- return badCommand('Usage', 'Usage: `ralph set <n>` (0=off, -1=unlimited, 1-64=extra iterations)');
2341
+ return badCommand(
2342
+ 'Usage',
2343
+ 'Usage: `ralph set <n>` (0=off, -1=unlimited, 1-64=extra iterations)',
2344
+ );
1802
2345
  }
1803
2346
  const parsed = Number.parseInt(req.args[2], 10);
1804
2347
  if (Number.isNaN(parsed)) {
1805
- return badCommand('Usage', 'Usage: `ralph set <n>` where n is an integer');
2348
+ return badCommand(
2349
+ 'Usage',
2350
+ 'Usage: `ralph set <n>` where n is an integer',
2351
+ );
1806
2352
  }
1807
2353
  if (parsed < -1 || parsed > MAX_RALPH_ITERATIONS) {
1808
- return badCommand('Range', `Ralph iterations must be between -1 and ${MAX_RALPH_ITERATIONS}.`);
2354
+ return badCommand(
2355
+ 'Range',
2356
+ `Ralph iterations must be between -1 and ${MAX_RALPH_ITERATIONS}.`,
2357
+ );
1809
2358
  }
1810
2359
  nextValue = parsed;
1811
2360
  } else {
@@ -1814,7 +2363,10 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1814
2363
  return badCommand('Usage', 'Usage: `ralph on|off|set <n>|info`');
1815
2364
  }
1816
2365
  if (parsed < -1 || parsed > MAX_RALPH_ITERATIONS) {
1817
- return badCommand('Range', `Ralph iterations must be between -1 and ${MAX_RALPH_ITERATIONS}.`);
2366
+ return badCommand(
2367
+ 'Range',
2368
+ `Ralph iterations must be between -1 and ${MAX_RALPH_ITERATIONS}.`,
2369
+ );
1818
2370
  }
1819
2371
  nextValue = parsed;
1820
2372
  }
@@ -1827,30 +2379,39 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1827
2379
  const restartNote = restarted
1828
2380
  ? ' Current session container restarted to apply immediately.'
1829
2381
  : '';
1830
- return plainCommand(`Ralph loop set to ${formatRalphIterations(normalized)}.${restartNote}`);
2382
+ return plainCommand(
2383
+ `Ralph loop set to ${formatRalphIterations(normalized)}.${restartNote}`,
2384
+ );
1831
2385
  }
1832
2386
 
1833
2387
  case 'clear': {
1834
- const deleted = clearSessionHistory(session.id);
1835
- return infoCommand('Session Cleared', `Deleted ${deleted} messages. Workspace files preserved.`);
2388
+ const deleted = memoryService.clearSessionHistory(session.id);
2389
+ return infoCommand(
2390
+ 'Session Cleared',
2391
+ `Deleted ${deleted} messages. Workspace files preserved.`,
2392
+ );
1836
2393
  }
1837
2394
 
1838
2395
  case 'status': {
1839
2396
  const status = getGatewayStatus();
1840
2397
  const delegationStatus = delegationQueueStatus();
1841
- const metrics = readSessionStatusSnapshot(session.id);
1842
2398
  const commitShort = resolveGitCommitShort();
1843
2399
  const sessionModel = session.model || HYBRIDAI_MODEL;
2400
+ const modelContextWindowTokens =
2401
+ resolveModelContextWindowFallback(sessionModel);
2402
+ const metrics = readSessionStatusSnapshot(session.id, {
2403
+ modelContextWindowTokens,
2404
+ });
1844
2405
  const queueLabel = `${delegationStatus.active} active / ${delegationStatus.queued} queued`;
1845
2406
  const proactiveQueued = getQueuedProactiveMessageCount();
1846
- const cacheKnown = metrics.cacheReadTokens != null || metrics.cacheWriteTokens != null;
1847
- const contextLabel = (
2407
+ const cacheKnown =
2408
+ metrics.cacheReadTokens != null || metrics.cacheWriteTokens != null;
2409
+ const contextLabel =
1848
2410
  metrics.contextUsedTokens != null && metrics.contextBudgetTokens != null
1849
- )
1850
- ? `${formatCompactNumber(metrics.contextUsedTokens)}/${formatCompactNumber(metrics.contextBudgetTokens)} (${formatPercent(metrics.contextUsagePercent)})`
1851
- : metrics.contextUsedTokens != null
1852
- ? `${formatCompactNumber(metrics.contextUsedTokens)} est`
1853
- : 'n/a';
2411
+ ? `${formatCompactNumber(metrics.contextUsedTokens)}/${formatCompactNumber(metrics.contextBudgetTokens)} (${formatPercent(metrics.contextUsagePercent)})`
2412
+ : metrics.contextUsedTokens != null
2413
+ ? `${formatCompactNumber(metrics.contextUsedTokens)}/? (window unknown)`
2414
+ : 'n/a';
1854
2415
  const lines = [
1855
2416
  `🦞 HybridClaw v${status.version}${commitShort ? ` (${commitShort})` : ''}`,
1856
2417
  `🧠 Model: ${sessionModel}`,
@@ -1870,43 +2431,244 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1870
2431
  case 'sessions': {
1871
2432
  const sessions = getAllSessions();
1872
2433
  if (sessions.length === 0) return plainCommand('No active sessions.');
1873
- const list = sessions.slice(0, 20).map((s) =>
1874
- `${s.id} — ${s.message_count} msgs, last active ${s.last_active}`
1875
- ).join('\n');
2434
+ const list = sessions
2435
+ .slice(0, 20)
2436
+ .map(
2437
+ (s) =>
2438
+ `${s.id} — ${s.message_count} msgs, last active ${s.last_active}`,
2439
+ )
2440
+ .join('\n');
1876
2441
  return infoCommand('Sessions', list);
1877
2442
  }
1878
2443
 
2444
+ case 'usage': {
2445
+ const sub = (req.args[1] || 'summary').toLowerCase();
2446
+ if (sub === 'daily' || sub === 'monthly') {
2447
+ const rows = listUsageByAgent({ window: sub });
2448
+ if (rows.length === 0) {
2449
+ return plainCommand(`No usage events recorded for ${sub} window.`);
2450
+ }
2451
+ const lines = rows.slice(0, 20).map((row) => {
2452
+ 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)}`;
2453
+ });
2454
+ return infoCommand(`Usage (${sub} · by agent)`, lines.join('\n'));
2455
+ }
2456
+
2457
+ if (sub === 'model') {
2458
+ const maybeWindow = (req.args[2] || '').toLowerCase();
2459
+ const window =
2460
+ maybeWindow === 'daily' || maybeWindow === 'monthly'
2461
+ ? maybeWindow
2462
+ : 'monthly';
2463
+ const modelAgentId =
2464
+ maybeWindow === 'daily' || maybeWindow === 'monthly'
2465
+ ? (req.args[3] || '').trim()
2466
+ : (req.args[2] || '').trim();
2467
+ const rows = listUsageByModel({
2468
+ window,
2469
+ agentId: modelAgentId || undefined,
2470
+ });
2471
+ if (rows.length === 0) {
2472
+ return plainCommand('No usage events recorded for model breakdown.');
2473
+ }
2474
+ const lines = rows.slice(0, 20).map((row) => {
2475
+ return `${row.model} — ${formatCompactNumber(row.total_tokens)} tokens · ${row.call_count} calls · ${formatUsd(row.total_cost_usd)}`;
2476
+ });
2477
+ const scope = modelAgentId ? `agent ${modelAgentId}` : 'all agents';
2478
+ return infoCommand(
2479
+ `Usage (${window} · by model · ${scope})`,
2480
+ lines.join('\n'),
2481
+ );
2482
+ }
2483
+
2484
+ if (sub !== 'summary') {
2485
+ return badCommand(
2486
+ 'Usage',
2487
+ 'Usage: `usage [summary|daily|monthly|model [daily|monthly] [agentId]]`',
2488
+ );
2489
+ }
2490
+
2491
+ const currentAgentId = resolveSessionAgentId(session);
2492
+ const daily = getUsageTotals({
2493
+ agentId: currentAgentId || undefined,
2494
+ window: 'daily',
2495
+ });
2496
+ const monthly = getUsageTotals({
2497
+ agentId: currentAgentId || undefined,
2498
+ window: 'monthly',
2499
+ });
2500
+ const topModels = listUsageByModel({
2501
+ agentId: currentAgentId || undefined,
2502
+ window: 'monthly',
2503
+ }).slice(0, 5);
2504
+ const scopeLabel = currentAgentId || 'all agents';
2505
+ const lines = [
2506
+ `Scope: ${scopeLabel}`,
2507
+ `Today: ${formatCompactNumber(daily.total_tokens)} tokens · ${daily.call_count} calls · ${formatUsd(daily.total_cost_usd)}`,
2508
+ `Month: ${formatCompactNumber(monthly.total_tokens)} tokens · ${monthly.call_count} calls · ${formatUsd(monthly.total_cost_usd)}`,
2509
+ ];
2510
+ if (topModels.length > 0) {
2511
+ lines.push('Top models (monthly):');
2512
+ lines.push(
2513
+ ...topModels.map(
2514
+ (row) =>
2515
+ `- ${row.model}: ${formatCompactNumber(row.total_tokens)} tokens · ${formatUsd(row.total_cost_usd)}`,
2516
+ ),
2517
+ );
2518
+ }
2519
+ return infoCommand('Usage Summary', lines.join('\n'));
2520
+ }
2521
+
2522
+ case 'export': {
2523
+ const sub = (req.args[1] || 'session').toLowerCase();
2524
+ if (sub !== 'session') {
2525
+ return badCommand('Usage', 'Usage: `export session [sessionId]`');
2526
+ }
2527
+ const targetSessionId = (req.args[2] || session.id || '').trim();
2528
+ if (!targetSessionId) {
2529
+ return badCommand('Usage', 'Usage: `export session [sessionId]`');
2530
+ }
2531
+ const targetSession = memoryService.getSessionById(targetSessionId);
2532
+ if (!targetSession) {
2533
+ return badCommand(
2534
+ 'Not Found',
2535
+ `Session \`${targetSessionId}\` was not found.`,
2536
+ );
2537
+ }
2538
+ const exportAgentId =
2539
+ resolveSessionAgentId(targetSession) || resolveSessionAgentId(session);
2540
+ if (!exportAgentId) {
2541
+ return badCommand(
2542
+ 'Missing Agent',
2543
+ 'Cannot export session: no agent/chatbot is configured for the target session.',
2544
+ );
2545
+ }
2546
+ const messages = memoryService.getRecentMessages(targetSessionId);
2547
+ const exported = exportSessionSnapshotJsonl({
2548
+ agentId: exportAgentId,
2549
+ sessionId: targetSessionId,
2550
+ channelId: targetSession.channel_id,
2551
+ summary: targetSession.session_summary,
2552
+ messages,
2553
+ reason: 'manual',
2554
+ });
2555
+ if (!exported) {
2556
+ return badCommand(
2557
+ 'Export Failed',
2558
+ 'Failed to write session export JSONL file. Check gateway logs for details.',
2559
+ );
2560
+ }
2561
+ return infoCommand(
2562
+ 'Session Exported',
2563
+ [
2564
+ `File: ${exported.path}`,
2565
+ `Messages: ${messages.length}`,
2566
+ `Summary: ${targetSession.session_summary ? 'yes' : 'no'}`,
2567
+ ].join('\n'),
2568
+ );
2569
+ }
2570
+
1879
2571
  case 'schedule': {
1880
2572
  const sub = req.args[1]?.toLowerCase();
1881
2573
  if (sub === 'add') {
1882
2574
  const rest = req.args.slice(2).join(' ');
2575
+ const atMatch = rest.match(/^at\s+"([^"]+)"\s+(.+)$/i);
2576
+ if (atMatch) {
2577
+ const [, runAtRaw, prompt] = atMatch;
2578
+ const parsedDate = new Date(runAtRaw);
2579
+ if (Number.isNaN(parsedDate.getTime())) {
2580
+ return badCommand(
2581
+ 'Invalid Time',
2582
+ `\`${runAtRaw}\` is not a valid ISO timestamp.`,
2583
+ );
2584
+ }
2585
+ const taskId = createTask(
2586
+ session.id,
2587
+ req.channelId,
2588
+ '',
2589
+ prompt,
2590
+ parsedDate.toISOString(),
2591
+ );
2592
+ rearmScheduler();
2593
+ return plainCommand(
2594
+ `Task #${taskId} created: one-shot at \`${parsedDate.toISOString()}\` — ${prompt}`,
2595
+ );
2596
+ }
2597
+
2598
+ const everyMatch = rest.match(/^every\s+(\d+)\s+(.+)$/i);
2599
+ if (everyMatch) {
2600
+ const [, everyRaw, prompt] = everyMatch;
2601
+ const everyMs = Number.parseInt(everyRaw, 10);
2602
+ if (!Number.isFinite(everyMs) || everyMs < 10_000) {
2603
+ return badCommand(
2604
+ 'Invalid Interval',
2605
+ 'Interval must be at least 10000ms.',
2606
+ );
2607
+ }
2608
+ const taskId = createTask(
2609
+ session.id,
2610
+ req.channelId,
2611
+ '',
2612
+ prompt,
2613
+ undefined,
2614
+ everyMs,
2615
+ );
2616
+ rearmScheduler();
2617
+ return plainCommand(
2618
+ `Task #${taskId} created: every \`${everyMs}ms\` — ${prompt}`,
2619
+ );
2620
+ }
2621
+
1883
2622
  const cronMatch = rest.match(/^"([^"]+)"\s+(.+)$/);
1884
2623
  if (!cronMatch) {
1885
- return badCommand('Usage', 'Usage: `schedule add "<cron>" <prompt>`');
2624
+ return badCommand(
2625
+ 'Usage',
2626
+ 'Usage: `schedule add "<cron>" <prompt>` or `schedule add at "<ISO time>" <prompt>` or `schedule add every <ms> <prompt>`',
2627
+ );
1886
2628
  }
1887
2629
  const [, cronExpr, prompt] = cronMatch;
1888
2630
  try {
1889
2631
  CronExpressionParser.parse(cronExpr);
1890
2632
  } catch {
1891
- return badCommand('Invalid Cron', `\`${cronExpr}\` is not a valid cron expression.`);
2633
+ return badCommand(
2634
+ 'Invalid Cron',
2635
+ `\`${cronExpr}\` is not a valid cron expression.`,
2636
+ );
1892
2637
  }
1893
2638
  const taskId = createTask(session.id, req.channelId, cronExpr, prompt);
1894
2639
  rearmScheduler();
1895
- return plainCommand(`Task #${taskId} created: \`${cronExpr}\` — ${prompt}`);
2640
+ return plainCommand(
2641
+ `Task #${taskId} created: cron \`${cronExpr}\` — ${prompt}`,
2642
+ );
1896
2643
  }
1897
2644
 
1898
2645
  if (sub === 'list') {
1899
2646
  const tasks = getTasksForSession(session.id);
1900
2647
  if (tasks.length === 0) return plainCommand('No scheduled tasks.');
1901
- const list = tasks.map((task) =>
1902
- `#${task.id} ${task.enabled ? 'enabled' : 'disabled'} \`${task.cron_expr}\` — ${task.prompt.slice(0, 60)}`
1903
- ).join('\n');
2648
+ const list = tasks
2649
+ .map((task) => {
2650
+ const scheduleLabel = task.run_at
2651
+ ? `at ${task.run_at}`
2652
+ : task.every_ms
2653
+ ? `every ${task.every_ms}ms`
2654
+ : task.cron_expr
2655
+ ? `cron ${task.cron_expr}`
2656
+ : 'unspecified';
2657
+ const statusLabel = task.last_status || 'n/a';
2658
+ const errorSuffix =
2659
+ task.consecutive_errors > 0
2660
+ ? ` · errors ${task.consecutive_errors}`
2661
+ : '';
2662
+ return `#${task.id} ${task.enabled ? 'enabled' : 'disabled'} (${scheduleLabel}) [${statusLabel}${errorSuffix}] — ${task.prompt.slice(0, 60)}`;
2663
+ })
2664
+ .join('\n');
1904
2665
  return infoCommand('Scheduled Tasks', list);
1905
2666
  }
1906
2667
 
1907
2668
  if (sub === 'remove') {
1908
2669
  const taskId = parseIntOrNull(req.args[2]);
1909
- if (!taskId) return badCommand('Usage', 'Usage: `schedule remove <id>`');
2670
+ if (!taskId)
2671
+ return badCommand('Usage', 'Usage: `schedule remove <id>`');
1910
2672
  deleteTask(taskId);
1911
2673
  rearmScheduler();
1912
2674
  return plainCommand(`Task #${taskId} removed.`);
@@ -1914,19 +2676,33 @@ export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<
1914
2676
 
1915
2677
  if (sub === 'toggle') {
1916
2678
  const taskId = parseIntOrNull(req.args[2]);
1917
- if (!taskId) return badCommand('Usage', 'Usage: `schedule toggle <id>`');
2679
+ if (!taskId)
2680
+ return badCommand('Usage', 'Usage: `schedule toggle <id>`');
1918
2681
  const tasks = getTasksForSession(session.id);
1919
2682
  const task = tasks.find((t) => t.id === taskId);
1920
- if (!task) return badCommand('Not Found', `Task #${taskId} was not found in this session.`);
1921
- toggleTask(taskId, !Boolean(task.enabled));
2683
+ if (!task)
2684
+ return badCommand(
2685
+ 'Not Found',
2686
+ `Task #${taskId} was not found in this session.`,
2687
+ );
2688
+ if (task.enabled) {
2689
+ pauseTask(taskId);
2690
+ } else {
2691
+ resumeTask(taskId);
2692
+ }
1922
2693
  rearmScheduler();
1923
- return plainCommand(`Task #${taskId} ${task.enabled ? 'disabled' : 'enabled'}.`);
2694
+ return plainCommand(
2695
+ `Task #${taskId} ${task.enabled ? 'disabled' : 'enabled'}.`,
2696
+ );
1924
2697
  }
1925
2698
 
1926
2699
  return badCommand('Usage', 'Usage: `schedule add|list|remove|toggle`');
1927
2700
  }
1928
2701
 
1929
2702
  default:
1930
- return badCommand('Unknown Command', `Unknown command: \`${cmd || '(empty)'}\`.`);
2703
+ return badCommand(
2704
+ 'Unknown Command',
2705
+ `Unknown command: \`${cmd || '(empty)'}\`.`,
2706
+ );
1931
2707
  }
1932
2708
  }