@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
package/src/gateway.ts CHANGED
@@ -1,19 +1,31 @@
1
- import fs from 'fs';
2
-
3
1
  import { AttachmentBuilder } from 'discord.js';
4
-
2
+ import fs from 'fs';
3
+ import {
4
+ buildResponseText,
5
+ formatError,
6
+ formatInfo,
7
+ } from './channels/discord/delivery.js';
8
+ import { rewriteUserMentionsForMessage } from './channels/discord/mentions.js';
9
+ import {
10
+ initDiscord,
11
+ type ReplyFn,
12
+ sendToChannel,
13
+ setDiscordMaintenancePresence,
14
+ } from './channels/discord/runtime.js';
5
15
  import {
6
16
  DISCORD_TOKEN,
17
+ getConfigSnapshot,
7
18
  HEARTBEAT_CHANNEL,
8
19
  HEARTBEAT_INTERVAL,
9
20
  HYBRIDAI_CHATBOT_ID,
10
- PROACTIVE_QUEUE_OUTSIDE_HOURS,
11
21
  onConfigChange,
22
+ PROACTIVE_QUEUE_OUTSIDE_HOURS,
12
23
  } from './config.js';
13
24
  import { stopAllContainers } from './container-runner.js';
14
25
  import {
15
26
  deleteQueuedProactiveMessage,
16
27
  enqueueProactiveMessage,
28
+ getMostRecentSessionChannelId,
17
29
  getQueuedProactiveMessageCount,
18
30
  initDatabase,
19
31
  listQueuedProactiveMessages,
@@ -25,25 +37,41 @@ import {
25
37
  renderGatewayCommand,
26
38
  runGatewayScheduledTask,
27
39
  } from './gateway-service.js';
28
- import { buildResponseText, formatError, formatInfo } from './channels/discord/delivery.js';
29
- import { rewriteUserMentionsForMessage } from './channels/discord/mentions.js';
30
40
  import { startHealthServer } from './health.js';
31
41
  import { startHeartbeat, stopHeartbeat } from './heartbeat.js';
32
42
  import { logger } from './logger.js';
33
- import { startObservabilityIngest, stopObservabilityIngest } from './observability-ingest.js';
34
- import { startScheduler, stopScheduler } from './scheduler.js';
43
+ import { memoryService } from './memory-service.js';
35
44
  import {
36
- initDiscord,
37
- sendToChannel,
38
- type ReplyFn,
39
- } from './channels/discord/runtime.js';
40
- import { isWithinActiveHours, proactiveWindowLabel } from './proactive-policy.js';
45
+ startObservabilityIngest,
46
+ stopObservabilityIngest,
47
+ } from './observability-ingest.js';
48
+ import {
49
+ isWithinActiveHours,
50
+ proactiveWindowLabel,
51
+ } from './proactive-policy.js';
52
+ import {
53
+ rearmScheduler,
54
+ type SchedulerDispatchRequest,
55
+ startScheduler,
56
+ stopScheduler,
57
+ } from './scheduler.js';
41
58
  import type { ArtifactMetadata } from './types.js';
42
59
 
43
60
  let detachConfigListener: (() => void) | null = null;
44
61
  let proactiveFlushTimer: ReturnType<typeof setInterval> | null = null;
62
+ let memoryConsolidationTimer: ReturnType<typeof setInterval> | null = null;
45
63
 
46
64
  const MAX_QUEUED_PROACTIVE_MESSAGES = 100;
65
+ const APPROVAL_PROMPT_DEFAULT_TTL_MS = 120_000;
66
+
67
+ interface PendingApprovalPrompt {
68
+ prompt: string;
69
+ createdAt: number;
70
+ expiresAt: number;
71
+ userId: string;
72
+ }
73
+
74
+ const pendingApprovalBySession = new Map<string, PendingApprovalPrompt>();
47
75
 
