@hivemagnus/magnus-server 0.0.0-stage → 1.0.2

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 (356) hide show
  1. package/.env.example +171 -0
  2. package/LICENSE +202 -0
  3. package/README.md +169 -2
  4. package/agent-version.json +1 -0
  5. package/dist/ai/HEARTBEAT.md +56 -0
  6. package/dist/ai/IDENTITY.md +54 -0
  7. package/dist/ai/MEMORY.md +40 -0
  8. package/dist/ai/TOOLS.md +538 -0
  9. package/dist/ai/USER.md +32 -0
  10. package/dist/cli/args.js +62 -0
  11. package/dist/cli/env-catalog.js +131 -0
  12. package/dist/cli/preflight.js +89 -0
  13. package/dist/cli.js +175 -0
  14. package/dist/config/cors.js +16 -0
  15. package/dist/config/env.js +392 -0
  16. package/dist/config/features.js +16 -0
  17. package/dist/config/index.js +3 -0
  18. package/dist/config/listener-security.js +38 -0
  19. package/dist/config/version.js +31 -0
  20. package/dist/db/client.js +44 -0
  21. package/dist/db/generated/prisma/browser.js +18 -0
  22. package/dist/db/generated/prisma/client.js +35 -0
  23. package/dist/db/generated/prisma/commonInputTypes.js +11 -0
  24. package/dist/db/generated/prisma/enums.js +42 -0
  25. package/dist/db/generated/prisma/internal/class.js +50 -0
  26. package/dist/db/generated/prisma/internal/prismaNamespace.js +1598 -0
  27. package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +1569 -0
  28. package/dist/db/generated/prisma/models/A2ATask.js +2 -0
  29. package/dist/db/generated/prisma/models/AdmissionFence.js +2 -0
  30. package/dist/db/generated/prisma/models/Agent.js +2 -0
  31. package/dist/db/generated/prisma/models/AiConversation.js +2 -0
  32. package/dist/db/generated/prisma/models/AiDashboard.js +2 -0
  33. package/dist/db/generated/prisma/models/AiMemory.js +2 -0
  34. package/dist/db/generated/prisma/models/AiMessage.js +2 -0
  35. package/dist/db/generated/prisma/models/AiScheduledOp.js +2 -0
  36. package/dist/db/generated/prisma/models/AlertConditionState.js +2 -0
  37. package/dist/db/generated/prisma/models/AlertRule.js +2 -0
  38. package/dist/db/generated/prisma/models/AlertViolation.js +2 -0
  39. package/dist/db/generated/prisma/models/AnomalyEvent.js +2 -0
  40. package/dist/db/generated/prisma/models/ApplicationObservation.js +2 -0
  41. package/dist/db/generated/prisma/models/ArdentInstallation.js +2 -0
  42. package/dist/db/generated/prisma/models/ArdentProfile.js +2 -0
  43. package/dist/db/generated/prisma/models/ArdentScopeEnrollment.js +2 -0
  44. package/dist/db/generated/prisma/models/CommandResourceFence.js +2 -0
  45. package/dist/db/generated/prisma/models/CommandResourceLease.js +2 -0
  46. package/dist/db/generated/prisma/models/CommandResult.js +2 -0
  47. package/dist/db/generated/prisma/models/CronJob.js +2 -0
  48. package/dist/db/generated/prisma/models/CronJobExecution.js +2 -0
  49. package/dist/db/generated/prisma/models/DeployJob.js +2 -0
  50. package/dist/db/generated/prisma/models/DriftScan.js +2 -0
  51. package/dist/db/generated/prisma/models/EngineeringMetricSample.js +2 -0
  52. package/dist/db/generated/prisma/models/EngineeringRecoveryExercise.js +2 -0
  53. package/dist/db/generated/prisma/models/ExecutionRun.js +2 -0
  54. package/dist/db/generated/prisma/models/FilePatchTask.js +2 -0
  55. package/dist/db/generated/prisma/models/GpuSnapshot.js +2 -0
  56. package/dist/db/generated/prisma/models/HealthSnapshot.js +2 -0
  57. package/dist/db/generated/prisma/models/HermesCoreRollout.js +2 -0
  58. package/dist/db/generated/prisma/models/HermesCoreRolloutHost.js +2 -0
  59. package/dist/db/generated/prisma/models/HermesCredentialPool.js +2 -0
  60. package/dist/db/generated/prisma/models/HermesCredentialPoolAttachment.js +2 -0
  61. package/dist/db/generated/prisma/models/HermesCronJob.js +2 -0
  62. package/dist/db/generated/prisma/models/HermesHook.js +2 -0
  63. package/dist/db/generated/prisma/models/HermesInstallation.js +2 -0
  64. package/dist/db/generated/prisma/models/HermesMcpServer.js +2 -0
  65. package/dist/db/generated/prisma/models/HermesMcpServerAttachment.js +2 -0
  66. package/dist/db/generated/prisma/models/HermesMessagingBinding.js +2 -0
  67. package/dist/db/generated/prisma/models/HermesOperationJob.js +2 -0
  68. package/dist/db/generated/prisma/models/HermesPluginInstall.js +2 -0
  69. package/dist/db/generated/prisma/models/HermesProfile.js +2 -0
  70. package/dist/db/generated/prisma/models/HermesProviderConfig.js +2 -0
  71. package/dist/db/generated/prisma/models/HermesRegistryClient.js +2 -0
  72. package/dist/db/generated/prisma/models/HermesRegistryInventory.js +2 -0
  73. package/dist/db/generated/prisma/models/HermesRegistryReceipt.js +2 -0
  74. package/dist/db/generated/prisma/models/HermesRegistryRevision.js +2 -0
  75. package/dist/db/generated/prisma/models/HermesRegistryTarget.js +2 -0
  76. package/dist/db/generated/prisma/models/HermesRole.js +2 -0
  77. package/dist/db/generated/prisma/models/HermesRoleApplication.js +2 -0
  78. package/dist/db/generated/prisma/models/HermesSkillAssignment.js +2 -0
  79. package/dist/db/generated/prisma/models/HermesToolsetPolicy.js +2 -0
  80. package/dist/db/generated/prisma/models/IncidentLesson.js +2 -0
  81. package/dist/db/generated/prisma/models/LibraryCategory.js +2 -0
  82. package/dist/db/generated/prisma/models/LibraryFile.js +2 -0
  83. package/dist/db/generated/prisma/models/LogEntry.js +2 -0
  84. package/dist/db/generated/prisma/models/MagnusReleaseCanary.js +2 -0
  85. package/dist/db/generated/prisma/models/MaintenanceWindow.js +2 -0
  86. package/dist/db/generated/prisma/models/ManagedCronDefinition.js +2 -0
  87. package/dist/db/generated/prisma/models/ManagedCronOccurrence.js +2 -0
  88. package/dist/db/generated/prisma/models/Node.js +2 -0
  89. package/dist/db/generated/prisma/models/Notification.js +2 -0
  90. package/dist/db/generated/prisma/models/NotificationPreference.js +2 -0
  91. package/dist/db/generated/prisma/models/RemediationExecution.js +2 -0
  92. package/dist/db/generated/prisma/models/RemediationRule.js +2 -0
  93. package/dist/db/generated/prisma/models/RemoteCommand.js +2 -0
  94. package/dist/db/generated/prisma/models/ResourceDrainParticipant.js +2 -0
  95. package/dist/db/generated/prisma/models/ResourceEnrollment.js +2 -0
  96. package/dist/db/generated/prisma/models/ResourceParticipantWork.js +2 -0
  97. package/dist/db/generated/prisma/models/Role.js +2 -0
  98. package/dist/db/generated/prisma/models/RoleApplication.js +2 -0
  99. package/dist/db/generated/prisma/models/RolloutTarget.js +2 -0
  100. package/dist/db/generated/prisma/models/SecurityExportOutbox.js +2 -0
  101. package/dist/db/generated/prisma/models/SecurityFusionAction.js +2 -0
  102. package/dist/db/generated/prisma/models/SecurityFusionActionApproval.js +2 -0
  103. package/dist/db/generated/prisma/models/SecurityFusionCase.js +2 -0
  104. package/dist/db/generated/prisma/models/SecurityFusionCaseActivity.js +2 -0
  105. package/dist/db/generated/prisma/models/SecurityFusionCaseEntity.js +2 -0
  106. package/dist/db/generated/prisma/models/SecurityFusionCaseEvent.js +2 -0
  107. package/dist/db/generated/prisma/models/SecurityFusionCaseRelationship.js +2 -0
  108. package/dist/db/generated/prisma/models/SecurityFusionCollector.js +2 -0
  109. package/dist/db/generated/prisma/models/SecurityFusionCommandOutbox.js +2 -0
  110. package/dist/db/generated/prisma/models/SecurityFusionDeletionReceipt.js +2 -0
  111. package/dist/db/generated/prisma/models/SecurityFusionDelivery.js +2 -0
  112. package/dist/db/generated/prisma/models/SecurityFusionEntity.js +2 -0
  113. package/dist/db/generated/prisma/models/SecurityFusionEntityAlias.js +2 -0
  114. package/dist/db/generated/prisma/models/SecurityFusionEvent.js +2 -0
  115. package/dist/db/generated/prisma/models/SecurityFusionEventEntity.js +2 -0
  116. package/dist/db/generated/prisma/models/SecurityFusionEvidence.js +2 -0
  117. package/dist/db/generated/prisma/models/SecurityFusionInvestigation.js +2 -0
  118. package/dist/db/generated/prisma/models/SecurityFusionNonce.js +2 -0
  119. package/dist/db/generated/prisma/models/SecurityFusionReceiptChainHead.js +2 -0
  120. package/dist/db/generated/prisma/models/SecurityFusionRuleVersion.js +2 -0
  121. package/dist/db/generated/prisma/models/SecurityFusionSourceState.js +2 -0
  122. package/dist/db/generated/prisma/models/SecurityServiceState.js +2 -0
  123. package/dist/db/generated/prisma/models/SharedResourceFence.js +2 -0
  124. package/dist/db/generated/prisma/models/SharedResourceLease.js +2 -0
  125. package/dist/db/generated/prisma/models/SharedResourceLeaseResource.js +2 -0
  126. package/dist/db/generated/prisma/models/SkillInstallation.js +2 -0
  127. package/dist/db/generated/prisma/models/SkillRegistryEntry.js +2 -0
  128. package/dist/db/generated/prisma/models/SkillTag.js +2 -0
  129. package/dist/db/generated/prisma/models/SkillVersion.js +2 -0
  130. package/dist/db/generated/prisma/models/TeamApprovalDecision.js +2 -0
  131. package/dist/db/generated/prisma/models/User.js +2 -0
  132. package/dist/db/generated/prisma/models/WebhookDelivery.js +2 -0
  133. package/dist/db/generated/prisma/models/WebhookEndpoint.js +2 -0
  134. package/dist/db/generated/prisma/models.js +2 -0
  135. package/dist/db/index.js +3 -0
  136. package/dist/db/read-retry.js +35 -0
  137. package/dist/db/seed.js +348 -0
  138. package/dist/index.js +162 -0
  139. package/dist/middleware/auth.js +361 -0
  140. package/dist/middleware/body-limit.js +14 -0
  141. package/dist/middleware/correlation-id.js +13 -0
  142. package/dist/middleware/error-handler.js +28 -0
  143. package/dist/middleware/iam-token.js +21 -0
  144. package/dist/middleware/rate-limit.js +78 -0
  145. package/dist/middleware/request-logger.js +85 -0
  146. package/dist/middleware/security-headers.js +23 -0
  147. package/dist/middleware/tracing.js +120 -0
  148. package/dist/middleware/user-status.js +33 -0
  149. package/dist/openapi/spec.js +2656 -0
  150. package/dist/queue/redis.js +20 -0
  151. package/dist/queue/requeue.js +27 -0
  152. package/dist/queue/workers.js +342 -0
  153. package/dist/routes/ai-schedule.js +73 -0
  154. package/dist/routes/alerts.js +79 -0
  155. package/dist/routes/anomalies.js +101 -0
  156. package/dist/routes/ardent.js +82 -0
  157. package/dist/routes/assistant.js +417 -0
  158. package/dist/routes/audit.js +138 -0
  159. package/dist/routes/codex.js +65 -0
  160. package/dist/routes/commands.js +226 -0
  161. package/dist/routes/credentials.js +58 -0
  162. package/dist/routes/cron.js +292 -0
  163. package/dist/routes/dependencies.js +49 -0
  164. package/dist/routes/deploy.js +18 -0
  165. package/dist/routes/discovery.js +168 -0
  166. package/dist/routes/docs.js +24 -0
  167. package/dist/routes/drift.js +126 -0
  168. package/dist/routes/events-stream.js +87 -0
  169. package/dist/routes/files.js +214 -0
  170. package/dist/routes/fleet-checks.js +21 -0
  171. package/dist/routes/health.js +133 -0
  172. package/dist/routes/heartbeat.js +545 -0
  173. package/dist/routes/hermes-registry.js +278 -0
  174. package/dist/routes/hermes.js +61 -0
  175. package/dist/routes/library.js +402 -0
  176. package/dist/routes/logs.js +172 -0
  177. package/dist/routes/managed-cron.js +23 -0
  178. package/dist/routes/mcp.js +39 -0
  179. package/dist/routes/metrics.js +187 -0
  180. package/dist/routes/nodes.js +951 -0
  181. package/dist/routes/notifications.js +130 -0
  182. package/dist/routes/ops.js +258 -0
  183. package/dist/routes/orchestra.js +430 -0
  184. package/dist/routes/predictions.js +23 -0
  185. package/dist/routes/profiling.js +68 -0
  186. package/dist/routes/quick-command.js +72 -0
  187. package/dist/routes/remediation.js +111 -0
  188. package/dist/routes/resource-leases.js +297 -0
  189. package/dist/routes/roles.js +370 -0
  190. package/dist/routes/rollout.js +124 -0
  191. package/dist/routes/scan.js +116 -0
  192. package/dist/routes/security-backup.js +58 -0
  193. package/dist/routes/security-fusion.js +605 -0
  194. package/dist/routes/security-integration.js +71 -0
  195. package/dist/routes/self-update.js +129 -0
  196. package/dist/routes/shared-resource-leases.js +78 -0
  197. package/dist/routes/skills.js +485 -0
  198. package/dist/routes/stats.js +96 -0
  199. package/dist/routes/team-approvals.js +23 -0
  200. package/dist/routes/terminal-ticket.js +15 -0
  201. package/dist/routes/terminal.js +17 -0
  202. package/dist/routes/topology.js +37 -0
  203. package/dist/routes/users.js +219 -0
  204. package/dist/routes/webhooks.js +137 -0
  205. package/dist/schemas/command-reconciliation.js +14 -0
  206. package/dist/schemas/commands.js +51 -0
  207. package/dist/schemas/cron.js +33 -0
  208. package/dist/schemas/deploy.js +20 -0
  209. package/dist/schemas/files.js +14 -0
  210. package/dist/schemas/hermes.js +69 -0
  211. package/dist/schemas/index.js +11 -0
  212. package/dist/schemas/library.js +48 -0
  213. package/dist/schemas/logs.js +35 -0
  214. package/dist/schemas/managed-cron.js +127 -0
  215. package/dist/schemas/node-responses.js +87 -0
  216. package/dist/schemas/node-session-receipt.js +84 -0
  217. package/dist/schemas/nodes.js +36 -0
  218. package/dist/schemas/orchestra.js +118 -0
  219. package/dist/schemas/resource-participant-reconciliation.js +99 -0
  220. package/dist/schemas/roles.js +54 -0
  221. package/dist/schemas/shared-resource-lease.js +132 -0
  222. package/dist/schemas/skills.js +44 -0
  223. package/dist/schemas/users.js +28 -0
  224. package/dist/scripts/run-disposable-recovery-exercise.js +39 -0
  225. package/dist/sdk/index.js +2 -0
  226. package/dist/sdk/node-api-client.js +474 -0
  227. package/dist/security/approval-assertion.js +66 -0
  228. package/dist/security/command-completion-registry.js +35 -0
  229. package/dist/security/command-request.js +12 -0
  230. package/dist/security/command-result-signature.js +34 -0
  231. package/dist/security/managed-http.js +45 -0
  232. package/dist/security/native-completion-registry.js +44 -0
  233. package/dist/server.js +178 -0
  234. package/dist/services/admission-fence.js +71 -0
  235. package/dist/services/ai-assistant-service.js +2523 -0
  236. package/dist/services/ai-correlation-service.js +225 -0
  237. package/dist/services/ai-export-service.js +141 -0
  238. package/dist/services/ai-insights-service.js +219 -0
  239. package/dist/services/ai-schedule-service.js +145 -0
  240. package/dist/services/ai-scratchpad-service.js +222 -0
  241. package/dist/services/ai-tools.js +1343 -0
  242. package/dist/services/alert-service.js +313 -0
  243. package/dist/services/anomaly-detector.js +419 -0
  244. package/dist/services/api-local-work.js +79 -0
  245. package/dist/services/application-probes.js +309 -0
  246. package/dist/services/ardent-scope-enrollment.js +123 -0
  247. package/dist/services/ardent-service.js +86 -0
  248. package/dist/services/audit-service.js +308 -0
  249. package/dist/services/codex-service.js +209 -0
  250. package/dist/services/command-lifecycle.js +60 -0
  251. package/dist/services/command-resource-leases.js +305 -0
  252. package/dist/services/control-host-node.js +242 -0
  253. package/dist/services/credential-rotation-service.js +294 -0
  254. package/dist/services/dependency-service.js +145 -0
  255. package/dist/services/disposable-recovery-runner.js +595 -0
  256. package/dist/services/docker-group-health.js +121 -0
  257. package/dist/services/engineering-assurance.js +492 -0
  258. package/dist/services/engineering-evidence-ledger.js +94 -0
  259. package/dist/services/engineering-metric-evidence.js +26 -0
  260. package/dist/services/event-bus.js +227 -0
  261. package/dist/services/fleet-check-log.js +102 -0
  262. package/dist/services/forecast-service.js +154 -0
  263. package/dist/services/health-retention.js +175 -0
  264. package/dist/services/heartbeat-stream.js +63 -0
  265. package/dist/services/hermes-catalog-import.js +62 -0
  266. package/dist/services/hermes-cli-parser.js +671 -0
  267. package/dist/services/hermes-core-rollout.js +90 -0
  268. package/dist/services/hermes-dashboard-endpoint.js +13 -0
  269. package/dist/services/hermes-plugin-source.js +149 -0
  270. package/dist/services/hermes-registry-service.js +258 -0
  271. package/dist/services/hermes-service.js +566 -0
  272. package/dist/services/hivemem-client.js +85 -0
  273. package/dist/services/host-local-writer.js +82 -0
  274. package/dist/services/incident-evidence.js +110 -0
  275. package/dist/services/incident-lessons.js +186 -0
  276. package/dist/services/llm-providers/anthropic.js +143 -0
  277. package/dist/services/llm-providers/index.js +7 -0
  278. package/dist/services/llm-providers/llama-cpp.js +237 -0
  279. package/dist/services/llm-providers/ollama.js +292 -0
  280. package/dist/services/llm-providers/openai-compatible.js +200 -0
  281. package/dist/services/llm-providers/provider-error.js +36 -0
  282. package/dist/services/llm-providers/registry.js +45 -0
  283. package/dist/services/llm-providers/types.js +2 -0
  284. package/dist/services/log-emitter.js +19 -0
  285. package/dist/services/log-retention.js +66 -0
  286. package/dist/services/maintenance-service.js +29 -0
  287. package/dist/services/managed-cron.js +493 -0
  288. package/dist/services/managed-ssh-admission.js +68 -0
  289. package/dist/services/monitor-pulse.js +10 -0
  290. package/dist/services/node-issue-monitor.js +71 -0
  291. package/dist/services/node-service.js +35 -0
  292. package/dist/services/notification-retention.js +51 -0
  293. package/dist/services/notification-service.js +94 -0
  294. package/dist/services/operational-observations.js +112 -0
  295. package/dist/services/operations-config.js +67 -0
  296. package/dist/services/ops-service.js +491 -0
  297. package/dist/services/orchestra-extras-service.js +370 -0
  298. package/dist/services/orchestra-resources-service.js +530 -0
  299. package/dist/services/os-scheduler-writer.js +73 -0
  300. package/dist/services/password-service.js +22 -0
  301. package/dist/services/prediction-service.js +284 -0
  302. package/dist/services/recovery-exercises.js +682 -0
  303. package/dist/services/remediation-service.js +325 -0
  304. package/dist/services/resource-enrollment.js +433 -0
  305. package/dist/services/resource-node-lock.js +18 -0
  306. package/dist/services/resource-participant-work.js +302 -0
  307. package/dist/services/role-service.js +388 -0
  308. package/dist/services/rollout-service.js +440 -0
  309. package/dist/services/security-export.js +185 -0
  310. package/dist/services/security-fusion-actions.js +22 -0
  311. package/dist/services/security-fusion-approvals.js +83 -0
  312. package/dist/services/security-fusion-evidence-integrity.js +144 -0
  313. package/dist/services/security-fusion-ingest.js +101 -0
  314. package/dist/services/security-fusion-outbox.js +185 -0
  315. package/dist/services/security-fusion-receipt-chain.js +134 -0
  316. package/dist/services/security-fusion-service.js +751 -0
  317. package/dist/services/security-fusion-verification.js +50 -0
  318. package/dist/services/self-update-service.js +200 -0
  319. package/dist/services/shared-resource-leases.js +413 -0
  320. package/dist/services/skill-publication-bundle.js +90 -0
  321. package/dist/services/skill-publication-settlement.js +36 -0
  322. package/dist/services/skill-publication-state.js +40 -0
  323. package/dist/services/skill-registry-service.js +327 -0
  324. package/dist/services/skill-service.js +148 -0
  325. package/dist/services/ssh-exec-service.js +139 -0
  326. package/dist/services/ssh-host-verification.js +27 -0
  327. package/dist/services/tailscale-node-key-service.js +75 -0
  328. package/dist/services/team-approvals.js +118 -0
  329. package/dist/services/topology-service.js +138 -0
  330. package/dist/services/transfer-authority-snapshot.js +151 -0
  331. package/dist/services/webhook-credential-migration.js +97 -0
  332. package/dist/services/webhook-security.js +242 -0
  333. package/dist/services/webhook-service.js +494 -0
  334. package/dist/types/auth.js +3 -0
  335. package/dist/types/index.js +3 -0
  336. package/dist/types/library.js +3 -0
  337. package/dist/types/notifications.js +3 -0
  338. package/dist/types/roles.js +3 -0
  339. package/dist/types/shared-contracts.js +3 -0
  340. package/dist/utils/env.js +141 -0
  341. package/dist/utils/heartbeat-peer.js +19 -0
  342. package/dist/utils/idempotency.js +45 -0
  343. package/dist/utils/ip.js +25 -0
  344. package/dist/utils/json-fields.js +94 -0
  345. package/dist/utils/local-tls.js +39 -0
  346. package/dist/utils/magnus-home.js +15 -0
  347. package/dist/utils/node-url.js +59 -0
  348. package/dist/utils/redact.js +26 -0
  349. package/dist/utils/safe-equal.js +13 -0
  350. package/dist/utils/sentinel-nodes.js +16 -0
  351. package/dist/utils/validate.js +41 -0
  352. package/package.json +119 -4
  353. package/prisma/migrations/20261031000000_baseline/migration.sql +2935 -0
  354. package/prisma/migrations/migration_lock.toml +3 -0
  355. package/prisma/schema.prisma +2185 -0
  356. package/prisma.config.ts +14 -0
