@hybridaione/hybridclaw 0.1.5

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 (196) hide show
  1. package/.env.example +14 -0
  2. package/.github/workflows/pages.yml +41 -0
  3. package/AGENTS.md +24 -0
  4. package/CHANGELOG.md +113 -0
  5. package/LICENSE +21 -0
  6. package/README.md +226 -0
  7. package/SECURITY.md +67 -0
  8. package/config.example.json +62 -0
  9. package/container/Dockerfile +23 -0
  10. package/container/package-lock.json +246 -0
  11. package/container/package.json +17 -0
  12. package/container/src/hybridai-client.ts +38 -0
  13. package/container/src/index.ts +198 -0
  14. package/container/src/ipc.ts +37 -0
  15. package/container/src/tools.ts +1008 -0
  16. package/container/src/types.ts +74 -0
  17. package/container/src/web-fetch.ts +389 -0
  18. package/container/tsconfig.json +16 -0
  19. package/dist/agent.d.ts +3 -0
  20. package/dist/agent.d.ts.map +1 -0
  21. package/dist/agent.js +21 -0
  22. package/dist/agent.js.map +1 -0
  23. package/dist/cli.d.ts +3 -0
  24. package/dist/cli.d.ts.map +1 -0
  25. package/dist/cli.js +32 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/config.d.ts +38 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +107 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/container-runner.d.ts +11 -0
  32. package/dist/container-runner.d.ts.map +1 -0
  33. package/dist/container-runner.js +246 -0
  34. package/dist/container-runner.js.map +1 -0
  35. package/dist/conversation.d.ts +18 -0
  36. package/dist/conversation.d.ts.map +1 -0
  37. package/dist/conversation.js +29 -0
  38. package/dist/conversation.js.map +1 -0
  39. package/dist/db.d.ts +29 -0
  40. package/dist/db.d.ts.map +1 -0
  41. package/dist/db.js +205 -0
  42. package/dist/db.js.map +1 -0
  43. package/dist/discord.d.ts +17 -0
  44. package/dist/discord.d.ts.map +1 -0
  45. package/dist/discord.js +115 -0
  46. package/dist/discord.js.map +1 -0
  47. package/dist/env.d.ts +6 -0
  48. package/dist/env.d.ts.map +1 -0
  49. package/dist/env.js +36 -0
  50. package/dist/env.js.map +1 -0
  51. package/dist/gateway-client.d.ts +8 -0
  52. package/dist/gateway-client.d.ts.map +1 -0
  53. package/dist/gateway-client.js +57 -0
  54. package/dist/gateway-client.js.map +1 -0
  55. package/dist/gateway-service.d.ts +23 -0
  56. package/dist/gateway-service.d.ts.map +1 -0
  57. package/dist/gateway-service.js +360 -0
  58. package/dist/gateway-service.js.map +1 -0
  59. package/dist/gateway-types.d.ts +40 -0
  60. package/dist/gateway-types.d.ts.map +1 -0
  61. package/dist/gateway-types.js +6 -0
  62. package/dist/gateway-types.js.map +1 -0
  63. package/dist/gateway.d.ts +2 -0
  64. package/dist/gateway.d.ts.map +1 -0
  65. package/dist/gateway.js +138 -0
  66. package/dist/gateway.js.map +1 -0
  67. package/dist/hatch.d.ts +7 -0
  68. package/dist/hatch.d.ts.map +1 -0
  69. package/dist/hatch.js +99 -0
  70. package/dist/hatch.js.map +1 -0
  71. package/dist/health.d.ts +2 -0
  72. package/dist/health.d.ts.map +1 -0
  73. package/dist/health.js +169 -0
  74. package/dist/health.js.map +1 -0
  75. package/dist/heartbeat.d.ts +3 -0
  76. package/dist/heartbeat.d.ts.map +1 -0
  77. package/dist/heartbeat.js +103 -0
  78. package/dist/heartbeat.js.map +1 -0
  79. package/dist/hybridai-bots.d.ts +5 -0
  80. package/dist/hybridai-bots.d.ts.map +1 -0
  81. package/dist/hybridai-bots.js +34 -0
  82. package/dist/hybridai-bots.js.map +1 -0
  83. package/dist/index.d.ts +2 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +60 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/ipc.d.ts +33 -0
  88. package/dist/ipc.d.ts.map +1 -0
  89. package/dist/ipc.js +142 -0
  90. package/dist/ipc.js.map +1 -0
  91. package/dist/logger.d.ts +3 -0
  92. package/dist/logger.d.ts.map +1 -0
  93. package/dist/logger.js +21 -0
  94. package/dist/logger.js.map +1 -0
  95. package/dist/mount-security.d.ts +28 -0
  96. package/dist/mount-security.d.ts.map +1 -0
  97. package/dist/mount-security.js +187 -0
  98. package/dist/mount-security.js.map +1 -0
  99. package/dist/onboarding.d.ts +7 -0
  100. package/dist/onboarding.d.ts.map +1 -0
  101. package/dist/onboarding.js +445 -0
  102. package/dist/onboarding.js.map +1 -0
  103. package/dist/prompt-hooks.d.ts +17 -0
  104. package/dist/prompt-hooks.d.ts.map +1 -0
  105. package/dist/prompt-hooks.js +83 -0
  106. package/dist/prompt-hooks.js.map +1 -0
  107. package/dist/runtime-config.d.ts +78 -0
  108. package/dist/runtime-config.d.ts.map +1 -0
  109. package/dist/runtime-config.js +471 -0
  110. package/dist/runtime-config.js.map +1 -0
  111. package/dist/scheduled-task-runner.d.ts +11 -0
  112. package/dist/scheduled-task-runner.d.ts.map +1 -0
  113. package/dist/scheduled-task-runner.js +16 -0
  114. package/dist/scheduled-task-runner.js.map +1 -0
  115. package/dist/scheduler.d.ts +11 -0
  116. package/dist/scheduler.d.ts.map +1 -0
  117. package/dist/scheduler.js +165 -0
  118. package/dist/scheduler.js.map +1 -0
  119. package/dist/session-maintenance.d.ts +9 -0
  120. package/dist/session-maintenance.d.ts.map +1 -0
  121. package/dist/session-maintenance.js +168 -0
  122. package/dist/session-maintenance.js.map +1 -0
  123. package/dist/session-transcripts.d.ts +11 -0
  124. package/dist/session-transcripts.d.ts.map +1 -0
  125. package/dist/session-transcripts.js +32 -0
  126. package/dist/session-transcripts.js.map +1 -0
  127. package/dist/side-effects.d.ts +3 -0
  128. package/dist/side-effects.d.ts.map +1 -0
  129. package/dist/side-effects.js +30 -0
  130. package/dist/side-effects.js.map +1 -0
  131. package/dist/skills.d.ts +32 -0
  132. package/dist/skills.d.ts.map +1 -0
  133. package/dist/skills.js +376 -0
  134. package/dist/skills.js.map +1 -0
  135. package/dist/tui.d.ts +2 -0
  136. package/dist/tui.d.ts.map +1 -0
  137. package/dist/tui.js +305 -0
  138. package/dist/tui.js.map +1 -0
  139. package/dist/types.d.ts +132 -0
  140. package/dist/types.d.ts.map +1 -0
  141. package/dist/types.js +3 -0
  142. package/dist/types.js.map +1 -0
  143. package/dist/workspace.d.ts +25 -0
  144. package/dist/workspace.d.ts.map +1 -0
  145. package/dist/workspace.js +154 -0
  146. package/dist/workspace.js.map +1 -0
  147. package/docs/chat.html +929 -0
  148. package/docs/hai_logo_free.png +0 -0
  149. package/docs/hero.png +0 -0
  150. package/docs/index.html +1213 -0
  151. package/package.json +34 -0
  152. package/skills/current-time/SKILL.md +26 -0
  153. package/skills/iss-position/SKILL.md +46 -0
  154. package/skills/iss-position/agents/openai.yaml +3 -0
  155. package/skills/iss-position/scripts/get_iss_position.py +107 -0
  156. package/skills/repo-orientation/SKILL.md +74 -0
  157. package/src/agent.ts +35 -0
  158. package/src/cli.ts +35 -0
  159. package/src/config.ts +137 -0
  160. package/src/container-runner.ts +305 -0
  161. package/src/conversation.ts +49 -0
  162. package/src/db.ts +290 -0
  163. package/src/discord.ts +156 -0
  164. package/src/env.ts +36 -0
  165. package/src/gateway-client.ts +73 -0
  166. package/src/gateway-service.ts +456 -0
  167. package/src/gateway-types.ts +47 -0
  168. package/src/gateway.ts +199 -0
  169. package/src/health.ts +189 -0
  170. package/src/heartbeat.ts +121 -0
  171. package/src/hybridai-bots.ts +48 -0
  172. package/src/ipc.ts +163 -0
  173. package/src/logger.ts +26 -0
  174. package/src/mount-security.ts +216 -0
  175. package/src/onboarding.ts +569 -0
  176. package/src/prompt-hooks.ts +113 -0
  177. package/src/runtime-config.ts +588 -0
  178. package/src/scheduled-task-runner.ts +26 -0
  179. package/src/scheduler.ts +196 -0
  180. package/src/session-maintenance.ts +263 -0
  181. package/src/session-transcripts.ts +45 -0
  182. package/src/side-effects.ts +41 -0
  183. package/src/skills.ts +429 -0
  184. package/src/tui.ts +327 -0
  185. package/src/types.ts +135 -0
  186. package/src/workspace.ts +171 -0
  187. package/templates/AGENTS.md +104 -0
  188. package/templates/BOOT.md +3 -0
  189. package/templates/BOOTSTRAP.md +54 -0
  190. package/templates/HEARTBEAT.md +5 -0
  191. package/templates/IDENTITY.md +18 -0
  192. package/templates/MEMORY.md +19 -0
  193. package/templates/SOUL.md +36 -0
  194. package/templates/TOOLS.md +22 -0
  195. package/templates/USER.md +17 -0
  196. package/tsconfig.json +18 -0
