@hybridaione/hybridclaw 0.2.2 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +85 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +41 -16
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +71 -8
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1303 -0
  12. package/container/src/browser-tools.ts +431 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +34 -13
  15. package/container/src/index.ts +451 -109
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +20 -10
  18. package/container/src/tools.ts +599 -225
  19. package/container/src/types.ts +32 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +10 -2
  23. package/dist/agent.js.map +1 -1
  24. package/dist/audit-cli.d.ts.map +1 -1
  25. package/dist/audit-cli.js +4 -2
  26. package/dist/audit-cli.js.map +1 -1
  27. package/dist/audit-events.d.ts.map +1 -1
  28. package/dist/audit-events.js +53 -3
  29. package/dist/audit-events.js.map +1 -1
  30. package/dist/audit-trail.d.ts.map +1 -1
  31. package/dist/audit-trail.js +17 -8
  32. package/dist/audit-trail.js.map +1 -1
  33. package/dist/channels/discord/attachments.d.ts.map +1 -1
  34. package/dist/channels/discord/attachments.js +14 -7
  35. package/dist/channels/discord/attachments.js.map +1 -1
  36. package/dist/channels/discord/debounce.d.ts +9 -0
  37. package/dist/channels/discord/debounce.d.ts.map +1 -0
  38. package/dist/channels/discord/debounce.js +20 -0
  39. package/dist/channels/discord/debounce.js.map +1 -0
  40. package/dist/channels/discord/delivery.d.ts +4 -1
  41. package/dist/channels/discord/delivery.d.ts.map +1 -1
  42. package/dist/channels/discord/delivery.js +19 -3
  43. package/dist/channels/discord/delivery.js.map +1 -1
  44. package/dist/channels/discord/human-delay.d.ts +16 -0
  45. package/dist/channels/discord/human-delay.d.ts.map +1 -0
  46. package/dist/channels/discord/human-delay.js +29 -0
  47. package/dist/channels/discord/human-delay.js.map +1 -0
  48. package/dist/channels/discord/inbound.d.ts +4 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +45 -4
  51. package/dist/channels/discord/inbound.js.map +1 -1
  52. package/dist/channels/discord/mentions.d.ts.map +1 -1
  53. package/dist/channels/discord/mentions.js +16 -4
  54. package/dist/channels/discord/mentions.js.map +1 -1
  55. package/dist/channels/discord/presence.d.ts +33 -0
  56. package/dist/channels/discord/presence.d.ts.map +1 -0
  57. package/dist/channels/discord/presence.js +111 -0
  58. package/dist/channels/discord/presence.js.map +1 -0
  59. package/dist/channels/discord/rate-limiter.d.ts +14 -0
  60. package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
  61. package/dist/channels/discord/rate-limiter.js +49 -0
  62. package/dist/channels/discord/rate-limiter.js.map +1 -0
  63. package/dist/channels/discord/reactions.d.ts +38 -0
  64. package/dist/channels/discord/reactions.d.ts.map +1 -0
  65. package/dist/channels/discord/reactions.js +151 -0
  66. package/dist/channels/discord/reactions.js.map +1 -0
  67. package/dist/channels/discord/runtime.d.ts +6 -3
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +621 -125
  70. package/dist/channels/discord/runtime.js.map +1 -1
  71. package/dist/channels/discord/stream.d.ts +4 -1
  72. package/dist/channels/discord/stream.d.ts.map +1 -1
  73. package/dist/channels/discord/stream.js +16 -8
  74. package/dist/channels/discord/stream.js.map +1 -1
  75. package/dist/channels/discord/tool-actions.d.ts.map +1 -1
  76. package/dist/channels/discord/tool-actions.js +24 -12
  77. package/dist/channels/discord/tool-actions.js.map +1 -1
  78. package/dist/channels/discord/typing.d.ts +15 -0
  79. package/dist/channels/discord/typing.d.ts.map +1 -0
  80. package/dist/channels/discord/typing.js +106 -0
  81. package/dist/channels/discord/typing.js.map +1 -0
  82. package/dist/chunk.d.ts.map +1 -1
  83. package/dist/chunk.js +4 -2
  84. package/dist/chunk.js.map +1 -1
  85. package/dist/cli.js +47 -22
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +19 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +103 -18
  90. package/dist/config.js.map +1 -1
  91. package/dist/container-runner.d.ts.map +1 -1
  92. package/dist/container-runner.js +58 -26
  93. package/dist/container-runner.js.map +1 -1
  94. package/dist/container-setup.d.ts.map +1 -1
  95. package/dist/container-setup.js +10 -9
  96. package/dist/container-setup.js.map +1 -1
  97. package/dist/conversation.d.ts +2 -2
  98. package/dist/conversation.d.ts.map +1 -1
  99. package/dist/conversation.js +1 -1
  100. package/dist/conversation.js.map +1 -1
  101. package/dist/db.d.ts +118 -2
  102. package/dist/db.d.ts.map +1 -1
  103. package/dist/db.js +1568 -50
  104. package/dist/db.js.map +1 -1
  105. package/dist/delegation-manager.d.ts.map +1 -1
  106. package/dist/delegation-manager.js +3 -2
  107. package/dist/delegation-manager.js.map +1 -1
  108. package/dist/gateway-client.d.ts +2 -2
  109. package/dist/gateway-client.d.ts.map +1 -1
  110. package/dist/gateway-client.js +10 -4
  111. package/dist/gateway-client.js.map +1 -1
  112. package/dist/gateway-service.d.ts +3 -3
  113. package/dist/gateway-service.d.ts.map +1 -1
  114. package/dist/gateway-service.js +563 -73
  115. package/dist/gateway-service.js.map +1 -1
  116. package/dist/gateway-types.d.ts +24 -0
  117. package/dist/gateway-types.d.ts.map +1 -1
  118. package/dist/gateway-types.js.map +1 -1
  119. package/dist/gateway.js +179 -24
  120. package/dist/gateway.js.map +1 -1
  121. package/dist/health.d.ts.map +1 -1
  122. package/dist/health.js +20 -10
  123. package/dist/health.js.map +1 -1
  124. package/dist/heartbeat.d.ts +4 -0
  125. package/dist/heartbeat.d.ts.map +1 -1
  126. package/dist/heartbeat.js +48 -20
  127. package/dist/heartbeat.js.map +1 -1
  128. package/dist/hybridai-bots.d.ts.map +1 -1
  129. package/dist/hybridai-bots.js +4 -2
  130. package/dist/hybridai-bots.js.map +1 -1
  131. package/dist/instruction-approval-audit.d.ts.map +1 -1
  132. package/dist/instruction-approval-audit.js.map +1 -1
  133. package/dist/instruction-integrity.d.ts.map +1 -1
  134. package/dist/instruction-integrity.js +8 -2
  135. package/dist/instruction-integrity.js.map +1 -1
  136. package/dist/ipc.d.ts.map +1 -1
  137. package/dist/ipc.js +6 -1
  138. package/dist/ipc.js.map +1 -1
  139. package/dist/logger.js.map +1 -1
  140. package/dist/memory-consolidation.d.ts +17 -0
  141. package/dist/memory-consolidation.d.ts.map +1 -0
  142. package/dist/memory-consolidation.js +25 -0
  143. package/dist/memory-consolidation.js.map +1 -0
  144. package/dist/memory-service.d.ts +200 -0
  145. package/dist/memory-service.d.ts.map +1 -0
  146. package/dist/memory-service.js +294 -0
  147. package/dist/memory-service.js.map +1 -0
  148. package/dist/mount-security.d.ts.map +1 -1
  149. package/dist/mount-security.js +31 -7
  150. package/dist/mount-security.js.map +1 -1
  151. package/dist/observability-ingest.d.ts.map +1 -1
  152. package/dist/observability-ingest.js +32 -11
  153. package/dist/observability-ingest.js.map +1 -1
  154. package/dist/onboarding.d.ts.map +1 -1
  155. package/dist/onboarding.js +32 -9
  156. package/dist/onboarding.js.map +1 -1
  157. package/dist/proactive-policy.d.ts.map +1 -1
  158. package/dist/proactive-policy.js +2 -1
  159. package/dist/proactive-policy.js.map +1 -1
  160. package/dist/prompt-hooks.d.ts.map +1 -1
  161. package/dist/prompt-hooks.js +9 -7
  162. package/dist/prompt-hooks.js.map +1 -1
  163. package/dist/runtime-config.d.ts +98 -1
  164. package/dist/runtime-config.d.ts.map +1 -1
  165. package/dist/runtime-config.js +477 -23
  166. package/dist/runtime-config.js.map +1 -1
  167. package/dist/scheduled-task-runner.d.ts +1 -0
  168. package/dist/scheduled-task-runner.d.ts.map +1 -1
  169. package/dist/scheduled-task-runner.js +29 -10
  170. package/dist/scheduled-task-runner.js.map +1 -1
  171. package/dist/scheduler.d.ts +43 -4
  172. package/dist/scheduler.d.ts.map +1 -1
  173. package/dist/scheduler.js +530 -56
  174. package/dist/scheduler.js.map +1 -1
  175. package/dist/session-export.d.ts +26 -0
  176. package/dist/session-export.d.ts.map +1 -0
  177. package/dist/session-export.js +149 -0
  178. package/dist/session-export.js.map +1 -0
  179. package/dist/session-maintenance.d.ts.map +1 -1
  180. package/dist/session-maintenance.js +75 -13
  181. package/dist/session-maintenance.js.map +1 -1
  182. package/dist/session-transcripts.d.ts.map +1 -1
  183. package/dist/session-transcripts.js.map +1 -1
  184. package/dist/side-effects.d.ts.map +1 -1
  185. package/dist/side-effects.js +14 -2
  186. package/dist/side-effects.js.map +1 -1
  187. package/dist/skills-guard.d.ts.map +1 -1
  188. package/dist/skills-guard.js +893 -130
  189. package/dist/skills-guard.js.map +1 -1
  190. package/dist/skills.d.ts +5 -0
  191. package/dist/skills.d.ts.map +1 -1
  192. package/dist/skills.js +29 -15
  193. package/dist/skills.js.map +1 -1
  194. package/dist/token-efficiency.d.ts.map +1 -1
  195. package/dist/token-efficiency.js.map +1 -1
  196. package/dist/tui.js +92 -11
  197. package/dist/tui.js.map +1 -1
  198. package/dist/types.d.ts +146 -0
  199. package/dist/types.d.ts.map +1 -1
  200. package/dist/types.js +24 -1
  201. package/dist/types.js.map +1 -1
  202. package/dist/update.d.ts.map +1 -1
  203. package/dist/update.js +42 -14
  204. package/dist/update.js.map +1 -1
  205. package/dist/workspace.d.ts.map +1 -1
  206. package/dist/workspace.js +49 -9
  207. package/dist/workspace.js.map +1 -1
  208. package/docs/chat.html +9 -3
  209. package/docs/index.html +37 -13
  210. package/package.json +8 -2
  211. package/src/agent.ts +16 -3
  212. package/src/audit-cli.ts +44 -16
  213. package/src/audit-events.ts +69 -5
  214. package/src/audit-trail.ts +41 -15
  215. package/src/channels/discord/attachments.ts +81 -27
  216. package/src/channels/discord/debounce.ts +25 -0
  217. package/src/channels/discord/delivery.ts +57 -13
  218. package/src/channels/discord/human-delay.ts +48 -0
  219. package/src/channels/discord/inbound.ts +66 -7
  220. package/src/channels/discord/mentions.ts +42 -18
  221. package/src/channels/discord/presence.ts +148 -0
  222. package/src/channels/discord/rate-limiter.ts +58 -0
  223. package/src/channels/discord/reactions.ts +211 -0
  224. package/src/channels/discord/runtime.ts +1048 -182
  225. package/src/channels/discord/stream.ts +73 -27
  226. package/src/channels/discord/tool-actions.ts +78 -37
  227. package/src/channels/discord/typing.ts +140 -0
  228. package/src/chunk.ts +12 -4
  229. package/src/cli.ts +141 -56
  230. package/src/config.ts +192 -34
  231. package/src/container-runner.ts +132 -42
  232. package/src/container-setup.ts +57 -22
  233. package/src/conversation.ts +9 -7
  234. package/src/db.ts +2217 -84
  235. package/src/delegation-manager.ts +6 -2
  236. package/src/gateway-client.ts +41 -17
  237. package/src/gateway-service.ts +1019 -201
  238. package/src/gateway-types.ts +33 -0
  239. package/src/gateway.ts +321 -48
  240. package/src/health.ts +66 -26
  241. package/src/heartbeat.ts +84 -22
  242. package/src/hybridai-bots.ts +14 -5
  243. package/src/instruction-approval-audit.ts +4 -1
  244. package/src/instruction-integrity.ts +30 -9
  245. package/src/ipc.ts +23 -5
  246. package/src/logger.ts +4 -1
  247. package/src/memory-consolidation.ts +41 -0
  248. package/src/memory-service.ts +606 -0
  249. package/src/mount-security.ts +58 -13
  250. package/src/observability-ingest.ts +134 -35
  251. package/src/onboarding.ts +126 -35
  252. package/src/proactive-policy.ts +3 -1
  253. package/src/prompt-hooks.ts +40 -17
  254. package/src/runtime-config.ts +1114 -99
  255. package/src/scheduled-task-runner.ts +63 -11
  256. package/src/scheduler.ts +683 -60
  257. package/src/session-export.ts +196 -0
  258. package/src/session-maintenance.ts +125 -22
  259. package/src/session-transcripts.ts +12 -3
  260. package/src/side-effects.ts +28 -5
  261. package/src/skills-guard.ts +1067 -219
  262. package/src/skills.ts +163 -65
  263. package/src/token-efficiency.ts +31 -9
  264. package/src/tui.ts +166 -25
  265. package/src/types.ts +195 -2
  266. package/src/update.ts +79 -23
  267. package/src/workspace.ts +63 -11
  268. package/tests/approval-policy.test.ts +224 -0
  269. package/tests/discord.basic.test.ts +82 -2
  270. package/tests/discord.human-presence.test.ts +85 -0
  271. package/tests/gateway-service.media-routing.test.ts +8 -2
  272. package/tests/memory-service.test.ts +1114 -0
  273. package/tests/token-efficiency.basic.test.ts +8 -2
  274. package/vitest.e2e.config.ts +3 -1
  275. package/vitest.integration.config.ts +3 -1
  276. package/vitest.live.config.ts +3 -1
  277. package/vitest.unit.config.ts +9 -0