48
76
  function isDiscordChannelId(channelId: string): boolean {
49
77
  return /^\d{16,22}$/.test(channelId);
@@ -57,9 +85,14 @@ function buildArtifactAttachments(
57
85
  for (const artifact of artifacts) {
58
86
  try {
59
87
  const content = fs.readFileSync(artifact.path);
60
- attachments.push(new AttachmentBuilder(content, { name: artifact.filename }));
88
+ attachments.push(
89
+ new AttachmentBuilder(content, { name: artifact.filename }),
90
+ );
61
91
  } catch (error) {
62
- logger.warn({ artifactPath: artifact.path, error }, 'Failed to read artifact for Discord attachment');
92
+ logger.warn(
93
+ { artifactPath: artifact.path, error },
94
+ 'Failed to read artifact for Discord attachment',
95
+ );
63
96
  }
64
97
  }
65
98
  return attachments;
@@ -75,7 +108,9 @@ function simplifyImageAttachmentNarration(
75
108
  ): string {
76
109
  if (!text.trim() || !artifacts || artifacts.length === 0) return text;
77
110
 
78
- const imageArtifacts = artifacts.filter((artifact) => artifact.mimeType.startsWith('image/'));
111
+ const imageArtifacts = artifacts.filter((artifact) =>
112
+ artifact.mimeType.startsWith('image/'),
113
+ );
79
114
  if (imageArtifacts.length === 0) return text;
80
115
 
81
116
  const pathHints = new Set<string>();
@@ -88,14 +123,18 @@ function simplifyImageAttachmentNarration(
88
123
  if (filename) pathHints.add(`.browser-artifacts/${filename}`);
89
124
  }
90
125
 
91
- const pathishLine = /(^`?\s*(\.\/|\/|~\/|[a-zA-Z]:\\|\.browser-artifacts\/))|([\\/][^\\/\s]+\.[a-zA-Z0-9]{1,8})/;
92
- const locationNarration = /(workspace|saved to|find it at|located at|liegt unter|pfad|path)/i;
126
+ const pathishLine =
127
+ /(^`?\s*(\.\/|\/|~\/|[a-zA-Z]:\\|\.browser-artifacts\/))|([\\/][^\\/\s]+\.[a-zA-Z0-9]{1,8})/;
128
+ const locationNarration =
129
+ /(workspace|saved to|find it at|located at|liegt unter|pfad|path)/i;
93
130
 
94
131
  let removedPathNarration = false;
95
132
  const keptLines: string[] = [];
96
133
  for (const line of text.split('\n')) {
97
134
  const normalizedLine = normalizePathForMatch(line);
98
- const mentionsArtifact = Array.from(pathHints).some((hint) => normalizedLine.includes(hint));
135
+ const mentionsArtifact = Array.from(pathHints).some((hint) =>
136
+ normalizedLine.includes(hint),
137
+ );
99
138
  const isPathLine = pathishLine.test(line.trim());
100
139
  const isLocationNarration = locationNarration.test(line);
101
140
  if (mentionsArtifact && (isPathLine || isLocationNarration)) {
@@ -107,11 +146,140 @@ function simplifyImageAttachmentNarration(
107
146
 
108
147
  if (!removedPathNarration) return text;
109
148
 
110
- const cleaned = keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
149
+ const cleaned = keptLines
150
+ .join('\n')
151
+ .replace(/\n{3,}/g, '\n\n')
152
+ .trim();
111
153
  if (cleaned) return cleaned;
112
154
  return imageArtifacts.length === 1 ? 'Here it is.' : 'Here they are.';
113
155
  }
114
156
 
157
+ function isApprovalPromptForPrivateDelivery(text: string): boolean {
158
+ const normalized = text.toLowerCase();
159
+ return (
160
+ normalized.includes('i need your approval before i') &&
161
+ normalized.includes('approval id:') &&
162
+ normalized.includes('reply `yes`')
163
+ );
164
+ }
165
+
166
+ function parseApprovalExpiryMs(prompt: string): number {
167
+ const match = prompt.match(/Approval expires in\s+(\d+)s\./i);
168
+ const seconds = match ? Number.parseInt(match[1], 10) : NaN;
169
+ if (!Number.isFinite(seconds) || seconds <= 0) {
170
+ return APPROVAL_PROMPT_DEFAULT_TTL_MS;
171
+ }
172
+ return Math.max(15_000, seconds * 1_000);
173
+ }
174
+
175
+ function extractApprovalId(prompt: string): string {
176
+ const match = prompt.match(/Approval ID:\s*([a-f0-9-]{6,64})/i);
177
+ return match?.[1]?.trim() || '';
178
+ }
179
+
180
+ function cleanupExpiredPendingApprovals(): void {
181
+ const now = Date.now();
182
+ for (const [sessionId, pending] of pendingApprovalBySession.entries()) {
183
+ if (pending.expiresAt <= now) {
184
+ pendingApprovalBySession.delete(sessionId);
185
+ }
186
+ }
187
+ }
188
+
189
+ async function handleApprovalCommand(params: {
190
+ sessionId: string;
191
+ guildId: string | null;
192
+ channelId: string;
193
+ userId: string;
194
+ username: string;
195
+ args: string[];
196
+ reply: ReplyFn;
197
+ }): Promise<boolean> {
198
+ const { sessionId, guildId, channelId, userId, username, args, reply } =
199
+ params;
200
+ if ((args[0] || '').toLowerCase() !== 'approve') return false;
201
+
202
+ cleanupExpiredPendingApprovals();
203
+ const pending = pendingApprovalBySession.get(sessionId) || null;
204
+ const action = (args[1] || 'view').trim().toLowerCase();
205
+ const providedApprovalId = (args[2] || '').trim();
206
+ const currentApprovalId = pending ? extractApprovalId(pending.prompt) : '';
207
+ const approvalId = providedApprovalId || currentApprovalId;
208
+
209
+ if (action === 'view' || action === 'status' || action === 'show') {
210
+ if (!pending || pending.userId !== userId) {
211
+ await reply('No pending approval request for you in this session.');
212
+ return true;
213
+ }
214
+ await reply(formatInfo('Pending Approval', pending.prompt));
215
+ return true;
216
+ }
217
+
218
+ const directive = (() => {
219
+ if (action === 'yes' || action === '1') return 'yes';
220
+ if (action === 'session' || action === '2') return 'yes for session';
221
+ if (action === 'agent' || action === '3') return 'yes for agent';
222
+ if (
223
+ action === 'no' ||
224
+ action === 'deny' ||
225
+ action === 'skip' ||
226
+ action === '4'
227
+ ) {
228
+ return 'no';
229
+ }
230
+ return null;
231
+ })();
232
+
233
+ if (!directive) {
234
+ await reply(
235
+ 'Usage: `/approve action:view|yes|session|agent|no [approval_id]`',
236
+ );
237
+ return true;
238
+ }
239
+
240
+ if (!approvalId && !pending) {
241
+ await reply('No pending approval request for this session.');
242
+ return true;
243
+ }
244
+
245
+ const approvalContent = approvalId ? `${directive} ${approvalId}` : directive;
246
+ const approvalResult = await handleGatewayMessage({
247
+ sessionId,
248
+ guildId,
249
+ channelId,
250
+ userId,
251
+ username,
252
+ content: approvalContent,
253
+ media: [],
254
+ });
255
+ if (approvalResult.status === 'error') {
256
+ await reply(
257
+ formatError('Approval Error', approvalResult.error || 'Unknown error'),
258
+ );
259
+ return true;
260
+ }
261
+
262
+ const resultText = buildResponseText(
263
+ approvalResult.result || 'No response from agent.',
264
+ approvalResult.toolsUsed,
265
+ );
266
+ if (isApprovalPromptForPrivateDelivery(resultText)) {
267
+ pendingApprovalBySession.set(sessionId, {
268
+ prompt: resultText,
269
+ createdAt: Date.now(),
270
+ expiresAt: Date.now() + parseApprovalExpiryMs(resultText),
271
+ userId,
272
+ });
273
+ await reply(formatInfo('Pending Approval', resultText));
274
+ return true;
275
+ }
276
+
277
+ pendingApprovalBySession.delete(sessionId);
278
+ const attachments = buildArtifactAttachments(approvalResult.artifacts);
279
+ await reply(resultText, attachments);
280
+ return true;
281
+ }
282
+
115
283
  async function deliverProactiveMessage(
116
284
  channelId: string,
117
285
  text: string,
@@ -120,7 +288,12 @@ async function deliverProactiveMessage(
120
288
  ): Promise<void> {
121
289
  if (!isWithinActiveHours()) {
122
290
  if (PROACTIVE_QUEUE_OUTSIDE_HOURS) {
123
- const { queued, dropped } = enqueueProactiveMessage(channelId, text, source, MAX_QUEUED_PROACTIVE_MESSAGES);
291
+ const { queued, dropped } = enqueueProactiveMessage(
292
+ channelId,
293
+ text,
294
+ source,
295
+ MAX_QUEUED_PROACTIVE_MESSAGES,
296
+ );
124
297
  logger.info(
125
298
  {
126
299
  source,
@@ -140,7 +313,10 @@ async function deliverProactiveMessage(
140
313
  }
141
314
  return;
142
315
  }
143
- logger.info({ source, channelId, activeHours: proactiveWindowLabel() }, 'Proactive message suppressed (outside active hours)');
316
+ logger.info(
317
+ { source, channelId, activeHours: proactiveWindowLabel() },
318
+ 'Proactive message suppressed (outside active hours)',
319
+ );
144
320
  return;
145
321
  }
146
322
 
@@ -155,18 +331,59 @@ async function sendProactiveMessageNow(
155
331
  ): Promise<void> {
156
332
  const attachments = buildArtifactAttachments(artifacts);
157
333
  if (!DISCORD_TOKEN || !isDiscordChannelId(channelId)) {
158
- logger.info({ source, channelId, text, artifactCount: attachments.length }, 'Proactive message (no Discord delivery)');
334
+ logger.info(
335
+ { source, channelId, text, artifactCount: attachments.length },
336
+ 'Proactive message (no Discord delivery)',
337
+ );
159
338
  return;
160
339
  }
161
340
 
162
341
  try {
163
342
  await sendToChannel(channelId, text, attachments);
164
343
  } catch (error) {
165
- logger.warn({ source, channelId, error, artifactCount: attachments.length }, 'Failed to send proactive message to Discord channel');
344
+ logger.warn(
345
+ { source, channelId, error, artifactCount: attachments.length },
346
+ 'Failed to send proactive message to Discord channel',
347
+ );
166
348
  logger.info({ source, channelId, text }, 'Proactive message fallback');
167
349
  }
168
350
  }
169
351
 
352
+ async function deliverWebhookMessage(
353
+ webhookUrl: string,
354
+ text: string,
355
+ source: string,
356
+ artifacts?: ArtifactMetadata[],
357
+ ): Promise<void> {
358
+ const response = await fetch(webhookUrl, {
359
+ method: 'POST',
360
+ headers: {
361
+ 'Content-Type': 'application/json; charset=utf-8',
362
+ },
363
+ body: JSON.stringify({
364
+ text,
365
+ source,
366
+ artifactCount: artifacts?.length || 0,
367
+ artifacts: (artifacts || []).map((artifact) => ({
368
+ filename: artifact.filename,
369
+ mimeType: artifact.mimeType,
370
+ })),
371
+ }),
372
+ });
373
+ if (!response.ok) {
374
+ const body = await response.text().catch(() => '');
375
+ throw new Error(
376
+ `Webhook delivery failed (${response.status}): ${body.slice(0, 300)}`,
377
+ );
378
+ }
379
+ }
380
+
381
+ function resolveLastUsedDiscordChannelId(): string | null {
382
+ const channelId = getMostRecentSessionChannelId();
383
+ if (!channelId) return null;
384
+ return isDiscordChannelId(channelId) ? channelId : null;
385
+ }
386
+
170
387
  async function flushQueuedProactiveMessages(): Promise<void> {
171
388
  if (!isWithinActiveHours()) return;
172
389
  const pending = listQueuedProactiveMessages(MAX_QUEUED_PROACTIVE_MESSAGES);
@@ -178,7 +395,11 @@ async function flushQueuedProactiveMessages(): Promise<void> {
178
395
 
179
396
  for (const item of pending) {
180
397
  if (!isWithinActiveHours()) break;
181
- await sendProactiveMessageNow(item.channel_id, item.text, `${item.source}:queued`);
398
+ await sendProactiveMessageNow(
399
+ item.channel_id,
400
+ item.text,
401
+ `${item.source}:queued`,
402
+ );
182
403
  deleteQueuedProactiveMessage(item.id);
183
404
  }
184
405
  }
@@ -202,6 +423,7 @@ async function startDiscordIntegration(): Promise<void> {
202
423
  context,
203
424
  ) => {
204
425
  try {
426
+ let sawTextDelta = false;
205
427
  const result = await handleGatewayMessage({
206
428
  sessionId,
207
429
  guildId,
@@ -211,15 +433,35 @@ async function startDiscordIntegration(): Promise<void> {
211
433
  content,
212
434
  media,
213
435
  onTextDelta: (delta) => {
436
+ if (!sawTextDelta) {
437
+ sawTextDelta = true;
438
+ context.emitLifecyclePhase('streaming');
439
+ }
214
440
  void context.stream.append(delta);
215
441
  },
442
+ onToolProgress: (event) => {
443
+ if (sawTextDelta) return;
444
+ if (event.phase === 'start') {
445
+ context.emitLifecyclePhase('toolUse');
446
+ } else {
447
+ context.emitLifecyclePhase('thinking');
448
+ }
449
+ },
216
450
  onProactiveMessage: async (message) => {
217
- await deliverProactiveMessage(channelId, message.text, 'delegate', message.artifacts);
451
+ await deliverProactiveMessage(
452
+ channelId,
453
+ message.text,
454
+ 'delegate',
455
+ message.artifacts,
456
+ );
218
457
  },
219
458
  abortSignal: context.abortSignal,
220
459
  });
221
460
  if (result.status === 'error') {
222
- const errorText = formatError('Agent Error', result.error || 'Unknown error');
461
+ const errorText = formatError(
462
+ 'Agent Error',
463
+ result.error || 'Unknown error',
464
+ );
223
465
  await context.stream.fail(errorText);
224
466
  return;
225
467
  }
@@ -233,13 +475,27 @@ async function startDiscordIntegration(): Promise<void> {
233
475
  context.sourceMessage,
234
476
  context.mentionLookup,
235
477
  );
236
- await context.stream.finalize(
237
- buildResponseText(renderedText, result.toolsUsed),
238
- attachments,
239
- );
478
+ const responseText = buildResponseText(renderedText, result.toolsUsed);
479
+ if (isApprovalPromptForPrivateDelivery(responseText)) {
480
+ pendingApprovalBySession.set(sessionId, {
481
+ prompt: responseText,
482
+ createdAt: Date.now(),
483
+ expiresAt: Date.now() + parseApprovalExpiryMs(responseText),
484
+ userId,
485
+ });
486
+ await context.stream.finalize(
487
+ `<@${userId}> approval required. Use \`/approve\` to view and respond privately.`,
488
+ );
489
+ return;
490
+ }
491
+ pendingApprovalBySession.delete(sessionId);
492
+ await context.stream.finalize(responseText, attachments);
240
493
  } catch (error) {
241
494
  const text = error instanceof Error ? error.message : String(error);
242
- logger.error({ error, sessionId, channelId }, 'Discord message handling failed');
495
+ logger.error(
496
+ { error, sessionId, channelId },
497
+ 'Discord message handling failed',
498
+ );
243
499
  const errorText = formatError('Gateway Error', text);
244
500
  await context.stream.fail(errorText);
245
501
  }
@@ -248,10 +504,25 @@ async function startDiscordIntegration(): Promise<void> {
248
504
  sessionId: string,
249
505
  guildId: string | null,
250
506
  channelId: string,
507
+ userId: string,
508
+ username: string,
251
509
  args: string[],
252
510
  reply: ReplyFn,
253
511
  ) => {
254
512
  try {
513
+ if (
514
+ await handleApprovalCommand({
515
+ sessionId,
516
+ guildId,
517
+ channelId,
518
+ userId,
519
+ username,
520
+ args,
521
+ reply,
522
+ })
523
+ ) {
524
+ return;
525
+ }
255
526
  const result = await handleGatewayCommand({
256
527
  sessionId,
257
528
  guildId,
@@ -269,7 +540,10 @@ async function startDiscordIntegration(): Promise<void> {
269
540
  await reply(renderGatewayCommand(result));
270
541
  } catch (error) {
271
542
  const text = error instanceof Error ? error.message : String(error);
272
- logger.error({ error, sessionId, channelId, args }, 'Discord command handling failed');
543
+ logger.error(
544
+ { error, sessionId, channelId, args },
545
+ 'Discord command handling failed',
546
+ );
273
547
  await reply(formatError('Gateway Error', text));
274
548
  }
275
549
  },
@@ -278,47 +552,141 @@ async function startDiscordIntegration(): Promise<void> {
278
552
  }
279
553
 
280
554
  function setupShutdown(): void {
281
- const shutdown = () => {
555
+ let shuttingDown = false;
556
+ const shutdown = async () => {
557
+ if (shuttingDown) return;
558
+ shuttingDown = true;
282
559
  logger.info('Shutting down gateway...');
283
560
  if (detachConfigListener) {
284
561
  detachConfigListener();
285
562
  detachConfigListener = null;
286
563
  }
564
+ await setDiscordMaintenancePresence().catch((error) => {
565
+ logger.debug(
566
+ { error },
567
+ 'Failed to set Discord maintenance presence during shutdown',
568
+ );
569
+ });
287
570
  stopHeartbeat();
288
571
  stopObservabilityIngest();
289
572
  stopAllContainers();
290
573
  stopScheduler();
574
+ stopMemoryConsolidationScheduler();
291
575
  if (proactiveFlushTimer) {
292
576
  clearInterval(proactiveFlushTimer);
293
577
  proactiveFlushTimer = null;
294
578
  }
295
579
  process.exit(0);
296
580
  };
297
- process.on('SIGINT', shutdown);
298
- process.on('SIGTERM', shutdown);
581
+ process.on('SIGINT', () => {
582
+ void shutdown();
583
+ });
584
+ process.on('SIGTERM', () => {
585
+ void shutdown();
586
+ });
299
587
  }
300
588
 
301
589
  async function runScheduledTask(
302
- sessionId: string,
303
- channelId: string,
304
- prompt: string,
305
- taskId: number,
590
+ request: SchedulerDispatchRequest,
306
591
  ): Promise<void> {
592
+ const sourceLabel =
593
+ request.source === 'db-task'
594
+ ? `schedule:${request.taskId ?? 'unknown'}`
595
+ : `schedule-job:${request.jobId ?? 'unknown'}`;
596
+ const resolvedDeliveryChannelId =
597
+ request.delivery.kind === 'channel'
598
+ ? request.delivery.channelId
599
+ : request.delivery.kind === 'last-channel'
600
+ ? resolveLastUsedDiscordChannelId()
601
+ : null;
602
+
603
+ if (request.actionKind === 'system_event') {
604
+ if (request.delivery.kind === 'webhook') {
605
+ await deliverWebhookMessage(
606
+ request.delivery.webhookUrl,
607
+ request.prompt,
608
+ `${sourceLabel}:system`,
609
+ );
610
+ return;
611
+ }
612
+ if (!resolvedDeliveryChannelId) {
613
+ throw new Error(
614
+ 'No Discord channel available for scheduled system event delivery.',
615
+ );
616
+ }
617
+ await deliverProactiveMessage(
618
+ resolvedDeliveryChannelId,
619
+ request.prompt,
620
+ `${sourceLabel}:system`,
621
+ );
622
+ return;
623
+ }
624
+
625
+ const runChannelId =
626
+ request.channelId || resolvedDeliveryChannelId || 'scheduler';
627
+ const taskId = request.taskId ?? -1;
628
+
307
629
  await runGatewayScheduledTask(
308
- sessionId,
309
- channelId,
310
- prompt,
630
+ request.sessionId,
631
+ runChannelId,
632
+ request.prompt,
311
633
  taskId,
312
634
  async (result) => {
313
- await deliverProactiveMessage(channelId, result.text, `schedule:${taskId}`, result.artifacts);
635
+ if (request.delivery.kind === 'webhook') {
636
+ await deliverWebhookMessage(
637
+ request.delivery.webhookUrl,
638
+ result.text,
639
+ sourceLabel,
640
+ result.artifacts,
641
+ );
642
+ logger.info(
643
+ {
644
+ jobId: request.jobId,
645
+ taskId: request.taskId,
646
+ source: request.source,
647
+ delivery: 'webhook',
648
+ result: result.text,
649
+ artifactCount: result.artifacts?.length || 0,
650
+ },
651
+ 'Scheduled task completed',
652
+ );
653
+ return;
654
+ }
655
+
656
+ if (!resolvedDeliveryChannelId) {
657
+ throw new Error('No Discord channel available for scheduled delivery.');
658
+ }
659
+ await deliverProactiveMessage(
660
+ resolvedDeliveryChannelId,
661
+ result.text,
662
+ sourceLabel,
663
+ result.artifacts,
664
+ );
314
665
  logger.info(
315
- { taskId, channelId, result: result.text, artifactCount: result.artifacts?.length || 0 },
666
+ {
667
+ jobId: request.jobId,
668
+ taskId: request.taskId,
669
+ source: request.source,
670
+ channelId: resolvedDeliveryChannelId,
671
+ result: result.text,
672
+ artifactCount: result.artifacts?.length || 0,
673
+ },
316
674
  'Scheduled task completed',
317
675
  );
318
676
  },
319
677
  (error) => {
320
- logger.error({ taskId, channelId, error }, 'Scheduled task failed');
678
+ logger.error(
679
+ {
680
+ jobId: request.jobId,
681
+ taskId: request.taskId,
682
+ source: request.source,
683
+ delivery: request.delivery.kind,
684
+ error,
685
+ },
686
+ 'Scheduled task failed',
687
+ );
321
688
  },
689
+ request.sessionId,
322
690
  );
323
691
  }
324
692
 
@@ -332,6 +700,46 @@ function startOrRestartHeartbeat(): void {
332
700
  });
333
701
  }
334
702
 
703
+ function stopMemoryConsolidationScheduler(): void {
704
+ if (!memoryConsolidationTimer) return;
705
+ clearInterval(memoryConsolidationTimer);
706
+ memoryConsolidationTimer = null;
707
+ }
708
+
709
+ function startOrRestartMemoryConsolidationScheduler(): void {
710
+ stopMemoryConsolidationScheduler();
711
+ const intervalHours = Math.max(
712
+ 0,
713
+ Math.trunc(getConfigSnapshot().memory.consolidationIntervalHours),
714
+ );
715
+ if (intervalHours <= 0) {
716
+ logger.info('Memory consolidation scheduler disabled');
717
+ return;
718
+ }
719
+
720
+ const intervalMs = intervalHours * 3_600_000;
721
+ memoryConsolidationTimer = setInterval(() => {
722
+ const { decayRate } = getConfigSnapshot().memory;
723
+ try {
724
+ const report = memoryService.consolidateMemories({ decayRate });
725
+ if (report.memoriesDecayed > 0) {
726
+ logger.info(
727
+ {
728
+ decayed: report.memoriesDecayed,
729
+ durationMs: report.durationMs,
730
+ decayRate,
731
+ },
732
+ 'Memory consolidation completed',
733
+ );
734
+ }
735
+ } catch (error) {
736
+ logger.warn({ error, decayRate }, 'Memory consolidation failed');
737
+ }
738
+ }, intervalMs);
739
+
740
+ logger.info({ intervalHours }, 'Memory consolidation scheduled');
741
+ }
742
+
335
743
  async function main(): Promise<void> {
336
744
  logger.info('Starting HybridClaw gateway');
337
745
  initDatabase();
@@ -343,9 +751,9 @@ async function main(): Promise<void> {
343
751
  startObservabilityIngest();
344
752
  detachConfigListener = onConfigChange((next, prev) => {
345
753
  const shouldRestart =
346
- next.hybridai.defaultChatbotId !== prev.hybridai.defaultChatbotId
347
- || next.heartbeat.intervalMs !== prev.heartbeat.intervalMs
348
- || next.heartbeat.enabled !== prev.heartbeat.enabled;
754
+ next.hybridai.defaultChatbotId !== prev.hybridai.defaultChatbotId ||
755
+ next.heartbeat.intervalMs !== prev.heartbeat.intervalMs ||
756
+ next.heartbeat.enabled !== prev.heartbeat.enabled;
349
757
  if (shouldRestart) {
350
758
  logger.info(
351
759
  {
@@ -358,9 +766,32 @@ async function main(): Promise<void> {
358
766
  startOrRestartHeartbeat();
359
767
  }
360
768
 
769
+ const schedulerChanged =
770
+ JSON.stringify(next.scheduler) !== JSON.stringify(prev.scheduler);
771
+ if (schedulerChanged) {
772
+ logger.info(
773
+ 'Config changed, re-arming scheduler for updated scheduler.jobs',
774
+ );
775
+ rearmScheduler();
776
+ }
777
+
778
+ const memoryChanged =
779
+ JSON.stringify(next.memory) !== JSON.stringify(prev.memory);
780
+ if (memoryChanged) {
781
+ logger.info(
782
+ {
783
+ consolidationIntervalHours: next.memory.consolidationIntervalHours,
784
+ decayRate: next.memory.decayRate,
785
+ },
786
+ 'Config changed, restarting memory consolidation scheduler',
787
+ );
788
+ startOrRestartMemoryConsolidationScheduler();
789
+ }
790
+
361
791
  const shouldRestartObservability =
362
- JSON.stringify(next.observability) !== JSON.stringify(prev.observability)
363
- || next.hybridai.defaultChatbotId !== prev.hybridai.defaultChatbotId;
792
+ JSON.stringify(next.observability) !==
793
+ JSON.stringify(prev.observability) ||
794
+ next.hybridai.defaultChatbotId !== prev.hybridai.defaultChatbotId;
364
795
  if (!shouldRestartObservability) return;
365
796
 
366
797
  logger.info(
@@ -374,6 +805,7 @@ async function main(): Promise<void> {
374
805
  startObservabilityIngest();
375
806
  });
376
807
  startScheduler(runScheduledTask);
808
+ startOrRestartMemoryConsolidationScheduler();
377
809
  proactiveFlushTimer = setInterval(() => {
378
810
  void flushQueuedProactiveMessages().catch((err) => {
379
811
  logger.warn({ err }, 'Failed to flush queued proactive messages');
@@ -383,7 +815,10 @@ async function main(): Promise<void> {
383
815
  logger.warn({ err }, 'Initial proactive queue flush failed');
384
816
  });
385
817
 
386
- logger.info({ ...getGatewayStatus(), discord: !!DISCORD_TOKEN }, 'HybridClaw gateway started');
818
+ logger.info(
819
+ { ...getGatewayStatus(), discord: !!DISCORD_TOKEN },
820
+ 'HybridClaw gateway started',
821
+ );
387
822
  }
388
823
 
389
824
  main().catch((err) => {