@@ -0,0 +1,588 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ import { loadEnvFile } from './env.js';
5
+
6
+ loadEnvFile();
7
+
8
+ export const CONFIG_FILE_NAME = 'config.json';
9
+ export const CONFIG_VERSION = 1;
10
+ export const SECURITY_POLICY_VERSION = '2026-02-28';
11
+
12
+ const KNOWN_LOG_LEVELS = new Set(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']);
13
+
14
+ type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent';
15
+
16
+ type DeepPartial<T> = {
17
+ [K in keyof T]?: T[K] extends Array<infer U>
18
+ ? U[]
19
+ : T[K] extends object
20
+ ? DeepPartial<T[K]>
21
+ : T[K];
22
+ };
23
+
24
+ export interface RuntimeSecurityConfig {
25
+ trustModelAccepted: boolean;
26
+ trustModelAcceptedAt: string;
27
+ trustModelVersion: string;
28
+ trustModelAcceptedBy: string;
29
+ }
30
+
31
+ export interface RuntimeConfig {
32
+ version: number;
33
+ security: RuntimeSecurityConfig;
34
+ discord: {
35
+ prefix: string;
36
+ };
37
+ hybridai: {
38
+ baseUrl: string;
39
+ defaultModel: string;
40
+ defaultChatbotId: string;
41
+ enableRag: boolean;
42
+ models: string[];
43
+ };
44
+ container: {
45
+ image: string;
46
+ memory: string;
47
+ cpus: string;
48
+ timeoutMs: number;
49
+ additionalMounts: string;
50
+ maxOutputBytes: number;
51
+ maxConcurrent: number;
52
+ };
53
+ heartbeat: {
54
+ enabled: boolean;
55
+ intervalMs: number;
56
+ channel: string;
57
+ };
58
+ ops: {
59
+ healthHost: string;
60
+ healthPort: number;
61
+ webApiToken: string;
62
+ gatewayBaseUrl: string;
63
+ gatewayApiToken: string;
64
+ dbPath: string;
65
+ logLevel: LogLevel;
66
+ };
67
+ sessionCompaction: {
68
+ enabled: boolean;
69
+ threshold: number;
70
+ keepRecent: number;
71
+ summaryMaxChars: number;
72
+ preCompactionMemoryFlush: {
73
+ enabled: boolean;
74
+ maxMessages: number;
75
+ maxChars: number;
76
+ };
77
+ };
78
+ promptHooks: {
79
+ bootstrapEnabled: boolean;
80
+ memoryEnabled: boolean;
81
+ safetyEnabled: boolean;
82
+ };
83
+ }
84
+
85
+ export type RuntimeConfigChangeListener = (next: RuntimeConfig, prev: RuntimeConfig) => void;
86
+
87
+ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
88
+ version: CONFIG_VERSION,
89
+ security: {
90
+ trustModelAccepted: false,
91
+ trustModelAcceptedAt: '',
92
+ trustModelVersion: '',
93
+ trustModelAcceptedBy: '',
94
+ },
95
+ discord: {
96
+ prefix: '!claw',
97
+ },
98
+ hybridai: {
99
+ baseUrl: 'https://hybridai.one',
100
+ defaultModel: 'gpt-5-nano',
101
+ defaultChatbotId: '',
102
+ enableRag: true,
103
+ models: ['gpt-5-nano', 'gpt-5-mini', 'gpt-5'],
104
+ },
105
+ container: {
106
+ image: 'hybridclaw-agent',
107
+ memory: '512m',
108
+ cpus: '1',
109
+ timeoutMs: 60_000,
110
+ additionalMounts: '',
111
+ maxOutputBytes: 10_485_760,
112
+ maxConcurrent: 5,
113
+ },
114
+ heartbeat: {
115
+ enabled: true,
116
+ intervalMs: 1_800_000,
117
+ channel: '',
118
+ },
119
+ ops: {
120
+ healthHost: '127.0.0.1',
121
+ healthPort: 9090,
122
+ webApiToken: '',
123
+ gatewayBaseUrl: 'http://127.0.0.1:9090',
124
+ gatewayApiToken: '',
125
+ dbPath: 'data/hybridclaw.db',
126
+ logLevel: 'info',
127
+ },
128
+ sessionCompaction: {
129
+ enabled: true,
130
+ threshold: 120,
131
+ keepRecent: 40,
132
+ summaryMaxChars: 8_000,
133
+ preCompactionMemoryFlush: {
134
+ enabled: true,
135
+ maxMessages: 80,
136
+ maxChars: 24_000,
137
+ },
138
+ },
139
+ promptHooks: {
140
+ bootstrapEnabled: true,
141
+ memoryEnabled: true,
142
+ safetyEnabled: true,
143
+ },
144
+ };
145
+
146
+ const CONFIG_PATH = path.join(process.cwd(), CONFIG_FILE_NAME);
147
+
148
+ let currentConfig: RuntimeConfig = cloneConfig(DEFAULT_RUNTIME_CONFIG);
149
+ let configWatcher: fs.FSWatcher | null = null;
150
+ let reloadTimer: ReturnType<typeof setTimeout> | null = null;
151
+ const listeners = new Set<RuntimeConfigChangeListener>();
152
+
153
+ function cloneConfig<T>(value: T): T {
154
+ return JSON.parse(JSON.stringify(value)) as T;
155
+ }
156
+
157
+ function isRecord(value: unknown): value is Record<string, unknown> {
158
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
159
+ }
160
+
161
+ function normalizeString(
162
+ value: unknown,
163
+ fallback: string,
164
+ opts?: { allowEmpty?: boolean; trim?: boolean },
165
+ ): string {
166
+ const trim = opts?.trim !== false;
167
+ const allowEmpty = opts?.allowEmpty ?? true;
168
+ if (typeof value !== 'string') return fallback;
169
+ const normalized = trim ? value.trim() : value;
170
+ if (!allowEmpty && normalized.length === 0) return fallback;
171
+ return normalized;
172
+ }
173
+
174
+ function normalizeBoolean(value: unknown, fallback: boolean): boolean {
175
+ if (typeof value === 'boolean') return value;
176
+ if (typeof value === 'string') {
177
+ const normalized = value.trim().toLowerCase();
178
+ if (normalized === 'true') return true;
179
+ if (normalized === 'false') return false;
180
+ }
181
+ return fallback;
182
+ }
183
+
184
+ function normalizeInteger(
185
+ value: unknown,
186
+ fallback: number,
187
+ opts?: { min?: number; max?: number },
188
+ ): number {
189
+ let parsed: number;
190
+ if (typeof value === 'number') {
191
+ parsed = Math.trunc(value);
192
+ } else if (typeof value === 'string' && value.trim()) {
193
+ parsed = Number.parseInt(value, 10);
194
+ } else {
195
+ parsed = fallback;
196
+ }
197
+
198
+ if (!Number.isFinite(parsed)) parsed = fallback;
199
+ if (opts?.min != null && parsed < opts.min) parsed = opts.min;
200
+ if (opts?.max != null && parsed > opts.max) parsed = opts.max;
201
+ return parsed;
202
+ }
203
+
204
+ function normalizeStringArray(value: unknown, fallback: string[]): string[] {
205
+ if (Array.isArray(value)) {
206
+ const normalized = value
207
+ .map((item) => (typeof item === 'string' ? item.trim() : ''))
208
+ .filter(Boolean);
209
+ if (normalized.length > 0) return normalized;
210
+ return fallback;
211
+ }
212
+
213
+ if (typeof value === 'string') {
214
+ const parsed = value
215
+ .split(',')
216
+ .map((item) => item.trim())
217
+ .filter(Boolean);
218
+ return parsed.length > 0 ? parsed : fallback;
219
+ }
220
+
221
+ return fallback;
222
+ }
223
+
224
+ function normalizeLogLevel(value: unknown, fallback: LogLevel): LogLevel {
225
+ const normalized = normalizeString(value, fallback, { allowEmpty: false }).toLowerCase();
226
+ if (KNOWN_LOG_LEVELS.has(normalized)) return normalized as LogLevel;
227
+ return fallback;
228
+ }
229
+
230
+ function normalizeBaseUrl(value: unknown, fallback: string): string {
231
+ const candidate = normalizeString(value, fallback, { allowEmpty: false });
232
+ return candidate.replace(/\/+$/, '') || fallback;
233
+ }
234
+
235
+ function parseConfigPatch(payload: unknown): DeepPartial<RuntimeConfig> {
236
+ if (!isRecord(payload)) {
237
+ throw new Error('config.json must contain a top-level object');
238
+ }
239
+ return payload as DeepPartial<RuntimeConfig>;
240
+ }
241
+
242
+ function readLegacyEnvPatch(): DeepPartial<RuntimeConfig> {
243
+ const env = process.env;
244
+
245
+ const patch: Record<string, unknown> = {
246
+ discord: {},
247
+ hybridai: {},
248
+ container: {},
249
+ heartbeat: {},
250
+ ops: {},
251
+ sessionCompaction: {
252
+ preCompactionMemoryFlush: {},
253
+ },
254
+ promptHooks: {},
255
+ };
256
+
257
+ const discord = patch.discord as Record<string, unknown>;
258
+ const hybridai = patch.hybridai as Record<string, unknown>;
259
+ const container = patch.container as Record<string, unknown>;
260
+ const heartbeat = patch.heartbeat as Record<string, unknown>;
261
+ const ops = patch.ops as Record<string, unknown>;
262
+ const sessionCompaction = patch.sessionCompaction as Record<string, unknown>;
263
+ const preCompactionMemoryFlush = sessionCompaction.preCompactionMemoryFlush as Record<string, unknown>;
264
+
265
+ if (env.DISCORD_PREFIX != null) discord.prefix = env.DISCORD_PREFIX;
266
+
267
+ if (env.HYBRIDAI_BASE_URL != null) hybridai.baseUrl = env.HYBRIDAI_BASE_URL;
268
+ if (env.HYBRIDAI_MODEL != null) hybridai.defaultModel = env.HYBRIDAI_MODEL;
269
+ if (env.HYBRIDAI_CHATBOT_ID != null) hybridai.defaultChatbotId = env.HYBRIDAI_CHATBOT_ID;
270
+ if (env.HYBRIDAI_ENABLE_RAG != null) hybridai.enableRag = env.HYBRIDAI_ENABLE_RAG;
271
+ if (env.HYBRIDAI_MODELS != null) hybridai.models = env.HYBRIDAI_MODELS;
272
+
273
+ if (env.CONTAINER_IMAGE != null) container.image = env.CONTAINER_IMAGE;
274
+ if (env.CONTAINER_MEMORY != null) container.memory = env.CONTAINER_MEMORY;
275
+ if (env.CONTAINER_CPUS != null) container.cpus = env.CONTAINER_CPUS;
276
+ if (env.CONTAINER_TIMEOUT != null) container.timeoutMs = env.CONTAINER_TIMEOUT;
277
+ if (env.ADDITIONAL_MOUNTS != null) container.additionalMounts = env.ADDITIONAL_MOUNTS;
278
+ if (env.CONTAINER_MAX_OUTPUT_SIZE != null) container.maxOutputBytes = env.CONTAINER_MAX_OUTPUT_SIZE;
279
+ if (env.MAX_CONCURRENT_CONTAINERS != null) container.maxConcurrent = env.MAX_CONCURRENT_CONTAINERS;
280
+
281
+ if (env.HEARTBEAT_ENABLED != null) heartbeat.enabled = env.HEARTBEAT_ENABLED;
282
+ if (env.HEARTBEAT_INTERVAL != null) heartbeat.intervalMs = env.HEARTBEAT_INTERVAL;
283
+ if (env.HEARTBEAT_CHANNEL != null) heartbeat.channel = env.HEARTBEAT_CHANNEL;
284
+
285
+ if (env.HEALTH_HOST != null) ops.healthHost = env.HEALTH_HOST;
286
+ if (env.HEALTH_PORT != null) ops.healthPort = env.HEALTH_PORT;
287
+ if (env.GATEWAY_BASE_URL != null) ops.gatewayBaseUrl = env.GATEWAY_BASE_URL;
288
+ if (env.DB_PATH != null) ops.dbPath = env.DB_PATH;
289
+ if (env.LOG_LEVEL != null) ops.logLevel = env.LOG_LEVEL;
290
+
291
+ if (env.SESSION_COMPACTION_ENABLED != null) sessionCompaction.enabled = env.SESSION_COMPACTION_ENABLED;
292
+ if (env.SESSION_COMPACTION_THRESHOLD != null) sessionCompaction.threshold = env.SESSION_COMPACTION_THRESHOLD;
293
+ if (env.SESSION_COMPACTION_KEEP_RECENT != null) sessionCompaction.keepRecent = env.SESSION_COMPACTION_KEEP_RECENT;
294
+ if (env.SESSION_COMPACTION_SUMMARY_MAX_CHARS != null) sessionCompaction.summaryMaxChars = env.SESSION_COMPACTION_SUMMARY_MAX_CHARS;
295
+ if (env.PRE_COMPACTION_MEMORY_FLUSH_ENABLED != null) {
296
+ preCompactionMemoryFlush.enabled = env.PRE_COMPACTION_MEMORY_FLUSH_ENABLED;
297
+ }
298
+ if (env.PRE_COMPACTION_MEMORY_FLUSH_MAX_MESSAGES != null) {
299
+ preCompactionMemoryFlush.maxMessages = env.PRE_COMPACTION_MEMORY_FLUSH_MAX_MESSAGES;
300
+ }
301
+ if (env.PRE_COMPACTION_MEMORY_FLUSH_MAX_CHARS != null) {
302
+ preCompactionMemoryFlush.maxChars = env.PRE_COMPACTION_MEMORY_FLUSH_MAX_CHARS;
303
+ }
304
+
305
+ return patch as DeepPartial<RuntimeConfig>;
306
+ }
307
+
308
+ function normalizeRuntimeConfig(patch?: DeepPartial<RuntimeConfig>): RuntimeConfig {
309
+ const raw = patch ?? {};
310
+
311
+ const rawSecurity = isRecord(raw.security) ? raw.security : {};
312
+ const rawDiscord = isRecord(raw.discord) ? raw.discord : {};
313
+ const rawHybridAi = isRecord(raw.hybridai) ? raw.hybridai : {};
314
+ const rawContainer = isRecord(raw.container) ? raw.container : {};
315
+ const rawHeartbeat = isRecord(raw.heartbeat) ? raw.heartbeat : {};
316
+ const rawOps = isRecord(raw.ops) ? raw.ops : {};
317
+ const rawSessionCompaction = isRecord(raw.sessionCompaction) ? raw.sessionCompaction : {};
318
+ const rawPreFlush = isRecord(rawSessionCompaction.preCompactionMemoryFlush)
319
+ ? rawSessionCompaction.preCompactionMemoryFlush
320
+ : {};
321
+ const rawPromptHooks = isRecord(raw.promptHooks) ? raw.promptHooks : {};
322
+
323
+ const defaultOps = DEFAULT_RUNTIME_CONFIG.ops;
324
+ const healthPort = normalizeInteger(rawOps.healthPort, defaultOps.healthPort, { min: 1, max: 65_535 });
325
+ const webApiToken = normalizeString(rawOps.webApiToken, defaultOps.webApiToken, { allowEmpty: true });
326
+
327
+ const threshold = normalizeInteger(
328
+ rawSessionCompaction.threshold,
329
+ DEFAULT_RUNTIME_CONFIG.sessionCompaction.threshold,
330
+ { min: 20 },
331
+ );
332
+ const keepRecentRaw = normalizeInteger(
333
+ rawSessionCompaction.keepRecent,
334
+ DEFAULT_RUNTIME_CONFIG.sessionCompaction.keepRecent,
335
+ { min: 1 },
336
+ );
337
+ const keepRecent = Math.min(keepRecentRaw, Math.max(1, threshold - 1));
338
+
339
+ const modelList = normalizeStringArray(rawHybridAi.models, DEFAULT_RUNTIME_CONFIG.hybridai.models);
340
+
341
+ return {
342
+ version: CONFIG_VERSION,
343
+ security: {
344
+ trustModelAccepted: normalizeBoolean(rawSecurity.trustModelAccepted, DEFAULT_RUNTIME_CONFIG.security.trustModelAccepted),
345
+ trustModelAcceptedAt: normalizeString(rawSecurity.trustModelAcceptedAt, DEFAULT_RUNTIME_CONFIG.security.trustModelAcceptedAt, { allowEmpty: true }),
346
+ trustModelVersion: normalizeString(rawSecurity.trustModelVersion, DEFAULT_RUNTIME_CONFIG.security.trustModelVersion, { allowEmpty: true }),
347
+ trustModelAcceptedBy: normalizeString(rawSecurity.trustModelAcceptedBy, DEFAULT_RUNTIME_CONFIG.security.trustModelAcceptedBy, { allowEmpty: true }),
348
+ },
349
+ discord: {
350
+ prefix: normalizeString(rawDiscord.prefix, DEFAULT_RUNTIME_CONFIG.discord.prefix, { allowEmpty: false }),
351
+ },
352
+ hybridai: {
353
+ baseUrl: normalizeBaseUrl(rawHybridAi.baseUrl, DEFAULT_RUNTIME_CONFIG.hybridai.baseUrl),
354
+ defaultModel: normalizeString(rawHybridAi.defaultModel, DEFAULT_RUNTIME_CONFIG.hybridai.defaultModel, { allowEmpty: false }),
355
+ defaultChatbotId: normalizeString(rawHybridAi.defaultChatbotId, DEFAULT_RUNTIME_CONFIG.hybridai.defaultChatbotId, { allowEmpty: true }),
356
+ enableRag: normalizeBoolean(rawHybridAi.enableRag, DEFAULT_RUNTIME_CONFIG.hybridai.enableRag),
357
+ models: modelList,
358
+ },
359
+ container: {
360
+ image: normalizeString(rawContainer.image, DEFAULT_RUNTIME_CONFIG.container.image, { allowEmpty: false }),
361
+ memory: normalizeString(rawContainer.memory, DEFAULT_RUNTIME_CONFIG.container.memory, { allowEmpty: false }),
362
+ cpus: normalizeString(rawContainer.cpus, DEFAULT_RUNTIME_CONFIG.container.cpus, { allowEmpty: false }),
363
+ timeoutMs: normalizeInteger(rawContainer.timeoutMs, DEFAULT_RUNTIME_CONFIG.container.timeoutMs, { min: 1_000 }),
364
+ additionalMounts: normalizeString(rawContainer.additionalMounts, DEFAULT_RUNTIME_CONFIG.container.additionalMounts, { allowEmpty: true }),
365
+ maxOutputBytes: normalizeInteger(rawContainer.maxOutputBytes, DEFAULT_RUNTIME_CONFIG.container.maxOutputBytes, { min: 1_024 }),
366
+ maxConcurrent: normalizeInteger(rawContainer.maxConcurrent, DEFAULT_RUNTIME_CONFIG.container.maxConcurrent, { min: 1 }),
367
+ },
368
+ heartbeat: {
369
+ enabled: normalizeBoolean(rawHeartbeat.enabled, DEFAULT_RUNTIME_CONFIG.heartbeat.enabled),
370
+ intervalMs: normalizeInteger(rawHeartbeat.intervalMs, DEFAULT_RUNTIME_CONFIG.heartbeat.intervalMs, { min: 10_000 }),
371
+ channel: normalizeString(rawHeartbeat.channel, DEFAULT_RUNTIME_CONFIG.heartbeat.channel, { allowEmpty: true }),
372
+ },
373
+ ops: {
374
+ healthHost: normalizeString(rawOps.healthHost, defaultOps.healthHost, { allowEmpty: false }),
375
+ healthPort,
376
+ webApiToken,
377
+ gatewayBaseUrl: normalizeBaseUrl(rawOps.gatewayBaseUrl, `http://127.0.0.1:${healthPort}`),
378
+ gatewayApiToken: normalizeString(rawOps.gatewayApiToken, webApiToken, { allowEmpty: true }),
379
+ dbPath: normalizeString(rawOps.dbPath, defaultOps.dbPath, { allowEmpty: false }),
380
+ logLevel: normalizeLogLevel(rawOps.logLevel, defaultOps.logLevel),
381
+ },
382
+ sessionCompaction: {
383
+ enabled: normalizeBoolean(rawSessionCompaction.enabled, DEFAULT_RUNTIME_CONFIG.sessionCompaction.enabled),
384
+ threshold,
385
+ keepRecent,
386
+ summaryMaxChars: normalizeInteger(
387
+ rawSessionCompaction.summaryMaxChars,
388
+ DEFAULT_RUNTIME_CONFIG.sessionCompaction.summaryMaxChars,
389
+ { min: 1_000 },
390
+ ),
391
+ preCompactionMemoryFlush: {
392
+ enabled: normalizeBoolean(rawPreFlush.enabled, DEFAULT_RUNTIME_CONFIG.sessionCompaction.preCompactionMemoryFlush.enabled),
393
+ maxMessages: normalizeInteger(
394
+ rawPreFlush.maxMessages,
395
+ DEFAULT_RUNTIME_CONFIG.sessionCompaction.preCompactionMemoryFlush.maxMessages,
396
+ { min: 8 },
397
+ ),
398
+ maxChars: normalizeInteger(
399
+ rawPreFlush.maxChars,
400
+ DEFAULT_RUNTIME_CONFIG.sessionCompaction.preCompactionMemoryFlush.maxChars,
401
+ { min: 4_000 },
402
+ ),
403
+ },
404
+ },
405
+ promptHooks: {
406
+ bootstrapEnabled: normalizeBoolean(rawPromptHooks.bootstrapEnabled, DEFAULT_RUNTIME_CONFIG.promptHooks.bootstrapEnabled),
407
+ memoryEnabled: normalizeBoolean(rawPromptHooks.memoryEnabled, DEFAULT_RUNTIME_CONFIG.promptHooks.memoryEnabled),
408
+ safetyEnabled: normalizeBoolean(rawPromptHooks.safetyEnabled, DEFAULT_RUNTIME_CONFIG.promptHooks.safetyEnabled),
409
+ },
410
+ };
411
+ }
412
+
413
+ function mergePatch(target: Record<string, unknown>, patch: Record<string, unknown>): void {
414
+ for (const [key, value] of Object.entries(patch)) {
415
+ if (value === undefined) continue;
416
+ if (Array.isArray(value)) {
417
+ target[key] = [...value];
418
+ continue;
419
+ }
420
+ if (isRecord(value)) {
421
+ const existing = target[key];
422
+ const nested = isRecord(existing) ? existing : {};
423
+ mergePatch(nested, value);
424
+ target[key] = nested;
425
+ continue;
426
+ }
427
+ target[key] = value;
428
+ }
429
+ }
430
+
431
+ function combinePatches(...patches: DeepPartial<RuntimeConfig>[]): DeepPartial<RuntimeConfig> {
432
+ const merged: Record<string, unknown> = {};
433
+ for (const patch of patches) {
434
+ if (!isRecord(patch)) continue;
435
+ mergePatch(merged, patch);
436
+ }
437
+ return merged as DeepPartial<RuntimeConfig>;
438
+ }
439
+
440
+ function loadConfigPatchFromDisk(): DeepPartial<RuntimeConfig> {
441
+ if (!fs.existsSync(CONFIG_PATH)) return {};
442
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
443
+ const parsed = JSON.parse(raw) as unknown;
444
+ return parseConfigPatch(parsed);
445
+ }
446
+
447
+ function writeConfigFile(config: RuntimeConfig): void {
448
+ const dir = path.dirname(CONFIG_PATH);
449
+ fs.mkdirSync(dir, { recursive: true });
450
+
451
+ const nextText = `${JSON.stringify(config, null, 2)}\n`;
452
+ const tmpPath = `${CONFIG_PATH}.tmp-${process.pid}-${Date.now()}`;
453
+ fs.writeFileSync(tmpPath, nextText, 'utf-8');
454
+ fs.renameSync(tmpPath, CONFIG_PATH);
455
+ }
456
+
457
+ function applyConfig(next: RuntimeConfig): void {
458
+ const prev = currentConfig;
459
+ currentConfig = cloneConfig(next);
460
+
461
+ if (JSON.stringify(prev) === JSON.stringify(currentConfig)) return;
462
+ for (const listener of listeners) {
463
+ try {
464
+ listener(cloneConfig(currentConfig), cloneConfig(prev));
465
+ } catch (err) {
466
+ console.warn(`[runtime-config] listener failed: ${err instanceof Error ? err.message : String(err)}`);
467
+ }
468
+ }
469
+ }
470
+
471
+ function loadRuntimeConfigFromSources(): RuntimeConfig {
472
+ const envPatch = readLegacyEnvPatch();
473
+ const diskPatch = loadConfigPatchFromDisk();
474
+ return normalizeRuntimeConfig(combinePatches(envPatch, diskPatch));
475
+ }
476
+
477
+ function reloadFromDisk(trigger: string): void {
478
+ try {
479
+ const next = loadRuntimeConfigFromSources();
480
+ applyConfig(next);
481
+ } catch (err) {
482
+ console.warn(`[runtime-config] reload failed (${trigger}): ${err instanceof Error ? err.message : String(err)}`);
483
+ }
484
+ }
485
+
486
+ function scheduleReload(trigger: string): void {
487
+ if (reloadTimer) clearTimeout(reloadTimer);
488
+ reloadTimer = setTimeout(() => {
489
+ reloadTimer = null;
490
+ reloadFromDisk(trigger);
491
+ }, 120);
492
+ }
493
+
494
+ function startWatcher(): void {
495
+ if (configWatcher) return;
496
+
497
+ try {
498
+ configWatcher = fs.watch(path.dirname(CONFIG_PATH), { persistent: false }, (_event, filename) => {
499
+ if (!filename) {
500
+ scheduleReload('unknown');
501
+ return;
502
+ }
503
+ if (filename.toString() !== path.basename(CONFIG_PATH)) return;
504
+ scheduleReload(`watch:${filename.toString()}`);
505
+ });
506
+
507
+ configWatcher.on('error', (err) => {
508
+ console.warn(`[runtime-config] watcher error: ${err instanceof Error ? err.message : String(err)}`);
509
+ configWatcher?.close();
510
+ configWatcher = null;
511
+ setTimeout(startWatcher, 1_000);
512
+ });
513
+ } catch (err) {
514
+ console.warn(`[runtime-config] watcher setup failed: ${err instanceof Error ? err.message : String(err)}`);
515
+ }
516
+ }
517
+
518
+ function ensureInitialConfigFile(): void {
519
+ if (fs.existsSync(CONFIG_PATH)) return;
520
+ const seeded = normalizeRuntimeConfig(readLegacyEnvPatch());
521
+ writeConfigFile(seeded);
522
+ }
523
+
524
+ function initializeRuntimeConfig(): void {
525
+ ensureInitialConfigFile();
526
+ reloadFromDisk('startup');
527
+ startWatcher();
528
+ }
529
+
530
+ initializeRuntimeConfig();
531
+
532
+ export function runtimeConfigPath(): string {
533
+ return CONFIG_PATH;
534
+ }
535
+
536
+ export function ensureRuntimeConfigFile(): boolean {
537
+ if (fs.existsSync(CONFIG_PATH)) return false;
538
+ ensureInitialConfigFile();
539
+ reloadFromDisk('ensure-file');
540
+ return true;
541
+ }
542
+
543
+ export function getRuntimeConfig(): RuntimeConfig {
544
+ return cloneConfig(currentConfig);
545
+ }
546
+
547
+ export function onRuntimeConfigChange(listener: RuntimeConfigChangeListener): () => void {
548
+ listeners.add(listener);
549
+ return () => listeners.delete(listener);
550
+ }
551
+
552
+ export function saveRuntimeConfig(next: RuntimeConfig): RuntimeConfig {
553
+ const normalized = normalizeRuntimeConfig(next);
554
+ writeConfigFile(normalized);
555
+ applyConfig(normalized);
556
+ return cloneConfig(normalized);
557
+ }
558
+
559
+ export function updateRuntimeConfig(mutator: (draft: RuntimeConfig) => void): RuntimeConfig {
560
+ const draft = cloneConfig(currentConfig);
561
+ mutator(draft);
562
+ return saveRuntimeConfig(draft);
563
+ }
564
+
565
+ export function isSecurityTrustAccepted(config: RuntimeConfig = currentConfig): boolean {
566
+ return Boolean(
567
+ config.security.trustModelAccepted
568
+ && config.security.trustModelAcceptedAt
569
+ && config.security.trustModelVersion === SECURITY_POLICY_VERSION,
570
+ );
571
+ }
572
+
573
+ export function acceptSecurityTrustModel(params?: {
574
+ acceptedAt?: string;
575
+ acceptedBy?: string | null;
576
+ policyVersion?: string;
577
+ }): RuntimeConfig {
578
+ const acceptedAt = normalizeString(params?.acceptedAt, new Date().toISOString(), { allowEmpty: false });
579
+ const acceptedBy = normalizeString(params?.acceptedBy ?? '', '', { allowEmpty: true });
580
+ const policyVersion = normalizeString(params?.policyVersion, SECURITY_POLICY_VERSION, { allowEmpty: false });
581
+
582
+ return updateRuntimeConfig((draft) => {
583
+ draft.security.trustModelAccepted = true;
584
+ draft.security.trustModelAcceptedAt = acceptedAt;
585
+ draft.security.trustModelAcceptedBy = acceptedBy;
586
+ draft.security.trustModelVersion = policyVersion;
587
+ });
588
+ }
@@ -0,0 +1,26 @@
1
+ import { runAgent } from './agent.js';
2
+ import type { ChatMessage } from './types.js';
3
+
4
+ export async function runIsolatedScheduledTask(params: {
5
+ taskId: number;
6
+ prompt: string;
7
+ channelId: string;
8
+ chatbotId: string;
9
+ model: string;
10
+ agentId: string;
11
+ onResult: (result: string) => void | Promise<void>;
12
+ onError: (error: unknown) => void;
13
+ }): Promise<void> {
14
+ const { taskId, prompt, channelId, chatbotId, model, agentId, onResult, onError } = params;
15
+ const cronSessionId = `cron:${taskId}`;
16
+ const messages: ChatMessage[] = [{ role: 'user', content: prompt }];
17
+
18
+ try {
19
+ const output = await runAgent(cronSessionId, messages, chatbotId, false, model, agentId, channelId, undefined, ['cron']);
20
+ if (output.status === 'success' && output.result) {
21
+ await onResult(output.result);
22
+ }
23
+ } catch (error) {
24
+ onError(error);
25
+ }
26
+ }