@hybridaione/hybridclaw 0.2.2 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +85 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +41 -16
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +71 -8
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1303 -0
  12. package/container/src/browser-tools.ts +431 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +34 -13
  15. package/container/src/index.ts +451 -109
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +20 -10
  18. package/container/src/tools.ts +599 -225
  19. package/container/src/types.ts +32 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +10 -2
  23. package/dist/agent.js.map +1 -1
  24. package/dist/audit-cli.d.ts.map +1 -1
  25. package/dist/audit-cli.js +4 -2
  26. package/dist/audit-cli.js.map +1 -1
  27. package/dist/audit-events.d.ts.map +1 -1
  28. package/dist/audit-events.js +53 -3
  29. package/dist/audit-events.js.map +1 -1
  30. package/dist/audit-trail.d.ts.map +1 -1
  31. package/dist/audit-trail.js +17 -8
  32. package/dist/audit-trail.js.map +1 -1
  33. package/dist/channels/discord/attachments.d.ts.map +1 -1
  34. package/dist/channels/discord/attachments.js +14 -7
  35. package/dist/channels/discord/attachments.js.map +1 -1
  36. package/dist/channels/discord/debounce.d.ts +9 -0
  37. package/dist/channels/discord/debounce.d.ts.map +1 -0
  38. package/dist/channels/discord/debounce.js +20 -0
  39. package/dist/channels/discord/debounce.js.map +1 -0
  40. package/dist/channels/discord/delivery.d.ts +4 -1
  41. package/dist/channels/discord/delivery.d.ts.map +1 -1
  42. package/dist/channels/discord/delivery.js +19 -3
  43. package/dist/channels/discord/delivery.js.map +1 -1
  44. package/dist/channels/discord/human-delay.d.ts +16 -0
  45. package/dist/channels/discord/human-delay.d.ts.map +1 -0
  46. package/dist/channels/discord/human-delay.js +29 -0
  47. package/dist/channels/discord/human-delay.js.map +1 -0
  48. package/dist/channels/discord/inbound.d.ts +4 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +45 -4
  51. package/dist/channels/discord/inbound.js.map +1 -1
  52. package/dist/channels/discord/mentions.d.ts.map +1 -1
  53. package/dist/channels/discord/mentions.js +16 -4
  54. package/dist/channels/discord/mentions.js.map +1 -1
  55. package/dist/channels/discord/presence.d.ts +33 -0
  56. package/dist/channels/discord/presence.d.ts.map +1 -0
  57. package/dist/channels/discord/presence.js +111 -0
  58. package/dist/channels/discord/presence.js.map +1 -0
  59. package/dist/channels/discord/rate-limiter.d.ts +14 -0
  60. package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
  61. package/dist/channels/discord/rate-limiter.js +49 -0
  62. package/dist/channels/discord/rate-limiter.js.map +1 -0
  63. package/dist/channels/discord/reactions.d.ts +38 -0
  64. package/dist/channels/discord/reactions.d.ts.map +1 -0
  65. package/dist/channels/discord/reactions.js +151 -0
  66. package/dist/channels/discord/reactions.js.map +1 -0
  67. package/dist/channels/discord/runtime.d.ts +6 -3
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +621 -125
  70. package/dist/channels/discord/runtime.js.map +1 -1
  71. package/dist/channels/discord/stream.d.ts +4 -1
  72. package/dist/channels/discord/stream.d.ts.map +1 -1
  73. package/dist/channels/discord/stream.js +16 -8
  74. package/dist/channels/discord/stream.js.map +1 -1
  75. package/dist/channels/discord/tool-actions.d.ts.map +1 -1
  76. package/dist/channels/discord/tool-actions.js +24 -12
  77. package/dist/channels/discord/tool-actions.js.map +1 -1
  78. package/dist/channels/discord/typing.d.ts +15 -0
  79. package/dist/channels/discord/typing.d.ts.map +1 -0
  80. package/dist/channels/discord/typing.js +106 -0
  81. package/dist/channels/discord/typing.js.map +1 -0
  82. package/dist/chunk.d.ts.map +1 -1
  83. package/dist/chunk.js +4 -2
  84. package/dist/chunk.js.map +1 -1
  85. package/dist/cli.js +47 -22
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +19 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +103 -18
  90. package/dist/config.js.map +1 -1
  91. package/dist/container-runner.d.ts.map +1 -1
  92. package/dist/container-runner.js +58 -26
  93. package/dist/container-runner.js.map +1 -1
  94. package/dist/container-setup.d.ts.map +1 -1
  95. package/dist/container-setup.js +10 -9
  96. package/dist/container-setup.js.map +1 -1
  97. package/dist/conversation.d.ts +2 -2
  98. package/dist/conversation.d.ts.map +1 -1
  99. package/dist/conversation.js +1 -1
  100. package/dist/conversation.js.map +1 -1
  101. package/dist/db.d.ts +118 -2
  102. package/dist/db.d.ts.map +1 -1
  103. package/dist/db.js +1568 -50
  104. package/dist/db.js.map +1 -1
  105. package/dist/delegation-manager.d.ts.map +1 -1
  106. package/dist/delegation-manager.js +3 -2
  107. package/dist/delegation-manager.js.map +1 -1
  108. package/dist/gateway-client.d.ts +2 -2
  109. package/dist/gateway-client.d.ts.map +1 -1
  110. package/dist/gateway-client.js +10 -4
  111. package/dist/gateway-client.js.map +1 -1
  112. package/dist/gateway-service.d.ts +3 -3
  113. package/dist/gateway-service.d.ts.map +1 -1
  114. package/dist/gateway-service.js +563 -73
  115. package/dist/gateway-service.js.map +1 -1
  116. package/dist/gateway-types.d.ts +24 -0
  117. package/dist/gateway-types.d.ts.map +1 -1
  118. package/dist/gateway-types.js.map +1 -1
  119. package/dist/gateway.js +179 -24
  120. package/dist/gateway.js.map +1 -1
  121. package/dist/health.d.ts.map +1 -1
  122. package/dist/health.js +20 -10
  123. package/dist/health.js.map +1 -1
  124. package/dist/heartbeat.d.ts +4 -0
  125. package/dist/heartbeat.d.ts.map +1 -1
  126. package/dist/heartbeat.js +48 -20
  127. package/dist/heartbeat.js.map +1 -1
  128. package/dist/hybridai-bots.d.ts.map +1 -1
  129. package/dist/hybridai-bots.js +4 -2
  130. package/dist/hybridai-bots.js.map +1 -1
  131. package/dist/instruction-approval-audit.d.ts.map +1 -1
  132. package/dist/instruction-approval-audit.js.map +1 -1
  133. package/dist/instruction-integrity.d.ts.map +1 -1
  134. package/dist/instruction-integrity.js +8 -2
  135. package/dist/instruction-integrity.js.map +1 -1
  136. package/dist/ipc.d.ts.map +1 -1
  137. package/dist/ipc.js +6 -1
  138. package/dist/ipc.js.map +1 -1
  139. package/dist/logger.js.map +1 -1
  140. package/dist/memory-consolidation.d.ts +17 -0
  141. package/dist/memory-consolidation.d.ts.map +1 -0
  142. package/dist/memory-consolidation.js +25 -0
  143. package/dist/memory-consolidation.js.map +1 -0
  144. package/dist/memory-service.d.ts +200 -0
  145. package/dist/memory-service.d.ts.map +1 -0
  146. package/dist/memory-service.js +294 -0
  147. package/dist/memory-service.js.map +1 -0
  148. package/dist/mount-security.d.ts.map +1 -1
  149. package/dist/mount-security.js +31 -7
  150. package/dist/mount-security.js.map +1 -1
  151. package/dist/observability-ingest.d.ts.map +1 -1
  152. package/dist/observability-ingest.js +32 -11
  153. package/dist/observability-ingest.js.map +1 -1
  154. package/dist/onboarding.d.ts.map +1 -1
  155. package/dist/onboarding.js +32 -9
  156. package/dist/onboarding.js.map +1 -1
  157. package/dist/proactive-policy.d.ts.map +1 -1
  158. package/dist/proactive-policy.js +2 -1
  159. package/dist/proactive-policy.js.map +1 -1
  160. package/dist/prompt-hooks.d.ts.map +1 -1
  161. package/dist/prompt-hooks.js +9 -7
  162. package/dist/prompt-hooks.js.map +1 -1
  163. package/dist/runtime-config.d.ts +98 -1
  164. package/dist/runtime-config.d.ts.map +1 -1
  165. package/dist/runtime-config.js +477 -23
  166. package/dist/runtime-config.js.map +1 -1
  167. package/dist/scheduled-task-runner.d.ts +1 -0
  168. package/dist/scheduled-task-runner.d.ts.map +1 -1
  169. package/dist/scheduled-task-runner.js +29 -10
  170. package/dist/scheduled-task-runner.js.map +1 -1
  171. package/dist/scheduler.d.ts +43 -4
  172. package/dist/scheduler.d.ts.map +1 -1
  173. package/dist/scheduler.js +530 -56
  174. package/dist/scheduler.js.map +1 -1
  175. package/dist/session-export.d.ts +26 -0
  176. package/dist/session-export.d.ts.map +1 -0
  177. package/dist/session-export.js +149 -0
  178. package/dist/session-export.js.map +1 -0
  179. package/dist/session-maintenance.d.ts.map +1 -1
  180. package/dist/session-maintenance.js +75 -13
  181. package/dist/session-maintenance.js.map +1 -1
  182. package/dist/session-transcripts.d.ts.map +1 -1
  183. package/dist/session-transcripts.js.map +1 -1
  184. package/dist/side-effects.d.ts.map +1 -1
  185. package/dist/side-effects.js +14 -2
  186. package/dist/side-effects.js.map +1 -1
  187. package/dist/skills-guard.d.ts.map +1 -1
  188. package/dist/skills-guard.js +893 -130
  189. package/dist/skills-guard.js.map +1 -1
  190. package/dist/skills.d.ts +5 -0
  191. package/dist/skills.d.ts.map +1 -1
  192. package/dist/skills.js +29 -15
  193. package/dist/skills.js.map +1 -1
  194. package/dist/token-efficiency.d.ts.map +1 -1
  195. package/dist/token-efficiency.js.map +1 -1
  196. package/dist/tui.js +92 -11
  197. package/dist/tui.js.map +1 -1
  198. package/dist/types.d.ts +146 -0
  199. package/dist/types.d.ts.map +1 -1
  200. package/dist/types.js +24 -1
  201. package/dist/types.js.map +1 -1
  202. package/dist/update.d.ts.map +1 -1
  203. package/dist/update.js +42 -14
  204. package/dist/update.js.map +1 -1
  205. package/dist/workspace.d.ts.map +1 -1
  206. package/dist/workspace.js +49 -9
  207. package/dist/workspace.js.map +1 -1
  208. package/docs/chat.html +9 -3
  209. package/docs/index.html +37 -13
  210. package/package.json +8 -2
  211. package/src/agent.ts +16 -3
  212. package/src/audit-cli.ts +44 -16
  213. package/src/audit-events.ts +69 -5
  214. package/src/audit-trail.ts +41 -15
  215. package/src/channels/discord/attachments.ts +81 -27
  216. package/src/channels/discord/debounce.ts +25 -0
  217. package/src/channels/discord/delivery.ts +57 -13
  218. package/src/channels/discord/human-delay.ts +48 -0
  219. package/src/channels/discord/inbound.ts +66 -7
  220. package/src/channels/discord/mentions.ts +42 -18
  221. package/src/channels/discord/presence.ts +148 -0
  222. package/src/channels/discord/rate-limiter.ts +58 -0
  223. package/src/channels/discord/reactions.ts +211 -0
  224. package/src/channels/discord/runtime.ts +1048 -182
  225. package/src/channels/discord/stream.ts +73 -27
  226. package/src/channels/discord/tool-actions.ts +78 -37
  227. package/src/channels/discord/typing.ts +140 -0
  228. package/src/chunk.ts +12 -4
  229. package/src/cli.ts +141 -56
  230. package/src/config.ts +192 -34
  231. package/src/container-runner.ts +132 -42
  232. package/src/container-setup.ts +57 -22
  233. package/src/conversation.ts +9 -7
  234. package/src/db.ts +2217 -84
  235. package/src/delegation-manager.ts +6 -2
  236. package/src/gateway-client.ts +41 -17
  237. package/src/gateway-service.ts +1019 -201
  238. package/src/gateway-types.ts +33 -0
  239. package/src/gateway.ts +321 -48
  240. package/src/health.ts +66 -26
  241. package/src/heartbeat.ts +84 -22
  242. package/src/hybridai-bots.ts +14 -5
  243. package/src/instruction-approval-audit.ts +4 -1
  244. package/src/instruction-integrity.ts +30 -9
  245. package/src/ipc.ts +23 -5
  246. package/src/logger.ts +4 -1
  247. package/src/memory-consolidation.ts +41 -0
  248. package/src/memory-service.ts +606 -0
  249. package/src/mount-security.ts +58 -13
  250. package/src/observability-ingest.ts +134 -35
  251. package/src/onboarding.ts +126 -35
  252. package/src/proactive-policy.ts +3 -1
  253. package/src/prompt-hooks.ts +40 -17
  254. package/src/runtime-config.ts +1114 -99
  255. package/src/scheduled-task-runner.ts +63 -11
  256. package/src/scheduler.ts +683 -60
  257. package/src/session-export.ts +196 -0
  258. package/src/session-maintenance.ts +125 -22
  259. package/src/session-transcripts.ts +12 -3
  260. package/src/side-effects.ts +28 -5
  261. package/src/skills-guard.ts +1067 -219
  262. package/src/skills.ts +163 -65
  263. package/src/token-efficiency.ts +31 -9
  264. package/src/tui.ts +166 -25
  265. package/src/types.ts +195 -2
  266. package/src/update.ts +79 -23
  267. package/src/workspace.ts +63 -11
  268. package/tests/approval-policy.test.ts +224 -0
  269. package/tests/discord.basic.test.ts +82 -2
  270. package/tests/discord.human-presence.test.ts +85 -0
  271. package/tests/gateway-service.media-routing.test.ts +8 -2
  272. package/tests/memory-service.test.ts +1114 -0
  273. package/tests/token-efficiency.basic.test.ts +8 -2
  274. package/vitest.e2e.config.ts +3 -1
  275. package/vitest.integration.config.ts +3 -1
  276. package/vitest.live.config.ts +3 -1
  277. package/vitest.unit.config.ts +9 -0
