@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,25 +1,28 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import { CronExpressionParser } from 'cron-parser';
2
- import { spawnSync } from 'child_process';
3
- import { APP_VERSION, DISCORD_COMMANDS_ONLY, DISCORD_RESPOND_TO_ALL_MESSAGES, HYBRIDAI_CHATBOT_ID, HYBRIDAI_ENABLE_RAG, HYBRIDAI_MODEL, HYBRIDAI_MODELS, PROACTIVE_AUTO_RETRY_BASE_DELAY_MS, PROACTIVE_AUTO_RETRY_ENABLED, PROACTIVE_AUTO_RETRY_MAX_ATTEMPTS, PROACTIVE_AUTO_RETRY_MAX_DELAY_MS, PROACTIVE_DELEGATION_MAX_DEPTH, PROACTIVE_DELEGATION_MAX_PER_TURN, PROACTIVE_RALPH_MAX_ITERATIONS, } from './config.js';
4
3
  import { runAgent } from './agent.js';
5
- import { getActiveContainerCount, stopSessionContainer } from './container-runner.js';
6
- import { clearSessionHistory, createTask, deleteTask, getAllSessions, getConversationHistory, getOrCreateSession, getQueuedProactiveMessageCount, getRecentStructuredAuditForSession, getSessionCount, getTasksForSession, logAudit, storeMessage, toggleTask, updateSessionChatbot, updateSessionModel, updateSessionRag, } from './db.js';
7
- import { emitToolExecutionAuditEvents, makeAuditRunId, recordAuditEvent } from './audit-events.js';
4
+ import { emitToolExecutionAuditEvents, makeAuditRunId, recordAuditEvent, } from './audit-events.js';
5
+ import { APP_VERSION, DISCORD_COMMANDS_ONLY, DISCORD_FREE_RESPONSE_CHANNELS, DISCORD_GROUP_POLICY, DISCORD_GUILDS, DISCORD_RESPOND_TO_ALL_MESSAGES, HYBRIDAI_CHATBOT_ID, HYBRIDAI_ENABLE_RAG, HYBRIDAI_MODEL, HYBRIDAI_MODELS, PROACTIVE_AUTO_RETRY_BASE_DELAY_MS, PROACTIVE_AUTO_RETRY_ENABLED, PROACTIVE_AUTO_RETRY_MAX_ATTEMPTS, PROACTIVE_AUTO_RETRY_MAX_DELAY_MS, PROACTIVE_DELEGATION_MAX_DEPTH, PROACTIVE_DELEGATION_MAX_PER_TURN, PROACTIVE_RALPH_MAX_ITERATIONS, } from './config.js';
6
+ import { getActiveContainerCount, stopSessionContainer, } from './container-runner.js';
7
+ import { buildConversationContext } from './conversation.js';
8
+ import { createTask, deleteTask, getAllSessions, getQueuedProactiveMessageCount, getRecentStructuredAuditForSession, getSessionCount, getTasksForSession, getUsageTotals, listUsageByAgent, listUsageByModel, logAudit, pauseTask, recordUsageEvent, resumeTask, updateSessionChatbot, updateSessionModel, updateSessionRag, } from './db.js';
9
+ import { delegationQueueStatus, enqueueDelegation, } from './delegation-manager.js';
10
+ import { renderGatewayCommand, } from './gateway-types.js';
8
11
  import { fetchHybridAIBots } from './hybridai-bots.js';
12
+ import { resolveModelContextWindowFallback, } from './hybridai-models.js';
9
13
  import { logger } from './logger.js';
14
+ import { memoryService } from './memory-service.js';
10
15
  import { getObservabilityIngestState } from './observability-ingest.js';
11
- import { rearmScheduler } from './scheduler.js';
16
+ import { updateRuntimeConfig } from './runtime-config.js';
17
+ import { runIsolatedScheduledTask } from './scheduled-task-runner.js';
18
+ import { getSchedulerStatus, rearmScheduler } from './scheduler.js';
19
+ import { exportSessionSnapshotJsonl } from './session-export.js';
12
20
  import { maybeCompactSession } from './session-maintenance.js';
13
21
  import { appendSessionTranscript } from './session-transcripts.js';
14
22
  import { processSideEffects } from './side-effects.js';
15
23
  import { expandSkillInvocation } from './skills.js';
16
- import { renderGatewayCommand, } from './gateway-types.js';
24
+ import { estimateTokenCountFromMessages, estimateTokenCountFromText, } from './token-efficiency.js';
17
25
  import { ensureBootstrapFiles } from './workspace.js';
18
- import { buildConversationContext } from './conversation.js';
19
- import { runIsolatedScheduledTask } from './scheduled-task-runner.js';
20
- import { delegationQueueStatus, enqueueDelegation } from './delegation-manager.js';
21
- import { estimateTokenCountFromMessages, estimateTokenCountFromText } from './token-efficiency.js';
22
- import { updateRuntimeConfig } from './runtime-config.js';
23
26
  const BOT_CACHE_TTL = 300_000; // 5 minutes
24
27
  const MAX_HISTORY_MESSAGES = 40;