@@ -0,0 +1,2523 @@
1
+ /**
2
+ * ai-assistant-service.ts — AI Fleet Operations Assistant powered by Ollama.
3
+ *
4
+ * Connects to an Ollama instance (`OLLAMA_URL`, default `http://127.0.0.1:11434`)
5
+ * using `OLLAMA_MODEL`. Provides fleet-aware AI analysis with tool calling,
6
+ * memory persistence, and heartbeat-driven proactive monitoring.
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import { resolve, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { nanoid } from 'nanoid';
12
+ import { z } from 'zod';
13
+ import { getDb } from '../db/index.js';
14
+ import { executeAiTool, listAvailableTools } from './ai-tools.js';
15
+ import { logAuditEvent } from './audit-service.js';
16
+ import { autoStoreToolResult, listScratchpadEntries } from './ai-scratchpad-service.js';
17
+ import { getLlmProvider, getLlmProviderStatuses } from './llm-providers/index.js';
18
+ import { getOllamaThinkingCapability } from './llm-providers/ollama.js';
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ // ── Configuration ──
21
+ const OLLAMA_BASE_URL = process.env['OLLAMA_URL'] ?? 'http://127.0.0.1:11434';
22
+ const OLLAMA_MODEL = process.env['OLLAMA_MODEL'] ?? 'gemma4:31b-it-q8_0';
23
+ const OPENAI_MODEL = process.env['OPENAI_MODEL'] ?? 'gpt-4.1-mini';
24
+ const OPENROUTER_MODEL = process.env['OPENROUTER_MODEL'] ?? 'openai/gpt-4.1-mini';
25
+ const ANTHROPIC_MODEL = process.env['ANTHROPIC_MODEL'] ?? 'claude-3-5-haiku-latest';
26
+ const AI_TEMPERATURE = Number(process.env['AI_TEMPERATURE'] ?? '0.3');
27
+ const AI_MAX_TOKENS = Number(process.env['AI_MAX_TOKENS'] ?? '4096');
28
+ const AI_MAX_CONTEXT_TOKENS = Number(process.env['AI_MAX_CONTEXT_TOKENS'] ?? '100000');
29
+ const DEFAULT_MAX_TOOL_ROUNDS = Number(process.env['AI_MAX_TOOL_ROUNDS'] ?? '100');
30
+ const MODEL_ROUTE_DEFINITIONS = {
31
+ chat: {
32
+ purpose: 'Interactive Governor chat and general user requests.',
33
+ reason: 'Selected when the assistant request type is chat, or when an unknown task type needs the safest general fallback.',
34
+ },
35
+ heartbeat: {
36
+ purpose: 'Automated fleet heartbeat analysis and proactive health summaries.',
37
+ reason: 'Selected by the scheduled AI heartbeat flow so periodic monitoring can use lower temperature and fewer tool rounds.',
38
+ },
39
+ summarization: {
40
+ purpose: 'Conversation compaction and short memory summaries.',
41
+ reason: 'Selected by conversation summarization code paths; tools and long reasoning are intentionally minimized.',
42
+ },
43
+ code_generation: {
44
+ purpose: 'Code-writing, config generation, and implementation-heavy tasks.',
45
+ reason: 'Selected by callers that explicitly mark work as code_generation so they can get deeper reasoning and a larger token budget.',
46
+ },
47
+ analysis: {
48
+ purpose: 'Background investigations, delegated agent work, and non-chat analysis.',
49
+ reason: 'Selected for analysis conversations and callers that do not fit a more specific operational route.',
50
+ },
51
+ dangerous: {
52
+ purpose: 'Safety-critical or destructive operations that need stronger review.',
53
+ reason: 'Legacy trait route for dangerous-tool intent, destructive actions, auth/security-sensitive operations, or commands requiring extra care.',
54
+ },
55
+ internet_data: {
56
+ purpose: 'External, web, or untrusted content processing.',
57
+ reason: 'Legacy trait route for internet-sourced data where provenance, prompt-injection risk, and synthesis quality matter.',
58
+ },
59
+ hard: {
60
+ purpose: 'Maximum-reasoning tasks and difficult planning/debugging.',
61
+ reason: 'Legacy trait route for work that should prefer the strongest configured reasoning provider before falling back.',
62
+ },
63
+ };
64
+ function getDefaultFallbacks(taskType) {
65
+ if (taskType === 'summarization')
66
+ return [];
67
+ if (taskType === 'heartbeat') {
68
+ return [
69
+ { provider: 'openrouter', model: OPENROUTER_MODEL },
70
+ { provider: 'openai', model: OPENAI_MODEL },
71
+ ];
72
+ }
73
+ if (taskType === 'dangerous') {
74
+ return [
75
+ { provider: 'anthropic', model: ANTHROPIC_MODEL },
76
+ { provider: 'openrouter', model: OPENROUTER_MODEL },
77
+ ];
78
+ }
79
+ if (taskType === 'internet_data') {
80
+ return [
81
+ { provider: 'openai', model: OPENAI_MODEL },
82
+ { provider: 'anthropic', model: ANTHROPIC_MODEL },
83
+ ];
84
+ }
85
+ if (taskType === 'hard') {
86
+ return [
87
+ { provider: 'openai', model: OPENAI_MODEL },
88
+ { provider: 'openrouter', model: OPENROUTER_MODEL },
89
+ ];
90
+ }
91
+ return [
92
+ { provider: 'openrouter', model: OPENROUTER_MODEL },
93
+ { provider: 'openai', model: OPENAI_MODEL },
94
+ { provider: 'anthropic', model: ANTHROPIC_MODEL },
95
+ ];
96
+ }
97
+ function applyRouteMetadata(profile, routeSource) {
98
+ const definition = MODEL_ROUTE_DEFINITIONS[profile.taskType];
99
+ return {
100
+ ...profile,
101
+ routePurpose: profile.routePurpose ?? definition?.purpose ?? 'Custom task route.',
102
+ routeReason: profile.routeReason ?? definition?.reason ?? 'Selected only when a caller explicitly requests this task type; otherwise unknown task types fall back to chat.',
103
+ routeSource,
104
+ };
105
+ }
106
+ function normalizeModelProfile(profile, routeSource = profile.routeSource ?? 'saved') {
107
+ return applyRouteMetadata({
108
+ ...profile,
109
+ provider: profile.provider ?? 'ollama',
110
+ model: profile.model ?? '',
111
+ fallbacks: profile.fallbacks ?? getDefaultFallbacks(profile.taskType),
112
+ }, routeSource);
113
+ }
114
+ /** Default model profiles — can be overridden via settings */
115
+ const DEFAULT_MODEL_PROFILES = [
116
+ normalizeModelProfile({ taskType: 'chat', provider: 'ollama', model: OLLAMA_MODEL, temperature: AI_TEMPERATURE, maxTokens: AI_MAX_TOKENS, maxToolRounds: DEFAULT_MAX_TOOL_ROUNDS, thinkingMode: 'standard' }, 'built-in'),
117
+ normalizeModelProfile({ taskType: 'heartbeat', provider: 'ollama', model: OLLAMA_MODEL, temperature: 0.2, maxTokens: AI_MAX_TOKENS, maxToolRounds: 2, thinkingMode: 'standard' }, 'built-in'),
118
+ normalizeModelProfile({ taskType: 'summarization', provider: 'ollama', model: OLLAMA_MODEL, temperature: 0.1, maxTokens: 2048, maxToolRounds: 1, thinkingMode: 'off', systemPromptSuffix: 'Be extremely concise.' }, 'built-in'),
119
+ normalizeModelProfile({ taskType: 'code_generation', provider: 'ollama', model: OLLAMA_MODEL, temperature: 0.1, maxTokens: AI_MAX_TOKENS * 2, maxToolRounds: 2, thinkingMode: 'deep', systemPromptSuffix: 'Focus on generating clean, working code.' }, 'built-in'),
120
+ normalizeModelProfile({ taskType: 'analysis', provider: 'ollama', model: OLLAMA_MODEL, temperature: 0.2, maxTokens: AI_MAX_TOKENS, maxToolRounds: DEFAULT_MAX_TOOL_ROUNDS, thinkingMode: 'standard' }, 'built-in'),
121
+ normalizeModelProfile({ taskType: 'dangerous', provider: 'openai', model: OPENAI_MODEL, temperature: 0.1, maxTokens: AI_MAX_TOKENS, maxToolRounds: DEFAULT_MAX_TOOL_ROUNDS, thinkingMode: 'deep' }, 'built-in'),
122
+ normalizeModelProfile({ taskType: 'internet_data', provider: 'openrouter', model: OPENROUTER_MODEL, temperature: 0.2, maxTokens: AI_MAX_TOKENS, maxToolRounds: 2, thinkingMode: 'standard' }, 'built-in'),
123
+ normalizeModelProfile({ taskType: 'hard', provider: 'anthropic', model: ANTHROPIC_MODEL, temperature: 0.2, maxTokens: AI_MAX_TOKENS * 2, maxToolRounds: DEFAULT_MAX_TOOL_ROUNDS, thinkingMode: 'deep' }, 'built-in'),
124
+ ];
125
+ function mergeModelProfiles(saved = []) {
126
+ const merged = new Map(DEFAULT_MODEL_PROFILES.map((profile) => [profile.taskType, normalizeModelProfile(profile, 'built-in')]));
127
+ for (const profile of saved) {
128
+ if (!profile?.taskType)
129
+ continue;
130
+ const existing = merged.get(profile.taskType);
131
+ merged.set(profile.taskType, normalizeModelProfile({ ...existing, ...profile }, existing ? 'saved' : 'custom'));
132
+ }
133
+ return Array.from(merged.values());
134
+ }
135
+ async function loadModelProfiles() {
136
+ const db = getDb();
137
+ const entry = await db.aiMemory.findUnique({ where: { key: 'ai_model_routes' } });
138
+ if (entry) {
139
+ try {
140
+ const saved = JSON.parse(entry.content);
141
+ if (Array.isArray(saved))
142
+ return mergeModelProfiles(saved);
143
+ }
144
+ catch { /* use defaults */ }
145
+ }
146
+ return mergeModelProfiles();
147
+ }
148
+ /**
149
+ * Select the model profile for a given task type.
150
+ * Falls back to the 'chat' profile if the task type isn't configured.
151
+ */
152
+ export async function selectModel(taskType) {
153
+ const profiles = await loadModelProfiles();
154
+ return normalizeModelProfile(profiles.find((p) => p.taskType === taskType)
155
+ ?? profiles.find((p) => p.taskType === 'chat')
156
+ ?? DEFAULT_MODEL_PROFILES[0]);
157
+ }
158
+ export async function getModelProfiles() {
159
+ return loadModelProfiles();
160
+ }
161
+ export async function updateModelProfiles(profiles) {
162
+ const db = getDb();
163
+ const content = JSON.stringify(profiles.map((profile) => {
164
+ const { routePurpose, routeReason, routeSource, ...persisted } = normalizeModelProfile(profile);
165
+ void routePurpose;
166
+ void routeReason;
167
+ void routeSource;
168
+ return persisted;
169
+ }));
170
+ const existing = await db.aiMemory.findUnique({ where: { key: 'ai_model_routes' } });
171
+ if (existing) {
172
+ await db.aiMemory.update({ where: { key: 'ai_model_routes' }, data: { content } });
173
+ }
174
+ else {
175
+ await db.aiMemory.create({
176
+ data: {
177
+ id: nanoid(),
178
+ key: 'ai_model_routes',
179
+ category: 'configuration',
180
+ content,
181
+ source: 'user',
182
+ relevance: 0.0,
183
+ },
184
+ });
185
+ }
186
+ }
187
+ // ── Context Window Management ──
188
+ /** Approximate token count using character-based heuristic. */
189
+ export function estimateTokens(text) {
190
+ return Math.ceil(text.length / 4);
191
+ }
192
+ /**
193
+ * Call the AI to summarize a conversation's messages.
194
+ * Stores the summary on the AiConversation record.
195
+ */
196
+ export async function summarizeConversation(conversationId) {
197
+ const db = getDb();
198
+ const messages = await db.aiMessage.findMany({
199
+ where: { conversationId, role: { in: ['user', 'assistant'] } },
200
+ orderBy: { createdAt: 'asc' },
201
+ select: { role: true, content: true },
202
+ });
203
+ if (messages.length < 5)
204
+ return null; // Too few messages to summarize
205
+ const transcript = messages.map((m) => `${m.role}: ${m.content.slice(0, 500)}`).join('\n');
206
+ const summaryPrompt = `Summarize this conversation in 3 concise sentences. Focus on the key topics, decisions, and outcomes:\n\n${transcript.slice(0, 6000)}`;
207
+ try {
208
+ const profile = await selectModel('summarization');
209
+ const result = await callLlm([
210
+ { role: 'system', content: 'You are a concise summarizer. Respond with ONLY the summary, no preamble.' },
211
+ { role: 'user', content: summaryPrompt },
212
+ ], {
213
+ provider: profile.provider,
214
+ model: profile.model,
215
+ fallbacks: profile.fallbacks,
216
+ thinkingMode: 'off',
217
+ temperature: profile.temperature,
218
+ maxTokens: profile.maxTokens,
219
+ });
220
+ const summary = result.content.slice(0, 500);
221
+ await db.aiConversation.update({
222
+ where: { id: conversationId },
223
+ data: { summary },
224
+ });
225
+ return summary;
226
+ }
227
+ catch {
228
+ return null;
229
+ }
230
+ }
231
+ // ── Tool Approval ──
232
+ /** Tools that require human approval before execution. */
233
+ export const DANGEROUS_TOOLS = new Set(['execCommand', 'writeFile', 'runSkill', 'createSkillDraft', 'generateRunbook']);
234
+ /**
235
+ * Check if a tool requires human approval based on the configured policy.
236
+ */
237
+ export async function getToolApprovalPolicy() {
238
+ const db = getDb();
239
+ const entry = await db.aiMemory.findUnique({ where: { key: 'tool_approval_policy' } });
240
+ if (entry) {
241
+ try {
242
+ return JSON.parse(entry.content);
243
+ }
244
+ catch { /* use defaults */ }
245
+ }
246
+ return {
247
+ alwaysAllow: ['getFleetStatus', 'getCodexFleetStatus', 'getNodeHealth', 'getHealthHistory', 'getWazuhFleetStatus', 'getActiveAnomalies',
248
+ 'getNodeAnomalies', 'getActiveAlerts', 'getRecentCommands', 'getNodeLogs',
249
+ 'readFile', 'listDirectory', 'recallMemories', 'searchNodes', 'getCronJobs',
250
+ 'getSkills', 'getDashboardStats', 'getAuditLog', 'getNodeDetails',
251
+ 'correlateAnomalies', 'getAnomalyTimeline', 'listSkillActions', 'listScratchpadVariables',
252
+ 'saveResult', 'transformData', 'compareSnapshots'],
253
+ requireApproval: ['execCommand', 'writeFile', 'runSkill', 'createSkillDraft', 'forgetMemory'],
254
+ alwaysDeny: [],
255
+ };
256
+ }
257
+ // ── Load AI context documents ──
258
+ function loadContextDoc(filename) {
259
+ try {
260
+ return readFileSync(resolve(__dirname, '../ai', filename), 'utf-8');
261
+ }
262
+ catch {
263
+ return `[${filename} not found]`;
264
+ }
265
+ }
266
+ const IDENTITY = loadContextDoc('IDENTITY.md');
267
+ const USER_DOC = loadContextDoc('USER.md');
268
+ const TOOLS_DOC = loadContextDoc('TOOLS.md');
269
+ const MEMORY_DOC = loadContextDoc('MEMORY.md');
270
+ const HEARTBEAT_DOC_DEFAULT = loadContextDoc('HEARTBEAT.md');
271
+ // ── System prompt construction ──
272
+ function buildSystemPrompt(context) {
273
+ const parts = [
274
+ IDENTITY,
275
+ '\n---\n',
276
+ USER_DOC,
277
+ '\n---\n',
278
+ TOOLS_DOC,
279
+ '\n---\n',
280
+ MEMORY_DOC,
281
+ ];
282
+ if (context.isHeartbeat) {
283
+ parts.push('\n---\n', context.heartbeatDoc ?? HEARTBEAT_DOC_DEFAULT);
284
+ }
285
+ if (context.userContext) {
286
+ parts.push('\n\n## Current User\n\n', context.userContext);
287
+ }
288
+ if (context.controllerContext) {
289
+ parts.push('\n\n## Active Controller Context\n\n', context.controllerContext);
290
+ }
291
+ if (context.memories) {
292
+ parts.push('\n\n## Current Memories\n\n', context.memories);
293
+ }
294
+ if (context.fleetStatus) {
295
+ parts.push('\n\n## Current Fleet Status\n\n', context.fleetStatus);
296
+ }
297
+ if (context.anomalies) {
298
+ parts.push('\n\n## Active Anomalies\n\n', context.anomalies);
299
+ }
300
+ if (context.recentHistory) {
301
+ parts.push('\n\n## Recent Conversation History\n\n', context.recentHistory);
302
+ }
303
+ if (context.replMode) {
304
+ parts.push('\n\n## REPL Mode\n\n', 'REPL mode is active. Reuse scratchpad variables when comparing, transforming, or summarizing data. Prefer `saveResult`, `transformData`, and `compareSnapshots` over re-fetching identical data when possible.');
305
+ }
306
+ if (context.scratchpadContext) {
307
+ parts.push('\n\n## Scratchpad Variables\n\n', context.scratchpadContext);
308
+ }
309
+ return parts.join('');
310
+ }
311
+ // ── LLM tool definitions ──
312
+ function getOllamaToolDefinitions() {
313
+ return [
314
+ {
315
+ type: 'function',
316
+ function: {
317
+ name: 'getFleetStatus',
318
+ description: 'Returns the current status of all nodes plus cached Codex CLI version, update, and ChatGPT OAuth posture',
319
+ parameters: { type: 'object', properties: {}, required: [] },
320
+ },
321
+ },
322
+ {
323
+ type: 'function',
324
+ function: {
325
+ name: 'getCodexFleetStatus',
326
+ description: 'Returns five-minute cached Codex installation, CLI version, update-availability, and ChatGPT OAuth posture',
327
+ parameters: { type: 'object', properties: {}, required: [] },
328
+ },
329
+ },
330
+ {
331
+ type: 'function',
332
+ function: {
333
+ name: 'getNodeHealth',
334
+ description: 'Returns detailed health data for a specific node including CPU, memory, disk, network, and services',
335
+ parameters: {
336
+ type: 'object',
337
+ properties: { nodeId: { type: 'string', description: 'The unique identifier of the node' } },
338
+ required: ['nodeId'],
339
+ },
340
+ },
341
+ },
342
+ {
343
+ type: 'function',
344
+ function: {
345
+ name: 'getHealthHistory',
346
+ description: 'Returns historical health snapshots for a node for trend analysis',
347
+ parameters: {
348
+ type: 'object',
349
+ properties: {
350
+ nodeId: { type: 'string', description: 'The unique identifier of the node' },
351
+ hours: { type: 'number', description: 'Number of hours to look back (default: 24)' },
352
+ },
353
+ required: ['nodeId'],
354
+ },
355
+ },
356
+ },
357
+ {
358
+ type: 'function',
359
+ function: {
360
+ name: 'getWazuhFleetStatus',
361
+ description: 'Returns Wazuh endpoint-security agent posture across the fleet, including installation, service state, enabled state, version, manager addresses, and sample time',
362
+ parameters: { type: 'object', properties: {}, required: [] },
363
+ },
364
+ },
365
+ {
366
+ type: 'function',
367
+ function: {
368
+ name: 'getActiveAnomalies',
369
+ description: 'Returns all unresolved anomaly events fleet-wide with severity and metrics',
370
+ parameters: { type: 'object', properties: {}, required: [] },
371
+ },
372
+ },
373
+ {
374
+ type: 'function',
375
+ function: {
376
+ name: 'getNodeAnomalies',
377
+ description: 'Returns anomaly events for a specific node with summary statistics',
378
+ parameters: {
379
+ type: 'object',
380
+ properties: { nodeId: { type: 'string', description: 'The unique identifier of the node' } },
381
+ required: ['nodeId'],
382
+ },
383
+ },
384
+ },
385
+ {
386
+ type: 'function',
387
+ function: {
388
+ name: 'getActiveAlerts',
389
+ description: 'Returns all active alert violations including rule name, metric, threshold, and severity',
390
+ parameters: { type: 'object', properties: {}, required: [] },
391
+ },
392
+ },
393
+ {
394
+ type: 'function',
395
+ function: {
396
+ name: 'getRecentCommands',
397
+ description: 'Returns recently executed commands across the fleet with status and results',
398
+ parameters: {
399
+ type: 'object',
400
+ properties: { hours: { type: 'number', description: 'Number of hours to look back (default: 1)' } },
401
+ required: [],
402
+ },
403
+ },
404
+ },
405
+ {
406
+ type: 'function',
407
+ function: {
408
+ name: 'getNodeLogs',
409
+ description: 'Returns recent log entries for a specific node',
410
+ parameters: {
411
+ type: 'object',
412
+ properties: {
413
+ nodeId: { type: 'string', description: 'The unique identifier of the node' },
414
+ lines: { type: 'number', description: 'Number of log lines to return (max: 200)' },
415
+ },
416
+ required: ['nodeId'],
417
+ },
418
+ },
419
+ },
420
+ {
421
+ type: 'function',
422
+ function: {
423
+ name: 'readFile',
424
+ description: 'Read the contents of a file on the control node (must be within workspace, under 1MB)',
425
+ parameters: {
426
+ type: 'object',
427
+ properties: { path: { type: 'string', description: 'File path relative to workspace root' } },
428
+ required: ['path'],
429
+ },
430
+ },
431
+ },
432
+ {
433
+ type: 'function',
434
+ function: {
435
+ name: 'writeFile',
436
+ description: 'Write content to a file (restricted to allowed directories)',
437
+ parameters: {
438
+ type: 'object',
439
+ properties: {
440
+ path: { type: 'string', description: 'File path relative to workspace root' },
441
+ content: { type: 'string', description: 'Content to write to the file' },
442
+ },
443
+ required: ['path', 'content'],
444
+ },
445
+ },
446
+ },
447
+ {
448
+ type: 'function',
449
+ function: {
450
+ name: 'listDirectory',
451
+ description: 'List contents of a directory with file sizes and modification times',
452
+ parameters: {
453
+ type: 'object',
454
+ properties: { path: { type: 'string', description: 'Directory path relative to workspace root (default: ".")' } },
455
+ required: [],
456
+ },
457
+ },
458
+ },
459
+ {
460
+ type: 'function',
461
+ function: {
462
+ name: 'storeMemory',
463
+ description: 'Store a memory in HiveMem for future reference (persists across conversations, shared with other agents)',
464
+ parameters: {
465
+ type: 'object',
466
+ properties: {
467
+ key: { type: 'string', description: 'Short label/key for the memory' },
468
+ content: { type: 'string', description: 'Content to store' },
469
+ category: { type: 'string', description: 'Category tag: general, node, pattern, or preference' },
470
+ },
471
+ required: ['key', 'content'],
472
+ },
473
+ },
474
+ },
475
+ {
476
+ type: 'function',
477
+ function: {
478
+ name: 'recallMemories',
479
+ description: 'Search HiveMem for stored memories using semantic search, optionally filtered by category',
480
+ parameters: {
481
+ type: 'object',
482
+ properties: {
483
+ category: { type: 'string', description: 'Filter by category tag' },
484
+ limit: { type: 'number', description: 'Maximum number of memories to return (max: 50)' },
485
+ },
486
+ required: [],
487
+ },
488
+ },
489
+ },
490
+ {
491
+ type: 'function',
492
+ function: {
493
+ name: 'forgetMemory',
494
+ description: 'Remove a specific memory from HiveMem by its ID',
495
+ parameters: {
496
+ type: 'object',
497
+ properties: { key: { type: 'string', description: 'ID of the memory to remove' } },
498
+ required: ['key'],
499
+ },
500
+ },
501
+ },
502
+ {
503
+ type: 'function',
504
+ function: {
505
+ name: 'execCommand',
506
+ description: 'Execute a shell command on a specific fleet node synchronously and return stdout/stderr/exit code. Use for admin tasks like checking service status, viewing configs, or running diagnostics.',
507
+ parameters: {
508
+ type: 'object',
509
+ properties: {
510
+ nodeId: { type: 'string', description: 'The unique identifier of the target node' },
511
+ command: { type: 'string', description: 'The command to execute (e.g., "systemctl", "cat", "df")' },
512
+ args: { type: 'string', description: 'JSON array of command arguments (e.g., ["status", "nginx"])' },
513
+ workingDirectory: { type: 'string', description: 'Working directory for command execution (optional)' },
514
+ timeoutSeconds: { type: 'number', description: 'Timeout in seconds (default: 60, max: 120)' },
515
+ },
516
+ required: ['nodeId', 'command'],
517
+ },
518
+ },
519
+ },
520
+ {
521
+ type: 'function',
522
+ function: {
523
+ name: 'searchNodes',
524
+ description: 'Search for nodes by name, hostname, IP address, OS, or filter by status (ONLINE, OFFLINE, DEGRADED)',
525
+ parameters: {
526
+ type: 'object',
527
+ properties: {
528
+ query: { type: 'string', description: 'Search term to match against node name, hostname, IP, or OS' },
529
+ status: { type: 'string', description: 'Filter by node status: ONLINE, OFFLINE, DEGRADED, PROVISIONING' },
530
+ },
531
+ required: [],
532
+ },
533
+ },
534
+ },
535
+ {
536
+ type: 'function',
537
+ function: {
538
+ name: 'getCronJobs',
539
+ description: 'List all scheduled cron jobs across the fleet, optionally filtered by node',
540
+ parameters: {
541
+ type: 'object',
542
+ properties: {
543
+ nodeId: { type: 'string', description: 'Filter cron jobs by node ID (optional — omit for all nodes)' },
544
+ },
545
+ required: [],
546
+ },
547
+ },
548
+ },
549
+ {
550
+ type: 'function',
551
+ function: {
552
+ name: 'getSkills',
553
+ description: 'List all available skill bundles with their version, description, tags, and files',
554
+ parameters: { type: 'object', properties: {}, required: [] },
555
+ },
556
+ },
557
+ {
558
+ type: 'function',
559
+ function: {
560
+ name: 'getDashboardStats',
561
+ description: 'Returns aggregated fleet statistics: node counts by status, command counts, cron job counts, and average CPU/memory across online nodes',
562
+ parameters: { type: 'object', properties: {}, required: [] },
563
+ },
564
+ },
565
+ {
566
+ type: 'function',
567
+ function: {
568
+ name: 'getAuditLog',
569
+ description: 'Query the audit log for recent actions. Returns timestamped events with actor, action, and target information.',
570
+ parameters: {
571
+ type: 'object',
572
+ properties: {
573
+ date: { type: 'string', description: 'Date in YYYY-MM-DD format (default: today)' },
574
+ action: { type: 'string', description: 'Filter by action type (e.g., "command.quick_exec", "node.deploy")' },
575
+ limit: { type: 'number', description: 'Maximum events to return (default: 25, max: 100)' },
576
+ },
577
+ required: [],
578
+ },
579
+ },
580
+ },
581
+ {
582
+ type: 'function',
583
+ function: {
584
+ name: 'getNodeDetails',
585
+ description: 'Returns comprehensive details for a node including agent info, latest health snapshot, tags, metadata, and optionally live software versions and available updates',
586
+ parameters: {
587
+ type: 'object',
588
+ properties: {
589
+ nodeId: { type: 'string', description: 'The unique identifier of the node' },
590
+ includeSoftwareVersions: { type: 'boolean', description: 'If true, runs a live command to fetch software versions and pending updates (takes a few seconds)' },
591
+ },
592
+ required: ['nodeId'],
593
+ },
594
+ },
595
+ },
596
+ {
597
+ type: 'function',
598
+ function: {
599
+ name: 'runSkill',
600
+ description: 'Execute a skill bundle on a specific fleet node. Runs the skill script and returns output. Use for automated remediation, diagnostics, or maintenance tasks.',
601
+ parameters: {
602
+ type: 'object',
603
+ properties: {
604
+ nodeId: { type: 'string', description: 'The unique identifier of the target node' },
605
+ skillName: { type: 'string', description: 'Name of the skill to execute (e.g., "disk-cleaner", "log-analyzer")' },
606
+ action: { type: 'string', description: 'Script to run within the skill (default: "run")' },
607
+ },
608
+ required: ['nodeId', 'skillName'],
609
+ },
610
+ },
611
+ },
612
+ {
613
+ type: 'function',
614
+ function: {
615
+ name: 'correlateAnomalies',
616
+ description: 'Analyze anomalies across the fleet to find correlated patterns. Groups anomalies by time proximity and identifies fleet-wide issues vs isolated incidents.',
617
+ parameters: {
618
+ type: 'object',
619
+ properties: {
620
+ windowMinutes: { type: 'number', description: 'Time window for grouping anomalies in minutes (default: 5)' },
621
+ includeResolved: { type: 'boolean', description: 'Include resolved anomalies in analysis (default: false)' },
622
+ },
623
+ required: [],
624
+ },
625
+ },
626
+ },
627
+ {
628
+ type: 'function',
629
+ function: {
630
+ name: 'getAnomalyTimeline',
631
+ description: 'Get a chronological timeline of anomalies with health context for trend analysis and root cause investigation.',
632
+ parameters: {
633
+ type: 'object',
634
+ properties: {
635
+ hours: { type: 'number', description: 'Number of hours to look back (default: 24)' },
636
+ nodeId: { type: 'string', description: 'Filter to a specific node (optional)' },
637
+ },
638
+ required: [],
639
+ },
640
+ },
641
+ },
642
+ {
643
+ type: 'function',
644
+ function: {
645
+ name: 'createSkillDraft',
646
+ description: 'Create a new skill bundle with a shell script. Use this to generate automated remediation or diagnostic scripts for the fleet.',
647
+ parameters: {
648
+ type: 'object',
649
+ properties: {
650
+ name: { type: 'string', description: 'Skill name in kebab-case (e.g., "memory-cleanup")' },
651
+ description: { type: 'string', description: 'Human-readable description of what the skill does' },
652
+ tags: { type: 'array', description: 'Tags for categorization (e.g., ["maintenance", "disk"])' },
653
+ scriptContent: { type: 'string', description: 'The bash script content for run.sh' },
654
+ },
655
+ required: ['name', 'description', 'scriptContent'],
656
+ },
657
+ },
658
+ },
659
+ {
660
+ type: 'function',
661
+ function: {
662
+ name: 'listSkillActions',
663
+ description: 'List available skills with detailed info including README content, files, and tags. Optionally filter by skill name.',
664
+ parameters: {
665
+ type: 'object',
666
+ properties: {
667
+ skillName: { type: 'string', description: 'Filter to a specific skill by name (optional)' },
668
+ },
669
+ required: [],
670
+ },
671
+ },
672
+ },
673
+ {
674
+ type: 'function',
675
+ function: {
676
+ name: 'generateRunbook',
677
+ description: 'Generate a complete operational runbook (multi-step skill bundle) from a natural language description. Creates a structured runbook with pre-checks, action steps, rollback procedures, and validation steps. Returns the runbook as a skill draft.',
678
+ parameters: {
679
+ type: 'object',
680
+ properties: {
681
+ name: { type: 'string', description: 'Runbook name in kebab-case (e.g., "postgres-failover")' },
682
+ description: { type: 'string', description: 'Description of the operational procedure' },
683
+ scenario: { type: 'string', description: 'Detailed natural language description of the scenario and steps needed' },
684
+ tags: { type: 'array', description: 'Tags for categorization (e.g., ["database", "disaster-recovery"])' },
685
+ },
686
+ required: ['name', 'description', 'scenario'],
687
+ },
688
+ },
689
+ },
690
+ {
691
+ type: 'function',
692
+ function: {
693
+ name: 'executeNaturalCommand',
694
+ description: 'Translate a natural language intent into a shell command for a fleet node. Returns a command preview for user confirmation before execution. Use this when the user asks to perform operations like "restart nginx on web-01", "check disk space on db-server", "show logs for apache". Supports: service management (start/stop/restart/status), package ops (install/update/remove), log viewing (journalctl, tail), disk ops (df, du), network diagnostics (ss, ping, traceroute), process listing, uptime, memory usage.',
695
+ parameters: {
696
+ type: 'object',
697
+ properties: {
698
+ intent: {
699
+ type: 'string',
700
+ description: 'The natural language description of what command to run (e.g., "restart nginx", "check disk space", "show logs for sshd")',
701
+ },
702
+ nodeName: {
703
+ type: 'string',
704
+ description: 'Optional node name or hostname to target (e.g., "web-01", "db-server"). If omitted, will try to extract from the intent text.',
705
+ },
706
+ },
707
+ required: ['intent'],
708
+ },
709
+ },
710
+ },
711
+ {
712
+ type: 'function',
713
+ function: {
714
+ name: 'getFleetPredictions',
715
+ description: 'Get predictive failure detection results across the fleet. Uses exponential smoothing to forecast CPU, memory, swap, and disk usage 1-6 hours ahead.',
716
+ parameters: { type: 'object', properties: {}, required: [] },
717
+ },
718
+ },
719
+ {
720
+ type: 'function',
721
+ function: {
722
+ name: 'getNodePredictions',
723
+ description: 'Get predictive failure forecasts for a specific node including trend analysis.',
724
+ parameters: {
725
+ type: 'object',
726
+ properties: {
727
+ nodeId: { type: 'string', description: 'The node ID to get predictions for' },
728
+ },
729
+ required: ['nodeId'],
730
+ },
731
+ },
732
+ },
733
+ ];
734
+ }
735
+ function getThinkingInstruction(mode) {
736
+ switch (mode) {
737
+ case 'deep':
738
+ return 'Reasoning: high';
739
+ case 'standard':
740
+ return 'Reasoning: medium';
741
+ default:
742
+ return null;
743
+ }
744
+ }
745
+ function getResponseSchema(schema) {
746
+ if (!schema) {
747
+ return { jsonSchema: null, validator: null };
748
+ }
749
+ if (typeof schema.safeParse === 'function') {
750
+ return {
751
+ jsonSchema: z.toJSONSchema(schema, { target: 'draft-7', io: 'input', unrepresentable: 'any' }),
752
+ validator: schema,
753
+ };
754
+ }
755
+ return { jsonSchema: schema, validator: null };
756
+ }
757
+ function parseStructuredResponse(content, validator) {
758
+ if (!content.trim())
759
+ return null;
760
+ try {
761
+ const parsed = JSON.parse(content);
762
+ if (!validator)
763
+ return parsed;
764
+ const result = validator.safeParse(parsed);
765
+ return result.success ? result.data : null;
766
+ }
767
+ catch {
768
+ return null;
769
+ }
770
+ }
771
+ function buildStructuredOutputInstruction(jsonSchema) {
772
+ return [
773
+ 'Return ONLY valid JSON matching this schema.',
774
+ 'Do not include markdown fences or commentary.',
775
+ JSON.stringify(jsonSchema),
776
+ ].join('\n\n');
777
+ }
778
+ async function callLlm(messages, options = {}) {
779
+ const { jsonSchema, validator } = getResponseSchema(options.responseSchema);
780
+ const providerChain = [
781
+ { provider: options.provider ?? 'ollama', model: options.model ?? OLLAMA_MODEL },
782
+ ...(options.fallbacks ?? []),
783
+ ].filter((candidate, index, array) => array.findIndex((entry) => entry.provider === candidate.provider && entry.model === candidate.model) === index);
784
+ let lastError = null;
785
+ let lastConfigurationError = null;
786
+ for (const candidate of providerChain) {
787
+ const provider = getLlmProvider(candidate.provider);
788
+ if (!provider.isConfigured()) {
789
+ lastConfigurationError ??= new Error(`${provider.name} is not configured`);
790
+ continue;
791
+ }
792
+ let preparedMessages = messages;
793
+ if (jsonSchema && !provider.supportsStructuredOutputs) {
794
+ preparedMessages = [
795
+ ...messages,
796
+ { role: 'system', content: buildStructuredOutputInstruction(jsonSchema) },
797
+ ];
798
+ }
799
+ for (let attempt = 0; attempt < 2; attempt++) {
800
+ try {
801
+ console.log(`[AI] Calling ${provider.name} model=${candidate.model} messages=${preparedMessages.length} tools=${options.tools?.length ?? 0} attempt=${attempt + 1}`);
802
+ const result = await provider.chat({
803
+ messages: preparedMessages,
804
+ model: candidate.model,
805
+ tools: provider.supportsToolCalling ? options.tools : undefined,
806
+ temperature: options.temperature,
807
+ maxTokens: options.maxTokens,
808
+ thinkingMode: options.thinkingMode,
809
+ jsonSchema: provider.supportsStructuredOutputs ? jsonSchema : null,
810
+ });
811
+ let content = result.content;
812
+ if (!content && result.nativeToolCalls.length === 0 && result.thinking) {
813
+ const thinkingToolCalls = parseToolCallsFromThinking(result.thinking);
814
+ if (thinkingToolCalls) {
815
+ content = thinkingToolCalls;
816
+ }
817
+ }
818
+ let structured = jsonSchema ? parseStructuredResponse(content, validator) : null;
819
+ if (jsonSchema && !structured && attempt === 0) {
820
+ preparedMessages = [
821
+ ...preparedMessages,
822
+ { role: 'assistant', content },
823
+ {
824
+ role: 'user',
825
+ content: 'Your previous response did not validate. Return ONLY valid JSON matching the schema. Do not include markdown fences, prose, or commentary.',
826
+ },
827
+ ];
828
+ continue;
829
+ }
830
+ return {
831
+ ...result,
832
+ content,
833
+ nativeToolCalls: result.nativeToolCalls.map((toolCall) => ({
834
+ ...(toolCall.id ? { id: toolCall.id } : {}),
835
+ name: toolCall.name,
836
+ arguments: toolCall.arguments,
837
+ })),
838
+ structured,
839
+ };
840
+ }
841
+ catch (error) {
842
+ const normalized = error instanceof Error ? error : new Error('Unknown LLM provider error');
843
+ lastError = normalized;
844
+ if (normalized instanceof Error && 'retryable' in normalized && normalized.retryable) {
845
+ break;
846
+ }
847
+ if (attempt === 0 && jsonSchema) {
848
+ preparedMessages = [
849
+ ...preparedMessages,
850
+ {
851
+ role: 'user',
852
+ content: 'Retry the response as valid JSON only. The previous attempt failed validation.',
853
+ },
854
+ ];
855
+ continue;
856
+ }
857
+ break;
858
+ }
859
+ }
860
+ }
861
+ throw lastError ?? lastConfigurationError ?? new Error('No configured LLM provider was able to satisfy the request');
862
+ }
863
+ // ── Tool call parsing and execution ──
864
+ // Maximum rounds of tool calling before forcing a final response
865
+ const MAX_TOOL_ROUNDS = DEFAULT_MAX_TOOL_ROUNDS;
866
+ const LOOP_PROGRESS_SIMILARITY_THRESHOLD = 0.9;
867
+ function stableSerialize(value) {
868
+ if (value == null || typeof value !== 'object') {
869
+ return JSON.stringify(value);
870
+ }
871
+ if (Array.isArray(value)) {
872
+ return `[${value.map((entry) => stableSerialize(entry)).join(',')}]`;
873
+ }
874
+ return `{${Object.keys(value)
875
+ .sort()
876
+ .map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`)
877
+ .join(',')}}`;
878
+ }
879
+ export function getToolCallSignature(call) {
880
+ return `${call.name}:${stableSerialize(call.arguments)}`;
881
+ }
882
+ function tokenizeComparableText(text) {
883
+ return new Set(text
884
+ .toLowerCase()
885
+ .replace(/[^a-z0-9\s]+/g, ' ')
886
+ .split(/\s+/)
887
+ .filter((token) => token.length >= 3));
888
+ }
889
+ export function getTextSimilarity(left, right) {
890
+ const leftTokens = tokenizeComparableText(left);
891
+ const rightTokens = tokenizeComparableText(right);
892
+ if (leftTokens.size === 0 || rightTokens.size === 0)
893
+ return 0;
894
+ let overlap = 0;
895
+ leftTokens.forEach((token) => {
896
+ if (rightTokens.has(token))
897
+ overlap += 1;
898
+ });
899
+ return overlap / Math.max(leftTokens.size, rightTokens.size);
900
+ }
901
+ export function hasStalledForwardProgress(previous, current) {
902
+ if (!previous.trim() || !current.trim())
903
+ return false;
904
+ return getTextSimilarity(previous, current) >= LOOP_PROGRESS_SIMILARITY_THRESHOLD;
905
+ }
906
+ function getToolLoopTokenBudget(maxTokens, maxToolRounds) {
907
+ return Math.max(maxTokens ?? AI_MAX_TOKENS, 1024) * Math.max(maxToolRounds, 1);
908
+ }
909
+ function hasExceededToolLoopBudget(totalEvalCount, maxTokens, maxToolRounds) {
910
+ return totalEvalCount >= getToolLoopTokenBudget(maxTokens, maxToolRounds);
911
+ }
912
+ function findRepeatedToolCall(calls, seenSignatures) {
913
+ const seenThisRound = new Set();
914
+ for (const call of calls) {
915
+ const signature = getToolCallSignature(call);
916
+ if (seenSignatures.has(signature) || seenThisRound.has(signature)) {
917
+ return call;
918
+ }
919
+ seenThisRound.add(signature);
920
+ }
921
+ return null;
922
+ }
923
+ function summarizeToolResults(toolResults) {
924
+ if (toolResults.length === 0) {
925
+ return 'No completed tool results were available when the loop was stopped.';
926
+ }
927
+ return toolResults.map((result) => {
928
+ if (!result.success) {
929
+ return `- **${result.name}**: Failed — ${result.error ?? 'unknown error'}`;
930
+ }
931
+ const rendered = typeof result.result === 'string'
932
+ ? result.result
933
+ : JSON.stringify(result.result, null, 2);
934
+ return `- **${result.name}**:\n${rendered}`;
935
+ }).join('\n\n');
936
+ }
937
+ function buildLoopStopSummary(reason, toolResults) {
938
+ return `${reason}\n\n${summarizeToolResults(toolResults)}`;
939
+ }
940
+ /**
941
+ * Extract complete JSON objects from text using bracket-counting.
942
+ * Handles nested braces, string literals, and escape sequences correctly.
943
+ */
944
+ function extractJsonObjects(text) {
945
+ const objects = [];
946
+ let i = 0;
947
+ while (i < text.length) {
948
+ if (text[i] === '{') {
949
+ let depth = 0;
950
+ let inString = false;
951
+ let escape = false;
952
+ const start = i;
953
+ for (let j = i; j < text.length; j++) {
954
+ const ch = text[j];
955
+ if (escape) {
956
+ escape = false;
957
+ continue;
958
+ }
959
+ if (ch === '\\' && inString) {
960
+ escape = true;
961
+ continue;
962
+ }
963
+ if (ch === '"') {
964
+ inString = !inString;
965
+ continue;
966
+ }
967
+ if (inString)
968
+ continue;
969
+ if (ch === '{')
970
+ depth++;
971
+ if (ch === '}') {
972
+ depth--;
973
+ if (depth === 0) {
974
+ objects.push(text.slice(start, j + 1));
975
+ i = j + 1;
976
+ break;
977
+ }
978
+ }
979
+ if (j === text.length - 1) {
980
+ i = j + 1;
981
+ }
982
+ }
983
+ if (depth > 0)
984
+ i = text.length; // Unterminated, bail
985
+ }
986
+ else {
987
+ i++;
988
+ }
989
+ }
990
+ return objects;
991
+ }
992
+ /**
993
+ * Attempt to parse a JSON string as a tool call.
994
+ * Normalizes alternate field names:
995
+ * - "tool" → "name" (model often uses "tool" instead of "name")
996
+ * - "parameters" → "arguments" (model often uses "parameters" instead of "arguments")
997
+ * - "params" → "arguments"
998
+ * - "function" → "name"
999
+ * Only returns a call if the tool name matches a registered tool.
1000
+ */
1001
+ function tryParseToolCall(jsonStr, knownTools) {
1002
+ try {
1003
+ const parsed = JSON.parse(jsonStr);
1004
+ const name = (parsed['name'] ?? parsed['tool'] ?? parsed['function']);
1005
+ const args = (parsed['arguments'] ?? parsed['parameters'] ?? parsed['params'] ?? {});
1006
+ if (typeof name !== 'string' || !name)
1007
+ return null;
1008
+ if (!knownTools.has(name))
1009
+ return null;
1010
+ return { name, arguments: args };
1011
+ }
1012
+ catch {
1013
+ return null;
1014
+ }
1015
+ }
1016
+ /**
1017
+ * Robust tool call parser — handles multiple formats models may produce:
1018
+ * 1. XML tags: <tool_call>{"name": "...", "arguments": {...}}</tool_call>
1019
+ * 2. JSON code blocks: ```json\n{...}\n```
1020
+ * 3. Raw JSON objects in text: {"tool": "...", "parameters": {...}}
1021
+ *
1022
+ * Returns on the first strategy that finds valid tool calls to avoid duplicates.
1023
+ * Validates tool names against the registered tool list.
1024
+ */
1025
+ function parseToolCalls(content) {
1026
+ const knownTools = new Set(listAvailableTools());
1027
+ const seen = new Set();
1028
+ function dedup(call) {
1029
+ const key = call.name + JSON.stringify(call.arguments);
1030
+ if (seen.has(key))
1031
+ return false;
1032
+ seen.add(key);
1033
+ return true;
1034
+ }
1035
+ // Strategy 1: XML <tool_call> tags (preferred format)
1036
+ const xmlCalls = [];
1037
+ for (const match of content.matchAll(/<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g)) {
1038
+ for (const jsonStr of extractJsonObjects(match[1])) {
1039
+ const call = tryParseToolCall(jsonStr, knownTools);
1040
+ if (call && dedup(call))
1041
+ xmlCalls.push(call);
1042
+ }
1043
+ }
1044
+ if (xmlCalls.length > 0)
1045
+ return xmlCalls;
1046
+ // Strategy 2: JSON code blocks (```json ... ```)
1047
+ const blockCalls = [];
1048
+ for (const match of content.matchAll(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/g)) {
1049
+ for (const jsonStr of extractJsonObjects(match[1])) {
1050
+ const call = tryParseToolCall(jsonStr, knownTools);
1051
+ if (call && dedup(call))
1052
+ blockCalls.push(call);
1053
+ }
1054
+ }
1055
+ if (blockCalls.length > 0)
1056
+ return blockCalls;
1057
+ // Strategy 3: Raw JSON objects in the text
1058
+ const rawCalls = [];
1059
+ for (const jsonStr of extractJsonObjects(content)) {
1060
+ const call = tryParseToolCall(jsonStr, knownTools);
1061
+ if (call && dedup(call))
1062
+ rawCalls.push(call);
1063
+ }
1064
+ return rawCalls;
1065
+ }
1066
+ /**
1067
+ * Remove tool call markup from an assistant response so the saved content
1068
+ * is clean text for the user to read. Strips:
1069
+ * - <tool_call>...</tool_call> blocks
1070
+ * - Standalone lines that are only a JSON tool call object
1071
+ * - Excess blank lines left behind
1072
+ */
1073
+ function stripToolCallMarkup(content, knownTools) {
1074
+ // Remove XML-wrapped tool calls
1075
+ let cleaned = content.replace(/<tool_call>\s*[\s\S]*?\s*<\/tool_call>/g, '');
1076
+ // Remove standalone JSON tool-call lines (bare JSON that parses as a tool call)
1077
+ cleaned = cleaned.replace(/^\s*\{[^\n]*\}\s*$/gm, (line) => {
1078
+ const call = tryParseToolCall(line.trim(), knownTools);
1079
+ return call ? '' : line;
1080
+ });
1081
+ // Remove empty code blocks that might remain
1082
+ cleaned = cleaned.replace(/```(?:json)?\s*```/g, '');
1083
+ // Collapse multiple blank lines into at most two
1084
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim();
1085
+ return cleaned || content;
1086
+ }
1087
+ /**
1088
+ * Check if thinking content contains tool calls and return them as formatted content.
1089
+ * The model sometimes emits tool call JSON inside the thinking block instead of content.
1090
+ * Returns the tool call markup as a string (ready for parseToolCalls), or null if none found.
1091
+ */
1092
+ function parseToolCallsFromThinking(thinking) {
1093
+ const knownTools = new Set(listAvailableTools());
1094
+ // Look for XML-wrapped tool calls in thinking
1095
+ const xmlMatches = [...thinking.matchAll(/<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g)];
1096
+ if (xmlMatches.length > 0) {
1097
+ return xmlMatches.map(m => `<tool_call>${m[1]}</tool_call>`).join('\n');
1098
+ }
1099
+ // Look for raw JSON tool call objects in thinking
1100
+ const jsonObjects = extractJsonObjects(thinking);
1101
+ const toolCallJsons = [];
1102
+ for (const jsonStr of jsonObjects) {
1103
+ const call = tryParseToolCall(jsonStr, knownTools);
1104
+ if (call) {
1105
+ toolCallJsons.push(`<tool_call>${JSON.stringify({ name: call.name, arguments: call.arguments })}</tool_call>`);
1106
+ }
1107
+ }
1108
+ if (toolCallJsons.length > 0)
1109
+ return toolCallJsons.join('\n');
1110
+ return null;
1111
+ }
1112
+ async function executeToolCalls(calls) {
1113
+ const policy = await getToolApprovalPolicy();
1114
+ const results = [];
1115
+ for (const call of calls) {
1116
+ // Check denial policy
1117
+ if (policy.alwaysDeny.includes(call.name)) {
1118
+ results.push({ name: call.name, success: false, result: null, error: 'Tool is denied by policy' });
1119
+ continue;
1120
+ }
1121
+ const result = await executeAiTool(call);
1122
+ results.push(result);
1123
+ }
1124
+ return results;
1125
+ }
1126
+ // ── Conversation management ──
1127
+ export async function getOrCreateConversation(type, options) {
1128
+ const db = getDb();
1129
+ // For heartbeat conversations, reuse today's
1130
+ if (type === 'heartbeat') {
1131
+ const today = new Date();
1132
+ today.setHours(0, 0, 0, 0);
1133
+ const existing = await db.aiConversation.findFirst({
1134
+ where: {
1135
+ type: 'heartbeat',
1136
+ createdAt: { gte: today },
1137
+ status: 'active',
1138
+ },
1139
+ select: { id: true },
1140
+ });
1141
+ if (existing)
1142
+ return existing.id;
1143
+ const dateStr = new Date().toISOString().split('T')[0];
1144
+ const conv = await db.aiConversation.create({
1145
+ data: {
1146
+ id: nanoid(),
1147
+ title: `Fleet Heartbeat — ${dateStr}`,
1148
+ type: 'heartbeat',
1149
+ createdBy: 'system',
1150
+ },
1151
+ });
1152
+ return conv.id;
1153
+ }
1154
+ // For chat/analysis, create a new conversation
1155
+ const conv = await db.aiConversation.create({
1156
+ data: {
1157
+ id: nanoid(),
1158
+ title: options?.title ?? 'New conversation',
1159
+ type,
1160
+ nodeId: options?.nodeId ?? null,
1161
+ createdBy: options?.createdBy ?? 'system',
1162
+ },
1163
+ });
1164
+ return conv.id;
1165
+ }
1166
+ // ── Memory system (HiveMem) ──
1167
+ async function getMemoriesAsContext() {
1168
+ const { getHiveMemClient } = await import('./hivemem-client.js');
1169
+ const hivemem = getHiveMemClient();
1170
+ if (!hivemem.configured)
1171
+ return 'Memory system not configured.';
1172
+ try {
1173
+ const results = await hivemem.browseMemories(50);
1174
+ if (results.memories.length === 0)
1175
+ return 'No stored memories yet.';
1176
+ return results.memories
1177
+ .map((m) => `- [${m.type}/${m.scope}] ${m.content}`)
1178
+ .join('\n');
1179
+ }
1180
+ catch {
1181
+ return 'Memory system temporarily unavailable.';
1182
+ }
1183
+ }
1184
+ /**
1185
+ * Retrieve the custom heartbeat prompt override if set, otherwise return null
1186
+ * (callers fall back to the default HEARTBEAT_DOC).
1187
+ */
1188
+ async function getHeartbeatPromptOverride() {
1189
+ const db = getDb();
1190
+ const record = await db.aiMemory.findUnique({ where: { key: 'heartbeat_prompt' } });
1191
+ if (record && record.content)
1192
+ return record.content;
1193
+ return null;
1194
+ }
1195
+ /**
1196
+ * Build a condensed summary of the most recent chat conversations (not heartbeat/analysis)
1197
+ * to inject as prior-session context so the AI can maintain continuity.
1198
+ * Respects a token budget of ~2 000 tokens (≈8 000 chars) across all summaries.
1199
+ */
1200
+ async function getRecentChatHistoryAsContext(excludeConversationId) {
1201
+ const db = getDb();
1202
+ const recentConvs = await db.aiConversation.findMany({
1203
+ where: {
1204
+ type: 'chat',
1205
+ status: 'active',
1206
+ id: { not: excludeConversationId },
1207
+ },
1208
+ orderBy: { updatedAt: 'desc' },
1209
+ take: 5,
1210
+ include: {
1211
+ messages: {
1212
+ where: { role: { in: ['user', 'assistant'] } },
1213
+ orderBy: { createdAt: 'asc' },
1214
+ take: 10,
1215
+ select: { role: true, content: true, createdAt: true },
1216
+ },
1217
+ },
1218
+ });
1219
+ if (recentConvs.length === 0)
1220
+ return null;
1221
+ const CHAR_BUDGET = 8_000;
1222
+ let used = 0;
1223
+ const lines = [];
1224
+ for (const conv of recentConvs) {
1225
+ if (conv.messages.length === 0)
1226
+ continue;
1227
+ const header = `### ${conv.title} (${new Date(conv.updatedAt).toLocaleDateString()})`;
1228
+ const msgLines = conv.messages.map((m) => `**${m.role === 'user' ? 'User' : 'Magnus AI'}**: ${m.content.slice(0, 500)}`);
1229
+ const block = [header, ...msgLines].join('\n');
1230
+ if (used + block.length > CHAR_BUDGET)
1231
+ break;
1232
+ lines.push(block);
1233
+ used += block.length;
1234
+ }
1235
+ if (lines.length === 0)
1236
+ return null;
1237
+ return lines.join('\n\n');
1238
+ }
1239
+ function getScratchpadAsContext(conversationId) {
1240
+ const entries = listScratchpadEntries(conversationId);
1241
+ if (entries.length === 0)
1242
+ return null;
1243
+ return entries
1244
+ .slice(0, 20)
1245
+ .map((entry) => `- ${entry.name} (${entry.source}) — ${entry.summary}`)
1246
+ .join('\n');
1247
+ }
1248
+ /**
1249
+ * Send a message to the AI assistant and get a response.
1250
+ * Handles tool calling with one round of execution.
1251
+ */
1252
+ export async function chat(request) {
1253
+ const db = getDb();
1254
+ // Get or create conversation
1255
+ const conversationId = request.conversationId ??
1256
+ await getOrCreateConversation(request.type ?? 'chat', { nodeId: request.nodeId, createdBy: request.createdBy });
1257
+ // Load conversation history. Automated heartbeats already receive a fresh
1258
+ // fleet/anomaly snapshot, so replaying prior heartbeat messages only bloats
1259
+ // the prompt and can push local models into timeout territory.
1260
+ const includeConversationHistory = request.type !== 'heartbeat';
1261
+ const history = includeConversationHistory
1262
+ ? await db.aiMessage.findMany({
1263
+ where: { conversationId },
1264
+ orderBy: { createdAt: 'asc' },
1265
+ take: 50, // Limit context window
1266
+ })
1267
+ : [];
1268
+ // Build context
1269
+ const memories = await getMemoriesAsContext();
1270
+ const heartbeatDoc = request.type === 'heartbeat'
1271
+ ? (await getHeartbeatPromptOverride()) ?? undefined
1272
+ : undefined;
1273
+ const recentHistory = (!request.conversationId && request.type !== 'heartbeat')
1274
+ ? (await getRecentChatHistoryAsContext(conversationId)) ?? undefined
1275
+ : undefined;
1276
+ // Load AI settings for model selection + model routing
1277
+ const aiSettings = await getAiSettings();
1278
+ const modelProfile = await selectModel(request.type ?? 'chat');
1279
+ const activeModel = aiSettings.model || modelProfile.model;
1280
+ const maxToolRounds = request.maxToolRounds ?? aiSettings.maxToolRounds ?? modelProfile.maxToolRounds ?? MAX_TOOL_ROUNDS;
1281
+ // Auto-fetch fleet status and anomaly context for user chats
1282
+ let fleetContext = request.fleetContext;
1283
+ let anomalyContext = request.anomalyContext;
1284
+ if (!fleetContext && request.type !== 'heartbeat') {
1285
+ try {
1286
+ const nodes = await db.node.findMany({
1287
+ where: { metadata: { not: '{"sentinel":true}' } },
1288
+ orderBy: { name: 'asc' },
1289
+ select: { id: true, name: true, hostname: true, ipAddress: true, status: true, lastHeartbeatAt: true },
1290
+ });
1291
+ fleetContext = `Fleet has ${nodes.length} node(s):\n` + nodes.map(n => `- ${n.name} (${n.hostname}, ${n.ipAddress}) — ${n.status}, last heartbeat: ${n.lastHeartbeatAt?.toISOString() ?? 'never'}`).join('\n');
1292
+ }
1293
+ catch { /* ignore */ }
1294
+ }
1295
+ if (!anomalyContext && request.type !== 'heartbeat') {
1296
+ try {
1297
+ const unresolvedCount = await db.anomalyEvent.count({ where: { resolved: false } });
1298
+ if (unresolvedCount > 0) {
1299
+ const recentAnomalies = await db.anomalyEvent.findMany({
1300
+ where: { resolved: false },
1301
+ orderBy: { createdAt: 'desc' },
1302
+ take: 20,
1303
+ select: { id: true, nodeId: true, metric: true, value: true, severity: true, createdAt: true },
1304
+ });
1305
+ anomalyContext = `${unresolvedCount} unresolved anomalies:\n` + recentAnomalies.map(a => `- [${a.severity}] ${a.metric}=${a.value.toFixed(1)} on node ${a.nodeId} at ${a.createdAt.toISOString()}`).join('\n');
1306
+ }
1307
+ }
1308
+ catch { /* ignore */ }
1309
+ }
1310
+ // Build user context string if user info is available
1311
+ let userContext;
1312
+ if (request.userInfo) {
1313
+ const parts = [];
1314
+ if (request.userInfo.email)
1315
+ parts.push(`Email: ${request.userInfo.email}`);
1316
+ if (request.userInfo.username)
1317
+ parts.push(`Username: ${request.userInfo.username}`);
1318
+ if (request.userInfo.role)
1319
+ parts.push(`Role: ${request.userInfo.role}`);
1320
+ if (parts.length > 0)
1321
+ userContext = parts.join('\n');
1322
+ }
1323
+ const systemPrompt = buildSystemPrompt({
1324
+ memories,
1325
+ fleetStatus: fleetContext,
1326
+ anomalies: anomalyContext,
1327
+ isHeartbeat: request.type === 'heartbeat',
1328
+ heartbeatDoc,
1329
+ recentHistory,
1330
+ controllerContext: request.controllerContext,
1331
+ userContext,
1332
+ scratchpadContext: request.replMode ? getScratchpadAsContext(conversationId) ?? undefined : undefined,
1333
+ replMode: request.replMode,
1334
+ });
1335
+ const messages = [];
1336
+ messages.push({ role: 'system', content: systemPrompt });
1337
+ // Add conversation history
1338
+ for (const msg of history) {
1339
+ messages.push({
1340
+ role: msg.role,
1341
+ content: msg.content,
1342
+ });
1343
+ }
1344
+ // Add current user message
1345
+ const userMessage = { role: 'user', content: request.message };
1346
+ if (request.attachments?.length) {
1347
+ userMessage.images = request.attachments
1348
+ .filter((a) => a.type === 'image')
1349
+ .map((a) => ({ type: 'image', data: a.data, mimeType: a.mimeType }));
1350
+ }
1351
+ messages.push(userMessage);
1352
+ // Save user message
1353
+ await db.aiMessage.create({
1354
+ data: {
1355
+ id: nanoid(),
1356
+ conversationId,
1357
+ role: 'user',
1358
+ content: request.message,
1359
+ isRead: true, // User messages are always "read"
1360
+ },
1361
+ });
1362
+ // Call Ollama with native tool definitions (filtered by tool config).
1363
+ // Heartbeat runs already pass pre-gathered fleet/anomaly context and should
1364
+ // avoid sending the full tool schema set on every scheduled analysis.
1365
+ const allTools = request.type === 'heartbeat' ? [] : getOllamaToolDefinitions();
1366
+ const config = allTools.length > 0 ? await getToolConfig() : [];
1367
+ const disabledSet = new Set(config.filter((e) => !e.enabled).map((e) => e.name));
1368
+ const llmTools = disabledSet.size > 0
1369
+ ? allTools.filter((t) => !disabledSet.has(t.function.name))
1370
+ : allTools;
1371
+ console.log(`[AI] chat() start — type=${request.type ?? 'chat'} provider=${modelProfile.provider ?? 'ollama'} model=${activeModel} conversationId=${conversationId} historySize=${history.length} nativeTools=${llmTools.length}`);
1372
+ let llmResult;
1373
+ try {
1374
+ llmResult = await callLlm(messages, {
1375
+ provider: modelProfile.provider,
1376
+ model: activeModel,
1377
+ fallbacks: modelProfile.fallbacks,
1378
+ tools: llmTools,
1379
+ temperature: modelProfile.temperature,
1380
+ maxTokens: modelProfile.maxTokens,
1381
+ thinkingMode: request.thinkingMode ?? aiSettings.thinkingMode ?? modelProfile.thinkingMode ?? 'standard',
1382
+ responseSchema: aiSettings.structuredOutputsEnabled ? request.responseSchema : undefined,
1383
+ });
1384
+ }
1385
+ catch (err) {
1386
+ console.error(`[AI] LLM call failed:`, err instanceof Error ? err.message : err);
1387
+ const providerLabel = modelProfile.provider === 'ollama' ? `Ollama at ${OLLAMA_BASE_URL}` : `provider ${modelProfile.provider ?? 'ollama'}`;
1388
+ const errorMsg = `⚠️ AI service unavailable: ${err instanceof Error ? err.message : 'Unknown error'}. Check ${providerLabel} and configured fallbacks.`;
1389
+ const errorMsgId = nanoid();
1390
+ await db.aiMessage.create({
1391
+ data: {
1392
+ id: errorMsgId,
1393
+ conversationId,
1394
+ role: 'assistant',
1395
+ content: errorMsg,
1396
+ isRead: false,
1397
+ },
1398
+ });
1399
+ return {
1400
+ conversationId,
1401
+ messageId: errorMsgId,
1402
+ content: errorMsg,
1403
+ toolCalls: [],
1404
+ tokenCount: 0,
1405
+ };
1406
+ }
1407
+ let responseContent = llmResult.content;
1408
+ let responseThinking = llmResult.thinking;
1409
+ let structuredAnalysis = llmResult.structured;
1410
+ let totalEvalCount = llmResult.evalCount;
1411
+ const knownTools = new Set(listAvailableTools());
1412
+ const seenToolCallSignatures = new Set();
1413
+ let previousComparableResponse = '';
1414
+ // ── Multi-round tool calling loop ──
1415
+ // Supports native Ollama tool calls (preferred) with text-based parsing fallback.
1416
+ // Up to MAX_TOOL_ROUNDS rounds before forcing a final response.
1417
+ const allToolResults = [];
1418
+ async function forceFinalResponse(instruction) {
1419
+ try {
1420
+ const forced = await callLlm([
1421
+ ...messages,
1422
+ { role: 'assistant', content: responseContent },
1423
+ { role: 'user', content: instruction },
1424
+ ], {
1425
+ provider: modelProfile.provider,
1426
+ model: activeModel,
1427
+ fallbacks: modelProfile.fallbacks,
1428
+ temperature: modelProfile.temperature,
1429
+ maxTokens: modelProfile.maxTokens,
1430
+ thinkingMode: request.thinkingMode ?? aiSettings.thinkingMode ?? modelProfile.thinkingMode ?? 'standard',
1431
+ responseSchema: aiSettings.structuredOutputsEnabled ? request.responseSchema : undefined,
1432
+ });
1433
+ responseContent = forced.content || buildLoopStopSummary(instruction, allToolResults);
1434
+ if (forced.thinking) {
1435
+ responseThinking = [responseThinking, forced.thinking].filter(Boolean).join('\n\n---\n\n');
1436
+ }
1437
+ if (forced.structured != null) {
1438
+ structuredAnalysis = forced.structured;
1439
+ }
1440
+ totalEvalCount += forced.evalCount;
1441
+ }
1442
+ catch {
1443
+ responseContent = buildLoopStopSummary(instruction, allToolResults);
1444
+ }
1445
+ }
1446
+ for (let round = 0; round < maxToolRounds; round++) {
1447
+ // Prefer native tool calls; fall back to text-based parsing
1448
+ const isNative = llmResult.nativeToolCalls.length > 0;
1449
+ const toolCalls = isNative
1450
+ ? llmResult.nativeToolCalls
1451
+ : parseToolCalls(responseContent);
1452
+ if (toolCalls.length === 0)
1453
+ break;
1454
+ const comparableResponse = stripToolCallMarkup(responseContent, knownTools);
1455
+ if (hasExceededToolLoopBudget(totalEvalCount, modelProfile.maxTokens, maxToolRounds)) {
1456
+ await forceFinalResponse('Stop calling tools and provide the best final answer now. The tool-round token budget has been reached. Use the results you already gathered.');
1457
+ break;
1458
+ }
1459
+ const repeatedToolCall = findRepeatedToolCall(toolCalls, seenToolCallSignatures);
1460
+ if (repeatedToolCall) {
1461
+ await forceFinalResponse(`Do not call any more tools. You already requested \`${repeatedToolCall.name}\` with the same arguments earlier. Use the existing evidence and respond with your best final answer.`);
1462
+ break;
1463
+ }
1464
+ if (previousComparableResponse && hasStalledForwardProgress(previousComparableResponse, comparableResponse)) {
1465
+ await forceFinalResponse('Do not call any more tools. Your last investigation step is too similar to the previous one and is no longer making forward progress. Summarize the best answer from the data you already have.');
1466
+ break;
1467
+ }
1468
+ previousComparableResponse = comparableResponse;
1469
+ toolCalls.forEach((call) => seenToolCallSignatures.add(getToolCallSignature(call)));
1470
+ console.log(`[AI] Round ${round + 1}: ${toolCalls.length} tool call(s) [${isNative ? 'native' : 'text-parsed'}]: ${toolCalls.map(t => t.name).join(', ')}`);
1471
+ // Execute all tool calls for this round
1472
+ const executableToolCalls = toolCalls.map((call) => ({
1473
+ ...call,
1474
+ arguments: {
1475
+ ...call.arguments,
1476
+ _conversationId: conversationId,
1477
+ },
1478
+ }));
1479
+ const roundResults = await executeToolCalls(executableToolCalls);
1480
+ allToolResults.push(...roundResults);
1481
+ if (request.replMode) {
1482
+ executableToolCalls.forEach((call, index) => {
1483
+ const result = roundResults[index];
1484
+ if (result?.success) {
1485
+ autoStoreToolResult(conversationId, call.name, result.result);
1486
+ }
1487
+ });
1488
+ }
1489
+ if (isNative) {
1490
+ // ── Native tool calling path ──
1491
+ // Per Ollama API spec: assistant message with tool_calls, then one `tool` role message per result.
1492
+ messages.push({
1493
+ role: 'assistant',
1494
+ content: responseContent,
1495
+ tool_calls: toolCalls,
1496
+ });
1497
+ roundResults.forEach((r, index) => {
1498
+ messages.push({
1499
+ role: 'tool',
1500
+ content: JSON.stringify(r.success ? r.result : { error: r.error }),
1501
+ tool_call_id: toolCalls[index] && 'id' in toolCalls[index] ? toolCalls[index].id : undefined,
1502
+ });
1503
+ });
1504
+ }
1505
+ else {
1506
+ // ── Text-based fallback path ──
1507
+ // Use "user" role for tool results wrapped in structured markers.
1508
+ const toolResultsContent = roundResults
1509
+ .map((r) => [
1510
+ `### Tool: ${r.name}`,
1511
+ `Status: ${r.success ? 'SUCCESS' : 'FAILED'}`,
1512
+ r.error ? `Error: ${r.error}` : `Result:\n${JSON.stringify(r.result, null, 2)}`,
1513
+ ].join('\n'))
1514
+ .join('\n\n---\n\n');
1515
+ messages.push({ role: 'assistant', content: responseContent });
1516
+ messages.push({
1517
+ role: 'user',
1518
+ content: [
1519
+ '[TOOL_RESULTS]',
1520
+ toolResultsContent,
1521
+ '[/TOOL_RESULTS]',
1522
+ '',
1523
+ round < maxToolRounds - 1
1524
+ ? 'Analyze these results and respond to the user. If you need additional data, you may call more tools.'
1525
+ : 'Analyze these results and provide your final response to the user. Do not call any more tools.',
1526
+ ].join('\n'),
1527
+ });
1528
+ }
1529
+ try {
1530
+ llmResult = await callLlm(messages, {
1531
+ provider: modelProfile.provider,
1532
+ model: activeModel,
1533
+ fallbacks: modelProfile.fallbacks,
1534
+ tools: llmTools,
1535
+ temperature: modelProfile.temperature,
1536
+ maxTokens: modelProfile.maxTokens,
1537
+ thinkingMode: request.thinkingMode ?? aiSettings.thinkingMode ?? modelProfile.thinkingMode ?? 'standard',
1538
+ responseSchema: aiSettings.structuredOutputsEnabled ? request.responseSchema : undefined,
1539
+ });
1540
+ responseContent = llmResult.content;
1541
+ if (llmResult.thinking) {
1542
+ responseThinking = [responseThinking, llmResult.thinking].filter(Boolean).join('\n\n---\n\n');
1543
+ }
1544
+ if (llmResult.structured != null) {
1545
+ structuredAnalysis = llmResult.structured;
1546
+ }
1547
+ totalEvalCount += llmResult.evalCount;
1548
+ }
1549
+ catch (roundErr) {
1550
+ console.error(`[AI] Multi-round Ollama call failed (round ${round + 1}):`, roundErr instanceof Error ? roundErr.message : roundErr);
1551
+ // Build a fallback response from the tool results we already have
1552
+ if (allToolResults.length > 0) {
1553
+ const fallbackLines = allToolResults.map((r) => {
1554
+ if (!r.success)
1555
+ return `**${r.name}**: Failed — ${r.error ?? 'unknown error'}`;
1556
+ return `**${r.name}**: ${JSON.stringify(r.result, null, 2)}`;
1557
+ });
1558
+ responseContent = `I retrieved the following data but was unable to complete analysis:\n\n${fallbackLines.join('\n\n')}`;
1559
+ }
1560
+ break;
1561
+ }
1562
+ }
1563
+ // Strip any remaining tool call markup from the final response so users see clean text
1564
+ const rawContent = responseContent;
1565
+ responseContent = stripToolCallMarkup(responseContent, knownTools);
1566
+ // Guard: never save an empty response
1567
+ if (!responseContent.trim()) {
1568
+ console.warn(`[AI] stripToolCallMarkup produced empty content. rawContent=${rawContent.length} chars, toolResults=${allToolResults.length}`);
1569
+ if (allToolResults.length > 0) {
1570
+ // Build a readable summary from the tool results
1571
+ const summaryLines = allToolResults.map((r) => {
1572
+ if (!r.success)
1573
+ return `- **${r.name}**: Failed — ${r.error ?? 'unknown error'}`;
1574
+ const resultStr = typeof r.result === 'string' ? r.result : JSON.stringify(r.result, null, 2);
1575
+ return `- **${r.name}**:\n${resultStr}`;
1576
+ });
1577
+ responseContent = `Here are the results from the tools I called:\n\n${summaryLines.join('\n\n')}`;
1578
+ }
1579
+ else {
1580
+ responseContent = rawContent || 'I was unable to generate a response. Please try rephrasing your question.';
1581
+ }
1582
+ }
1583
+ console.log(`[AI] chat() complete — content=${responseContent.length} chars, toolCalls=${allToolResults.length}, evalCount=${totalEvalCount}`);
1584
+ if (structuredAnalysis != null && request.structuredTextRenderer) {
1585
+ const rendered = request.structuredTextRenderer(structuredAnalysis);
1586
+ if (rendered.trim()) {
1587
+ responseContent = rendered;
1588
+ }
1589
+ }
1590
+ // Use actual token count from Ollama when available, fall back to estimate
1591
+ const tokenCount = totalEvalCount > 0 ? totalEvalCount : Math.ceil(responseContent.length / 4);
1592
+ // Save assistant response
1593
+ const messageId = nanoid();
1594
+ await db.aiMessage.create({
1595
+ data: {
1596
+ id: messageId,
1597
+ conversationId,
1598
+ role: 'assistant',
1599
+ content: responseContent,
1600
+ toolCalls: JSON.stringify(allToolResults),
1601
+ thinking: responseThinking || null,
1602
+ structuredAnalysis: structuredAnalysis != null ? JSON.stringify(structuredAnalysis) : null,
1603
+ tokenCount,
1604
+ isRead: false,
1605
+ },
1606
+ });
1607
+ // Update conversation timestamp
1608
+ await db.aiConversation.update({
1609
+ where: { id: conversationId },
1610
+ data: { updatedAt: new Date() },
1611
+ });
1612
+ // Auto-summarize if conversation is getting long
1613
+ const totalMessages = await db.aiMessage.count({ where: { conversationId } });
1614
+ if (totalMessages > 20 && totalMessages % 10 === 0) {
1615
+ summarizeConversation(conversationId).catch(() => { }); // fire-and-forget
1616
+ }
1617
+ return {
1618
+ conversationId,
1619
+ messageId,
1620
+ content: responseContent,
1621
+ toolCalls: allToolResults,
1622
+ tokenCount,
1623
+ thinking: responseThinking || undefined,
1624
+ structuredAnalysis: structuredAnalysis ?? undefined,
1625
+ };
1626
+ }
1627
+ // ── Conversation list / message queries ──
1628
+ export async function listConversations(params) {
1629
+ const db = getDb();
1630
+ const page = params.page ?? 1;
1631
+ const pageSize = Math.min(params.pageSize ?? 20, 50);
1632
+ const skip = (page - 1) * pageSize;
1633
+ const where = {};
1634
+ if (params.type)
1635
+ where['type'] = params.type;
1636
+ // 'all' is a sentinel meaning no status filter
1637
+ if (params.status && params.status !== 'all')
1638
+ where['status'] = params.status;
1639
+ const [conversations, total] = await Promise.all([
1640
+ db.aiConversation.findMany({
1641
+ where,
1642
+ orderBy: { updatedAt: 'desc' },
1643
+ skip,
1644
+ take: pageSize,
1645
+ include: {
1646
+ _count: { select: { messages: true, children: true } },
1647
+ messages: {
1648
+ orderBy: { createdAt: 'desc' },
1649
+ take: 1,
1650
+ select: { content: true, role: true, createdAt: true },
1651
+ },
1652
+ },
1653
+ }),
1654
+ db.aiConversation.count({ where }),
1655
+ ]);
1656
+ return {
1657
+ conversations: conversations.map((c) => ({
1658
+ ...c,
1659
+ lastMessage: c.messages[0] ?? null,
1660
+ messages: undefined,
1661
+ messageCount: c._count.messages,
1662
+ childCount: c._count.children,
1663
+ })),
1664
+ total,
1665
+ };
1666
+ }
1667
+ export async function getConversationMessages(conversationId, params) {
1668
+ const db = getDb();
1669
+ const page = params?.page ?? 1;
1670
+ const pageSize = Math.min(params?.pageSize ?? 50, 100);
1671
+ const skip = (page - 1) * pageSize;
1672
+ const [messages, total] = await Promise.all([
1673
+ db.aiMessage.findMany({
1674
+ where: { conversationId },
1675
+ orderBy: { createdAt: 'asc' },
1676
+ skip,
1677
+ take: pageSize,
1678
+ }),
1679
+ db.aiMessage.count({ where: { conversationId } }),
1680
+ ]);
1681
+ return {
1682
+ messages: messages.map((m) => ({
1683
+ ...m,
1684
+ toolCalls: JSON.parse(m.toolCalls),
1685
+ thinking: m.thinking ?? undefined,
1686
+ structuredAnalysis: m.structuredAnalysis ? JSON.parse(m.structuredAnalysis) : undefined,
1687
+ pendingApproval: m.pendingApproval ? JSON.parse(m.pendingApproval) : null,
1688
+ })),
1689
+ total,
1690
+ };
1691
+ }
1692
+ export async function getUnreadAiMessageCount() {
1693
+ const db = getDb();
1694
+ return db.aiMessage.count({
1695
+ where: { role: 'assistant', isRead: false },
1696
+ });
1697
+ }
1698
+ export async function markAiMessagesRead(messageIds) {
1699
+ const db = getDb();
1700
+ const result = await db.aiMessage.updateMany({
1701
+ where: { id: { in: messageIds }, isRead: false },
1702
+ data: { isRead: true, readAt: new Date() },
1703
+ });
1704
+ return result.count;
1705
+ }
1706
+ export async function markConversationRead(conversationId) {
1707
+ const db = getDb();
1708
+ const result = await db.aiMessage.updateMany({
1709
+ where: { conversationId, isRead: false, role: 'assistant' },
1710
+ data: { isRead: true, readAt: new Date() },
1711
+ });
1712
+ return result.count;
1713
+ }
1714
+ export async function archiveConversation(conversationId) {
1715
+ const db = getDb();
1716
+ await db.aiConversation.update({
1717
+ where: { id: conversationId },
1718
+ data: { status: 'archived' },
1719
+ });
1720
+ }
1721
+ export async function deleteConversation(conversationId) {
1722
+ const db = getDb();
1723
+ // Messages cascade via foreign key, but delete explicitly to be safe
1724
+ await db.aiMessage.deleteMany({ where: { conversationId } });
1725
+ await db.aiConversation.delete({ where: { id: conversationId } });
1726
+ }
1727
+ async function upsertConfigurationEntry(key, content, source = 'user') {
1728
+ const db = getDb();
1729
+ const existing = await db.aiMemory.findUnique({ where: { key } });
1730
+ if (existing) {
1731
+ await db.aiMemory.update({
1732
+ where: { key },
1733
+ data: { content, updatedAt: new Date() },
1734
+ });
1735
+ return;
1736
+ }
1737
+ await db.aiMemory.create({
1738
+ data: {
1739
+ id: nanoid(),
1740
+ key,
1741
+ category: 'configuration',
1742
+ content,
1743
+ source,
1744
+ relevance: 0.0,
1745
+ },
1746
+ });
1747
+ }
1748
+ export async function updateToolApprovalPolicy(policy) {
1749
+ const normalized = {
1750
+ alwaysAllow: [...new Set(policy.alwaysAllow)].sort(),
1751
+ requireApproval: [...new Set(policy.requireApproval)].sort(),
1752
+ alwaysDeny: [...new Set(policy.alwaysDeny)].sort(),
1753
+ };
1754
+ await upsertConfigurationEntry('tool_approval_policy', JSON.stringify(normalized));
1755
+ }
1756
+ export async function forkConversation(conversationId, options) {
1757
+ const db = getDb();
1758
+ const conversation = await db.aiConversation.findUnique({
1759
+ where: { id: conversationId },
1760
+ include: { messages: { orderBy: { createdAt: 'asc' } } },
1761
+ });
1762
+ if (!conversation) {
1763
+ throw new Error('Conversation not found');
1764
+ }
1765
+ const forkIndex = options?.fromMessageId
1766
+ ? conversation.messages.findIndex((message) => message.id === options.fromMessageId)
1767
+ : conversation.messages.length - 1;
1768
+ if (options?.fromMessageId && forkIndex === -1) {
1769
+ throw new Error('Fork source message not found');
1770
+ }
1771
+ const messagesToCopy = conversation.messages.slice(0, Math.max(forkIndex + 1, 0));
1772
+ const siblingCount = await db.aiConversation.count({ where: { parentId: conversationId } });
1773
+ const title = `${conversation.title} — Branch ${siblingCount + 1}`;
1774
+ const fork = await db.aiConversation.create({
1775
+ data: {
1776
+ id: nanoid(),
1777
+ title,
1778
+ type: conversation.type,
1779
+ nodeId: conversation.nodeId,
1780
+ createdBy: options?.createdBy ?? conversation.createdBy,
1781
+ parentId: conversation.id,
1782
+ forkedFromMessageId: options?.fromMessageId ?? messagesToCopy.at(-1)?.id ?? null,
1783
+ },
1784
+ });
1785
+ if (messagesToCopy.length > 0) {
1786
+ await db.aiMessage.createMany({
1787
+ data: messagesToCopy.map((message) => ({
1788
+ id: nanoid(),
1789
+ conversationId: fork.id,
1790
+ role: message.role,
1791
+ content: message.content,
1792
+ toolCalls: message.toolCalls,
1793
+ thinking: message.thinking,
1794
+ structuredAnalysis: message.structuredAnalysis,
1795
+ tokenCount: message.tokenCount,
1796
+ isRead: true,
1797
+ readAt: new Date(),
1798
+ pendingApproval: null,
1799
+ })),
1800
+ });
1801
+ }
1802
+ return {
1803
+ id: fork.id,
1804
+ title: fork.title,
1805
+ parentId: fork.parentId,
1806
+ forkedFromMessageId: fork.forkedFromMessageId,
1807
+ };
1808
+ }
1809
+ export async function createConversationShare(conversationId, createdBy, options) {
1810
+ const db = getDb();
1811
+ const conversation = await db.aiConversation.findUnique({ where: { id: conversationId } });
1812
+ if (!conversation) {
1813
+ throw new Error('Conversation not found');
1814
+ }
1815
+ const token = nanoid(24);
1816
+ const expiresAt = new Date(Date.now() + (options?.ttlHours ?? 72) * 60 * 60 * 1000).toISOString();
1817
+ await upsertConfigurationEntry(`share:conversation:${token}`, JSON.stringify({
1818
+ token,
1819
+ conversationId,
1820
+ createdBy,
1821
+ expiresAt,
1822
+ createdAt: new Date().toISOString(),
1823
+ }), 'user');
1824
+ return { token, expiresAt };
1825
+ }
1826
+ export async function getSharedConversation(token) {
1827
+ const db = getDb();
1828
+ const record = await db.aiMemory.findUnique({ where: { key: `share:conversation:${token}` } });
1829
+ if (!record)
1830
+ return null;
1831
+ let payload;
1832
+ try {
1833
+ payload = JSON.parse(record.content);
1834
+ }
1835
+ catch {
1836
+ return null;
1837
+ }
1838
+ if (new Date(payload.expiresAt).getTime() <= Date.now()) {
1839
+ await db.aiMemory.delete({ where: { key: `share:conversation:${token}` } }).catch(() => { });
1840
+ return null;
1841
+ }
1842
+ const conversation = await db.aiConversation.findUnique({
1843
+ where: { id: payload.conversationId },
1844
+ include: {
1845
+ messages: {
1846
+ orderBy: { createdAt: 'asc' },
1847
+ },
1848
+ _count: { select: { messages: true, children: true } },
1849
+ },
1850
+ });
1851
+ if (!conversation)
1852
+ return null;
1853
+ return {
1854
+ token,
1855
+ expiresAt: payload.expiresAt,
1856
+ conversation: {
1857
+ id: conversation.id,
1858
+ title: conversation.title,
1859
+ type: conversation.type,
1860
+ createdAt: conversation.createdAt.toISOString(),
1861
+ updatedAt: conversation.updatedAt.toISOString(),
1862
+ parentId: conversation.parentId,
1863
+ forkedFromMessageId: conversation.forkedFromMessageId,
1864
+ messageCount: conversation._count.messages,
1865
+ childCount: conversation._count.children,
1866
+ },
1867
+ messages: conversation.messages.map((message) => ({
1868
+ id: message.id,
1869
+ role: message.role,
1870
+ content: message.content,
1871
+ toolCalls: JSON.parse(message.toolCalls),
1872
+ thinking: message.thinking,
1873
+ structuredAnalysis: message.structuredAnalysis ? JSON.parse(message.structuredAnalysis) : null,
1874
+ createdAt: message.createdAt.toISOString(),
1875
+ })),
1876
+ };
1877
+ }
1878
+ /**
1879
+ * Return the current tool enable/disable config stored in AiMemory.
1880
+ * Tools that are not listed in the config are assumed enabled.
1881
+ */
1882
+ export async function getToolConfig() {
1883
+ const db = getDb();
1884
+ const record = await db.aiMemory.findUnique({ where: { key: 'tool_config' } });
1885
+ if (!record)
1886
+ return [];
1887
+ try {
1888
+ return JSON.parse(record.content);
1889
+ }
1890
+ catch {
1891
+ return [];
1892
+ }
1893
+ }
1894
+ export async function updateToolConfig(entries) {
1895
+ const db = getDb();
1896
+ const content = JSON.stringify(entries);
1897
+ const existing = await db.aiMemory.findUnique({ where: { key: 'tool_config' } });
1898
+ if (existing) {
1899
+ await db.aiMemory.update({
1900
+ where: { key: 'tool_config' },
1901
+ data: { content, updatedAt: new Date() },
1902
+ });
1903
+ }
1904
+ else {
1905
+ await db.aiMemory.create({
1906
+ data: {
1907
+ id: nanoid(),
1908
+ key: 'tool_config',
1909
+ category: 'configuration',
1910
+ content,
1911
+ source: 'user',
1912
+ relevance: 0.0, // excluded from AI context injection
1913
+ },
1914
+ });
1915
+ }
1916
+ }
1917
+ // ── Heartbeat Prompt ──
1918
+ /**
1919
+ * Return the current heartbeat prompt. If a user override is stored in AiMemory
1920
+ * it is returned; otherwise the default HEARTBEAT.md content is returned.
1921
+ */
1922
+ export async function getHeartbeatPrompt() {
1923
+ const db = getDb();
1924
+ const record = await db.aiMemory.findUnique({ where: { key: 'heartbeat_prompt' } });
1925
+ if (record?.content) {
1926
+ return { content: record.content, isCustom: true };
1927
+ }
1928
+ return { content: HEARTBEAT_DOC_DEFAULT, isCustom: false };
1929
+ }
1930
+ export async function updateHeartbeatPrompt(content) {
1931
+ const db = getDb();
1932
+ const existing = await db.aiMemory.findUnique({ where: { key: 'heartbeat_prompt' } });
1933
+ if (existing) {
1934
+ await db.aiMemory.update({
1935
+ where: { key: 'heartbeat_prompt' },
1936
+ data: { content, updatedAt: new Date() },
1937
+ });
1938
+ }
1939
+ else {
1940
+ await db.aiMemory.create({
1941
+ data: {
1942
+ id: nanoid(),
1943
+ key: 'heartbeat_prompt',
1944
+ category: 'configuration',
1945
+ content,
1946
+ source: 'user',
1947
+ relevance: 0.0,
1948
+ },
1949
+ });
1950
+ }
1951
+ }
1952
+ export async function resetHeartbeatPrompt() {
1953
+ const db = getDb();
1954
+ await db.aiMemory.deleteMany({ where: { key: 'heartbeat_prompt' } });
1955
+ }
1956
+ export async function listOllamaModels() {
1957
+ const res = await fetch(`${OLLAMA_BASE_URL}/api/tags`, {
1958
+ signal: AbortSignal.timeout(5000),
1959
+ });
1960
+ if (!res.ok)
1961
+ throw new Error(`Ollama API error: ${res.status}`);
1962
+ const data = (await res.json());
1963
+ return (data.models ?? []).map((m) => ({
1964
+ name: m.name,
1965
+ size: m.size,
1966
+ parameterSize: m.details?.parameter_size ?? '',
1967
+ family: m.details?.family ?? '',
1968
+ quantizationLevel: m.details?.quantization_level ?? '',
1969
+ }));
1970
+ }
1971
+ export async function getModelContextLength(modelName) {
1972
+ try {
1973
+ const res = await fetch(`${OLLAMA_BASE_URL}/api/show`, {
1974
+ method: 'POST',
1975
+ headers: { 'Content-Type': 'application/json' },
1976
+ body: JSON.stringify({ name: modelName }),
1977
+ });
1978
+ if (!res.ok)
1979
+ return null;
1980
+ const data = (await res.json());
1981
+ for (const [key, val] of Object.entries(data.model_info ?? {})) {
1982
+ if (key.includes('context_length'))
1983
+ return val;
1984
+ }
1985
+ return null;
1986
+ }
1987
+ catch {
1988
+ return null;
1989
+ }
1990
+ }
1991
+ export async function getAiSettings() {
1992
+ const db = getDb();
1993
+ const entry = await db.aiMemory.findUnique({ where: { key: 'ai_settings' } });
1994
+ let saved = {};
1995
+ if (entry) {
1996
+ try {
1997
+ saved = JSON.parse(entry.content);
1998
+ }
1999
+ catch { /* ignore */ }
2000
+ }
2001
+ // Verify chosen model is still available, fall back to default if not
2002
+ let model = saved.model ?? OLLAMA_MODEL;
2003
+ try {
2004
+ const models = await listOllamaModels();
2005
+ if (!models.some((m) => m.name === model))
2006
+ model = OLLAMA_MODEL;
2007
+ }
2008
+ catch {
2009
+ model = OLLAMA_MODEL;
2010
+ }
2011
+ return {
2012
+ model,
2013
+ defaultModel: OLLAMA_MODEL,
2014
+ maxContextTokens: saved.maxContextTokens ?? AI_MAX_CONTEXT_TOKENS,
2015
+ systemPrompt: saved.systemPrompt ?? null,
2016
+ isCustomSystemPrompt: saved.systemPrompt != null,
2017
+ defaultSystemPrompt: IDENTITY,
2018
+ thinkingMode: saved.thinkingMode ?? 'standard',
2019
+ showReasoning: saved.showReasoning ?? true,
2020
+ maxToolRounds: saved.maxToolRounds ?? DEFAULT_MAX_TOOL_ROUNDS,
2021
+ replEnabled: saved.replEnabled ?? false,
2022
+ structuredOutputsEnabled: saved.structuredOutputsEnabled ?? true,
2023
+ };
2024
+ }
2025
+ export async function updateAiSettings(settings) {
2026
+ const db = getDb();
2027
+ const existing = await db.aiMemory.findUnique({ where: { key: 'ai_settings' } });
2028
+ let current = {};
2029
+ if (existing) {
2030
+ try {
2031
+ current = JSON.parse(existing.content);
2032
+ }
2033
+ catch { /* ignore */ }
2034
+ }
2035
+ const merged = { ...current, ...settings };
2036
+ const content = JSON.stringify(merged);
2037
+ if (existing) {
2038
+ await db.aiMemory.update({
2039
+ where: { key: 'ai_settings' },
2040
+ data: { content, updatedAt: new Date() },
2041
+ });
2042
+ }
2043
+ else {
2044
+ await db.aiMemory.create({
2045
+ data: {
2046
+ id: nanoid(),
2047
+ key: 'ai_settings',
2048
+ category: 'configuration',
2049
+ content,
2050
+ source: 'user',
2051
+ relevance: 0.0,
2052
+ },
2053
+ });
2054
+ }
2055
+ }
2056
+ // ── Check Ollama availability ──
2057
+ export async function checkOllamaHealth() {
2058
+ try {
2059
+ const response = await fetch(`${OLLAMA_BASE_URL}/api/tags`, {
2060
+ signal: AbortSignal.timeout(5000),
2061
+ });
2062
+ if (!response.ok) {
2063
+ return { available: false, model: OLLAMA_MODEL, url: OLLAMA_BASE_URL, error: `HTTP ${response.status}` };
2064
+ }
2065
+ const data = (await response.json());
2066
+ const modelAvailable = data.models?.some((m) => m.name === OLLAMA_MODEL || m.name.startsWith(OLLAMA_MODEL.split(':')[0]));
2067
+ return {
2068
+ available: !!modelAvailable,
2069
+ model: OLLAMA_MODEL,
2070
+ url: OLLAMA_BASE_URL,
2071
+ error: modelAvailable ? undefined : `Model ${OLLAMA_MODEL} not found`,
2072
+ };
2073
+ }
2074
+ catch (err) {
2075
+ return {
2076
+ available: false,
2077
+ model: OLLAMA_MODEL,
2078
+ url: OLLAMA_BASE_URL,
2079
+ error: err instanceof Error ? err.message : 'Unknown error',
2080
+ };
2081
+ }
2082
+ }
2083
+ export async function getProviderStatuses(force = false) {
2084
+ return getLlmProviderStatuses(force);
2085
+ }
2086
+ /**
2087
+ * Stream a chat response as SSE events.
2088
+ * Calls Ollama with stream=true and forwards token deltas.
2089
+ */
2090
+ export async function chatStream(request, onEvent) {
2091
+ const db = getDb();
2092
+ try {
2093
+ const aiSettings = await getAiSettings();
2094
+ const profile = await selectModel(request.type ?? 'chat');
2095
+ const primaryProvider = getLlmProvider(profile.provider ?? 'ollama');
2096
+ if (!primaryProvider.supportsStreaming || !primaryProvider.streamChat) {
2097
+ const fallback = await chat(request);
2098
+ if (fallback.thinking) {
2099
+ onEvent({ event: 'thinking_token', data: { text: fallback.thinking, round: 1 } });
2100
+ }
2101
+ for (const toolCall of fallback.toolCalls) {
2102
+ onEvent({
2103
+ event: 'tool_result',
2104
+ data: { name: toolCall.name, success: toolCall.success, result: toolCall.result },
2105
+ });
2106
+ }
2107
+ onEvent({ event: 'token', data: { text: fallback.content } });
2108
+ onEvent({
2109
+ event: 'done',
2110
+ data: { conversationId: fallback.conversationId, messageId: fallback.messageId, tokenCount: fallback.tokenCount },
2111
+ });
2112
+ return;
2113
+ }
2114
+ // ── Conversation setup (same as chat()) ──
2115
+ let conversationId = request.conversationId;
2116
+ let conversation;
2117
+ if (conversationId) {
2118
+ conversation = await db.aiConversation.findUnique({
2119
+ where: { id: conversationId },
2120
+ include: { messages: { orderBy: { createdAt: 'asc' } } },
2121
+ });
2122
+ }
2123
+ if (!conversation) {
2124
+ conversation = await db.aiConversation.create({
2125
+ data: {
2126
+ id: nanoid(),
2127
+ title: request.message.slice(0, 80) || 'New conversation',
2128
+ type: request.type ?? 'chat',
2129
+ nodeId: request.nodeId ?? null,
2130
+ createdBy: request.createdBy ?? 'system',
2131
+ },
2132
+ include: { messages: { orderBy: { createdAt: 'asc' } } },
2133
+ });
2134
+ }
2135
+ conversationId = conversation.id;
2136
+ // Save user message
2137
+ await db.aiMessage.create({
2138
+ data: {
2139
+ id: nanoid(),
2140
+ conversationId,
2141
+ role: 'user',
2142
+ content: request.message,
2143
+ },
2144
+ });
2145
+ // Build system prompt and message history
2146
+ const activeModel = aiSettings.model || profile.model;
2147
+ const maxToolRounds = request.maxToolRounds ?? aiSettings.maxToolRounds ?? profile.maxToolRounds ?? DEFAULT_MAX_TOOL_ROUNDS;
2148
+ const memories = await getMemoriesAsContext();
2149
+ const systemPrompt = buildSystemPrompt({
2150
+ memories,
2151
+ fleetStatus: request.fleetContext,
2152
+ anomalies: request.anomalyContext,
2153
+ controllerContext: request.controllerContext,
2154
+ userContext: request.userInfo
2155
+ ? [request.userInfo.email ? `Email: ${request.userInfo.email}` : null, request.userInfo.username ? `Username: ${request.userInfo.username}` : null, request.userInfo.role ? `Role: ${request.userInfo.role}` : null]
2156
+ .filter(Boolean)
2157
+ .join('\n')
2158
+ : undefined,
2159
+ scratchpadContext: request.replMode ? getScratchpadAsContext(conversationId) ?? undefined : undefined,
2160
+ replMode: request.replMode,
2161
+ });
2162
+ const messages = [{ role: 'system', content: systemPrompt }];
2163
+ // Add conversation history (sliding window)
2164
+ const historyMessages = conversation.messages.slice(-30);
2165
+ for (const msg of historyMessages) {
2166
+ if (msg.role === 'system')
2167
+ continue;
2168
+ messages.push({ role: msg.role, content: msg.content });
2169
+ }
2170
+ const userStreamMsg = { role: 'user', content: request.message };
2171
+ if (request.attachments?.length) {
2172
+ userStreamMsg.images = request.attachments
2173
+ .filter((a) => a.type === 'image')
2174
+ .map((a) => ({ type: 'image', data: a.data, mimeType: a.mimeType }));
2175
+ }
2176
+ messages.push(userStreamMsg);
2177
+ const allTools = getOllamaToolDefinitions();
2178
+ const config = await getToolConfig();
2179
+ const disabledSet = new Set(config.filter((entry) => !entry.enabled).map((entry) => entry.name));
2180
+ const tools = disabledSet.size > 0
2181
+ ? allTools.filter((tool) => !disabledSet.has(tool.function.name))
2182
+ : allTools;
2183
+ const approvalPolicy = await getToolApprovalPolicy();
2184
+ let totalTokens = 0;
2185
+ let finalContent = '';
2186
+ let finalThinking = '';
2187
+ const allToolResults = [];
2188
+ const seenToolCallSignatures = new Set();
2189
+ const knownTools = new Set(listAvailableTools());
2190
+ let previousComparableResponse = '';
2191
+ // ── Multi-round tool calling loop with streaming ──
2192
+ for (let round = 1; round <= maxToolRounds; round++) {
2193
+ onEvent({ event: 'thinking', data: { round, status: `Calling AI model (round ${round})...` } });
2194
+ const reasoningInstruction = getThinkingInstruction((await getOllamaThinkingCapability(OLLAMA_BASE_URL, activeModel))
2195
+ ? (request.thinkingMode ?? aiSettings.thinkingMode ?? profile.thinkingMode ?? 'standard')
2196
+ : 'off');
2197
+ const streamingMessages = reasoningInstruction
2198
+ ? [{ role: 'system', content: reasoningInstruction }, ...messages]
2199
+ : messages;
2200
+ // Call Ollama with streaming
2201
+ const streamResponse = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
2202
+ method: 'POST',
2203
+ headers: { 'Content-Type': 'application/json' },
2204
+ body: JSON.stringify({
2205
+ model: activeModel,
2206
+ messages: streamingMessages,
2207
+ tools: round <= maxToolRounds ? tools : undefined,
2208
+ stream: true,
2209
+ options: {
2210
+ temperature: profile.temperature,
2211
+ num_predict: profile.maxTokens,
2212
+ },
2213
+ }),
2214
+ });
2215
+ if (!streamResponse.ok || !streamResponse.body) {
2216
+ onEvent({ event: 'error', data: { message: `Ollama returned ${streamResponse.status}` } });
2217
+ return;
2218
+ }
2219
+ // Read NDJSON stream
2220
+ const reader = streamResponse.body.getReader();
2221
+ const decoder = new TextDecoder();
2222
+ let buffer = '';
2223
+ let roundContent = '';
2224
+ let roundThinking = '';
2225
+ let nativeToolCalls = [];
2226
+ while (true) {
2227
+ const { done, value } = await reader.read();
2228
+ if (done)
2229
+ break;
2230
+ buffer += decoder.decode(value, { stream: true });
2231
+ const lines = buffer.split('\n');
2232
+ buffer = lines.pop();
2233
+ for (const line of lines) {
2234
+ if (!line.trim())
2235
+ continue;
2236
+ try {
2237
+ const chunk = JSON.parse(line);
2238
+ if (chunk.message?.thinking) {
2239
+ roundThinking += chunk.message.thinking;
2240
+ onEvent({ event: 'thinking_token', data: { text: chunk.message.thinking, round } });
2241
+ }
2242
+ if (chunk.message?.content) {
2243
+ roundContent += chunk.message.content;
2244
+ onEvent({ event: 'token', data: { text: chunk.message.content } });
2245
+ }
2246
+ if (chunk.message?.tool_calls) {
2247
+ nativeToolCalls = chunk.message.tool_calls;
2248
+ }
2249
+ if (chunk.eval_count) {
2250
+ totalTokens += chunk.eval_count;
2251
+ }
2252
+ }
2253
+ catch { /* skip malformed lines */ }
2254
+ }
2255
+ }
2256
+ // Process any remaining buffer
2257
+ if (buffer.trim()) {
2258
+ try {
2259
+ const chunk = JSON.parse(buffer);
2260
+ if (chunk.message?.thinking) {
2261
+ roundThinking += chunk.message.thinking;
2262
+ onEvent({ event: 'thinking_token', data: { text: chunk.message.thinking, round } });
2263
+ }
2264
+ if (chunk.message?.content) {
2265
+ roundContent += chunk.message.content;
2266
+ onEvent({ event: 'token', data: { text: chunk.message.content } });
2267
+ }
2268
+ if (chunk.message?.tool_calls)
2269
+ nativeToolCalls = chunk.message.tool_calls;
2270
+ if (chunk.eval_count)
2271
+ totalTokens += chunk.eval_count;
2272
+ }
2273
+ catch { /* skip */ }
2274
+ }
2275
+ // Strip thinking content if present
2276
+ const thinkRegex = /<think>[\s\S]*?<\/think>/g;
2277
+ roundContent = roundContent.replace(thinkRegex, '').trim();
2278
+ finalContent = roundContent;
2279
+ if (roundThinking.trim()) {
2280
+ finalThinking = [finalThinking, roundThinking.trim()].filter(Boolean).join('\n\n---\n\n');
2281
+ }
2282
+ const comparableResponse = stripToolCallMarkup(roundContent, knownTools);
2283
+ const mappedToolCalls = nativeToolCalls.map((toolCall) => ({
2284
+ name: toolCall.function.name,
2285
+ arguments: toolCall.function.arguments ?? {},
2286
+ }));
2287
+ if (mappedToolCalls.length > 0) {
2288
+ if (hasExceededToolLoopBudget(totalTokens, profile.maxTokens, maxToolRounds)) {
2289
+ finalContent = comparableResponse || buildLoopStopSummary('I stopped requesting more tools because the tool-round token budget was reached. Here is the best answer from the data gathered so far.', allToolResults);
2290
+ break;
2291
+ }
2292
+ const repeatedToolCall = findRepeatedToolCall(mappedToolCalls, seenToolCallSignatures);
2293
+ if (repeatedToolCall) {
2294
+ finalContent = comparableResponse || buildLoopStopSummary(`I stopped requesting more tools because \`${repeatedToolCall.name}\` was requested again with the same arguments. Here is the best answer from the data gathered so far.`, allToolResults);
2295
+ break;
2296
+ }
2297
+ if (previousComparableResponse && hasStalledForwardProgress(previousComparableResponse, comparableResponse)) {
2298
+ finalContent = comparableResponse || buildLoopStopSummary('I stopped requesting more tools because the investigation stopped making forward progress. Here is the best answer from the data gathered so far.', allToolResults);
2299
+ break;
2300
+ }
2301
+ previousComparableResponse = comparableResponse;
2302
+ mappedToolCalls.forEach((call) => seenToolCallSignatures.add(getToolCallSignature(call)));
2303
+ }
2304
+ // Check for tool calls
2305
+ if (nativeToolCalls.length === 0)
2306
+ break; // No tools, we're done
2307
+ // Execute tool calls
2308
+ messages.push({
2309
+ role: 'assistant',
2310
+ content: roundContent,
2311
+ tool_calls: mappedToolCalls,
2312
+ });
2313
+ const toolResults = [];
2314
+ for (const tc of nativeToolCalls) {
2315
+ const toolName = tc.function.name;
2316
+ const toolArgs = tc.function.arguments;
2317
+ // Check approval policy
2318
+ if (approvalPolicy.alwaysDeny.includes(toolName)) {
2319
+ toolResults.push({ name: toolName, success: false, result: null, error: 'Tool is denied by policy' });
2320
+ continue;
2321
+ }
2322
+ if (approvalPolicy.requireApproval.includes(toolName)) {
2323
+ // Save pending approval and stop
2324
+ const approvalData = JSON.stringify({
2325
+ toolName,
2326
+ toolArgs,
2327
+ round,
2328
+ conversationId,
2329
+ });
2330
+ const msgId = nanoid();
2331
+ await db.aiMessage.create({
2332
+ data: {
2333
+ id: msgId,
2334
+ conversationId,
2335
+ role: 'assistant',
2336
+ content: finalContent || `I need to run \`${toolName}\` but it requires your approval.`,
2337
+ toolCalls: JSON.stringify(toolResults),
2338
+ thinking: finalThinking || null,
2339
+ tokenCount: totalTokens,
2340
+ pendingApproval: approvalData,
2341
+ },
2342
+ });
2343
+ onEvent({
2344
+ event: 'done',
2345
+ data: { conversationId, messageId: msgId, tokenCount: totalTokens },
2346
+ });
2347
+ return;
2348
+ }
2349
+ onEvent({ event: 'tool_start', data: { name: toolName, round } });
2350
+ const result = await executeAiTool({
2351
+ name: toolName,
2352
+ arguments: {
2353
+ ...toolArgs,
2354
+ _conversationId: conversationId,
2355
+ },
2356
+ });
2357
+ toolResults.push(result);
2358
+ allToolResults.push(result);
2359
+ onEvent({
2360
+ event: 'tool_result',
2361
+ data: { name: toolName, success: result.success, result: result.result },
2362
+ });
2363
+ if (request.replMode && result.success) {
2364
+ autoStoreToolResult(conversationId, toolName, result.result);
2365
+ onEvent({
2366
+ event: 'scratchpad_update',
2367
+ data: {
2368
+ entries: listScratchpadEntries(conversationId).map((entry) => ({
2369
+ name: entry.name,
2370
+ source: entry.source,
2371
+ summary: entry.summary,
2372
+ })),
2373
+ },
2374
+ });
2375
+ }
2376
+ // If executeNaturalCommand returned a command preview, create a pending approval for execution
2377
+ if (toolName === 'executeNaturalCommand' && result.success) {
2378
+ const preview = result.result;
2379
+ if (preview?.type === 'commandPreview' && preview.requiresConfirmation !== false) {
2380
+ const approvalData = JSON.stringify({
2381
+ toolName: 'executeNaturalCommand',
2382
+ toolArgs: { ...toolArgs, _preview: { command: preview.command, args: preview.args, nodeId: preview.nodeId, nodeName: preview.nodeName } },
2383
+ round,
2384
+ conversationId,
2385
+ });
2386
+ const msgId = nanoid();
2387
+ await db.aiMessage.create({
2388
+ data: {
2389
+ id: msgId,
2390
+ conversationId,
2391
+ role: 'assistant',
2392
+ content: finalContent || `I'd like to run \`${preview.command} ${(preview.args ?? []).join(' ')}\` on **${preview.nodeName}**. Please confirm to proceed.`,
2393
+ toolCalls: JSON.stringify(toolResults),
2394
+ thinking: finalThinking || null,
2395
+ tokenCount: totalTokens,
2396
+ pendingApproval: approvalData,
2397
+ },
2398
+ });
2399
+ onEvent({
2400
+ event: 'done',
2401
+ data: { conversationId, messageId: msgId, tokenCount: totalTokens },
2402
+ });
2403
+ return;
2404
+ }
2405
+ }
2406
+ // Add tool result to messages for next round
2407
+ messages.push({
2408
+ role: 'tool',
2409
+ content: JSON.stringify({ name: toolName, result: result.result, error: result.error }),
2410
+ });
2411
+ }
2412
+ }
2413
+ // Save the final assistant message
2414
+ const messageId = nanoid();
2415
+ await db.aiMessage.create({
2416
+ data: {
2417
+ id: messageId,
2418
+ conversationId,
2419
+ role: 'assistant',
2420
+ content: finalContent,
2421
+ thinking: finalThinking || null,
2422
+ tokenCount: totalTokens,
2423
+ },
2424
+ });
2425
+ // Auto-summarize if conversation is getting long
2426
+ const messageCount = await db.aiMessage.count({ where: { conversationId } });
2427
+ if (messageCount > 20 && messageCount % 10 === 0) {
2428
+ summarizeConversation(conversationId).catch(() => { }); // fire-and-forget
2429
+ }
2430
+ onEvent({
2431
+ event: 'done',
2432
+ data: { conversationId, messageId, tokenCount: totalTokens },
2433
+ });
2434
+ }
2435
+ catch (err) {
2436
+ onEvent({
2437
+ event: 'error',
2438
+ data: { message: err instanceof Error ? err.message : 'Unknown streaming error' },
2439
+ });
2440
+ }
2441
+ }
2442
+ // ── Tool Approval Actions ──
2443
+ /**
2444
+ * Approve a pending tool call and resume the conversation.
2445
+ */
2446
+ export async function approveToolCall(messageId, actorId = 'operator') {
2447
+ const db = getDb();
2448
+ const message = await db.aiMessage.findUnique({ where: { id: messageId } });
2449
+ if (!message?.pendingApproval)
2450
+ return null;
2451
+ let approval;
2452
+ try {
2453
+ approval = JSON.parse(message.pendingApproval);
2454
+ }
2455
+ catch {
2456
+ return null;
2457
+ }
2458
+ // Clear the pending approval
2459
+ await db.aiMessage.update({
2460
+ where: { id: messageId },
2461
+ data: { pendingApproval: null },
2462
+ });
2463
+ await logAuditEvent({
2464
+ action: 'assistant.tool_approval',
2465
+ actorId,
2466
+ actorType: 'user',
2467
+ targetType: 'ai_message',
2468
+ targetId: messageId,
2469
+ details: { toolName: approval.toolName, conversationId: approval.conversationId, decision: 'approved' },
2470
+ });
2471
+ // Execute the tool
2472
+ const toolResult = await executeAiTool({
2473
+ name: approval.toolName,
2474
+ arguments: {
2475
+ ...approval.toolArgs,
2476
+ _conversationId: approval.conversationId,
2477
+ },
2478
+ });
2479
+ // Continue the conversation with the tool result
2480
+ return chat({
2481
+ conversationId: approval.conversationId,
2482
+ message: `[Tool approved and executed] ${approval.toolName}: ${JSON.stringify(toolResult.result).slice(0, 2000)}`,
2483
+ type: 'chat',
2484
+ createdBy: 'system',
2485
+ });
2486
+ }
2487
+ /**
2488
+ * Reject a pending tool call.
2489
+ */
2490
+ export async function rejectToolCall(messageId, actorId = 'operator') {
2491
+ const db = getDb();
2492
+ const message = await db.aiMessage.findUnique({ where: { id: messageId } });
2493
+ if (!message?.pendingApproval)
2494
+ return null;
2495
+ let approval;
2496
+ try {
2497
+ approval = JSON.parse(message.pendingApproval);
2498
+ }
2499
+ catch {
2500
+ return null;
2501
+ }
2502
+ // Clear the pending approval
2503
+ await db.aiMessage.update({
2504
+ where: { id: messageId },
2505
+ data: { pendingApproval: null },
2506
+ });
2507
+ await logAuditEvent({
2508
+ action: 'assistant.tool_approval',
2509
+ actorId,
2510
+ actorType: 'user',
2511
+ targetType: 'ai_message',
2512
+ targetId: messageId,
2513
+ details: { toolName: approval.toolName, conversationId: approval.conversationId, decision: 'rejected' },
2514
+ });
2515
+ // Tell the AI the tool was rejected
2516
+ return chat({
2517
+ conversationId: approval.conversationId,
2518
+ message: `[Tool rejected by operator] The tool "${approval.toolName}" was denied. Please suggest an alternative approach or explain what you were trying to do.`,
2519
+ type: 'chat',
2520
+ createdBy: 'system',
2521
+ });
2522
+ }
2523
+ //# sourceMappingURL=ai-assistant-service.js.map