@@ -1,4 +1,10 @@
1
- import { appendAuditEvent, createAuditRunId, parseJsonObject, truncateAuditText, type AuditEventPayload } from './audit-trail.js';
1
+ import {
2
+ type AuditEventPayload,
3
+ appendAuditEvent,
4
+ createAuditRunId,
5
+ parseJsonObject,
6
+ truncateAuditText,
7
+ } from './audit-trail.js';
2
8
  import { logStructuredAuditEvent } from './db.js';
3
9
  import { logger } from './logger.js';
4
10
  import type { ToolExecution } from './types.js';
@@ -35,7 +41,8 @@ function summarizeToolResult(text: string): string {
35
41
  return truncateAuditText(text, 280);
36
42
  }
37
43
 
38
- const SENSITIVE_ARG_KEY_RE = /(pass(word)?|secret|token|api[_-]?key|authorization|cookie|credential|session)/i;
44
+ const SENSITIVE_ARG_KEY_RE =
45
+ /(pass(word)?|secret|token|api[_-]?key|authorization|cookie|credential|session)/i;
39
46
 
40
47
  function sanitizeAuditArguments(toolName: string, value: unknown): unknown {
41
48
  if (Array.isArray(value)) {
@@ -69,7 +76,10 @@ export function emitToolExecutionAuditEvents(input: {
69
76
  toolExecutions.forEach((execution, index) => {
70
77
  const toolCallId = `${runId}:tool:${index + 1}`;
71
78
  const argumentsObject = parseJsonObject(execution.arguments || '{}');
72
- const auditArguments = sanitizeAuditArguments(execution.name, argumentsObject);
79
+ const auditArguments = sanitizeAuditArguments(
80
+ execution.name,
81
+ argumentsObject,
82
+ );
73
83
 
74
84
  recordAuditEvent({
75
85
  sessionId,
@@ -90,11 +100,65 @@ export function emitToolExecutionAuditEvents(input: {
90
100
  action: `tool:${execution.name}`,
91
101
  resource: 'container.sandbox',
92
102
  allowed: !execution.blocked,
93
- reason: execution.blockedReason || 'allowed',
103
+ reason:
104
+ execution.blockedReason ||
105
+ execution.approvalReason ||
106
+ (execution.approvalDecision
107
+ ? `approval:${execution.approvalDecision}`
108
+ : 'allowed'),
94
109
  },
95
110
  });
96
111
 
97
- if (execution.blocked) {
112
+ const isRedApprovalAction =
113
+ execution.approvalTier === 'red' || execution.approvalBaseTier === 'red';
114
+ const hasApprovalDecision = typeof execution.approvalDecision === 'string';
115
+ if (isRedApprovalAction || hasApprovalDecision) {
116
+ const description =
117
+ execution.approvalReason ||
118
+ execution.blockedReason ||
119
+ `Approval flow for tool ${execution.name}`;
120
+ recordAuditEvent({
121
+ sessionId,
122
+ runId,
123
+ event: {
124
+ type: 'approval.request',
125
+ toolCallId,
126
+ action: execution.approvalActionKey || `tool:${execution.name}`,
127
+ description,
128
+ policyName: 'trusted-coworker',
129
+ },
130
+ });
131
+
132
+ const decision = execution.approvalDecision;
133
+ const approved =
134
+ decision === 'approved_once' ||
135
+ decision === 'approved_session' ||
136
+ decision === 'approved_agent' ||
137
+ decision === 'promoted';
138
+ const pending = decision === 'required';
139
+ if (decision && decision !== 'auto' && decision !== 'implicit') {
140
+ recordAuditEvent({
141
+ sessionId,
142
+ runId,
143
+ event: {
144
+ type: 'approval.response',
145
+ toolCallId,
146
+ action: execution.approvalActionKey || `tool:${execution.name}`,
147
+ description: pending
148
+ ? `${description} (pending user response)`
149
+ : description,
150
+ approved,
151
+ approvedBy: pending
152
+ ? 'pending-user-response'
153
+ : approved
154
+ ? 'local-user'
155
+ : 'policy-engine',
156
+ method: pending || approved ? 'prompt' : 'policy',
157
+ policyName: 'trusted-coworker',
158
+ },
159
+ });
160
+ }
161
+ } else if (execution.blocked) {
98
162
  recordAuditEvent({
99
163
  sessionId,
100
164
  runId,
@@ -92,10 +92,12 @@ function stableStringify(value: unknown): string {
92
92
  const type = typeof value;
93
93
 
94
94
  if (type === 'string') return JSON.stringify(value);
95
- if (type === 'number') return Number.isFinite(value as number) ? String(value) : 'null';
95
+ if (type === 'number')
96
+ return Number.isFinite(value as number) ? String(value) : 'null';
96
97
  if (type === 'boolean') return value ? 'true' : 'false';
97
98
  if (type === 'bigint') return JSON.stringify((value as bigint).toString());
98
- if (type === 'undefined' || type === 'function' || type === 'symbol') return 'null';
99
+ if (type === 'undefined' || type === 'function' || type === 'symbol')
100
+ return 'null';
99
101
 
100
102
  if (Array.isArray(value)) {
101
103
  return `[${value.map((entry) => stableStringify(entry === undefined ? null : entry)).join(',')}]`;
@@ -106,7 +108,12 @@ function stableStringify(value: unknown): string {
106
108
  const keys = Object.keys(obj).sort((a, b) => a.localeCompare(b));
107
109
  for (const key of keys) {
108
110
  const raw = obj[key];
109
- if (raw === undefined || typeof raw === 'function' || typeof raw === 'symbol') continue;
111
+ if (
112
+ raw === undefined ||
113
+ typeof raw === 'function' ||
114
+ typeof raw === 'symbol'
115
+ )
116
+ continue;
110
117
  parts.push(`${JSON.stringify(key)}:${stableStringify(raw)}`);
111
118
  }
112
119
  return `{${parts.join(',')}}`;
@@ -167,7 +174,10 @@ function computeWireRecordHash(record: Omit<WireRecord, '_hash'>): string {
167
174
  return sha256(stableStringify(record));
168
175
  }
169
176
 
170
- function readSessionStateFromDisk(sessionId: string, filePath: string): SessionAuditState {
177
+ function readSessionStateFromDisk(
178
+ sessionId: string,
179
+ filePath: string,
180
+ ): SessionAuditState {
171
181
  if (!fs.existsSync(filePath) || fs.statSync(filePath).size === 0) {
172
182
  const metadata: WireMetadataRecord = {
173
183
  type: 'metadata',
@@ -184,7 +194,10 @@ function readSessionStateFromDisk(sessionId: string, filePath: string): SessionA
184
194
  }
185
195
 
186
196
  const raw = fs.readFileSync(filePath, 'utf-8');
187
- const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
197
+ const lines = raw
198
+ .split('\n')
199
+ .map((line) => line.trim())
200
+ .filter(Boolean);
188
201
  if (lines.length === 0) {
189
202
  const metadata: WireMetadataRecord = {
190
203
  type: 'metadata',
@@ -209,8 +222,14 @@ function readSessionStateFromDisk(sessionId: string, filePath: string): SessionA
209
222
  const metadata: WireMetadataRecord = {
210
223
  type: 'metadata',
211
224
  protocolVersion: AUDIT_PROTOCOL_VERSION,
212
- sessionId: typeof firstParsed.sessionId === 'string' ? firstParsed.sessionId : sessionId,
213
- createdAt: typeof firstParsed.createdAt === 'string' ? firstParsed.createdAt : new Date().toISOString(),
225
+ sessionId:
226
+ typeof firstParsed.sessionId === 'string'
227
+ ? firstParsed.sessionId
228
+ : sessionId,
229
+ createdAt:
230
+ typeof firstParsed.createdAt === 'string'
231
+ ? firstParsed.createdAt
232
+ : new Date().toISOString(),
214
233
  };
215
234
  lastHash = computeMetadataHash(metadata);
216
235
  startIndex = 1;
@@ -223,10 +242,10 @@ function readSessionStateFromDisk(sessionId: string, filePath: string): SessionA
223
242
  try {
224
243
  const parsed = JSON.parse(lines[i]) as Partial<WireRecord>;
225
244
  if (
226
- typeof parsed.seq === 'number'
227
- && Number.isFinite(parsed.seq)
228
- && typeof parsed._hash === 'string'
229
- && parsed._hash
245
+ typeof parsed.seq === 'number' &&
246
+ Number.isFinite(parsed.seq) &&
247
+ typeof parsed._hash === 'string' &&
248
+ parsed._hash
230
249
  ) {
231
250
  seq = parsed.seq;
232
251
  lastHash = parsed._hash;
@@ -319,7 +338,8 @@ export function verifyAuditSessionChain(sessionId: string): AuditVerifyResult {
319
338
  const metadata: WireMetadataRecord = {
320
339
  type: 'metadata',
321
340
  protocolVersion: AUDIT_PROTOCOL_VERSION,
322
- sessionId: typeof first.sessionId === 'string' ? first.sessionId : sessionId,
341
+ sessionId:
342
+ typeof first.sessionId === 'string' ? first.sessionId : sessionId,
323
343
  createdAt: typeof first.createdAt === 'string' ? first.createdAt : '',
324
344
  };
325
345
  expectedPrevHash = computeMetadataHash(metadata);
@@ -335,12 +355,16 @@ export function verifyAuditSessionChain(sessionId: string): AuditVerifyResult {
335
355
  try {
336
356
  parsed = JSON.parse(lines[i]) as WireRecord;
337
357
  } catch (err) {
338
- errors.push(`Line ${lineNo}: invalid JSON (${err instanceof Error ? err.message : 'parse failure'}).`);
358
+ errors.push(
359
+ `Line ${lineNo}: invalid JSON (${err instanceof Error ? err.message : 'parse failure'}).`,
360
+ );
339
361
  continue;
340
362
  }
341
363
 
342
364
  if (parsed.version !== AUDIT_PROTOCOL_VERSION) {
343
- errors.push(`Line ${lineNo}: unsupported version "${String(parsed.version)}".`);
365
+ errors.push(
366
+ `Line ${lineNo}: unsupported version "${String(parsed.version)}".`,
367
+ );
344
368
  continue;
345
369
  }
346
370
  if (!Number.isFinite(parsed.seq) || parsed.seq <= 0) {
@@ -348,7 +372,9 @@ export function verifyAuditSessionChain(sessionId: string): AuditVerifyResult {
348
372
  continue;
349
373
  }
350
374
  if (parsed.seq !== expectedSeq) {
351
- errors.push(`Line ${lineNo}: expected seq ${expectedSeq}, got ${parsed.seq}.`);
375
+ errors.push(
376
+ `Line ${lineNo}: expected seq ${expectedSeq}, got ${parsed.seq}.`,
377
+ );
352
378
  }
353
379
  if (parsed._prevHash !== expectedPrevHash) {
354
380
  errors.push(`Line ${lineNo}: previous hash mismatch.`);
@@ -1,8 +1,10 @@
1
+ import { randomUUID } from 'crypto';
2
+ import type {
3
+ Attachment as DiscordAttachment,
4
+ Message as DiscordMessage,
5
+ } from 'discord.js';
1
6
  import fs from 'fs';
2
7
  import path from 'path';
3
- import { randomUUID } from 'crypto';
4
-
5
- import type { Attachment as DiscordAttachment, Message as DiscordMessage } from 'discord.js';
6
8
 
7
9
  import { DATA_DIR } from '../../config.js';
8
10
  import { logger } from '../../logger.js';
@@ -11,7 +13,9 @@ import type { MediaContextItem } from '../../types.js';
11
13
  const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
12
14
  const MAX_ATTACHMENT_CONTEXT_CHARS = 16_000;
13
15
  const MAX_SINGLE_ATTACHMENT_CHARS = 8_000;
14
- const DISCORD_MEDIA_CACHE_DIR = path.resolve(path.join(DATA_DIR, 'discord-media-cache'));
16
+ const DISCORD_MEDIA_CACHE_DIR = path.resolve(
17
+ path.join(DATA_DIR, 'discord-media-cache'),
18
+ );
15
19
  const CONTAINER_DISCORD_MEDIA_CACHE_DIR = '/discord-media-cache';
16
20
  const DISCORD_ATTACHMENT_FETCH_TIMEOUT_MS = 12_000;
17
21
  const DISCORD_CDN_HOST_PATTERNS: RegExp[] = [
@@ -26,10 +30,20 @@ export interface AttachmentContextResult {
26
30
  media: MediaContextItem[];
27
31
  }
28
32
 
29
- export function looksLikeTextAttachment(name: string, contentType: string): boolean {
33
+ export function looksLikeTextAttachment(
34
+ name: string,
35
+ contentType: string,
36
+ ): boolean {
30
37
  if (contentType.startsWith('text/')) return true;
31
- if (contentType.includes('json') || contentType.includes('xml') || contentType.includes('yaml')) return true;
32
- return /\.(txt|md|markdown|json|ya?ml|js|jsx|ts|tsx|py|rb|go|rs|java|c|cpp|h|hpp|cs|php|html?|css|scss|sql|log|csv)$/i.test(name);
38
+ if (
39
+ contentType.includes('json') ||
40
+ contentType.includes('xml') ||
41
+ contentType.includes('yaml')
42
+ )
43
+ return true;
44
+ return /\.(txt|md|markdown|json|ya?ml|js|jsx|ts|tsx|py|rb|go|rs|java|c|cpp|h|hpp|cs|php|html?|css|scss|sql|log|csv)$/i.test(
45
+ name,
46
+ );
33
47
  }
34
48
 
35
49
  function looksLikeImageAttachment(name: string, contentType: string): boolean {
@@ -38,14 +52,19 @@ function looksLikeImageAttachment(name: string, contentType: string): boolean {
38
52
  }
39
53
 
40
54
  function sanitizeAttachmentFilename(name: string): string {
41
- const base = name.trim().replace(/[^\w.\-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
55
+ const base = name
56
+ .trim()
57
+ .replace(/[^\w.-]+/g, '-')
58
+ .replace(/-+/g, '-')
59
+ .replace(/^-+|-+$/g, '');
42
60
  const bounded = base.slice(0, 96);
43
61
  return bounded || 'attachment';
44
62
  }
45
63
 
46
64
  function normalizeAttachmentPathForContainer(hostPath: string): string | null {
47
65
  const relative = path.relative(DISCORD_MEDIA_CACHE_DIR, hostPath);
48
- if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
66
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
67
+ return null;
49
68
  return `${CONTAINER_DISCORD_MEDIA_CACHE_DIR}/${relative.replace(/\\/g, '/')}`;
50
69
  }
51
70
 
@@ -57,10 +76,15 @@ function isAllowedDiscordAttachmentUrl(rawUrl: string): boolean {
57
76
  return false;
58
77
  }
59
78
  if (parsed.protocol !== 'https:') return false;
60
- return DISCORD_CDN_HOST_PATTERNS.some((pattern) => pattern.test(parsed.hostname));
79
+ return DISCORD_CDN_HOST_PATTERNS.some((pattern) =>
80
+ pattern.test(parsed.hostname),
81
+ );
61
82
  }
62
83
 
63
- async function fetchAttachmentText(url: string, maxChars: number): Promise<string | null> {
84
+ async function fetchAttachmentText(
85
+ url: string,
86
+ maxChars: number,
87
+ ): Promise<string | null> {
64
88
  try {
65
89
  const response = await fetch(url);
66
90
  if (!response.ok) return null;
@@ -102,7 +126,10 @@ async function cacheDiscordImageAttachment(params: {
102
126
  }
103
127
 
104
128
  const controller = new AbortController();
105
- const timer = setTimeout(() => controller.abort(), DISCORD_ATTACHMENT_FETCH_TIMEOUT_MS);
129
+ const timer = setTimeout(
130
+ () => controller.abort(),
131
+ DISCORD_ATTACHMENT_FETCH_TIMEOUT_MS,
132
+ );
106
133
  try {
107
134
  const response = await fetch(candidateUrl, { signal: controller.signal });
108
135
  if (!response.ok) {
@@ -110,17 +137,27 @@ async function cacheDiscordImageAttachment(params: {
110
137
  continue;
111
138
  }
112
139
 
113
- const resolvedMime = String(response.headers.get('content-type') || fallbackMimeType || '')
140
+ const resolvedMime = String(
141
+ response.headers.get('content-type') || fallbackMimeType || '',
142
+ )
114
143
  .split(';')[0]
115
144
  .trim()
116
145
  .toLowerCase();
117
146
  if (!resolvedMime.startsWith('image/')) {
118
- fetchErrors.push(`invalid_type:${resolvedMime || 'unknown'}@${candidateUrl}`);
147
+ fetchErrors.push(
148
+ `invalid_type:${resolvedMime || 'unknown'}@${candidateUrl}`,
149
+ );
119
150
  continue;
120
151
  }
121
152
 
122
- const contentLength = Number.parseInt(response.headers.get('content-length') || '', 10);
123
- if (Number.isFinite(contentLength) && contentLength > MAX_ATTACHMENT_BYTES) {
153
+ const contentLength = Number.parseInt(
154
+ response.headers.get('content-length') || '',
155
+ 10,
156
+ );
157
+ if (
158
+ Number.isFinite(contentLength) &&
159
+ contentLength > MAX_ATTACHMENT_BYTES
160
+ ) {
124
161
  fetchErrors.push(`too_large_header:${contentLength}@${candidateUrl}`);
125
162
  continue;
126
163
  }
@@ -155,18 +192,22 @@ async function cacheDiscordImageAttachment(params: {
155
192
  }
156
193
  }
157
194
 
158
- const fallbackUrl = sourceCandidates.find((url) => isAllowedDiscordAttachmentUrl(url))
159
- || sourceCandidates[0]
160
- || '';
195
+ const fallbackUrl =
196
+ sourceCandidates.find((url) => isAllowedDiscordAttachmentUrl(url)) ||
197
+ sourceCandidates[0] ||
198
+ '';
161
199
  return {
162
200
  path: null,
163
201
  sourceUrl: fallbackUrl,
164
202
  mimeType: fallbackMimeType,
165
- cacheError: fetchErrors.length > 0 ? fetchErrors.join(' | ') : 'cache_failed',
203
+ cacheError:
204
+ fetchErrors.length > 0 ? fetchErrors.join(' | ') : 'cache_failed',
166
205
  };
167
206
  }
168
207
 
169
- export async function buildAttachmentContext(messages: DiscordMessage[]): Promise<AttachmentContextResult> {
208
+ export async function buildAttachmentContext(
209
+ messages: DiscordMessage[],
210
+ ): Promise<AttachmentContextResult> {
170
211
  const lines: string[] = [];
171
212
  const media: MediaContextItem[] = [];
172
213
  let remainingChars = MAX_ATTACHMENT_CONTEXT_CHARS;
@@ -179,7 +220,9 @@ export async function buildAttachmentContext(messages: DiscordMessage[]): Promis
179
220
  const size = attachment.size || 0;
180
221
  const contentType = (attachment.contentType || '').toLowerCase();
181
222
  if (size > MAX_ATTACHMENT_BYTES) {
182
- lines.push(`- ${name}: skipped (size ${size} bytes exceeds 10MB limit)`);
223
+ lines.push(
224
+ `- ${name}: skipped (size ${size} bytes exceeds 10MB limit)`,
225
+ );
183
226
  if (looksLikeImageAttachment(name, contentType)) {
184
227
  mediaOrder += 1;
185
228
  media.push({
@@ -220,7 +263,9 @@ export async function buildAttachmentContext(messages: DiscordMessage[]): Promis
220
263
  filename: name,
221
264
  });
222
265
  if (cached.path) {
223
- lines.push(`- ${name}: image attachment cached (${size} bytes, ${cached.mimeType || contentType || 'unknown type'})`);
266
+ lines.push(
267
+ `- ${name}: image attachment cached (${size} bytes, ${cached.mimeType || contentType || 'unknown type'})`,
268
+ );
224
269
  logger.info(
225
270
  {
226
271
  messageId: msg.id,
@@ -233,7 +278,9 @@ export async function buildAttachmentContext(messages: DiscordMessage[]): Promis
233
278
  'Discord image attachment cached successfully',
234
279
  );
235
280
  } else {
236
- lines.push(`- ${name}: image attachment (cache failed, using URL fallback)`);
281
+ lines.push(
282
+ `- ${name}: image attachment (cache failed, using URL fallback)`,
283
+ );
237
284
  logger.warn(
238
285
  {
239
286
  messageId: msg.id,
@@ -250,7 +297,10 @@ export async function buildAttachmentContext(messages: DiscordMessage[]): Promis
250
297
  }
251
298
 
252
299
  if (looksLikeTextAttachment(name, contentType)) {
253
- const maxChars = Math.min(MAX_SINGLE_ATTACHMENT_CHARS, Math.max(500, remainingChars));
300
+ const maxChars = Math.min(
301
+ MAX_SINGLE_ATTACHMENT_CHARS,
302
+ Math.max(500, remainingChars),
303
+ );
254
304
  const text = await fetchAttachmentText(attachment.url, maxChars);
255
305
  if (!text) {
256
306
  lines.push(`- ${name}: text attachment (failed to read content)`);
@@ -261,7 +311,9 @@ export async function buildAttachmentContext(messages: DiscordMessage[]): Promis
261
311
  remainingChars -= block.length;
262
312
  lines.push(block);
263
313
  if (remainingChars <= 0) {
264
- lines.push('- Additional attachment content omitted (context budget reached).');
314
+ lines.push(
315
+ '- Additional attachment content omitted (context budget reached).',
316
+ );
265
317
  return {
266
318
  context: `[Attachments]\n${lines.join('\n')}\n\n`,
267
319
  media,
@@ -270,7 +322,9 @@ export async function buildAttachmentContext(messages: DiscordMessage[]): Promis
270
322
  continue;
271
323
  }
272
324
 
273
- lines.push(`- ${name}: attachment (${size} bytes, ${contentType || 'unknown type'})`);
325
+ lines.push(
326
+ `- ${name}: attachment (${size} bytes, ${contentType || 'unknown type'})`,
327
+ );
274
328
  }
275
329
  }
276
330
 
@@ -0,0 +1,25 @@
1
+ const CONTROL_COMMAND_RE = /^\/(stop|pause|clear|cancel|resume)\b/i;
2
+
3
+ export const DEFAULT_DEBOUNCE_MS = 2_500;
4
+ export const DEFAULT_DEBOUNCE_MAX_BUFFER = 5;
5
+
6
+ export function resolveInboundDebounceMs(
7
+ globalDebounceMs: number,
8
+ channelOverrideMs?: number,
9
+ ): number {
10
+ const selected = channelOverrideMs ?? globalDebounceMs;
11
+ return Math.max(0, Math.floor(selected));
12
+ }
13
+
14
+ export function shouldDebounceInbound(params: {
15
+ content: string;
16
+ hasAttachments: boolean;
17
+ isPrefixedCommand: boolean;
18
+ }): boolean {
19
+ const normalized = params.content.trim();
20
+ if (!normalized) return false;
21
+ if (params.hasAttachments) return false;
22
+ if (params.isPrefixedCommand) return false;
23
+ if (CONTROL_COMMAND_RE.test(normalized)) return false;
24
+ return true;
25
+ }
@@ -1,9 +1,21 @@
1
- import { AttachmentBuilder, type ChatInputCommandInteraction, type Message as DiscordMessage } from 'discord.js';
1
+ import type {
2
+ AttachmentBuilder,
3
+ ChatInputCommandInteraction,
4
+ Message as DiscordMessage,
5
+ } from 'discord.js';
2
6
 
3
7
  import { chunkMessage } from '../../chunk.js';
4
- import { rewriteUserMentions, type MentionLookup } from './mentions.js';
8
+ import {
9
+ getHumanDelayMs,
10
+ type HumanDelayConfig,
11
+ sleep,
12
+ } from './human-delay.js';
13
+ import { type MentionLookup, rewriteUserMentions } from './mentions.js';
5
14
 
6
- export type DiscordRetryFn = <T>(label: string, fn: () => Promise<T>) => Promise<T>;
15
+ export type DiscordRetryFn = <T>(
16
+ label: string,
17
+ fn: () => Promise<T>,
18
+ ) => Promise<T>;
7
19
 
8
20
  export function buildResponseText(text: string, toolsUsed?: string[]): string {
9
21
  let body = text;
@@ -27,12 +39,16 @@ export function prepareChunkedPayloads(
27
39
  files?: AttachmentBuilder[],
28
40
  mentionLookup?: MentionLookup,
29
41
  ): { content: string; files?: AttachmentBuilder[] }[] {
30
- const prepared = mentionLookup ? rewriteUserMentions(text, mentionLookup) : text;
42
+ const prepared = mentionLookup
43
+ ? rewriteUserMentions(text, mentionLookup)
44
+ : text;
31
45
  const chunks = chunkMessage(prepared, { maxChars: 1_900, maxLines: 20 });
32
46
  const safeChunks = chunks.length > 0 ? chunks : ['(no content)'];
33
47
  return safeChunks.map((content, i) => ({
34
48
  content,
35
- ...(i === safeChunks.length - 1 && files && files.length > 0 ? { files } : {}),
49
+ ...(i === safeChunks.length - 1 && files && files.length > 0
50
+ ? { files }
51
+ : {}),
36
52
  }));
37
53
  }
38
54
 
@@ -42,6 +58,7 @@ export async function sendChunkedReply(params: {
42
58
  withRetry: DiscordRetryFn;
43
59
  files?: AttachmentBuilder[];
44
60
  mentionLookup?: MentionLookup;
61
+ humanDelay?: HumanDelayConfig;
45
62
  }): Promise<void> {
46
63
  const payloads = prepareChunkedPayloads(
47
64
  params.text,
@@ -52,9 +69,20 @@ export async function sendChunkedReply(params: {
52
69
  if (i === 0) {
53
70
  await params.withRetry('reply', () => params.msg.reply(payloads[i]));
54
71
  } else {
55
- await params.withRetry('send', () => (params.msg.channel as unknown as {
56
- send: (next: { content: string; files?: AttachmentBuilder[] }) => Promise<void>;
57
- }).send(payloads[i]));
72
+ const delayMs = getHumanDelayMs(params.humanDelay);
73
+ if (delayMs > 0) {
74
+ await sleep(delayMs);
75
+ }
76
+ await params.withRetry('send', () =>
77
+ (
78
+ params.msg.channel as unknown as {
79
+ send: (next: {
80
+ content: string;
81
+ files?: AttachmentBuilder[];
82
+ }) => Promise<void>;
83
+ }
84
+ ).send(payloads[i]),
85
+ );
58
86
  }
59
87
  }
60
88
  }
@@ -65,14 +93,24 @@ export async function sendChunkedDirectReply(params: {
65
93
  withRetry: DiscordRetryFn;
66
94
  files?: AttachmentBuilder[];
67
95
  mentionLookup?: MentionLookup;
96
+ humanDelay?: HumanDelayConfig;
68
97
  }): Promise<void> {
69
98
  const payloads = prepareChunkedPayloads(
70
99
  params.text,
71
100
  params.files,
72
101
  params.mentionLookup,
73
102
  );
74
- const dm = await params.withRetry('dm-open', () => params.msg.author.createDM());
75
- for (const payload of payloads) {
103
+ const dm = await params.withRetry('dm-open', () =>
104
+ params.msg.author.createDM(),
105
+ );
106
+ for (let i = 0; i < payloads.length; i += 1) {
107
+ if (i > 0) {
108
+ const delayMs = getHumanDelayMs(params.humanDelay);
109
+ if (delayMs > 0) {
110
+ await sleep(delayMs);
111
+ }
112
+ }
113
+ const payload = payloads[i];
76
114
  await params.withRetry('dm-send', () => dm.send(payload));
77
115
  }
78
116
  }
@@ -88,12 +126,18 @@ export async function sendChunkedInteractionReply(params: {
88
126
  const payload = { ...payloads[i], ephemeral: true };
89
127
  if (i === 0) {
90
128
  if (params.interaction.replied || params.interaction.deferred) {
91
- await params.withRetry('interaction-followup', () => params.interaction.followUp(payload));
129
+ await params.withRetry('interaction-followup', () =>
130
+ params.interaction.followUp(payload),
131
+ );
92
132
  } else {
93
- await params.withRetry('interaction-reply', () => params.interaction.reply(payload));
133
+ await params.withRetry('interaction-reply', () =>
134
+ params.interaction.reply(payload),
135
+ );
94
136
  }
95
137
  continue;
96
138
  }
97
- await params.withRetry('interaction-followup', () => params.interaction.followUp(payload));
139
+ await params.withRetry('interaction-followup', () =>
140
+ params.interaction.followUp(payload),
141
+ );
98
142
  }
99
143
  }
@@ -0,0 +1,48 @@
1
+ export interface HumanDelayConfig {
2
+ mode: 'off' | 'natural' | 'custom';
3
+ minMs?: number;
4
+ maxMs?: number;
5
+ }
6
+
7
+ export interface ResolvedHumanDelayConfig {
8
+ mode: 'off' | 'natural' | 'custom';
9
+ minMs: number;
10
+ maxMs: number;
11
+ }
12
+
13
+ export const DEFAULT_HUMAN_DELAY_MIN_MS = 800;
14
+ export const DEFAULT_HUMAN_DELAY_MAX_MS = 2_500;
15
+
16
+ export function resolveHumanDelayConfig(
17
+ config?: HumanDelayConfig,
18
+ ): ResolvedHumanDelayConfig {
19
+ const mode = config?.mode ?? 'natural';
20
+ const minMs = Math.max(
21
+ 0,
22
+ Math.floor(config?.minMs ?? DEFAULT_HUMAN_DELAY_MIN_MS),
23
+ );
24
+ const rawMax = Math.max(
25
+ 0,
26
+ Math.floor(config?.maxMs ?? DEFAULT_HUMAN_DELAY_MAX_MS),
27
+ );
28
+ const maxMs = Math.max(minMs, rawMax);
29
+ return {
30
+ mode,
31
+ minMs,
32
+ maxMs,
33
+ };
34
+ }
35
+
36
+ export function getHumanDelayMs(config?: HumanDelayConfig): number {
37
+ const resolved = resolveHumanDelayConfig(config);
38
+ if (resolved.mode === 'off') return 0;
39
+ if (resolved.maxMs <= resolved.minMs) return resolved.minMs;
40
+ const span = resolved.maxMs - resolved.minMs + 1;
41
+ return resolved.minMs + Math.floor(Math.random() * span);
42
+ }
43
+
44
+ export async function sleep(ms: number): Promise<void> {
45
+ const waitMs = Math.max(0, Math.floor(ms));
46
+ if (waitMs <= 0) return;
47
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
48
+ }