25
28
  const BASE_SUBAGENT_ALLOWED_TOOLS = [
@@ -37,6 +40,7 @@ const BASE_SUBAGENT_ALLOWED_TOOLS = [
37
40
  'browser_snapshot',
38
41
  'browser_click',
39
42
  'browser_type',
43
+ 'browser_upload',
40
44
  'browser_press',
41
45
  'browser_scroll',
42
46
  'browser_back',
@@ -50,10 +54,15 @@ const BASE_SUBAGENT_ALLOWED_TOOLS = [
50
54
  'browser_network',
51
55
  'browser_close',
52
56
  ];
53
- const ORCHESTRATOR_SUBAGENT_ALLOWED_TOOLS = [...BASE_SUBAGENT_ALLOWED_TOOLS, 'delegate'];
57
+ const ORCHESTRATOR_SUBAGENT_ALLOWED_TOOLS = [
58
+ ...BASE_SUBAGENT_ALLOWED_TOOLS,
59
+ 'delegate',
60
+ ];
54
61
  const MAX_DELEGATION_TASKS = 6;
55
62
  const MAX_DELEGATION_USER_CHARS = 500;
56
63
  const MAX_RALPH_ITERATIONS = 64;
64
+ const DISCORD_CHANNEL_MODE_VALUES = new Set(['off', 'mention', 'free']);
65
+ const DISCORD_GROUP_POLICY_VALUES = new Set(['open', 'allowlist', 'disabled']);
57
66
  const IMAGE_QUESTION_RE = /(what(?:'s| is)? on (?:the )?(?:image|picture|photo|screenshot)|describe (?:this|the) (?:image|picture|photo)|image|picture|photo|screenshot|ocr|diagram|chart|grafik|bild|foto|was steht|was ist auf dem bild)/i;
58
67
  const BROWSER_TAB_RE = /(browser|tab|current tab|web page|website|seite im browser|aktuellen tab)/i;
59
68
  const TRANSIENT_DELEGATION_ERROR_PATTERNS = [
@@ -106,7 +115,9 @@ function normalizeMediaContextItems(raw) {
106
115
  for (const item of raw) {
107
116
  if (!item || typeof item !== 'object')
108
117
  continue;
109
- const path = typeof item.path === 'string' && item.path.trim() ? item.path.trim() : null;
118
+ const path = typeof item.path === 'string' && item.path.trim()
119
+ ? item.path.trim()
120
+ : null;
110
121
  const url = typeof item.url === 'string' ? item.url.trim() : '';
111
122
  const originalUrl = typeof item.originalUrl === 'string' ? item.originalUrl.trim() : '';
112
123
  const filename = typeof item.filename === 'string' ? item.filename.trim() : '';
@@ -132,7 +143,9 @@ function normalizeMediaContextItems(raw) {
132
143
  function buildMediaPromptContext(media) {
133
144
  if (media.length === 0)
134
145
  return '';
135
- const mediaPaths = media.map((item) => item.path).filter((path) => Boolean(path));
146
+ const mediaPaths = media
147
+ .map((item) => item.path)
148
+ .filter((path) => Boolean(path));
136
149
  const mediaUrls = media.map((item) => item.url);
137
150
  const mediaTypes = media.map((item) => item.mimeType || 'unknown');
138
151
  const payload = media.map((item, index) => ({
@@ -237,7 +250,9 @@ function formatRelativeTime(raw) {
237
250
  return `${Math.max(1, Math.floor(deltaMs / 86_400_000))}d ago`;
238
251
  }
239
252
  function numberFromUnknown(value) {
240
- if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value))
253
+ if (typeof value !== 'number' ||
254
+ Number.isNaN(value) ||
255
+ !Number.isFinite(value))
241
256
  return null;
242
257
  return value;
243
258
  }
@@ -265,7 +280,9 @@ function formatCompactNumber(value) {
265
280
  return 'n/a';
266
281
  const abs = Math.abs(value);
267
282
  if (abs >= 1_000_000) {
268
- const scaled = abs >= 10_000_000 ? (value / 1_000_000).toFixed(0) : (value / 1_000_000).toFixed(1);
283
+ const scaled = abs >= 10_000_000
284
+ ? (value / 1_000_000).toFixed(0)
285
+ : (value / 1_000_000).toFixed(1);
269
286
  return `${scaled.replace(/\.0$/, '')}M`;
270
287
  }
271
288
  if (abs >= 1_000) {
@@ -279,17 +296,111 @@ function formatPercent(value) {
279
296
  return 'n/a';
280
297
  return `${Math.max(0, Math.min(100, Math.round(value)))}%`;
281
298
  }
299
+ function formatUsd(value) {
300
+ if (value == null || Number.isNaN(value) || !Number.isFinite(value)) {
301
+ return 'n/a';
302
+ }
303
+ if (value <= 0)
304
+ return '$0.0000';
305
+ if (value >= 1)
306
+ return `$${value.toFixed(2)}`;
307
+ if (value >= 0.01)
308
+ return `$${value.toFixed(4)}`;
309
+ return `$${value.toFixed(6)}`;
310
+ }
311
+ function resolveSessionAgentId(session) {
312
+ const sessionAgent = session.chatbot_id?.trim();
313
+ if (sessionAgent)
314
+ return sessionAgent;
315
+ const defaultAgent = HYBRIDAI_CHATBOT_ID?.trim();
316
+ if (defaultAgent)
317
+ return defaultAgent;
318
+ return null;
319
+ }
320
+ function extractUsageCostUsd(tokenUsage) {
321
+ if (!tokenUsage)
322
+ return 0;
323
+ const costCarrier = tokenUsage;
324
+ const value = firstNumber([
325
+ costCarrier.costUsd,
326
+ costCarrier.costUSD,
327
+ costCarrier.cost_usd,
328
+ costCarrier.estimatedCostUsd,
329
+ costCarrier.estimated_cost_usd,
330
+ ]);
331
+ if (value == null)
332
+ return 0;
333
+ return Math.max(0, value);
334
+ }
335
+ function formatCanonicalContextPrompt(params) {
336
+ const sections = [];
337
+ const summary = (params.summary || '').trim();
338
+ if (summary) {
339
+ sections.push(['### Canonical Session Summary', summary].join('\n'));
340
+ }
341
+ if (params.recentMessages.length > 0) {
342
+ const lines = params.recentMessages.slice(-6).map((entry) => {
343
+ const role = (entry.role || 'user').trim().toLowerCase();
344
+ const who = role === 'assistant' ? 'Assistant' : 'User';
345
+ const from = entry.channel_id && entry.channel_id.trim()
346
+ ? `${entry.channel_id.trim()} (${entry.session_id})`
347
+ : entry.session_id;
348
+ const compact = entry.content.replace(/\s+/g, ' ').trim();
349
+ const short = compact.length > 180 ? `${compact.slice(0, 180)}...` : compact;
350
+ return `- ${who} [${from}]: ${short}`;
351
+ });
352
+ sections.push([
353
+ '### Cross-Channel Recall',
354
+ 'Recent context from other sessions/channels for this user:',
355
+ ...lines,
356
+ ].join('\n'));
357
+ }
358
+ const merged = sections.join('\n\n').trim();
359
+ return merged || null;
360
+ }
282
361
  function resolveActivationModeLabel() {
283
362
  if (DISCORD_COMMANDS_ONLY)
284
363
  return 'commands-only';
364
+ if (DISCORD_GROUP_POLICY === 'disabled')
365
+ return 'disabled';
366
+ if (DISCORD_GROUP_POLICY === 'allowlist')
367
+ return 'allowlist';
368
+ if (DISCORD_FREE_RESPONSE_CHANNELS.length > 0)
369
+ return `mention + ${DISCORD_FREE_RESPONSE_CHANNELS.length} free channel(s)`;
285
370
  if (DISCORD_RESPOND_TO_ALL_MESSAGES)
286
371
  return 'all messages';
287
372
  return 'mention';
288
373
  }
289
- function readSessionStatusSnapshot(sessionId) {
374
+ function resolveGuildChannelMode(guildId, channelId) {
375
+ if (!guildId)
376
+ return 'free';
377
+ if (DISCORD_GROUP_POLICY === 'disabled')
378
+ return 'off';
379
+ const guild = DISCORD_GUILDS[guildId];
380
+ const explicit = guild?.channels[channelId]?.mode;
381
+ if (DISCORD_GROUP_POLICY === 'allowlist') {
382
+ return explicit ?? 'off';
383
+ }
384
+ if (explicit === 'off' || explicit === 'mention' || explicit === 'free') {
385
+ return explicit;
386
+ }
387
+ if (DISCORD_FREE_RESPONSE_CHANNELS.includes(channelId))
388
+ return 'free';
389
+ if (guild) {
390
+ const defaultMode = guild.defaultMode;
391
+ if (defaultMode === 'off' ||
392
+ defaultMode === 'mention' ||
393
+ defaultMode === 'free') {
394
+ return defaultMode;
395
+ }
396
+ }
397
+ if (DISCORD_RESPOND_TO_ALL_MESSAGES)
398
+ return 'free';
399
+ return 'mention';
400
+ }
401
+ function readSessionStatusSnapshot(sessionId, options) {
290
402
  const entries = getRecentStructuredAuditForSession(sessionId, 160);
291
403
  let usagePayload = null;
292
- let contextPayload = null;
293
404
  for (const entry of entries) {
294
405
  const payload = parseAuditPayload(entry);
295
406
  if (!payload)
@@ -298,10 +409,7 @@ function readSessionStatusSnapshot(sessionId) {
298
409
  if (!usagePayload && payloadType === 'model.usage') {
299
410
  usagePayload = payload;
300
411
  }
301
- else if (!contextPayload && payloadType === 'context.optimization') {
302
- contextPayload = payload;
303
- }
304
- if (usagePayload && contextPayload)
412
+ if (usagePayload)
305
413
  break;
306
414
  }
307
415
  const promptTokens = firstNumber([
@@ -317,25 +425,58 @@ function readSessionStatusSnapshot(sessionId) {
317
425
  const cacheReadTokens = firstNumber([
318
426
  usagePayload?.cacheReadTokens,
319
427
  usagePayload?.cacheReadInputTokens,
428
+ usagePayload?.apiCacheReadTokens,
429
+ usagePayload?.cacheRead,
430
+ usagePayload?.cache_read,
431
+ usagePayload?.cache_read_tokens,
432
+ usagePayload?.cache_read_input_tokens,
433
+ usagePayload?.cached_tokens,
434
+ usagePayload?.prompt_tokens_details
435
+ ?.cached_tokens,
320
436
  ]);
321
437
  const cacheWriteTokens = firstNumber([
322
438
  usagePayload?.cacheWriteTokens,
323
439
  usagePayload?.cacheWriteInputTokens,
440
+ usagePayload?.apiCacheWriteTokens,
441
+ usagePayload?.cacheWrite,
442
+ usagePayload?.cache_write,
443
+ usagePayload?.cache_write_tokens,
444
+ usagePayload?.cache_write_input_tokens,
445
+ usagePayload?.cache_creation_input_tokens,
324
446
  ]);
325
447
  const cacheRead = Math.max(0, cacheReadTokens || 0);
326
448
  const cacheWrite = Math.max(0, cacheWriteTokens || 0);
327
449
  const cacheTotal = cacheRead + cacheWrite;
328
450
  const cacheHitPercent = cacheTotal > 0 ? (cacheRead / cacheTotal) * 100 : null;
329
- const contextUsedTokens = firstNumber([contextPayload?.historyEstimatedTokens]);
330
- const contextBudgetTokens = (() => {
331
- const maxChars = firstNumber([contextPayload?.historyMaxChars]);
332
- if (maxChars == null || maxChars <= 0)
333
- return null;
334
- return Math.max(1, Math.round(maxChars / 4));
335
- })();
336
- const contextUsagePercent = (contextUsedTokens != null
337
- && contextBudgetTokens != null
338
- && contextBudgetTokens > 0)
451
+ const contextUsedTokens = firstNumber([
452
+ usagePayload?.contextTokens,
453
+ usagePayload?.context_tokens,
454
+ usagePayload?.tokensInContext,
455
+ usagePayload?.tokens_in_context,
456
+ usagePayload?.promptTokens,
457
+ usagePayload?.apiPromptTokens,
458
+ usagePayload?.estimatedPromptTokens,
459
+ ]);
460
+ const contextBudgetTokens = firstNumber([
461
+ usagePayload?.contextWindowTokens,
462
+ usagePayload?.context_window_tokens,
463
+ usagePayload?.modelContextWindowTokens,
464
+ usagePayload?.model_context_window_tokens,
465
+ usagePayload?.modelContextWindow,
466
+ usagePayload?.model_context_window,
467
+ usagePayload?.maxContextTokens,
468
+ usagePayload?.max_context_tokens,
469
+ usagePayload?.contextWindow,
470
+ usagePayload?.context_window,
471
+ usagePayload?.contextLength,
472
+ usagePayload?.context_length,
473
+ usagePayload?.maxContextSize,
474
+ usagePayload?.max_context_size,
475
+ options?.modelContextWindowTokens,
476
+ ]);
477
+ const contextUsagePercent = contextUsedTokens != null &&
478
+ contextBudgetTokens != null &&
479
+ contextBudgetTokens > 0
339
480
  ? (contextUsedTokens / contextBudgetTokens) * 100
340
481
  : null;
341
482
  return {
@@ -387,17 +528,53 @@ function isVersionOnlyQuestion(raw) {
387
528
  if (detailedRuntimeTokens.some((token) => text.includes(token)))
388
529
  return false;
389
530
  const words = text.split(' ').filter(Boolean);
390
- if (words.length > 8
391
- && !text.includes('welche version')
392
- && !text.includes('what version')
393
- && !text.includes('which version')) {
531
+ if (words.length > 8 &&
532
+ !text.includes('welche version') &&
533
+ !text.includes('what version') &&
534
+ !text.includes('which version')) {
394
535
  return false;
395
536
  }
396
537
  return true;
397
538
  }
398
539
  function recordSuccessfulTurn(opts) {
399
- storeMessage(opts.sessionId, opts.userId, opts.username, 'user', opts.userContent);
400
- storeMessage(opts.sessionId, 'assistant', null, 'assistant', opts.resultText);
540
+ memoryService.storeTurn({
541
+ sessionId: opts.sessionId,
542
+ user: {
543
+ userId: opts.userId,
544
+ username: opts.username,
545
+ content: opts.userContent,
546
+ },
547
+ assistant: {
548
+ userId: 'assistant',
549
+ username: null,
550
+ content: opts.resultText,
551
+ },
552
+ });
553
+ try {
554
+ if (opts.userId.trim()) {
555
+ memoryService.appendCanonicalMessages({
556
+ agentId: opts.agentId,
557
+ userId: opts.userId,
558
+ newMessages: [
559
+ {
560
+ role: 'user',
561
+ content: opts.userContent,
562
+ sessionId: opts.sessionId,
563
+ channelId: opts.channelId,
564
+ },
565
+ {
566
+ role: 'assistant',
567
+ content: opts.resultText,
568
+ sessionId: opts.sessionId,
569
+ channelId: opts.channelId,
570
+ },
571
+ ],
572
+ });
573
+ }
574
+ }
575
+ catch (err) {
576
+ logger.debug({ sessionId: opts.sessionId, userId: opts.userId, err }, 'Failed to append canonical session memory');
577
+ }
401
578
  appendSessionTranscript(opts.agentId, {
402
579
  sessionId: opts.sessionId,
403
580
  channelId: opts.channelId,
@@ -484,13 +661,21 @@ function buildTokenUsageAuditPayload(messages, resultText, tokenUsage) {
484
661
  const fallbackEstimatedCompletionTokens = estimateTokenCountFromText(resultText || '');
485
662
  const estimatedPromptTokens = tokenUsage?.estimatedPromptTokens || fallbackEstimatedPromptTokens;
486
663
  const estimatedCompletionTokens = tokenUsage?.estimatedCompletionTokens || fallbackEstimatedCompletionTokens;
487
- const estimatedTotalTokens = tokenUsage?.estimatedTotalTokens || (estimatedPromptTokens + estimatedCompletionTokens);
664
+ const estimatedTotalTokens = tokenUsage?.estimatedTotalTokens ||
665
+ estimatedPromptTokens + estimatedCompletionTokens;
488
666
  const apiUsageAvailable = tokenUsage?.apiUsageAvailable === true;
489
667
  const apiPromptTokens = tokenUsage?.apiPromptTokens || 0;
490
668
  const apiCompletionTokens = tokenUsage?.apiCompletionTokens || 0;
491
- const apiTotalTokens = tokenUsage?.apiTotalTokens || (apiPromptTokens + apiCompletionTokens);
492
- const promptTokens = apiUsageAvailable ? apiPromptTokens : estimatedPromptTokens;
493
- const completionTokens = apiUsageAvailable ? apiCompletionTokens : estimatedCompletionTokens;
669
+ const apiTotalTokens = tokenUsage?.apiTotalTokens || apiPromptTokens + apiCompletionTokens;
670
+ const apiCacheUsageAvailable = tokenUsage?.apiCacheUsageAvailable === true;
671
+ const apiCacheReadTokens = tokenUsage?.apiCacheReadTokens || 0;
672
+ const apiCacheWriteTokens = tokenUsage?.apiCacheWriteTokens || 0;
673
+ const promptTokens = apiUsageAvailable
674
+ ? apiPromptTokens
675
+ : estimatedPromptTokens;
676
+ const completionTokens = apiUsageAvailable
677
+ ? apiCompletionTokens
678
+ : estimatedCompletionTokens;
494
679
  const totalTokens = apiUsageAvailable ? apiTotalTokens : estimatedTotalTokens;
495
680
  return {
496
681
  modelCalls: tokenUsage ? Math.max(1, tokenUsage.modelCalls) : 0,
@@ -506,6 +691,17 @@ function buildTokenUsageAuditPayload(messages, resultText, tokenUsage) {
506
691
  apiPromptTokens,
507
692
  apiCompletionTokens,
508
693
  apiTotalTokens,
694
+ ...(apiCacheUsageAvailable
695
+ ? {
696
+ apiCacheUsageAvailable,
697
+ apiCacheReadTokens,
698
+ apiCacheWriteTokens,
699
+ cacheReadTokens: apiCacheReadTokens,
700
+ cacheReadInputTokens: apiCacheReadTokens,
701
+ cacheWriteTokens: apiCacheWriteTokens,
702
+ cacheWriteInputTokens: apiCacheWriteTokens,
703
+ }
704
+ : {}),
509
705
  };
510
706
  }
511
707
  export function getGatewayStatus() {
@@ -520,10 +716,15 @@ export function getGatewayStatus() {
520
716
  ragDefault: HYBRIDAI_ENABLE_RAG,
521
717
  timestamp: new Date().toISOString(),
522
718
  observability: getObservabilityIngestState(),
719
+ scheduler: {
720
+ jobs: getSchedulerStatus(),
721
+ },
523
722
  };
524
723
  }
525
724
  export function getGatewayHistory(sessionId, limit = MAX_HISTORY_MESSAGES) {
526
- return getConversationHistory(sessionId, Math.max(1, Math.min(limit, 200))).reverse();
725
+ return memoryService
726
+ .getConversationHistory(sessionId, Math.max(1, Math.min(limit, 200)))
727
+ .reverse();
527
728
  }
528
729
  function extractDelegationDepth(sessionId) {
529
730
  const match = sessionId.match(/^delegate:d(\d+):/);
@@ -533,7 +734,9 @@ function extractDelegationDepth(sessionId) {
533
734
  return Number.isFinite(parsed) ? parsed : 0;
534
735
  }
535
736
  function nextDelegationSessionId(parentSessionId, nextDepth) {
536
- const safeParent = parentSessionId.replace(/[^a-zA-Z0-9:_-]/g, '-').slice(0, 48);
737
+ const safeParent = parentSessionId
738
+ .replace(/[^a-zA-Z0-9:_-]/g, '-')
739
+ .slice(0, 48);
537
740
  const nonce = Math.random().toString(36).slice(2, 8);
538
741
  return `delegate:d${nextDepth}:${safeParent}:${Date.now()}:${nonce}`;
539
742
  }
@@ -602,7 +805,9 @@ function classifyDelegationError(errorText) {
602
805
  return 'unknown';
603
806
  }
604
807
  function inferDelegationStatus(errorText) {
605
- return /timeout|timed out|deadline exceeded/i.test(errorText) ? 'timeout' : 'failed';
808
+ return /timeout|timed out|deadline exceeded/i.test(errorText)
809
+ ? 'timeout'
810
+ : 'failed';
606
811
  }
607
812
  function normalizeDelegationTask(raw, fallbackModel) {
608
813
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
@@ -661,7 +866,9 @@ function normalizeDelegationEffect(effect, fallbackModel) {
661
866
  return { error: `${mode} delegation requires at least one task` };
662
867
  }
663
868
  if (sourceTasks.length > MAX_DELEGATION_TASKS) {
664
- return { error: `${mode} delegation exceeds max tasks (${MAX_DELEGATION_TASKS})` };
869
+ return {
870
+ error: `${mode} delegation exceeds max tasks (${MAX_DELEGATION_TASKS})`,
871
+ };
665
872
  }
666
873
  const tasks = [];
667
874
  for (let i = 0; i < sourceTasks.length; i++) {
@@ -700,7 +907,9 @@ async function runDelegationTaskWithRetry(input) {
700
907
  const { parentSessionId, childDepth, channelId, chatbotId, enableRag, agentId, mode, task, } = input;
701
908
  const allowedTools = resolveSubagentAllowedTools(childDepth);
702
909
  const canDelegate = allowedTools.includes('delegate');
703
- const maxAttempts = PROACTIVE_AUTO_RETRY_ENABLED ? PROACTIVE_AUTO_RETRY_MAX_ATTEMPTS : 1;
910
+ const maxAttempts = PROACTIVE_AUTO_RETRY_ENABLED
911
+ ? PROACTIVE_AUTO_RETRY_MAX_ATTEMPTS
912
+ : 1;
704
913
  let attempt = 0;
705
914
  let delayMs = PROACTIVE_AUTO_RETRY_BASE_DELAY_MS;
706
915
  let lastError = 'Delegation failed with unknown error';
@@ -716,7 +925,14 @@ async function runDelegationTaskWithRetry(input) {
716
925
  const startedAt = Date.now();
717
926
  try {
718
927
  const output = await runAgent(sessionId, [
719
- { role: 'system', content: buildSubagentSystemPrompt({ depth: childDepth, canDelegate, mode }) },
928
+ {
929
+ role: 'system',
930
+ content: buildSubagentSystemPrompt({
931
+ depth: childDepth,
932
+ canDelegate,
933
+ mode,
934
+ }),
935
+ },
720
936
  { role: 'user', content: task.prompt },
721
937
  ], chatbotId, enableRag, task.model, agentId, channelId, undefined, allowedTools);
722
938
  const durationMs = Date.now() - startedAt;
@@ -742,7 +958,14 @@ async function runDelegationTaskWithRetry(input) {
742
958
  const shouldRetry = classification === 'transient' && attempt < maxAttempts;
743
959
  if (!shouldRetry)
744
960
  break;
745
- logger.warn({ parentSessionId, sessionId, attempt, maxAttempts, delayMs, errorText }, 'Delegation retry scheduled after transient error');
961
+ logger.warn({
962
+ parentSessionId,
963
+ sessionId,
964
+ attempt,
965
+ maxAttempts,
966
+ delayMs,
967
+ errorText,
968
+ }, 'Delegation retry scheduled after transient error');
746
969
  await sleep(delayMs);
747
970
  delayMs = Math.min(delayMs * 2, PROACTIVE_AUTO_RETRY_MAX_DELAY_MS);
748
971
  }
@@ -756,7 +979,14 @@ async function runDelegationTaskWithRetry(input) {
756
979
  const shouldRetry = classification === 'transient' && attempt < maxAttempts;
757
980
  if (!shouldRetry)
758
981
  break;
759
- logger.warn({ parentSessionId, sessionId, attempt, maxAttempts, delayMs, errorText }, 'Delegation retry scheduled after transient exception');
982
+ logger.warn({
983
+ parentSessionId,
984
+ sessionId,
985
+ attempt,
986
+ maxAttempts,
987
+ delayMs,
988
+ errorText,
989
+ }, 'Delegation retry scheduled after transient exception');
760
990
  await sleep(delayMs);
761
991
  delayMs = Math.min(delayMs * 2, PROACTIVE_AUTO_RETRY_MAX_DELAY_MS);
762
992
  }
@@ -776,8 +1006,14 @@ function formatDelegationCompletion(params) {
776
1006
  const { mode, label, entries, totalDurationMs } = params;
777
1007
  const completedCount = entries.filter((entry) => entry.run.status === 'completed').length;
778
1008
  const failedCount = entries.length - completedCount;
779
- const overallStatus = failedCount === 0 ? 'completed' : completedCount === 0 ? 'failed' : 'partial';
780
- const heading = label?.trim() ? `[Delegate: ${label.trim()}]` : `[Delegate ${mode}]`;
1009
+ const overallStatus = failedCount === 0
1010
+ ? 'completed'
1011
+ : completedCount === 0
1012
+ ? 'failed'
1013
+ : 'partial';
1014
+ const heading = label?.trim()
1015
+ ? `[Delegate: ${label.trim()}]`
1016
+ : `[Delegate ${mode}]`;
781
1017
  const userLines = [
782
1018
  `${heading} ${overallStatus} (${completedCount}/${entries.length} completed, ${formatDurationMs(totalDurationMs)}).`,
783
1019
  ];
@@ -836,7 +1072,13 @@ function formatDelegationCompletion(params) {
836
1072
  }
837
1073
  async function publishDelegationCompletion(params) {
838
1074
  const { parentSessionId, channelId, agentId, forLLM, forUser, artifacts, onProactiveMessage, } = params;
839
- storeMessage(parentSessionId, 'assistant', null, 'assistant', forLLM);
1075
+ memoryService.storeMessage({
1076
+ sessionId: parentSessionId,
1077
+ userId: 'assistant',
1078
+ username: null,
1079
+ role: 'assistant',
1080
+ content: forLLM,
1081
+ });
840
1082
  appendSessionTranscript(agentId, {
841
1083
  sessionId: parentSessionId,
842
1084
  channelId,
@@ -849,7 +1091,11 @@ async function publishDelegationCompletion(params) {
849
1091
  await onProactiveMessage({ text: forUser, artifacts });
850
1092
  return;
851
1093
  }
852
- logger.info({ parentSessionId, message: forUser, artifactCount: artifacts?.length || 0 }, 'Delegation completion (no proactive channel callback)');
1094
+ logger.info({
1095
+ parentSessionId,
1096
+ message: forUser,
1097
+ artifactCount: artifacts?.length || 0,
1098
+ }, 'Delegation completion (no proactive channel callback)');
853
1099
  }
854
1100
  function enqueueDelegationFromSideEffect(params) {
855
1101
  const { plan, parentSessionId, channelId, chatbotId, enableRag, agentId, onProactiveMessage, parentDepth, } = params;
@@ -951,7 +1197,7 @@ function enqueueDelegationFromSideEffect(params) {
951
1197
  export async function handleGatewayMessage(req) {
952
1198
  const startedAt = Date.now();
953
1199
  const runId = makeAuditRunId('turn');
954
- const session = getOrCreateSession(req.sessionId, req.guildId, req.channelId);
1200
+ const session = memoryService.getOrCreateSession(req.sessionId, req.guildId, req.channelId);
955
1201
  const chatbotId = req.chatbotId ?? session.chatbot_id ?? HYBRIDAI_CHATBOT_ID;
956
1202
  const enableRag = req.enableRag ?? session.enable_rag === 1;
957
1203
  const model = req.model ?? session.model ?? HYBRIDAI_MODEL;
@@ -1027,10 +1273,20 @@ export async function handleGatewayMessage(req) {
1027
1273
  if (isVersionOnlyQuestion(req.content)) {
1028
1274
  const resultText = `HybridClaw v${APP_VERSION}`;
1029
1275
  recordSuccessfulTurn({
1030
- sessionId: req.sessionId, agentId, chatbotId, enableRag, model,
1031
- channelId: req.channelId, runId, turnIndex,
1032
- userId: req.userId, username: req.username,
1033
- userContent: req.content, resultText, toolCallCount: 0, startedAt,
1276
+ sessionId: req.sessionId,
1277
+ agentId,
1278
+ chatbotId,
1279
+ enableRag,
1280
+ model,
1281
+ channelId: req.channelId,
1282
+ runId,
1283
+ turnIndex,
1284
+ userId: req.userId,
1285
+ username: req.username,
1286
+ userContent: req.content,
1287
+ resultText,
1288
+ toolCallCount: 0,
1289
+ startedAt,
1034
1290
  });
1035
1291
  return {
1036
1292
  status: 'success',
@@ -1038,10 +1294,39 @@ export async function handleGatewayMessage(req) {
1038
1294
  toolsUsed: [],
1039
1295
  };
1040
1296
  }
1041
- const history = getConversationHistory(req.sessionId, MAX_HISTORY_MESSAGES);
1297
+ const history = memoryService.getConversationHistory(req.sessionId, MAX_HISTORY_MESSAGES);
1298
+ let canonicalContext = {
1299
+ summary: null,
1300
+ recent_messages: [],
1301
+ };
1302
+ if (req.userId.trim()) {
1303
+ try {
1304
+ canonicalContext = memoryService.getCanonicalContext({
1305
+ agentId,
1306
+ userId: req.userId,
1307
+ windowSize: 12,
1308
+ excludeSessionId: req.sessionId,
1309
+ });
1310
+ }
1311
+ catch (err) {
1312
+ logger.debug({ sessionId: req.sessionId, userId: req.userId, err }, 'Failed to load canonical session context');
1313
+ }
1314
+ }
1315
+ const canonicalPromptSummary = formatCanonicalContextPrompt({
1316
+ summary: canonicalContext.summary,
1317
+ recentMessages: canonicalContext.recent_messages,
1318
+ });
1319
+ const memoryContext = memoryService.buildPromptMemoryContext({
1320
+ session,
1321
+ query: req.content,
1322
+ });
1323
+ const mergedSessionSummary = [canonicalPromptSummary, memoryContext.promptSummary]
1324
+ .filter((value) => typeof value === 'string' && value.trim().length > 0)
1325
+ .join('\n\n')
1326
+ .trim() || null;
1042
1327
  const { messages, skills, historyStats } = buildConversationContext({
1043
1328
  agentId,
1044
- sessionSummary: session.session_summary,
1329
+ sessionSummary: mergedSessionSummary,
1045
1330
  history,
1046
1331
  runtimeInfo: {
1047
1332
  chatbotId,
@@ -1069,6 +1354,8 @@ export async function handleGatewayMessage(req) {
1069
1354
  perMessageTruncatedCount: historyStats.perMessageTruncatedCount,
1070
1355
  middleCompressionApplied: historyStats.middleCompressionApplied,
1071
1356
  historyEstimatedTokens: estimateTokenCountFromMessages(messages.slice(historyStart)),
1357
+ canonicalSummaryIncluded: Boolean(canonicalPromptSummary),
1358
+ canonicalRecentMessagesIncluded: canonicalContext.recent_messages.length,
1072
1359
  },
1073
1360
  });
1074
1361
  const mediaPolicy = resolveMediaToolPolicy(req.content, media);
@@ -1091,12 +1378,17 @@ export async function handleGatewayMessage(req) {
1091
1378
  try {
1092
1379
  const scheduledTasks = getTasksForSession(req.sessionId);
1093
1380
  const output = await runAgent(req.sessionId, messages, chatbotId, enableRag, model, agentId, req.channelId, scheduledTasks, undefined, mediaPolicy.blockedTools, req.onTextDelta, req.onToolProgress, req.abortSignal, media);
1381
+ const effectiveUserContent = typeof output.effectiveUserPrompt === 'string' &&
1382
+ output.effectiveUserPrompt.trim()
1383
+ ? output.effectiveUserPrompt.trim()
1384
+ : req.content;
1094
1385
  const toolExecutions = output.toolExecutions || [];
1095
1386
  emitToolExecutionAuditEvents({
1096
1387
  sessionId: req.sessionId,
1097
1388
  runId,
1098
1389
  toolExecutions,
1099
1390
  });
1391
+ const usagePayload = buildTokenUsageAuditPayload(messages, output.result, output.tokenUsage);
1100
1392
  recordAuditEvent({
1101
1393
  sessionId: req.sessionId,
1102
1394
  runId,
@@ -1106,25 +1398,44 @@ export async function handleGatewayMessage(req) {
1106
1398
  model,
1107
1399
  durationMs: Date.now() - startedAt,
1108
1400
  toolCallCount: toolExecutions.length,
1109
- ...buildTokenUsageAuditPayload(messages, output.result, output.tokenUsage),
1401
+ ...usagePayload,
1110
1402
  },
1111
1403
  });
1404
+ recordUsageEvent({
1405
+ sessionId: req.sessionId,
1406
+ agentId,
1407
+ model,
1408
+ inputTokens: firstNumber([usagePayload.promptTokens]) || 0,
1409
+ outputTokens: firstNumber([usagePayload.completionTokens]) || 0,
1410
+ totalTokens: firstNumber([usagePayload.totalTokens]) || 0,
1411
+ toolCalls: toolExecutions.length,
1412
+ costUsd: extractUsageCostUsd(output.tokenUsage),
1413
+ });
1112
1414
  const parentDepth = extractDelegationDepth(req.sessionId);
1113
1415
  let acceptedDelegations = 0;
1114
1416
  processSideEffects(output, req.sessionId, req.channelId, {
1115
1417
  onDelegation: (effect) => {
1116
1418
  const normalized = normalizeDelegationEffect(effect, model);
1117
1419
  if (!normalized.plan) {
1118
- logger.warn({ sessionId: req.sessionId, error: normalized.error || 'unknown', effect }, 'Delegation skipped — invalid payload');
1420
+ logger.warn({
1421
+ sessionId: req.sessionId,
1422
+ error: normalized.error || 'unknown',
1423
+ effect,
1424
+ }, 'Delegation skipped — invalid payload');
1119
1425
  return;
1120
1426
  }
1121
1427
  const childDepth = parentDepth + 1;
1122
1428
  if (childDepth > PROACTIVE_DELEGATION_MAX_DEPTH) {
1123
- logger.info({ sessionId: req.sessionId, childDepth, maxDepth: PROACTIVE_DELEGATION_MAX_DEPTH }, 'Delegation skipped — depth limit reached');
1429
+ logger.info({
1430
+ sessionId: req.sessionId,
1431
+ childDepth,
1432
+ maxDepth: PROACTIVE_DELEGATION_MAX_DEPTH,
1433
+ }, 'Delegation skipped — depth limit reached');
1124
1434
  return;
1125
1435
  }
1126
1436
  const requestedRuns = normalized.plan.tasks.length;
1127
- if (acceptedDelegations + requestedRuns > PROACTIVE_DELEGATION_MAX_PER_TURN) {
1437
+ if (acceptedDelegations + requestedRuns >
1438
+ PROACTIVE_DELEGATION_MAX_PER_TURN) {
1128
1439
  logger.info({
1129
1440
  sessionId: req.sessionId,
1130
1441
  limit: PROACTIVE_DELEGATION_MAX_PER_TURN,
@@ -1193,10 +1504,20 @@ export async function handleGatewayMessage(req) {
1193
1504
  }
1194
1505
  const resultText = output.result || 'No response from agent.';
1195
1506
  recordSuccessfulTurn({
1196
- sessionId: req.sessionId, agentId, chatbotId, enableRag, model,
1197
- channelId: req.channelId, runId, turnIndex,
1198
- userId: req.userId, username: req.username,
1199
- userContent: req.content, resultText, toolCallCount: toolExecutions.length, startedAt,
1507
+ sessionId: req.sessionId,
1508
+ agentId,
1509
+ chatbotId,
1510
+ enableRag,
1511
+ model,
1512
+ channelId: req.channelId,
1513
+ runId,
1514
+ turnIndex,
1515
+ userId: req.userId,
1516
+ username: req.username,
1517
+ userContent: effectiveUserContent,
1518
+ resultText,
1519
+ toolCallCount: toolExecutions.length,
1520
+ startedAt,
1200
1521
  });
1201
1522
  return {
1202
1523
  status: 'success',
@@ -1205,6 +1526,7 @@ export async function handleGatewayMessage(req) {
1205
1526
  artifacts: output.artifacts,
1206
1527
  toolExecutions,
1207
1528
  tokenUsage: output.tokenUsage,
1529
+ effectiveUserPrompt: output.effectiveUserPrompt,
1208
1530
  };
1209
1531
  }
1210
1532
  catch (err) {
@@ -1253,8 +1575,8 @@ export async function handleGatewayMessage(req) {
1253
1575
  };
1254
1576
  }
1255
1577
  }
1256
- export async function runGatewayScheduledTask(origSessionId, channelId, prompt, taskId, onResult, onError) {
1257
- const session = getOrCreateSession(origSessionId, null, channelId);
1578
+ export async function runGatewayScheduledTask(origSessionId, channelId, prompt, taskId, onResult, onError, runKey) {
1579
+ const session = memoryService.getOrCreateSession(origSessionId, null, channelId);
1258
1580
  const chatbotId = session.chatbot_id || HYBRIDAI_CHATBOT_ID;
1259
1581
  if (!chatbotId)
1260
1582
  return;
@@ -1267,13 +1589,14 @@ export async function runGatewayScheduledTask(origSessionId, channelId, prompt,
1267
1589
  chatbotId,
1268
1590
  model,
1269
1591
  agentId,
1592
+ sessionKey: runKey,
1270
1593
  onResult,
1271
1594
  onError,
1272
1595
  });
1273
1596
  }
1274
1597
  export async function handleGatewayCommand(req) {
1275
1598
  const cmd = (req.args[0] || '').toLowerCase();
1276
- const session = getOrCreateSession(req.sessionId, req.guildId, req.channelId);
1599
+ const session = memoryService.getOrCreateSession(req.sessionId, req.guildId, req.channelId);
1277
1600
  switch (cmd) {
1278
1601
  case 'help': {
1279
1602
  const help = [
@@ -1284,11 +1607,20 @@ export async function handleGatewayCommand(req) {
1284
1607
  '`model set <name>` — Set model for this session',
1285
1608
  '`model info` — Show current model',
1286
1609
  '`rag [on|off]` — Toggle or set RAG mode',
1610
+ '`channel mode [off|mention|free]` — Set or inspect this Discord channel response mode',
1611
+ '`channel policy [open|allowlist|disabled]` — Set or inspect guild channel policy',
1287
1612
  '`ralph [on|off|set <n>|info]` — Configure Ralph loop (0 off, -1 unlimited)',
1288
1613
  '`clear` — Clear session history',
1289
1614
  '`/status` — Show runtime status (Discord slash command, private to caller)',
1615
+ '`/approve [view|yes|session|agent|no] [approval_id]` — View/respond to pending approvals privately',
1616
+ '`/channel-mode <off|mention|free>` — Set this Discord channel response mode',
1617
+ '`/channel-policy <open|allowlist|disabled>` — Set Discord guild channel policy',
1290
1618
  '`sessions` — List active sessions',
1291
- '`schedule add "<cron>" <prompt>`Add scheduled task',
1619
+ '`usage [summary|daily|monthly|model [daily|monthly] [agentId]]`Usage/cost aggregates',
1620
+ '`export session [sessionId]` — Export session JSONL snapshot for debugging',
1621
+ '`schedule add "<cron>" <prompt>` — Add cron scheduled task',
1622
+ '`schedule add at "<ISO time>" <prompt>` — Add one-shot task',
1623
+ '`schedule add every <ms> <prompt>` — Add interval task',
1292
1624
  '`schedule list` — List scheduled tasks',
1293
1625
  '`schedule remove <id>` — Remove a task',
1294
1626
  '`schedule toggle <id>` — Enable/disable a task',
@@ -1302,7 +1634,9 @@ export async function handleGatewayCommand(req) {
1302
1634
  const bots = await fetchHybridAIBots({ cacheTtlMs: BOT_CACHE_TTL });
1303
1635
  if (bots.length === 0)
1304
1636
  return plainCommand('No bots available.');
1305
- const list = bots.map((b) => `• ${b.name} (${b.id})${b.description ? ` — ${b.description}` : ''}`).join('\n');
1637
+ const list = bots
1638
+ .map((b) => `• ${b.name} (${b.id})${b.description ? ` — ${b.description}` : ''}`)
1639
+ .join('\n');
1306
1640
  return infoCommand('Available Bots', list);
1307
1641
  }
1308
1642
  catch (err) {
@@ -1316,7 +1650,8 @@ export async function handleGatewayCommand(req) {
1316
1650
  let resolvedBotId = requested;
1317
1651
  try {
1318
1652
  const bots = await fetchHybridAIBots({ cacheTtlMs: BOT_CACHE_TTL });
1319
- const matched = bots.find((b) => b.id === requested || b.name.toLowerCase() === requested.toLowerCase());
1653
+ const matched = bots.find((b) => b.id === requested ||
1654
+ b.name.toLowerCase() === requested.toLowerCase());
1320
1655
  if (matched)
1321
1656
  resolvedBotId = matched.id;
1322
1657
  }
@@ -1355,7 +1690,8 @@ export async function handleGatewayCommand(req) {
1355
1690
  const modelName = req.args[2];
1356
1691
  if (!modelName)
1357
1692
  return badCommand('Usage', 'Usage: `model set <name>`');
1358
- if (HYBRIDAI_MODELS.length > 0 && !HYBRIDAI_MODELS.includes(modelName)) {
1693
+ if (HYBRIDAI_MODELS.length > 0 &&
1694
+ !HYBRIDAI_MODELS.includes(modelName)) {
1359
1695
  return badCommand('Unknown Model', `\`${modelName}\` is not in the available models list.`);
1360
1696
  }
1361
1697
  updateSessionModel(session.id, modelName);
@@ -1380,6 +1716,60 @@ export async function handleGatewayCommand(req) {
1380
1716
  }
1381
1717
  return badCommand('Usage', 'Usage: `rag [on|off]`');
1382
1718
  }
1719
+ case 'channel': {
1720
+ const sub = (req.args[1] || '').toLowerCase();
1721
+ if (sub === 'mode' || !sub) {
1722
+ const guildId = req.guildId;
1723
+ if (!guildId) {
1724
+ return badCommand('Guild Only', '`channel mode` is only available in Discord guild channels.');
1725
+ }
1726
+ const requestedMode = (req.args[sub ? 2 : 1] || '').toLowerCase();
1727
+ if (!requestedMode) {
1728
+ const currentMode = resolveGuildChannelMode(guildId, req.channelId);
1729
+ return infoCommand('Channel Mode', [
1730
+ `Current mode: \`${currentMode}\``,
1731
+ `Group policy: \`${DISCORD_GROUP_POLICY}\``,
1732
+ `Config path: \`discord.guilds.${guildId}.channels.${req.channelId}.mode\``,
1733
+ 'Usage: `channel mode off|mention|free`',
1734
+ ].join('\n'));
1735
+ }
1736
+ if (!DISCORD_CHANNEL_MODE_VALUES.has(requestedMode)) {
1737
+ return badCommand('Usage', 'Usage: `channel mode off|mention|free`');
1738
+ }
1739
+ const mode = requestedMode;
1740
+ updateRuntimeConfig((draft) => {
1741
+ const guild = draft.discord.guilds[guildId] ?? {
1742
+ defaultMode: 'mention',
1743
+ channels: {},
1744
+ };
1745
+ guild.channels[req.channelId] = { mode };
1746
+ draft.discord.guilds[guildId] = guild;
1747
+ });
1748
+ return plainCommand(`Set channel mode to \`${mode}\` for this channel. (Policy: \`${DISCORD_GROUP_POLICY}\`)`);
1749
+ }
1750
+ if (sub === 'policy') {
1751
+ const requestedPolicy = (req.args[2] || '').toLowerCase();
1752
+ if (!requestedPolicy) {
1753
+ return infoCommand('Channel Policy', [
1754
+ `Current policy: \`${DISCORD_GROUP_POLICY}\``,
1755
+ 'Policies:',
1756
+ '• `open` — all guild channels are active unless a per-channel mode overrides',
1757
+ '• `allowlist` — only channels listed under `discord.guilds.<guild>.channels` are active',
1758
+ '• `disabled` — all guild channels are disabled',
1759
+ 'Usage: `channel policy open|allowlist|disabled`',
1760
+ ].join('\n'));
1761
+ }
1762
+ if (!DISCORD_GROUP_POLICY_VALUES.has(requestedPolicy)) {
1763
+ return badCommand('Usage', 'Usage: `channel policy open|allowlist|disabled`');
1764
+ }
1765
+ const policy = requestedPolicy;
1766
+ updateRuntimeConfig((draft) => {
1767
+ draft.discord.groupPolicy = policy;
1768
+ });
1769
+ return plainCommand(`Discord group policy set to \`${policy}\`.`);
1770
+ }
1771
+ return badCommand('Usage', 'Usage: `channel mode [off|mention|free]` or `channel policy [open|allowlist|disabled]`');
1772
+ }
1383
1773
  case 'ralph': {
1384
1774
  const sub = (req.args[1] || '').toLowerCase();
1385
1775
  if (!sub || sub === 'info' || sub === 'status') {
@@ -1392,7 +1782,10 @@ export async function handleGatewayCommand(req) {
1392
1782
  }
1393
1783
  let nextValue = null;
1394
1784
  if (sub === 'on') {
1395
- nextValue = PROACTIVE_RALPH_MAX_ITERATIONS === 0 ? 3 : PROACTIVE_RALPH_MAX_ITERATIONS;
1785
+ nextValue =
1786
+ PROACTIVE_RALPH_MAX_ITERATIONS === 0
1787
+ ? 3
1788
+ : PROACTIVE_RALPH_MAX_ITERATIONS;
1396
1789
  }
1397
1790
  else if (sub === 'off') {
1398
1791
  nextValue = 0;
@@ -1431,22 +1824,25 @@ export async function handleGatewayCommand(req) {
1431
1824
  return plainCommand(`Ralph loop set to ${formatRalphIterations(normalized)}.${restartNote}`);
1432
1825
  }
1433
1826
  case 'clear': {
1434
- const deleted = clearSessionHistory(session.id);
1827
+ const deleted = memoryService.clearSessionHistory(session.id);
1435
1828
  return infoCommand('Session Cleared', `Deleted ${deleted} messages. Workspace files preserved.`);
1436
1829
  }
1437
1830
  case 'status': {
1438
1831
  const status = getGatewayStatus();
1439
1832
  const delegationStatus = delegationQueueStatus();
1440
- const metrics = readSessionStatusSnapshot(session.id);
1441
1833
  const commitShort = resolveGitCommitShort();
1442
1834
  const sessionModel = session.model || HYBRIDAI_MODEL;
1835
+ const modelContextWindowTokens = resolveModelContextWindowFallback(sessionModel);
1836
+ const metrics = readSessionStatusSnapshot(session.id, {
1837
+ modelContextWindowTokens,
1838
+ });
1443
1839
  const queueLabel = `${delegationStatus.active} active / ${delegationStatus.queued} queued`;
1444
1840
  const proactiveQueued = getQueuedProactiveMessageCount();
1445
1841
  const cacheKnown = metrics.cacheReadTokens != null || metrics.cacheWriteTokens != null;
1446
- const contextLabel = (metrics.contextUsedTokens != null && metrics.contextBudgetTokens != null)
1842
+ const contextLabel = metrics.contextUsedTokens != null && metrics.contextBudgetTokens != null
1447
1843
  ? `${formatCompactNumber(metrics.contextUsedTokens)}/${formatCompactNumber(metrics.contextBudgetTokens)} (${formatPercent(metrics.contextUsagePercent)})`
1448
1844
  : metrics.contextUsedTokens != null
1449
- ? `${formatCompactNumber(metrics.contextUsedTokens)} est`
1845
+ ? `${formatCompactNumber(metrics.contextUsedTokens)}/? (window unknown)`
1450
1846
  : 'n/a';
1451
1847
  const lines = [
1452
1848
  `🦞 HybridClaw v${status.version}${commitShort ? ` (${commitShort})` : ''}`,
@@ -1467,16 +1863,137 @@ export async function handleGatewayCommand(req) {
1467
1863
  const sessions = getAllSessions();
1468
1864
  if (sessions.length === 0)
1469
1865
  return plainCommand('No active sessions.');
1470
- const list = sessions.slice(0, 20).map((s) => `${s.id} — ${s.message_count} msgs, last active ${s.last_active}`).join('\n');
1866
+ const list = sessions
1867
+ .slice(0, 20)
1868
+ .map((s) => `${s.id} — ${s.message_count} msgs, last active ${s.last_active}`)
1869
+ .join('\n');
1471
1870
  return infoCommand('Sessions', list);
1472
1871
  }
1872
+ case 'usage': {
1873
+ const sub = (req.args[1] || 'summary').toLowerCase();
1874
+ if (sub === 'daily' || sub === 'monthly') {
1875
+ const rows = listUsageByAgent({ window: sub });
1876
+ if (rows.length === 0) {
1877
+ return plainCommand(`No usage events recorded for ${sub} window.`);
1878
+ }
1879
+ const lines = rows.slice(0, 20).map((row) => {
1880
+ 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)}`;
1881
+ });
1882
+ return infoCommand(`Usage (${sub} · by agent)`, lines.join('\n'));
1883
+ }
1884
+ if (sub === 'model') {
1885
+ const maybeWindow = (req.args[2] || '').toLowerCase();
1886
+ const window = maybeWindow === 'daily' || maybeWindow === 'monthly'
1887
+ ? maybeWindow
1888
+ : 'monthly';
1889
+ const modelAgentId = maybeWindow === 'daily' || maybeWindow === 'monthly'
1890
+ ? (req.args[3] || '').trim()
1891
+ : (req.args[2] || '').trim();
1892
+ const rows = listUsageByModel({
1893
+ window,
1894
+ agentId: modelAgentId || undefined,
1895
+ });
1896
+ if (rows.length === 0) {
1897
+ return plainCommand('No usage events recorded for model breakdown.');
1898
+ }
1899
+ const lines = rows.slice(0, 20).map((row) => {
1900
+ return `${row.model} — ${formatCompactNumber(row.total_tokens)} tokens · ${row.call_count} calls · ${formatUsd(row.total_cost_usd)}`;
1901
+ });
1902
+ const scope = modelAgentId ? `agent ${modelAgentId}` : 'all agents';
1903
+ return infoCommand(`Usage (${window} · by model · ${scope})`, lines.join('\n'));
1904
+ }
1905
+ if (sub !== 'summary') {
1906
+ return badCommand('Usage', 'Usage: `usage [summary|daily|monthly|model [daily|monthly] [agentId]]`');
1907
+ }
1908
+ const currentAgentId = resolveSessionAgentId(session);
1909
+ const daily = getUsageTotals({
1910
+ agentId: currentAgentId || undefined,
1911
+ window: 'daily',
1912
+ });
1913
+ const monthly = getUsageTotals({
1914
+ agentId: currentAgentId || undefined,
1915
+ window: 'monthly',
1916
+ });
1917
+ const topModels = listUsageByModel({
1918
+ agentId: currentAgentId || undefined,
1919
+ window: 'monthly',
1920
+ }).slice(0, 5);
1921
+ const scopeLabel = currentAgentId || 'all agents';
1922
+ const lines = [
1923
+ `Scope: ${scopeLabel}`,
1924
+ `Today: ${formatCompactNumber(daily.total_tokens)} tokens · ${daily.call_count} calls · ${formatUsd(daily.total_cost_usd)}`,
1925
+ `Month: ${formatCompactNumber(monthly.total_tokens)} tokens · ${monthly.call_count} calls · ${formatUsd(monthly.total_cost_usd)}`,
1926
+ ];
1927
+ if (topModels.length > 0) {
1928
+ lines.push('Top models (monthly):');
1929
+ lines.push(...topModels.map((row) => `- ${row.model}: ${formatCompactNumber(row.total_tokens)} tokens · ${formatUsd(row.total_cost_usd)}`));
1930
+ }
1931
+ return infoCommand('Usage Summary', lines.join('\n'));
1932
+ }
1933
+ case 'export': {
1934
+ const sub = (req.args[1] || 'session').toLowerCase();
1935
+ if (sub !== 'session') {
1936
+ return badCommand('Usage', 'Usage: `export session [sessionId]`');
1937
+ }
1938
+ const targetSessionId = (req.args[2] || session.id || '').trim();
1939
+ if (!targetSessionId) {
1940
+ return badCommand('Usage', 'Usage: `export session [sessionId]`');
1941
+ }
1942
+ const targetSession = memoryService.getSessionById(targetSessionId);
1943
+ if (!targetSession) {
1944
+ return badCommand('Not Found', `Session \`${targetSessionId}\` was not found.`);
1945
+ }
1946
+ const exportAgentId = resolveSessionAgentId(targetSession) || resolveSessionAgentId(session);
1947
+ if (!exportAgentId) {
1948
+ return badCommand('Missing Agent', 'Cannot export session: no agent/chatbot is configured for the target session.');
1949
+ }
1950
+ const messages = memoryService.getRecentMessages(targetSessionId);
1951
+ const exported = exportSessionSnapshotJsonl({
1952
+ agentId: exportAgentId,
1953
+ sessionId: targetSessionId,
1954
+ channelId: targetSession.channel_id,
1955
+ summary: targetSession.session_summary,
1956
+ messages,
1957
+ reason: 'manual',
1958
+ });
1959
+ if (!exported) {
1960
+ return badCommand('Export Failed', 'Failed to write session export JSONL file. Check gateway logs for details.');
1961
+ }
1962
+ return infoCommand('Session Exported', [
1963
+ `File: ${exported.path}`,
1964
+ `Messages: ${messages.length}`,
1965
+ `Summary: ${targetSession.session_summary ? 'yes' : 'no'}`,
1966
+ ].join('\n'));
1967
+ }
1473
1968
  case 'schedule': {
1474
1969
  const sub = req.args[1]?.toLowerCase();
1475
1970
  if (sub === 'add') {
1476
1971
  const rest = req.args.slice(2).join(' ');
1972
+ const atMatch = rest.match(/^at\s+"([^"]+)"\s+(.+)$/i);
1973
+ if (atMatch) {
1974
+ const [, runAtRaw, prompt] = atMatch;
1975
+ const parsedDate = new Date(runAtRaw);
1976
+ if (Number.isNaN(parsedDate.getTime())) {
1977
+ return badCommand('Invalid Time', `\`${runAtRaw}\` is not a valid ISO timestamp.`);
1978
+ }
1979
+ const taskId = createTask(session.id, req.channelId, '', prompt, parsedDate.toISOString());
1980
+ rearmScheduler();
1981
+ return plainCommand(`Task #${taskId} created: one-shot at \`${parsedDate.toISOString()}\` — ${prompt}`);
1982
+ }
1983
+ const everyMatch = rest.match(/^every\s+(\d+)\s+(.+)$/i);
1984
+ if (everyMatch) {
1985
+ const [, everyRaw, prompt] = everyMatch;
1986
+ const everyMs = Number.parseInt(everyRaw, 10);
1987
+ if (!Number.isFinite(everyMs) || everyMs < 10_000) {
1988
+ return badCommand('Invalid Interval', 'Interval must be at least 10000ms.');
1989
+ }
1990
+ const taskId = createTask(session.id, req.channelId, '', prompt, undefined, everyMs);
1991
+ rearmScheduler();
1992
+ return plainCommand(`Task #${taskId} created: every \`${everyMs}ms\` — ${prompt}`);
1993
+ }
1477
1994
  const cronMatch = rest.match(/^"([^"]+)"\s+(.+)$/);
1478
1995
  if (!cronMatch) {
1479
- return badCommand('Usage', 'Usage: `schedule add "<cron>" <prompt>`');
1996
+ return badCommand('Usage', 'Usage: `schedule add "<cron>" <prompt>` or `schedule add at "<ISO time>" <prompt>` or `schedule add every <ms> <prompt>`');
1480
1997
  }
1481
1998
  const [, cronExpr, prompt] = cronMatch;
1482
1999
  try {
@@ -1487,13 +2004,28 @@ export async function handleGatewayCommand(req) {
1487
2004
  }
1488
2005
  const taskId = createTask(session.id, req.channelId, cronExpr, prompt);
1489
2006
  rearmScheduler();
1490
- return plainCommand(`Task #${taskId} created: \`${cronExpr}\` — ${prompt}`);
2007
+ return plainCommand(`Task #${taskId} created: cron \`${cronExpr}\` — ${prompt}`);
1491
2008
  }
1492
2009
  if (sub === 'list') {
1493
2010
  const tasks = getTasksForSession(session.id);
1494
2011
  if (tasks.length === 0)
1495
2012
  return plainCommand('No scheduled tasks.');
1496
- const list = tasks.map((task) => `#${task.id} ${task.enabled ? 'enabled' : 'disabled'} \`${task.cron_expr}\` — ${task.prompt.slice(0, 60)}`).join('\n');
2013
+ const list = tasks
2014
+ .map((task) => {
2015
+ const scheduleLabel = task.run_at
2016
+ ? `at ${task.run_at}`
2017
+ : task.every_ms
2018
+ ? `every ${task.every_ms}ms`
2019
+ : task.cron_expr
2020
+ ? `cron ${task.cron_expr}`
2021
+ : 'unspecified';
2022
+ const statusLabel = task.last_status || 'n/a';
2023
+ const errorSuffix = task.consecutive_errors > 0
2024
+ ? ` · errors ${task.consecutive_errors}`
2025
+ : '';
2026
+ return `#${task.id} ${task.enabled ? 'enabled' : 'disabled'} (${scheduleLabel}) [${statusLabel}${errorSuffix}] — ${task.prompt.slice(0, 60)}`;
2027
+ })
2028
+ .join('\n');
1497
2029
  return infoCommand('Scheduled Tasks', list);
1498
2030
  }
1499
2031
  if (sub === 'remove') {
@@ -1512,7 +2044,12 @@ export async function handleGatewayCommand(req) {
1512
2044
  const task = tasks.find((t) => t.id === taskId);
1513
2045
  if (!task)
1514
2046
  return badCommand('Not Found', `Task #${taskId} was not found in this session.`);
1515
- toggleTask(taskId, !Boolean(task.enabled));
2047
+ if (task.enabled) {
2048
+ pauseTask(taskId);
2049
+ }
2050
+ else {
2051
+ resumeTask(taskId);
2052
+ }
1516
2053
  rearmScheduler();
1517
2054
  return plainCommand(`Task #${taskId} ${task.enabled ? 'disabled' : 'enabled'}.`);
1518
2055
  }