@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.
- package/.env.example +171 -0
- package/LICENSE +202 -0
- package/README.md +169 -2
- package/agent-version.json +1 -0
- package/dist/ai/HEARTBEAT.md +56 -0
- package/dist/ai/IDENTITY.md +54 -0
- package/dist/ai/MEMORY.md +40 -0
- package/dist/ai/TOOLS.md +538 -0
- package/dist/ai/USER.md +32 -0
- package/dist/cli/args.js +62 -0
- package/dist/cli/env-catalog.js +131 -0
- package/dist/cli/preflight.js +89 -0
- package/dist/cli.js +175 -0
- package/dist/config/cors.js +16 -0
- package/dist/config/env.js +392 -0
- package/dist/config/features.js +16 -0
- package/dist/config/index.js +3 -0
- package/dist/config/listener-security.js +38 -0
- package/dist/config/version.js +31 -0
- package/dist/db/client.js +44 -0
- package/dist/db/generated/prisma/browser.js +18 -0
- package/dist/db/generated/prisma/client.js +35 -0
- package/dist/db/generated/prisma/commonInputTypes.js +11 -0
- package/dist/db/generated/prisma/enums.js +42 -0
- package/dist/db/generated/prisma/internal/class.js +50 -0
- package/dist/db/generated/prisma/internal/prismaNamespace.js +1598 -0
- package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +1569 -0
- package/dist/db/generated/prisma/models/A2ATask.js +2 -0
- package/dist/db/generated/prisma/models/AdmissionFence.js +2 -0
- package/dist/db/generated/prisma/models/Agent.js +2 -0
- package/dist/db/generated/prisma/models/AiConversation.js +2 -0
- package/dist/db/generated/prisma/models/AiDashboard.js +2 -0
- package/dist/db/generated/prisma/models/AiMemory.js +2 -0
- package/dist/db/generated/prisma/models/AiMessage.js +2 -0
- package/dist/db/generated/prisma/models/AiScheduledOp.js +2 -0
- package/dist/db/generated/prisma/models/AlertConditionState.js +2 -0
- package/dist/db/generated/prisma/models/AlertRule.js +2 -0
- package/dist/db/generated/prisma/models/AlertViolation.js +2 -0
- package/dist/db/generated/prisma/models/AnomalyEvent.js +2 -0
- package/dist/db/generated/prisma/models/ApplicationObservation.js +2 -0
- package/dist/db/generated/prisma/models/ArdentInstallation.js +2 -0
- package/dist/db/generated/prisma/models/ArdentProfile.js +2 -0
- package/dist/db/generated/prisma/models/ArdentScopeEnrollment.js +2 -0
- package/dist/db/generated/prisma/models/CommandResourceFence.js +2 -0
- package/dist/db/generated/prisma/models/CommandResourceLease.js +2 -0
- package/dist/db/generated/prisma/models/CommandResult.js +2 -0
- package/dist/db/generated/prisma/models/CronJob.js +2 -0
- package/dist/db/generated/prisma/models/CronJobExecution.js +2 -0
- package/dist/db/generated/prisma/models/DeployJob.js +2 -0
- package/dist/db/generated/prisma/models/DriftScan.js +2 -0
- package/dist/db/generated/prisma/models/EngineeringMetricSample.js +2 -0
- package/dist/db/generated/prisma/models/EngineeringRecoveryExercise.js +2 -0
- package/dist/db/generated/prisma/models/ExecutionRun.js +2 -0
- package/dist/db/generated/prisma/models/FilePatchTask.js +2 -0
- package/dist/db/generated/prisma/models/GpuSnapshot.js +2 -0
- package/dist/db/generated/prisma/models/HealthSnapshot.js +2 -0
- package/dist/db/generated/prisma/models/HermesCoreRollout.js +2 -0
- package/dist/db/generated/prisma/models/HermesCoreRolloutHost.js +2 -0
- package/dist/db/generated/prisma/models/HermesCredentialPool.js +2 -0
- package/dist/db/generated/prisma/models/HermesCredentialPoolAttachment.js +2 -0
- package/dist/db/generated/prisma/models/HermesCronJob.js +2 -0
- package/dist/db/generated/prisma/models/HermesHook.js +2 -0
- package/dist/db/generated/prisma/models/HermesInstallation.js +2 -0
- package/dist/db/generated/prisma/models/HermesMcpServer.js +2 -0
- package/dist/db/generated/prisma/models/HermesMcpServerAttachment.js +2 -0
- package/dist/db/generated/prisma/models/HermesMessagingBinding.js +2 -0
- package/dist/db/generated/prisma/models/HermesOperationJob.js +2 -0
- package/dist/db/generated/prisma/models/HermesPluginInstall.js +2 -0
- package/dist/db/generated/prisma/models/HermesProfile.js +2 -0
- package/dist/db/generated/prisma/models/HermesProviderConfig.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryClient.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryInventory.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryReceipt.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryRevision.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryTarget.js +2 -0
- package/dist/db/generated/prisma/models/HermesRole.js +2 -0
- package/dist/db/generated/prisma/models/HermesRoleApplication.js +2 -0
- package/dist/db/generated/prisma/models/HermesSkillAssignment.js +2 -0
- package/dist/db/generated/prisma/models/HermesToolsetPolicy.js +2 -0
- package/dist/db/generated/prisma/models/IncidentLesson.js +2 -0
- package/dist/db/generated/prisma/models/LibraryCategory.js +2 -0
- package/dist/db/generated/prisma/models/LibraryFile.js +2 -0
- package/dist/db/generated/prisma/models/LogEntry.js +2 -0
- package/dist/db/generated/prisma/models/MagnusReleaseCanary.js +2 -0
- package/dist/db/generated/prisma/models/MaintenanceWindow.js +2 -0
- package/dist/db/generated/prisma/models/ManagedCronDefinition.js +2 -0
- package/dist/db/generated/prisma/models/ManagedCronOccurrence.js +2 -0
- package/dist/db/generated/prisma/models/Node.js +2 -0
- package/dist/db/generated/prisma/models/Notification.js +2 -0
- package/dist/db/generated/prisma/models/NotificationPreference.js +2 -0
- package/dist/db/generated/prisma/models/RemediationExecution.js +2 -0
- package/dist/db/generated/prisma/models/RemediationRule.js +2 -0
- package/dist/db/generated/prisma/models/RemoteCommand.js +2 -0
- package/dist/db/generated/prisma/models/ResourceDrainParticipant.js +2 -0
- package/dist/db/generated/prisma/models/ResourceEnrollment.js +2 -0
- package/dist/db/generated/prisma/models/ResourceParticipantWork.js +2 -0
- package/dist/db/generated/prisma/models/Role.js +2 -0
- package/dist/db/generated/prisma/models/RoleApplication.js +2 -0
- package/dist/db/generated/prisma/models/RolloutTarget.js +2 -0
- package/dist/db/generated/prisma/models/SecurityExportOutbox.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionAction.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionActionApproval.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCase.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCaseActivity.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCaseEntity.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCaseEvent.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCaseRelationship.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCollector.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCommandOutbox.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionDeletionReceipt.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionDelivery.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEntity.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEntityAlias.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEvent.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEventEntity.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEvidence.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionInvestigation.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionNonce.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionReceiptChainHead.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionRuleVersion.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionSourceState.js +2 -0
- package/dist/db/generated/prisma/models/SecurityServiceState.js +2 -0
- package/dist/db/generated/prisma/models/SharedResourceFence.js +2 -0
- package/dist/db/generated/prisma/models/SharedResourceLease.js +2 -0
- package/dist/db/generated/prisma/models/SharedResourceLeaseResource.js +2 -0
- package/dist/db/generated/prisma/models/SkillInstallation.js +2 -0
- package/dist/db/generated/prisma/models/SkillRegistryEntry.js +2 -0
- package/dist/db/generated/prisma/models/SkillTag.js +2 -0
- package/dist/db/generated/prisma/models/SkillVersion.js +2 -0
- package/dist/db/generated/prisma/models/TeamApprovalDecision.js +2 -0
- package/dist/db/generated/prisma/models/User.js +2 -0
- package/dist/db/generated/prisma/models/WebhookDelivery.js +2 -0
- package/dist/db/generated/prisma/models/WebhookEndpoint.js +2 -0
- package/dist/db/generated/prisma/models.js +2 -0
- package/dist/db/index.js +3 -0
- package/dist/db/read-retry.js +35 -0
- package/dist/db/seed.js +348 -0
- package/dist/index.js +162 -0
- package/dist/middleware/auth.js +361 -0
- package/dist/middleware/body-limit.js +14 -0
- package/dist/middleware/correlation-id.js +13 -0
- package/dist/middleware/error-handler.js +28 -0
- package/dist/middleware/iam-token.js +21 -0
- package/dist/middleware/rate-limit.js +78 -0
- package/dist/middleware/request-logger.js +85 -0
- package/dist/middleware/security-headers.js +23 -0
- package/dist/middleware/tracing.js +120 -0
- package/dist/middleware/user-status.js +33 -0
- package/dist/openapi/spec.js +2656 -0
- package/dist/queue/redis.js +20 -0
- package/dist/queue/requeue.js +27 -0
- package/dist/queue/workers.js +342 -0
- package/dist/routes/ai-schedule.js +73 -0
- package/dist/routes/alerts.js +79 -0
- package/dist/routes/anomalies.js +101 -0
- package/dist/routes/ardent.js +82 -0
- package/dist/routes/assistant.js +417 -0
- package/dist/routes/audit.js +138 -0
- package/dist/routes/codex.js +65 -0
- package/dist/routes/commands.js +226 -0
- package/dist/routes/credentials.js +58 -0
- package/dist/routes/cron.js +292 -0
- package/dist/routes/dependencies.js +49 -0
- package/dist/routes/deploy.js +18 -0
- package/dist/routes/discovery.js +168 -0
- package/dist/routes/docs.js +24 -0
- package/dist/routes/drift.js +126 -0
- package/dist/routes/events-stream.js +87 -0
- package/dist/routes/files.js +214 -0
- package/dist/routes/fleet-checks.js +21 -0
- package/dist/routes/health.js +133 -0
- package/dist/routes/heartbeat.js +545 -0
- package/dist/routes/hermes-registry.js +278 -0
- package/dist/routes/hermes.js +61 -0
- package/dist/routes/library.js +402 -0
- package/dist/routes/logs.js +172 -0
- package/dist/routes/managed-cron.js +23 -0
- package/dist/routes/mcp.js +39 -0
- package/dist/routes/metrics.js +187 -0
- package/dist/routes/nodes.js +951 -0
- package/dist/routes/notifications.js +130 -0
- package/dist/routes/ops.js +258 -0
- package/dist/routes/orchestra.js +430 -0
- package/dist/routes/predictions.js +23 -0
- package/dist/routes/profiling.js +68 -0
- package/dist/routes/quick-command.js +72 -0
- package/dist/routes/remediation.js +111 -0
- package/dist/routes/resource-leases.js +297 -0
- package/dist/routes/roles.js +370 -0
- package/dist/routes/rollout.js +124 -0
- package/dist/routes/scan.js +116 -0
- package/dist/routes/security-backup.js +58 -0
- package/dist/routes/security-fusion.js +605 -0
- package/dist/routes/security-integration.js +71 -0
- package/dist/routes/self-update.js +129 -0
- package/dist/routes/shared-resource-leases.js +78 -0
- package/dist/routes/skills.js +485 -0
- package/dist/routes/stats.js +96 -0
- package/dist/routes/team-approvals.js +23 -0
- package/dist/routes/terminal-ticket.js +15 -0
- package/dist/routes/terminal.js +17 -0
- package/dist/routes/topology.js +37 -0
- package/dist/routes/users.js +219 -0
- package/dist/routes/webhooks.js +137 -0
- package/dist/schemas/command-reconciliation.js +14 -0
- package/dist/schemas/commands.js +51 -0
- package/dist/schemas/cron.js +33 -0
- package/dist/schemas/deploy.js +20 -0
- package/dist/schemas/files.js +14 -0
- package/dist/schemas/hermes.js +69 -0
- package/dist/schemas/index.js +11 -0
- package/dist/schemas/library.js +48 -0
- package/dist/schemas/logs.js +35 -0
- package/dist/schemas/managed-cron.js +127 -0
- package/dist/schemas/node-responses.js +87 -0
- package/dist/schemas/node-session-receipt.js +84 -0
- package/dist/schemas/nodes.js +36 -0
- package/dist/schemas/orchestra.js +118 -0
- package/dist/schemas/resource-participant-reconciliation.js +99 -0
- package/dist/schemas/roles.js +54 -0
- package/dist/schemas/shared-resource-lease.js +132 -0
- package/dist/schemas/skills.js +44 -0
- package/dist/schemas/users.js +28 -0
- package/dist/scripts/run-disposable-recovery-exercise.js +39 -0
- package/dist/sdk/index.js +2 -0
- package/dist/sdk/node-api-client.js +474 -0
- package/dist/security/approval-assertion.js +66 -0
- package/dist/security/command-completion-registry.js +35 -0
- package/dist/security/command-request.js +12 -0
- package/dist/security/command-result-signature.js +34 -0
- package/dist/security/managed-http.js +45 -0
- package/dist/security/native-completion-registry.js +44 -0
- package/dist/server.js +178 -0
- package/dist/services/admission-fence.js +71 -0
- package/dist/services/ai-assistant-service.js +2523 -0
- package/dist/services/ai-correlation-service.js +225 -0
- package/dist/services/ai-export-service.js +141 -0
- package/dist/services/ai-insights-service.js +219 -0
- package/dist/services/ai-schedule-service.js +145 -0
- package/dist/services/ai-scratchpad-service.js +222 -0
- package/dist/services/ai-tools.js +1343 -0
- package/dist/services/alert-service.js +313 -0
- package/dist/services/anomaly-detector.js +419 -0
- package/dist/services/api-local-work.js +79 -0
- package/dist/services/application-probes.js +309 -0
- package/dist/services/ardent-scope-enrollment.js +123 -0
- package/dist/services/ardent-service.js +86 -0
- package/dist/services/audit-service.js +308 -0
- package/dist/services/codex-service.js +209 -0
- package/dist/services/command-lifecycle.js +60 -0
- package/dist/services/command-resource-leases.js +305 -0
- package/dist/services/control-host-node.js +242 -0
- package/dist/services/credential-rotation-service.js +294 -0
- package/dist/services/dependency-service.js +145 -0
- package/dist/services/disposable-recovery-runner.js +595 -0
- package/dist/services/docker-group-health.js +121 -0
- package/dist/services/engineering-assurance.js +492 -0
- package/dist/services/engineering-evidence-ledger.js +94 -0
- package/dist/services/engineering-metric-evidence.js +26 -0
- package/dist/services/event-bus.js +227 -0
- package/dist/services/fleet-check-log.js +102 -0
- package/dist/services/forecast-service.js +154 -0
- package/dist/services/health-retention.js +175 -0
- package/dist/services/heartbeat-stream.js +63 -0
- package/dist/services/hermes-catalog-import.js +62 -0
- package/dist/services/hermes-cli-parser.js +671 -0
- package/dist/services/hermes-core-rollout.js +90 -0
- package/dist/services/hermes-dashboard-endpoint.js +13 -0
- package/dist/services/hermes-plugin-source.js +149 -0
- package/dist/services/hermes-registry-service.js +258 -0
- package/dist/services/hermes-service.js +566 -0
- package/dist/services/hivemem-client.js +85 -0
- package/dist/services/host-local-writer.js +82 -0
- package/dist/services/incident-evidence.js +110 -0
- package/dist/services/incident-lessons.js +186 -0
- package/dist/services/llm-providers/anthropic.js +143 -0
- package/dist/services/llm-providers/index.js +7 -0
- package/dist/services/llm-providers/llama-cpp.js +237 -0
- package/dist/services/llm-providers/ollama.js +292 -0
- package/dist/services/llm-providers/openai-compatible.js +200 -0
- package/dist/services/llm-providers/provider-error.js +36 -0
- package/dist/services/llm-providers/registry.js +45 -0
- package/dist/services/llm-providers/types.js +2 -0
- package/dist/services/log-emitter.js +19 -0
- package/dist/services/log-retention.js +66 -0
- package/dist/services/maintenance-service.js +29 -0
- package/dist/services/managed-cron.js +493 -0
- package/dist/services/managed-ssh-admission.js +68 -0
- package/dist/services/monitor-pulse.js +10 -0
- package/dist/services/node-issue-monitor.js +71 -0
- package/dist/services/node-service.js +35 -0
- package/dist/services/notification-retention.js +51 -0
- package/dist/services/notification-service.js +94 -0
- package/dist/services/operational-observations.js +112 -0
- package/dist/services/operations-config.js +67 -0
- package/dist/services/ops-service.js +491 -0
- package/dist/services/orchestra-extras-service.js +370 -0
- package/dist/services/orchestra-resources-service.js +530 -0
- package/dist/services/os-scheduler-writer.js +73 -0
- package/dist/services/password-service.js +22 -0
- package/dist/services/prediction-service.js +284 -0
- package/dist/services/recovery-exercises.js +682 -0
- package/dist/services/remediation-service.js +325 -0
- package/dist/services/resource-enrollment.js +433 -0
- package/dist/services/resource-node-lock.js +18 -0
- package/dist/services/resource-participant-work.js +302 -0
- package/dist/services/role-service.js +388 -0
- package/dist/services/rollout-service.js +440 -0
- package/dist/services/security-export.js +185 -0
- package/dist/services/security-fusion-actions.js +22 -0
- package/dist/services/security-fusion-approvals.js +83 -0
- package/dist/services/security-fusion-evidence-integrity.js +144 -0
- package/dist/services/security-fusion-ingest.js +101 -0
- package/dist/services/security-fusion-outbox.js +185 -0
- package/dist/services/security-fusion-receipt-chain.js +134 -0
- package/dist/services/security-fusion-service.js +751 -0
- package/dist/services/security-fusion-verification.js +50 -0
- package/dist/services/self-update-service.js +200 -0
- package/dist/services/shared-resource-leases.js +413 -0
- package/dist/services/skill-publication-bundle.js +90 -0
- package/dist/services/skill-publication-settlement.js +36 -0
- package/dist/services/skill-publication-state.js +40 -0
- package/dist/services/skill-registry-service.js +327 -0
- package/dist/services/skill-service.js +148 -0
- package/dist/services/ssh-exec-service.js +139 -0
- package/dist/services/ssh-host-verification.js +27 -0
- package/dist/services/tailscale-node-key-service.js +75 -0
- package/dist/services/team-approvals.js +118 -0
- package/dist/services/topology-service.js +138 -0
- package/dist/services/transfer-authority-snapshot.js +151 -0
- package/dist/services/webhook-credential-migration.js +97 -0
- package/dist/services/webhook-security.js +242 -0
- package/dist/services/webhook-service.js +494 -0
- package/dist/types/auth.js +3 -0
- package/dist/types/index.js +3 -0
- package/dist/types/library.js +3 -0
- package/dist/types/notifications.js +3 -0
- package/dist/types/roles.js +3 -0
- package/dist/types/shared-contracts.js +3 -0
- package/dist/utils/env.js +141 -0
- package/dist/utils/heartbeat-peer.js +19 -0
- package/dist/utils/idempotency.js +45 -0
- package/dist/utils/ip.js +25 -0
- package/dist/utils/json-fields.js +94 -0
- package/dist/utils/local-tls.js +39 -0
- package/dist/utils/magnus-home.js +15 -0
- package/dist/utils/node-url.js +59 -0
- package/dist/utils/redact.js +26 -0
- package/dist/utils/safe-equal.js +13 -0
- package/dist/utils/sentinel-nodes.js +16 -0
- package/dist/utils/validate.js +41 -0
- package/package.json +119 -4
- package/prisma/migrations/20261031000000_baseline/migration.sql +2935 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +2185 -0
- 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
|