@@ -20,9 +20,27 @@ export interface GatewayChatResult {
20
20
  arguments: string;
21
21
  result: string;
22
22
  durationMs: number;
23
+ isError?: boolean;
24
+ blocked?: boolean;
25
+ blockedReason?: string;
26
+ approvalTier?: 'green' | 'yellow' | 'red';
27
+ approvalBaseTier?: 'green' | 'yellow' | 'red';
28
+ approvalDecision?:
29
+ | 'auto'
30
+ | 'implicit'
31
+ | 'approved_once'
32
+ | 'approved_session'
33
+ | 'approved_agent'
34
+ | 'promoted'
35
+ | 'required'
36
+ | 'denied';
37
+ approvalActionKey?: string;
38
+ approvalReason?: string;
39
+ approvalRequestId?: string;
23
40
  }>;
24
41
  tokenUsage?: TokenUsageStats;
25
42
  error?: string;
43
+ effectiveUserPrompt?: string;
26
44
  }
27
45
 
28
46
  export interface GatewayChatToolProgressEvent {
@@ -75,6 +93,18 @@ export interface GatewayCommandRequest {
75
93
  args: string[];
76
94
  }
77
95
 
96
+ export interface GatewaySchedulerJobStatus {
97
+ id: string;
98
+ name: string;
99
+ description: string | null;
100
+ enabled: boolean;
101
+ lastRun: string | null;
102
+ lastStatus: 'success' | 'error' | null;
103
+ nextRunAt: string | null;
104
+ disabled: boolean;
105
+ consecutiveErrors: number;
106
+ }
107
+
78
108
  export interface GatewayStatus {
79
109
  status: 'ok';
80
110
  pid?: number;
@@ -96,6 +126,9 @@ export interface GatewayStatus {
96
126
  lastFailureAt: string | null;
97
127
  lastError: string | null;
98
128
  };
129
+ scheduler?: {
130
+ jobs: GatewaySchedulerJobStatus[];
131
+ };
99
132
  }
100
133
 
101
134
  export function renderGatewayCommand(result: GatewayCommandResult): string {
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,23 +37,29 @@ 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;
47
65
 
@@ -57,9 +75,14 @@ function buildArtifactAttachments(
57
75
  for (const artifact of artifacts) {
58
76
  try {
59
77
  const content = fs.readFileSync(artifact.path);
60
- attachments.push(new AttachmentBuilder(content, { name: artifact.filename }));
78
+ attachments.push(
79
+ new AttachmentBuilder(content, { name: artifact.filename }),
80
+ );
61
81
  } catch (error) {
62
- logger.warn({ artifactPath: artifact.path, error }, 'Failed to read artifact for Discord attachment');
82
+ logger.warn(
83
+ { artifactPath: artifact.path, error },
84
+ 'Failed to read artifact for Discord attachment',
85
+ );
63
86
  }
64
87
  }
65
88
  return attachments;
@@ -75,7 +98,9 @@ function simplifyImageAttachmentNarration(
75
98
  ): string {
76
99
  if (!text.trim() || !artifacts || artifacts.length === 0) return text;
77
100
 
78
- const imageArtifacts = artifacts.filter((artifact) => artifact.mimeType.startsWith('image/'));
101
+ const imageArtifacts = artifacts.filter((artifact) =>
102
+ artifact.mimeType.startsWith('image/'),
103
+ );
79
104
  if (imageArtifacts.length === 0) return text;
80
105
 
81
106
  const pathHints = new Set<string>();
@@ -88,14 +113,18 @@ function simplifyImageAttachmentNarration(
88
113
  if (filename) pathHints.add(`.browser-artifacts/${filename}`);
89
114
  }
90
115
 
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;
116
+ const pathishLine =
117
+ /(^`?\s*(\.\/|\/|~\/|[a-zA-Z]:\\|\.browser-artifacts\/))|([\\/][^\\/\s]+\.[a-zA-Z0-9]{1,8})/;
118
+ const locationNarration =
119
+ /(workspace|saved to|find it at|located at|liegt unter|pfad|path)/i;
93
120
 
94
121
  let removedPathNarration = false;
95
122
  const keptLines: string[] = [];
96
123
  for (const line of text.split('\n')) {
97
124
  const normalizedLine = normalizePathForMatch(line);
98
- const mentionsArtifact = Array.from(pathHints).some((hint) => normalizedLine.includes(hint));
125
+ const mentionsArtifact = Array.from(pathHints).some((hint) =>
126
+ normalizedLine.includes(hint),
127
+ );
99
128
  const isPathLine = pathishLine.test(line.trim());
100
129
  const isLocationNarration = locationNarration.test(line);
101
130
  if (mentionsArtifact && (isPathLine || isLocationNarration)) {
@@ -107,7 +136,10 @@ function simplifyImageAttachmentNarration(
107
136
 
108
137
  if (!removedPathNarration) return text;
109
138
 
110
- const cleaned = keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
139
+ const cleaned = keptLines
140
+ .join('\n')
141
+ .replace(/\n{3,}/g, '\n\n')
142
+ .trim();
111
143
  if (cleaned) return cleaned;
112
144
  return imageArtifacts.length === 1 ? 'Here it is.' : 'Here they are.';
113
145
  }
@@ -120,7 +152,12 @@ async function deliverProactiveMessage(
120
152
  ): Promise<void> {
121
153
  if (!isWithinActiveHours()) {
122
154
  if (PROACTIVE_QUEUE_OUTSIDE_HOURS) {
123
- const { queued, dropped } = enqueueProactiveMessage(channelId, text, source, MAX_QUEUED_PROACTIVE_MESSAGES);
155
+ const { queued, dropped } = enqueueProactiveMessage(
156
+ channelId,
157
+ text,
158
+ source,
159
+ MAX_QUEUED_PROACTIVE_MESSAGES,
160
+ );
124
161
  logger.info(
125
162
  {
126
163
  source,
@@ -140,7 +177,10 @@ async function deliverProactiveMessage(
140
177
  }
141
178
  return;
142
179
  }
143
- logger.info({ source, channelId, activeHours: proactiveWindowLabel() }, 'Proactive message suppressed (outside active hours)');
180
+ logger.info(
181
+ { source, channelId, activeHours: proactiveWindowLabel() },
182
+ 'Proactive message suppressed (outside active hours)',
183
+ );
144
184
  return;
145
185
  }
146
186
 
@@ -155,18 +195,59 @@ async function sendProactiveMessageNow(
155
195
  ): Promise<void> {
156
196
  const attachments = buildArtifactAttachments(artifacts);
157
197
  if (!DISCORD_TOKEN || !isDiscordChannelId(channelId)) {
158
- logger.info({ source, channelId, text, artifactCount: attachments.length }, 'Proactive message (no Discord delivery)');
198
+ logger.info(
199
+ { source, channelId, text, artifactCount: attachments.length },
200
+ 'Proactive message (no Discord delivery)',
201
+ );
159
202
  return;
160
203
  }
161
204
 
162
205
  try {
163
206
  await sendToChannel(channelId, text, attachments);
164
207
  } catch (error) {
165
- logger.warn({ source, channelId, error, artifactCount: attachments.length }, 'Failed to send proactive message to Discord channel');
208
+ logger.warn(
209
+ { source, channelId, error, artifactCount: attachments.length },
210
+ 'Failed to send proactive message to Discord channel',
211
+ );
166
212
  logger.info({ source, channelId, text }, 'Proactive message fallback');
167
213
  }
168
214
  }
169
215
 
216
+ async function deliverWebhookMessage(
217
+ webhookUrl: string,
218
+ text: string,
219
+ source: string,
220
+ artifacts?: ArtifactMetadata[],
221
+ ): Promise<void> {
222
+ const response = await fetch(webhookUrl, {
223
+ method: 'POST',
224
+ headers: {
225
+ 'Content-Type': 'application/json; charset=utf-8',
226
+ },
227
+ body: JSON.stringify({
228
+ text,
229
+ source,
230
+ artifactCount: artifacts?.length || 0,
231
+ artifacts: (artifacts || []).map((artifact) => ({
232
+ filename: artifact.filename,
233
+ mimeType: artifact.mimeType,
234
+ })),
235
+ }),
236
+ });
237
+ if (!response.ok) {
238
+ const body = await response.text().catch(() => '');
239
+ throw new Error(
240
+ `Webhook delivery failed (${response.status}): ${body.slice(0, 300)}`,
241
+ );
242
+ }
243
+ }
244
+
245
+ function resolveLastUsedDiscordChannelId(): string | null {
246
+ const channelId = getMostRecentSessionChannelId();
247
+ if (!channelId) return null;
248
+ return isDiscordChannelId(channelId) ? channelId : null;
249
+ }
250
+
170
251
  async function flushQueuedProactiveMessages(): Promise<void> {
171
252
  if (!isWithinActiveHours()) return;
172
253
  const pending = listQueuedProactiveMessages(MAX_QUEUED_PROACTIVE_MESSAGES);
@@ -178,7 +259,11 @@ async function flushQueuedProactiveMessages(): Promise<void> {
178
259
 
179
260
  for (const item of pending) {
180
261
  if (!isWithinActiveHours()) break;
181
- await sendProactiveMessageNow(item.channel_id, item.text, `${item.source}:queued`);
262
+ await sendProactiveMessageNow(
263
+ item.channel_id,
264
+ item.text,
265
+ `${item.source}:queued`,
266
+ );
182
267
  deleteQueuedProactiveMessage(item.id);
183
268
  }
184
269
  }
@@ -202,6 +287,7 @@ async function startDiscordIntegration(): Promise<void> {
202
287
  context,
203
288
  ) => {
204
289
  try {
290
+ let sawTextDelta = false;
205
291
  const result = await handleGatewayMessage({
206
292
  sessionId,
207
293
  guildId,
@@ -211,15 +297,35 @@ async function startDiscordIntegration(): Promise<void> {
211
297
  content,
212
298
  media,
213
299
  onTextDelta: (delta) => {
300
+ if (!sawTextDelta) {
301
+ sawTextDelta = true;
302
+ context.emitLifecyclePhase('streaming');
303
+ }
214
304
  void context.stream.append(delta);
215
305
  },
306
+ onToolProgress: (event) => {
307
+ if (sawTextDelta) return;
308
+ if (event.phase === 'start') {
309
+ context.emitLifecyclePhase('toolUse');
310
+ } else {
311
+ context.emitLifecyclePhase('thinking');
312
+ }
313
+ },
216
314
  onProactiveMessage: async (message) => {
217
- await deliverProactiveMessage(channelId, message.text, 'delegate', message.artifacts);
315
+ await deliverProactiveMessage(
316
+ channelId,
317
+ message.text,
318
+ 'delegate',
319
+ message.artifacts,
320
+ );
218
321
  },
219
322
  abortSignal: context.abortSignal,
220
323
  });
221
324
  if (result.status === 'error') {
222
- const errorText = formatError('Agent Error', result.error || 'Unknown error');
325
+ const errorText = formatError(
326
+ 'Agent Error',
327
+ result.error || 'Unknown error',
328
+ );
223
329
  await context.stream.fail(errorText);
224
330
  return;
225
331
  }
@@ -239,7 +345,10 @@ async function startDiscordIntegration(): Promise<void> {
239
345
  );
240
346
  } catch (error) {
241
347
  const text = error instanceof Error ? error.message : String(error);
242
- logger.error({ error, sessionId, channelId }, 'Discord message handling failed');
348
+ logger.error(
349
+ { error, sessionId, channelId },
350
+ 'Discord message handling failed',
351
+ );
243
352
  const errorText = formatError('Gateway Error', text);
244
353
  await context.stream.fail(errorText);
245
354
  }
@@ -269,7 +378,10 @@ async function startDiscordIntegration(): Promise<void> {
269
378
  await reply(renderGatewayCommand(result));
270
379
  } catch (error) {
271
380
  const text = error instanceof Error ? error.message : String(error);
272
- logger.error({ error, sessionId, channelId, args }, 'Discord command handling failed');
381
+ logger.error(
382
+ { error, sessionId, channelId, args },
383
+ 'Discord command handling failed',
384
+ );
273
385
  await reply(formatError('Gateway Error', text));
274
386
  }
275
387
  },
@@ -278,47 +390,141 @@ async function startDiscordIntegration(): Promise<void> {
278
390
  }
279
391
 
280
392
  function setupShutdown(): void {
281
- const shutdown = () => {
393
+ let shuttingDown = false;
394
+ const shutdown = async () => {
395
+ if (shuttingDown) return;
396
+ shuttingDown = true;
282
397
  logger.info('Shutting down gateway...');
283
398
  if (detachConfigListener) {
284
399
  detachConfigListener();
285
400
  detachConfigListener = null;
286
401
  }
402
+ await setDiscordMaintenancePresence().catch((error) => {
403
+ logger.debug(
404
+ { error },
405
+ 'Failed to set Discord maintenance presence during shutdown',
406
+ );
407
+ });
287
408
  stopHeartbeat();
288
409
  stopObservabilityIngest();
289
410
  stopAllContainers();
290
411
  stopScheduler();
412
+ stopMemoryConsolidationScheduler();
291
413
  if (proactiveFlushTimer) {
292
414
  clearInterval(proactiveFlushTimer);
293
415
  proactiveFlushTimer = null;
294
416
  }
295
417
  process.exit(0);
296
418
  };
297
- process.on('SIGINT', shutdown);
298
- process.on('SIGTERM', shutdown);
419
+ process.on('SIGINT', () => {
420
+ void shutdown();
421
+ });
422
+ process.on('SIGTERM', () => {
423
+ void shutdown();
424
+ });
299
425
  }
300
426
 
301
427
  async function runScheduledTask(
302
- sessionId: string,
303
- channelId: string,
304
- prompt: string,
305
- taskId: number,
428
+ request: SchedulerDispatchRequest,
306
429
  ): Promise<void> {
430
+ const sourceLabel =
431
+ request.source === 'db-task'
432
+ ? `schedule:${request.taskId ?? 'unknown'}`
433
+ : `schedule-job:${request.jobId ?? 'unknown'}`;
434
+ const resolvedDeliveryChannelId =
435
+ request.delivery.kind === 'channel'
436
+ ? request.delivery.channelId
437
+ : request.delivery.kind === 'last-channel'
438
+ ? resolveLastUsedDiscordChannelId()
439
+ : null;
440
+
441
+ if (request.actionKind === 'system_event') {
442
+ if (request.delivery.kind === 'webhook') {
443
+ await deliverWebhookMessage(
444
+ request.delivery.webhookUrl,
445
+ request.prompt,
446
+ `${sourceLabel}:system`,
447
+ );
448
+ return;
449
+ }
450
+ if (!resolvedDeliveryChannelId) {
451
+ throw new Error(
452
+ 'No Discord channel available for scheduled system event delivery.',
453
+ );
454
+ }
455
+ await deliverProactiveMessage(
456
+ resolvedDeliveryChannelId,
457
+ request.prompt,
458
+ `${sourceLabel}:system`,
459
+ );
460
+ return;
461
+ }
462
+
463
+ const runChannelId =
464
+ request.channelId || resolvedDeliveryChannelId || 'scheduler';
465
+ const taskId = request.taskId ?? -1;
466
+
307
467
  await runGatewayScheduledTask(
308
- sessionId,
309
- channelId,
310
- prompt,
468
+ request.sessionId,
469
+ runChannelId,
470
+ request.prompt,
311
471
  taskId,
312
472
  async (result) => {
313
- await deliverProactiveMessage(channelId, result.text, `schedule:${taskId}`, result.artifacts);
473
+ if (request.delivery.kind === 'webhook') {
474
+ await deliverWebhookMessage(
475
+ request.delivery.webhookUrl,
476
+ result.text,
477
+ sourceLabel,
478
+ result.artifacts,
479
+ );
480
+ logger.info(
481
+ {
482
+ jobId: request.jobId,
483
+ taskId: request.taskId,
484
+ source: request.source,
485
+ delivery: 'webhook',
486
+ result: result.text,
487
+ artifactCount: result.artifacts?.length || 0,
488
+ },
489
+ 'Scheduled task completed',
490
+ );
491
+ return;
492
+ }
493
+
494
+ if (!resolvedDeliveryChannelId) {
495
+ throw new Error('No Discord channel available for scheduled delivery.');
496
+ }
497
+ await deliverProactiveMessage(
498
+ resolvedDeliveryChannelId,
499
+ result.text,
500
+ sourceLabel,
501
+ result.artifacts,
502
+ );
314
503
  logger.info(
315
- { taskId, channelId, result: result.text, artifactCount: result.artifacts?.length || 0 },
504
+ {
505
+ jobId: request.jobId,
506
+ taskId: request.taskId,
507
+ source: request.source,
508
+ channelId: resolvedDeliveryChannelId,
509
+ result: result.text,
510
+ artifactCount: result.artifacts?.length || 0,
511
+ },
316
512
  'Scheduled task completed',
317
513
  );
318
514
  },
319
515
  (error) => {
320
- logger.error({ taskId, channelId, error }, 'Scheduled task failed');
516
+ logger.error(
517
+ {
518
+ jobId: request.jobId,
519
+ taskId: request.taskId,
520
+ source: request.source,
521
+ delivery: request.delivery.kind,
522
+ error,
523
+ },
524
+ 'Scheduled task failed',
525
+ );
321
526
  },
527
+ request.sessionId,
322
528
  );
323
529
  }
324
530
 
@@ -332,6 +538,46 @@ function startOrRestartHeartbeat(): void {
332
538
  });
333
539
  }
334
540
 
541
+ function stopMemoryConsolidationScheduler(): void {
542
+ if (!memoryConsolidationTimer) return;
543
+ clearInterval(memoryConsolidationTimer);
544
+ memoryConsolidationTimer = null;
545
+ }
546
+
547
+ function startOrRestartMemoryConsolidationScheduler(): void {
548
+ stopMemoryConsolidationScheduler();
549
+ const intervalHours = Math.max(
550
+ 0,
551
+ Math.trunc(getConfigSnapshot().memory.consolidationIntervalHours),
552
+ );
553
+ if (intervalHours <= 0) {
554
+ logger.info('Memory consolidation scheduler disabled');
555
+ return;
556
+ }
557
+
558
+ const intervalMs = intervalHours * 3_600_000;
559
+ memoryConsolidationTimer = setInterval(() => {
560
+ const { decayRate } = getConfigSnapshot().memory;
561
+ try {
562
+ const report = memoryService.consolidateMemories({ decayRate });
563
+ if (report.memoriesDecayed > 0) {
564
+ logger.info(
565
+ {
566
+ decayed: report.memoriesDecayed,
567
+ durationMs: report.durationMs,
568
+ decayRate,
569
+ },
570
+ 'Memory consolidation completed',
571
+ );
572
+ }
573
+ } catch (error) {
574
+ logger.warn({ error, decayRate }, 'Memory consolidation failed');
575
+ }
576
+ }, intervalMs);
577
+
578
+ logger.info({ intervalHours }, 'Memory consolidation scheduled');
579
+ }
580
+
335
581
  async function main(): Promise<void> {
336
582
  logger.info('Starting HybridClaw gateway');
337
583
  initDatabase();
@@ -343,9 +589,9 @@ async function main(): Promise<void> {
343
589
  startObservabilityIngest();
344
590
  detachConfigListener = onConfigChange((next, prev) => {
345
591
  const shouldRestart =
346
- next.hybridai.defaultChatbotId !== prev.hybridai.defaultChatbotId
347
- || next.heartbeat.intervalMs !== prev.heartbeat.intervalMs
348
- || next.heartbeat.enabled !== prev.heartbeat.enabled;
592
+ next.hybridai.defaultChatbotId !== prev.hybridai.defaultChatbotId ||
593
+ next.heartbeat.intervalMs !== prev.heartbeat.intervalMs ||
594
+ next.heartbeat.enabled !== prev.heartbeat.enabled;
349
595
  if (shouldRestart) {
350
596
  logger.info(
351
597
  {
@@ -358,9 +604,32 @@ async function main(): Promise<void> {
358
604
  startOrRestartHeartbeat();
359
605
  }
360
606
 
607
+ const schedulerChanged =
608
+ JSON.stringify(next.scheduler) !== JSON.stringify(prev.scheduler);
609
+ if (schedulerChanged) {
610
+ logger.info(
611
+ 'Config changed, re-arming scheduler for updated scheduler.jobs',
612
+ );
613
+ rearmScheduler();
614
+ }
615
+
616
+ const memoryChanged =
617
+ JSON.stringify(next.memory) !== JSON.stringify(prev.memory);
618
+ if (memoryChanged) {
619
+ logger.info(
620
+ {
621
+ consolidationIntervalHours: next.memory.consolidationIntervalHours,
622
+ decayRate: next.memory.decayRate,
623
+ },
624
+ 'Config changed, restarting memory consolidation scheduler',
625
+ );
626
+ startOrRestartMemoryConsolidationScheduler();
627
+ }
628
+
361
629
  const shouldRestartObservability =
362
- JSON.stringify(next.observability) !== JSON.stringify(prev.observability)
363
- || next.hybridai.defaultChatbotId !== prev.hybridai.defaultChatbotId;
630
+ JSON.stringify(next.observability) !==
631
+ JSON.stringify(prev.observability) ||
632
+ next.hybridai.defaultChatbotId !== prev.hybridai.defaultChatbotId;
364
633
  if (!shouldRestartObservability) return;
365
634
 
366
635
  logger.info(
@@ -374,6 +643,7 @@ async function main(): Promise<void> {
374
643
  startObservabilityIngest();
375
644
  });
376
645
  startScheduler(runScheduledTask);
646
+ startOrRestartMemoryConsolidationScheduler();
377
647
  proactiveFlushTimer = setInterval(() => {
378
648
  void flushQueuedProactiveMessages().catch((err) => {
379
649
  logger.warn({ err }, 'Failed to flush queued proactive messages');
@@ -383,7 +653,10 @@ async function main(): Promise<void> {
383
653
  logger.warn({ err }, 'Initial proactive queue flush failed');
384
654
  });
385
655
 
386
- logger.info({ ...getGatewayStatus(), discord: !!DISCORD_TOKEN }, 'HybridClaw gateway started');
656
+ logger.info(
657
+ { ...getGatewayStatus(), discord: !!DISCORD_TOKEN },
658
+ 'HybridClaw gateway started',
659
+ );
387
660
  }
388
661
 
389
662
  main().catch((err) => {