@synap-core/eve 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +251 -0
  2. package/dist/add-R6RGUYER.js +22 -0
  3. package/dist/add-R6RGUYER.js.map +1 -0
  4. package/dist/auth-7FFZNARZ-4OIRGZ7R.js +31 -0
  5. package/dist/auth-7FFZNARZ-4OIRGZ7R.js.map +1 -0
  6. package/dist/chunk-22RIHU4J.js +975 -0
  7. package/dist/chunk-22RIHU4J.js.map +1 -0
  8. package/dist/chunk-23XQDB7K.js +1773 -0
  9. package/dist/chunk-23XQDB7K.js.map +1 -0
  10. package/dist/chunk-5TBV2VHZ.js +6067 -0
  11. package/dist/chunk-5TBV2VHZ.js.map +1 -0
  12. package/dist/chunk-D7P3IYYR.js +44 -0
  13. package/dist/chunk-D7P3IYYR.js.map +1 -0
  14. package/dist/chunk-EHPC7CKE.js +757 -0
  15. package/dist/chunk-EHPC7CKE.js.map +1 -0
  16. package/dist/chunk-FAE5UZFE.js +17 -0
  17. package/dist/chunk-FAE5UZFE.js.map +1 -0
  18. package/dist/chunk-HRF6TNXX.js +53 -0
  19. package/dist/chunk-HRF6TNXX.js.map +1 -0
  20. package/dist/chunk-HVJEW7TU.js +495 -0
  21. package/dist/chunk-HVJEW7TU.js.map +1 -0
  22. package/dist/chunk-JO5H5G6U.js +1529 -0
  23. package/dist/chunk-JO5H5G6U.js.map +1 -0
  24. package/dist/chunk-MUEQNBE6.js +1140 -0
  25. package/dist/chunk-MUEQNBE6.js.map +1 -0
  26. package/dist/chunk-O5WTCVMZ.js +3391 -0
  27. package/dist/chunk-O5WTCVMZ.js.map +1 -0
  28. package/dist/chunk-ROKIQIEX.js +65 -0
  29. package/dist/chunk-ROKIQIEX.js.map +1 -0
  30. package/dist/chunk-T7GCAJK5.js +4895 -0
  31. package/dist/chunk-T7GCAJK5.js.map +1 -0
  32. package/dist/chunk-UOQWJ3S4.js +138 -0
  33. package/dist/chunk-UOQWJ3S4.js.map +1 -0
  34. package/dist/chunk-YQWV5W4U.js +3479 -0
  35. package/dist/chunk-YQWV5W4U.js.map +1 -0
  36. package/dist/chunk-ZPDNZRVW.js +272 -0
  37. package/dist/chunk-ZPDNZRVW.js.map +1 -0
  38. package/dist/config-store-KTOWIKAS-XVZHNPKW.js +11 -0
  39. package/dist/config-store-KTOWIKAS-XVZHNPKW.js.map +1 -0
  40. package/dist/dist-2VRFSXMO.js +40 -0
  41. package/dist/dist-2VRFSXMO.js.map +1 -0
  42. package/dist/dist-C2QB4IKT.js +340 -0
  43. package/dist/dist-C2QB4IKT.js.map +1 -0
  44. package/dist/dist-DEIUVZ57.js +299 -0
  45. package/dist/dist-DEIUVZ57.js.map +1 -0
  46. package/dist/dist-DSKHGECB.js +36 -0
  47. package/dist/dist-DSKHGECB.js.map +1 -0
  48. package/dist/dist-GN2DN7CI.js +95 -0
  49. package/dist/dist-GN2DN7CI.js.map +1 -0
  50. package/dist/dist-TZL4XW2R.js +58 -0
  51. package/dist/dist-TZL4XW2R.js.map +1 -0
  52. package/dist/dist-ZP2APOYV.js +53 -0
  53. package/dist/dist-ZP2APOYV.js.map +1 -0
  54. package/dist/index.js +15083 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/operational-5RT6AZJI-ZEMPD32Q.js +12 -0
  57. package/dist/operational-5RT6AZJI-ZEMPD32Q.js.map +1 -0
  58. package/dist/reconcile-A4I3Z7VX-GQMEN4GC.js +12 -0
  59. package/dist/reconcile-A4I3Z7VX-GQMEN4GC.js.map +1 -0
  60. package/dist/rsshub-GOJBTNOD-UNNVKBFL.js +18 -0
  61. package/dist/rsshub-GOJBTNOD-UNNVKBFL.js.map +1 -0
  62. package/dist/synap-image-install-X36REYQ5-UGO4MAOP.js +17 -0
  63. package/dist/synap-image-install-X36REYQ5-UGO4MAOP.js.map +1 -0
  64. package/package.json +44 -0
@@ -0,0 +1,3391 @@
1
+ import { createRequire as __eveCreateRequire } from 'module'; const require = __eveCreateRequire(import.meta.url);
2
+ import {
3
+ readAgentKeyOrLegacy,
4
+ readAgentKeyOrLegacySync,
5
+ readEveSecrets,
6
+ writeEveSecrets
7
+ } from "./chunk-T7GCAJK5.js";
8
+ import {
9
+ appendOperationalEvent
10
+ } from "./chunk-HRF6TNXX.js";
11
+ import {
12
+ __require
13
+ } from "./chunk-FAE5UZFE.js";
14
+
15
+ // ../@eve/dna/dist/chunk-7ERVFFFQ.js
16
+ import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "fs";
17
+ import { homedir } from "os";
18
+ import { join } from "path";
19
+ import { randomBytes } from "crypto";
20
+ import { existsSync as existsSync2 } from "fs";
21
+ import { execSync } from "child_process";
22
+ import { join as join2 } from "path";
23
+ import { execSync as execSync3 } from "child_process";
24
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
25
+ import { homedir as homedir2 } from "os";
26
+ import { join as join4, dirname as dirname2 } from "path";
27
+ import { Socket } from "net";
28
+ import { execSync as execSync2 } from "child_process";
29
+ import { readFileSync, existsSync as existsSync3 } from "fs";
30
+ import { createRequire } from "module";
31
+ import { createHash } from "crypto";
32
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
33
+ import { fileURLToPath } from "url";
34
+ import { dirname, join as join3 } from "path";
35
+ var COMPONENTS = [
36
+ {
37
+ id: "traefik",
38
+ organ: "legs",
39
+ label: "Traefik",
40
+ emoji: "\u{1F9BF}",
41
+ description: "Reverse proxy & routing. Handles domain exposure, SSL termination, and service discovery for all Eve services. Always installed.",
42
+ longDescription: `Traefik is the front door of your stack. It listens on ports 80 and 443, terminates SSL with automatic Let's Encrypt certificates, and routes incoming requests to the right container based on the hostname.
43
+
44
+ Every other Eve service hides behind it \u2014 Synap pod, Open WebUI, OpenClaw, the dashboard itself. When you set a domain (e.g. \`mystack.com\`), Traefik provisions certs for every \`<service>.mystack.com\` subdomain and routes traffic by container name on the shared \`eve-network\`.
45
+
46
+ Traefik is always-on infrastructure. It can't be removed; the rest of the stack depends on it for any externally-reachable URL.`,
47
+ homepage: "https://traefik.io",
48
+ category: "infrastructure",
49
+ alwaysInstall: true,
50
+ service: {
51
+ containerName: "eve-legs-traefik",
52
+ internalPort: 80,
53
+ hostPort: 80,
54
+ subdomain: null,
55
+ // Traefik itself isn't routed by Traefik
56
+ healthPath: "/"
57
+ },
58
+ materializers: ["traefik-routes"],
59
+ health: { kind: "docker" },
60
+ lifecycle: { restartStrategy: "restart" },
61
+ doctor: { critical: true }
62
+ },
63
+ {
64
+ id: "freellmapi",
65
+ organ: "brain",
66
+ label: "FreeLLMAPI",
67
+ emoji: "\u{1F3B0}",
68
+ description: "Self-hosted gateway pooling free-tier LLM quota behind one OpenAI-compatible endpoint.",
69
+ longDescription: `FreeLLMAPI is a self-hosted router that sits in front of ~34 providers'
70
+ FREE TIERS and exposes them as a single OpenAI-compatible \`/v1\` endpoint. You supply your own
71
+ free-tier keys through its dashboard; it tracks per-key quota, scores models, and fails over on
72
+ 429/5xx.
73
+
74
+ Inside Eve it is registered as a LOW-PRIORITY provider in your pod's ai_providers table, which
75
+ means the Intelligence Service reaches for it only after your paid providers \u2014 a free fallback
76
+ lane for when OpenRouter credits run dry, not the default brain.
77
+
78
+ Two things worth knowing. It is capability-aware about TOOL CALLING: requests carrying \`tools\`
79
+ are routed only to models that can emit structured tool_calls, and it repairs tool calls a
80
+ failover model emitted in another model's dialect. And its own README is explicit that free tiers
81
+ are "not a stable, supported inference substrate" \u2014 your agreement with each upstream provider
82
+ still governs traffic proxied through it, so this is for personal experimentation, not production
83
+ load.`,
84
+ homepage: "https://github.com/tashfeenahmed/freellmapi",
85
+ category: "add-on",
86
+ requires: ["traefik"],
87
+ service: {
88
+ containerName: "eve-brain-freellmapi",
89
+ // ONE port serves both the dashboard and the /v1 API — not a split.
90
+ internalPort: 3001,
91
+ hostPort: null,
92
+ subdomain: "llm",
93
+ healthPath: "/api/ping"
94
+ },
95
+ health: { kind: "http", path: "/api/ping" },
96
+ lifecycle: { restartStrategy: "restart" },
97
+ doctor: { critical: false }
98
+ },
99
+ {
100
+ id: "nango",
101
+ organ: "arms",
102
+ label: "Nango",
103
+ emoji: "\u{1F517}",
104
+ description: "Self-hosted OAuth integration platform. Connects Google, Slack, GitHub and 300+ services to your pod \u2014 tokens stay on your server.",
105
+ longDescription: `Nango is the integration layer that lets your pod speak OAuth with the outside world. Connect Google Contacts, Gmail, Google Calendar, Slack, GitHub, and hundreds more \u2014 without routing credentials through any third-party cloud.
106
+
107
+ Why a sovereign stack needs Nango: most SaaS APIs require OAuth. Without it, you either paste API keys manually or give a cloud service access to your tokens. Nango self-hosted handles the full OAuth dance on your machine \u2014 the access tokens and refresh tokens never leave your server.
108
+
109
+ Once Nango is running, Synap's CRM can sync Google Contacts \u2192 contacts, Gmail threads \u2192 notes, and Google Calendar events \u2192 activity. Eve can trigger real-time enrichment from any connected service. You configure OAuth app credentials once (Google Cloud Console, Slack app settings) and Nango handles the rest.
110
+
111
+ Requires Postgres (already part of the Brain). Listens on port 3003, accessible only within the Eve network \u2014 not exposed publicly.`,
112
+ homepage: "https://nango.dev",
113
+ category: "infrastructure",
114
+ requires: ["traefik", "synap"],
115
+ service: {
116
+ containerName: "eve-arms-nango",
117
+ internalPort: 3003,
118
+ hostPort: null,
119
+ subdomain: "nango",
120
+ healthPath: "/health",
121
+ additionalSubdomains: [{
122
+ subdomain: "connect",
123
+ internalPort: 3009,
124
+ // env.js is dynamically generated by the API (port 3003), not the static SPA server
125
+ pathProxies: [{ path: "/env.js", internalPort: 3003 }]
126
+ }]
127
+ },
128
+ health: { kind: "http", path: "/health" },
129
+ lifecycle: { restartStrategy: "restart" },
130
+ doctor: { critical: false }
131
+ },
132
+ {
133
+ id: "ollama",
134
+ organ: "brain",
135
+ label: "Ollama",
136
+ emoji: "\u{1F916}",
137
+ description: "Local AI inference engine. Runs open-source models (Llama, Mistral, etc.) on your server. Keeps your data private.",
138
+ longDescription: `Ollama is a local model runtime \u2014 a self-contained LLM server you can pull models into and call via an OpenAI-compatible API. Llama, Mistral, Qwen, Gemma \u2014 pick what fits your hardware.
139
+
140
+ Why a sovereign stack runs Ollama: your prompts and outputs never leave the machine. Synap IS will route to Ollama as a provider just like it routes to Anthropic or OpenAI, so agents and Open WebUI can use a local model without any code change.
141
+
142
+ Heads up: model quality and speed depend heavily on your server's RAM and GPU. A 7B model is the practical baseline on CPU; 13B+ benefits from a GPU.`,
143
+ homepage: "https://ollama.ai",
144
+ category: "data",
145
+ requires: ["traefik"],
146
+ service: {
147
+ containerName: "eve-brain-ollama",
148
+ internalPort: 11434,
149
+ hostPort: 11434,
150
+ subdomain: "ai",
151
+ healthPath: "/"
152
+ },
153
+ health: { kind: "http", path: "/" },
154
+ lifecycle: { restartStrategy: "restart" },
155
+ doctor: { critical: false }
156
+ },
157
+ {
158
+ id: "synap",
159
+ organ: "brain",
160
+ label: "Synap Data Pod",
161
+ emoji: "\u{1F9E0}",
162
+ description: "Your sovereign second brain. Stores and organises all your data \u2014 notes, tasks, contacts, bookmarks. The foundation of your personal AI infrastructure.",
163
+ longDescription: `Synap is the data store every other component reads from. It captures notes, tasks, contacts, bookmarks, and any other entity type you define \u2014 typed JSONB profiles in Postgres, indexed by Typesense and pgvector for full-text and semantic search.
164
+
165
+ The pod exposes both a tRPC API (for the Synap web/desktop apps) and a REST Hub Protocol (for AI agents like OpenClaw and external connectors). Once installed, agents can read and write data through Synap rather than each maintaining their own private store.
166
+
167
+ This is the brain of the brain organ \u2014 most of what makes your stack feel intelligent depends on Synap being healthy. Watch its logs first when something feels off.`,
168
+ homepage: "https://github.com/synap-core/synap-backend",
169
+ category: "data",
170
+ requires: ["traefik"],
171
+ service: {
172
+ containerName: "eve-brain-synap",
173
+ internalPort: 4e3,
174
+ hostPort: null,
175
+ // bound only inside docker network
176
+ subdomain: "pod",
177
+ healthPath: "/health"
178
+ },
179
+ materializers: ["backend-env", "ai-wiring"],
180
+ health: { kind: "http", path: "/health" },
181
+ lifecycle: { restartStrategy: "compose-up", envBound: true },
182
+ doctor: { critical: true, integrationId: "synap" }
183
+ },
184
+ {
185
+ id: "pod-admin",
186
+ organ: "brain",
187
+ label: "Pod Admin Console",
188
+ emoji: "\u{1F6E0}\uFE0F",
189
+ description: "Operator console for the Synap pod \u2014 workspaces, users, audit. Replaces the legacy admin SPA.",
190
+ longDescription: `Pod Admin is the Next.js operator console for your Synap Data Pod. It's where you manage workspaces, users, agent provisioning, and audit logs. After Kratos login the browser redirects here, so it's the surface humans interact with most.
191
+
192
+ Co-located with the synap pod (same docker-compose project, same image release cadence) but exposed on its own subdomain (\`pod-admin.<root>\`) so the API origin (\`pod.<root>\`) and the UI origin stay clean. Kratos session cookies are scoped to the bare root domain so login at one carries over to the other.`,
193
+ homepage: "https://github.com/synap-core/synap-backend",
194
+ category: "data",
195
+ requires: ["synap"],
196
+ service: {
197
+ containerName: "eve-brain-pod-admin",
198
+ internalPort: 3e3,
199
+ hostPort: null,
200
+ subdomain: "pod-admin",
201
+ healthPath: "/"
202
+ },
203
+ health: { kind: "http", path: "/" },
204
+ lifecycle: { restartStrategy: "compose-up", envBound: true },
205
+ doctor: { critical: false, integrationId: "synap" }
206
+ },
207
+ {
208
+ id: "openclaw",
209
+ organ: "arms",
210
+ label: "OpenClaw",
211
+ emoji: "\u{1F9BE}",
212
+ description: "AI action layer. Connects your stack to 24+ messaging platforms and executes agent skills. Prefer Hermes for new installs.",
213
+ longDescription: `OpenClaw turns your AI from a chat companion into an agent that can act across 24+ platforms \u2014 Telegram, Discord, WhatsApp, iMessage, Slack, Signal, Matrix, and more. It runs as a sandboxed Node.js gateway with shell access, a filesystem, MCP server mode (exposing channels to Claude Code and other MCP clients), and a skill system backed by the ClawHub registry (5,400+ community skills).
214
+
215
+ Eve wires OpenClaw against Synap as its OpenAI-compatible provider, so the agent's brain is any provider you've configured. When the agent reads or writes data it goes through Synap's Hub Protocol \u2014 full audit trail, proposals when permissions don't allow direct writes.
216
+
217
+ **Note:** For new installs, Hermes is the recommended agent. Hermes has a contractual memory plugin that guarantees 100% of conversations sync to your Synap pod, better multi-agent orchestration, and a stronger security record. OpenClaw remains fully supported and is the better choice if you need MCP server mode for Claude Code or iMessage support.`,
218
+ homepage: "https://openclaw.ai",
219
+ category: "agent",
220
+ requires: ["synap"],
221
+ deprecated: true,
222
+ deprecationNotice: "Prefer Hermes for new installs \u2014 it has native memory plugin integration with Synap, better security, and true multi-agent orchestration. OpenClaw stays fully functional and is the right choice for MCP bridge or iMessage. See docs/openclaw-vs-hermes.md.",
223
+ replacedBy: "hermes",
224
+ service: {
225
+ containerName: "eve-arms-openclaw",
226
+ internalPort: 18789,
227
+ hostPort: 18789,
228
+ subdomain: "openclaw"
229
+ },
230
+ materializers: ["openclaw-config", "ai-wiring"],
231
+ health: { kind: "docker" },
232
+ lifecycle: { restartStrategy: "recreate", envBound: true },
233
+ doctor: { critical: false, integrationId: "openclaw-synap" }
234
+ },
235
+ {
236
+ id: "hermes",
237
+ organ: "arms",
238
+ label: "Hermes",
239
+ emoji: "\u{1F9E0}",
240
+ description: "AI agent with sovereign memory. Routes all conversations and channel messages to your Synap pod \u2014 nothing is lost.",
241
+ longDescription: `Hermes is the primary AI agent for your Eve stack. It runs as a headless gateway (port 8642) that handles messaging from Telegram, Discord, WhatsApp, Signal, Matrix, Slack, and 14 more platforms. Unlike other agents, Hermes has a contractual memory architecture: a custom Synap plugin (auto-generated by Eve) guarantees that 100% of every conversation turn is synced to your Data Pod.
242
+
243
+ Key capabilities: true multi-agent orchestration (spawn specialised sub-agents), 7 terminal backends (local, Docker, SSH, Modal, Daytona), 61 built-in tools, self-improving skills (the agent creates and refines its own skills during use), and a dashboard on port 9119.
244
+
245
+ The Synap plugin is the crown jewel: Eve generates \`synap_provider.py\` into the Hermes plugin directory. Hermes loads it on startup and from that point every turn \u2014 prefetch (memory context injected before each reply) + sync_turn (conversation written to Synap after each reply) + on_session_end (facts extracted at the end) \u2014 all route through Hub Protocol to your pod. Hermes stays stateless; Synap is the brain.`,
246
+ homepage: "https://hermes-agent.nousresearch.com",
247
+ category: "agent",
248
+ requires: ["synap"],
249
+ service: {
250
+ containerName: "eve-builder-hermes",
251
+ internalPort: 8642,
252
+ hostPort: 8642,
253
+ subdomain: "hermes",
254
+ healthPath: "/health"
255
+ },
256
+ materializers: ["hermes-env", "ai-wiring"],
257
+ health: { kind: "http", path: "/health" },
258
+ lifecycle: { restartStrategy: "recreate", envBound: true },
259
+ doctor: { critical: false, integrationId: "hermes-synap" }
260
+ },
261
+ {
262
+ id: "stalwart",
263
+ organ: "mouth",
264
+ label: "Stalwart Mail",
265
+ emoji: "\u{1F4E7}",
266
+ description: "Sovereign mail server. SMTP/IMAP/JMAP in one Rust binary \u2014 own your inbox, no Gmail in the loop.",
267
+ longDescription: `Stalwart is a complete, self-hosted mail server: SMTP, IMAP, POP3, JMAP and ManageSieve in a single Rust binary. It replaces renting Gmail (and routing your mail through an aggregator) with owning the mailbox outright on your own server.
268
+
269
+ Why a sovereign stack runs Stalwart: email is the last big piece of your digital life that usually lives in someone else's cloud. With Stalwart, every message \u2014 inbound and outbound \u2014 passes through your machine. DKIM, SPF, DMARC and MTA-STS records are generated for you; you publish them in DNS and your mail authenticates like any major provider.
270
+
271
+ The HTTP surface (JMAP + admin console) is fronted by Traefik at \`mail.<domain>\`. The raw mail protocols (SMTP 25, submission 465/587, IMAP 143/993, POP3 110/995, ManageSieve 4190) are published directly on the host \u2014 Traefik is HTTP-only, and binding them straight to the container preserves the real sender IP that SPF/DMARC depend on.
272
+
273
+ Once running, Synap's email channel talks to Stalwart over JMAP: your agent reads threads and drafts replies, and every send is gated by a proposal you approve. Pair it with the Bulwark webmail component for a human inbox at \`webmail.<domain>\`.
274
+
275
+ Heads up: deliverability needs a PTR/reverse-DNS record set at your VPS provider (the one record Stalwart can't automate), and some providers block outbound port 25 \u2014 \`eve doctor\` checks both. Stalwart is pre-1.0; the image tag is pinned and upgrades may require data migration.`,
276
+ homepage: "https://stalw.art",
277
+ category: "data",
278
+ requires: ["traefik"],
279
+ service: {
280
+ containerName: "eve-mouth-stalwart",
281
+ // HTTP listener (JMAP + admin). Fronted by Traefik via Docker DNS; the
282
+ // raw-TCP mail ports are published on the host by the install recipe.
283
+ internalPort: 8080,
284
+ hostPort: null,
285
+ subdomain: "mail",
286
+ healthPath: "/healthz/live"
287
+ },
288
+ health: { kind: "http", path: "/healthz/live" },
289
+ lifecycle: { restartStrategy: "restart" },
290
+ doctor: { critical: false }
291
+ },
292
+ {
293
+ id: "bulwark",
294
+ organ: "mouth",
295
+ label: "Bulwark Webmail",
296
+ emoji: "\u2709\uFE0F",
297
+ description: "Modern JMAP webmail for Stalwart. A fast, private inbox UI at webmail.<domain>.",
298
+ longDescription: `Bulwark is a modern, self-hosted webmail client built for Stalwart over the JMAP protocol \u2014 email, calendar, contacts and files in one fast UI. It's the human front door to the mailbox your Stalwart server hosts.
299
+
300
+ Eve runs it as a sidecar fronted by Traefik at \`webmail.<domain>\`. On first launch Bulwark's setup wizard asks for the JMAP server URL (\`https://mail.<domain>\`), OAuth, branding and an admin password \u2014 Eve pre-fills the JMAP URL for you.
301
+
302
+ Install this when you want a browser inbox alongside the agent-driven email channel in Synap. Skip it if you only ever touch email through your AI.`,
303
+ homepage: "https://bulwarkmail.org",
304
+ category: "add-on",
305
+ requires: ["stalwart"],
306
+ service: {
307
+ containerName: "eve-mouth-bulwark",
308
+ internalPort: 3e3,
309
+ hostPort: null,
310
+ subdomain: "webmail",
311
+ healthPath: "/"
312
+ },
313
+ health: { kind: "http", path: "/" },
314
+ lifecycle: { restartStrategy: "restart" },
315
+ doctor: { critical: false }
316
+ },
317
+ {
318
+ id: "rsshub",
319
+ organ: "eyes",
320
+ label: "RSSHub Feeds",
321
+ emoji: "\u{1F441}\uFE0F",
322
+ description: "Data perception layer. Turns any website into RSS feeds so your AI can stay informed about what matters.",
323
+ longDescription: `RSSHub generates RSS feeds for sources that don't expose them \u2014 Twitter accounts, GitHub issues, LinkedIn updates, news sites, anything that publishes regularly without a feed. It runs ~1,000 source adapters out of the box.
324
+
325
+ For a sovereign stack this matters because it's how your AI keeps up with the world without any external service in the loop. Synap subscribes to RSSHub feeds, captures items as bookmark entities, and your agents see new content in their context the same way they see your manually-captured notes.
326
+
327
+ This is the eyes organ \u2014 the part of your stack that watches things you care about so you don't have to.`,
328
+ homepage: "https://docs.rsshub.app",
329
+ category: "perception",
330
+ requires: ["synap"],
331
+ service: {
332
+ containerName: "eve-eyes-rsshub",
333
+ internalPort: 1200,
334
+ hostPort: 1200,
335
+ subdomain: "feeds",
336
+ healthPath: "/"
337
+ },
338
+ health: { kind: "http", path: "/" },
339
+ lifecycle: { restartStrategy: "restart" },
340
+ doctor: { critical: false }
341
+ },
342
+ {
343
+ id: "dokploy",
344
+ label: "Dokploy",
345
+ emoji: "\u{1F527}",
346
+ description: "Low-code PaaS for deploying applications. Optional \u2014 install later if you need a visual deployment dashboard.",
347
+ longDescription: `Dokploy is a self-hosted PaaS \u2014 think Heroku or Railway, running on your own server. It manages app deployments, databases, and SSL through a visual UI, sitting alongside Eve's Traefik (or replacing it if you prefer Dokploy's routing).
348
+
349
+ Install this if you want a clicky deploy story for the apps Hermes generates \u2014 push code, click deploy, get a URL. Skip it if you're happy running compose files by hand.`,
350
+ homepage: "https://dokploy.com",
351
+ category: "add-on",
352
+ lifecycle: { restartStrategy: "none" },
353
+ doctor: { critical: false }
354
+ },
355
+ {
356
+ id: "opencode",
357
+ label: "OpenCode",
358
+ emoji: "\u{1F4BB}",
359
+ description: "AI-powered code editor. Lets your agent write and edit code directly on your server.",
360
+ longDescription: `OpenCode is an AI-augmented code editor exposed as a service. Your agents (and you) can open files, edit them, run commands \u2014 all from the same machine your stack lives on, with Synap's data as context.
361
+
362
+ This is how a sovereign stack does development without bouncing through someone else's cloud IDE. It pairs especially well with Hermes for "describe what you want, watch the agent write it" loops.`,
363
+ homepage: "https://github.com/sst/opencode",
364
+ category: "add-on",
365
+ lifecycle: { restartStrategy: "none" },
366
+ doctor: { critical: false }
367
+ },
368
+ {
369
+ id: "openclaude",
370
+ label: "OpenClaude",
371
+ emoji: "\u{1F916}",
372
+ description: "Claude Code as a service. Exposes Claude Code to your agent for advanced coding tasks.",
373
+ longDescription: `OpenClaude wraps Claude Code as a long-running service so your agents can delegate hard coding tasks to it. Where OpenClaw is general-purpose action, OpenClaude is specifically the heavyweight coding sub-agent.
374
+
375
+ Use this when you want the best-in-class coding model on tap from inside your stack \u2014 without exposing your codebase to a third party that doesn't run on your hardware.`,
376
+ category: "add-on",
377
+ lifecycle: { restartStrategy: "none" },
378
+ doctor: { critical: false }
379
+ },
380
+ {
381
+ id: "claude-code",
382
+ label: "Claude Code CLI",
383
+ emoji: "\u26A1",
384
+ description: "Anthropic's Claude Code CLI installed on your server. Run AI-powered coding sessions directly from the terminal.",
385
+ longDescription: `Claude Code is Anthropic's official AI coding assistant that runs in your terminal. It can read, write and edit files, run tests, execute commands, and navigate large codebases \u2014 all from a single CLI.
386
+
387
+ Installing it on your server means your Eve agents (especially Hermes) can spawn a Claude Code session on the server itself to tackle complex coding tasks. It's also just useful to have available when you SSH in.
388
+
389
+ Requires an Anthropic API key. After install, run \`claude\` to start a session or \`claude --help\` for all options.`,
390
+ homepage: "https://claude.ai/code",
391
+ category: "add-on",
392
+ lifecycle: { restartStrategy: "none" },
393
+ doctor: { critical: false }
394
+ },
395
+ {
396
+ id: "t3code",
397
+ label: "T3 Code",
398
+ emoji: "\u26A1",
399
+ description: "Self-hosted code execution backend (WebSocket). Connects the DevPlane pipeline to a T3 Code server for executing, verifying, and debugging phases.",
400
+ longDescription: `T3 Code is a self-hosted WebSocket server that wraps OpenAI Codex. The DevPlane agent pipeline delegates code execution phases (executing / verifying / debugging) to it via the Eve CodexFeaturePoller daemon.
401
+
402
+ Unlike other builders, T3 Code is not installed by Eve \u2014 it runs separately on your infrastructure. This command stores the connection details (URL + API key) so Eve can wire them into the backend and CodexFeaturePoller automatically.`,
403
+ homepage: "https://github.com/openai/codex",
404
+ category: "add-on",
405
+ lifecycle: { restartStrategy: "none" },
406
+ doctor: { critical: false }
407
+ },
408
+ {
409
+ id: "openwebui",
410
+ label: "Open WebUI",
411
+ emoji: "\u{1F4AC}",
412
+ description: "Self-hosted chat UI wired to Synap IS. No external DB \u2014 SQLite by default. Eve installs inline Filter Functions for memory injection + channel sync.",
413
+ longDescription: `Open WebUI is the chat interface for everyone in your household or team \u2014 looks like ChatGPT, runs on your stack. Eve wires it against Synap IS as the OpenAI-compatible backend, so any provider you've configured (Anthropic, OpenAI, OpenRouter, Ollama) is available behind a normal model picker.
414
+
415
+ State (conversations, users, settings) lives in a local SQLite by default \u2014 no external database.
416
+
417
+ Eve also installs two inline Filter Functions directly into Open WebUI:
418
+ - Synap Memory Injection \u2014 pre-prompt hook that pulls relevant entities + memories from your pod and injects them as context.
419
+ - Synap Channel Sync \u2014 mirrors every conversation into a Synap thread so your agents can read it as context.
420
+
421
+ The other Hub Protocol operations (entity create, knowledge search, calendar lookup, \u2026) are exposed as model-callable tools via the registered OpenAPI tool server, so the model decides when to invoke them.`,
422
+ homepage: "https://openwebui.com",
423
+ category: "add-on",
424
+ requires: ["synap"],
425
+ service: {
426
+ containerName: "hestia-openwebui",
427
+ internalPort: 8080,
428
+ hostPort: 3011,
429
+ subdomain: "chat",
430
+ healthPath: "/health"
431
+ },
432
+ materializers: ["openwebui-config", "ai-wiring"],
433
+ health: { kind: "http", path: "/health" },
434
+ lifecycle: { restartStrategy: "compose-up", envBound: true },
435
+ doctor: { critical: false, integrationId: "openwebui-synap" }
436
+ },
437
+ {
438
+ id: "eve-dashboard",
439
+ label: "Eve Dashboard",
440
+ emoji: "\u{1F33F}",
441
+ description: "Web dashboard for Eve \u2014 view installed components, manage AI providers, configure your stack from a browser.",
442
+ longDescription: `The Eve Dashboard is the meta-UI you're looking at right now: a Next.js app shipped as a Docker container that joins the same network as everything else. It reads your stack's secrets and state via mounted host paths and talks to Docker via the daemon socket.
443
+
444
+ It's the front door \u2014 the place where you check that your stack is alive, jump into other components' UIs, and configure the plumbing every component needs (AI keys, domain, SSL). The actual work happens in the apps it launches you into.
445
+
446
+ Always-on infrastructure, like Traefik. Removing it would leave you with no UI control plane.`,
447
+ category: "infrastructure",
448
+ requires: ["traefik"],
449
+ alwaysInstall: true,
450
+ service: {
451
+ containerName: "eve-dashboard",
452
+ // Internal port the Next standalone server listens on (PORT env, default 3000).
453
+ internalPort: 3e3,
454
+ // Host port for direct localhost access (when no domain is configured).
455
+ hostPort: 7979,
456
+ subdomain: "eve",
457
+ healthPath: "/api/state"
458
+ },
459
+ materializers: ["traefik-routes"],
460
+ health: { kind: "http", path: "/api/state" },
461
+ lifecycle: { restartStrategy: "recreate", envBound: true },
462
+ doctor: { critical: true }
463
+ }
464
+ ];
465
+ var EVE_DASHBOARD_SERVICE = {
466
+ id: "eve-dashboard",
467
+ label: "Eve Dashboard",
468
+ emoji: "\u{1F33F}",
469
+ service: {
470
+ containerName: "eve-dashboard",
471
+ internalPort: 3e3,
472
+ hostPort: 7979,
473
+ subdomain: "eve",
474
+ healthPath: "/api/state"
475
+ }
476
+ };
477
+ function resolveComponent(id) {
478
+ const comp = COMPONENTS.find((c) => c.id === id);
479
+ if (!comp) {
480
+ throw new Error(`Unknown component: ${id}. Available: ${COMPONENTS.map((c) => c.id).join(", ")}`);
481
+ }
482
+ return comp;
483
+ }
484
+ function allComponentIds() {
485
+ return COMPONENTS.filter((c) => !c.category.includes("add-on")).map((c) => c.id);
486
+ }
487
+ function addonComponentIds() {
488
+ return COMPONENTS.filter((c) => c.category === "add-on").map((c) => c.id);
489
+ }
490
+ function selectedIds(selected) {
491
+ return COMPONENTS.filter((c) => selected[c.id]).map((c) => c.id);
492
+ }
493
+ function serviceComponents() {
494
+ return COMPONENTS.filter((c) => c.service !== void 0 && c.service.subdomain !== null);
495
+ }
496
+ function validateBaseDomain(input) {
497
+ const trimmed = input.trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
498
+ if (!trimmed.includes(".")) return null;
499
+ const firstLabel = trimmed.split(".")[0];
500
+ const reserved = new Set(
501
+ COMPONENTS.map((c) => c.service?.subdomain).filter((s) => typeof s === "string" && s.length > 0)
502
+ );
503
+ if (reserved.has(firstLabel)) {
504
+ const root = trimmed.slice(firstLabel.length + 1);
505
+ return `"${trimmed}" looks like a service subdomain (first label "${firstLabel}" matches a registered component). Pass the bare root domain instead, e.g. "${root}". Eve will append component subdomains automatically (pod.${root}, pod-admin.${root}, eve.${root}, \u2026).`;
506
+ }
507
+ return null;
508
+ }
509
+ function publicComponentUrl(componentId, domain, ssl) {
510
+ const cleanDomain = domain?.trim();
511
+ if (!cleanDomain || cleanDomain === "localhost") return null;
512
+ const comp = COMPONENTS.find((c) => c.id === componentId);
513
+ if (!comp || !comp.service?.subdomain) return null;
514
+ const scheme = ssl ? "https" : "http";
515
+ return `${scheme}://${comp.service.subdomain}.${cleanDomain}`;
516
+ }
517
+ function isLoopbackUrl(url) {
518
+ if (!url) return false;
519
+ try {
520
+ const u = new URL(url);
521
+ return u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "::1" || u.hostname === "[::1]";
522
+ } catch {
523
+ return false;
524
+ }
525
+ }
526
+ var SYNAP_BACKEND_INTERNAL_URL = "http://eve-brain-synap:4000";
527
+ var SYNAP_HOST_LOOPBACK_PORT = 14e3;
528
+ function resolveSynapUrl(secrets) {
529
+ const stored = secrets?.synap?.apiUrl?.trim();
530
+ if (stored && !isLoopbackUrl(stored)) return stored;
531
+ const domain = secrets?.domain?.primary?.trim();
532
+ if (domain && domain !== "localhost") {
533
+ const ssl = secrets?.domain?.ssl ?? false;
534
+ return `${ssl ? "https" : "http"}://pod.${domain}`;
535
+ }
536
+ if (stored) return stored;
537
+ return "";
538
+ }
539
+ var DEFAULT_HUB_PATH = "/api/hub";
540
+ function resolveHubBaseUrl(secrets) {
541
+ if (secrets?.synap?.hubBaseUrl?.trim()) {
542
+ return secrets.synap.hubBaseUrl.replace(/\/$/, "");
543
+ }
544
+ const base = resolveSynapUrl(secrets).replace(/\/$/, "");
545
+ if (!base) return void 0;
546
+ return `${base}${DEFAULT_HUB_PATH}`;
547
+ }
548
+ function defaultSkillsDir() {
549
+ return join(homedir(), ".eve", "skills");
550
+ }
551
+ async function ensureEveSkillsLayout(skillsDir = defaultSkillsDir(), hubBaseUrl, apiKey) {
552
+ mkdirSync(join(skillsDir, "synap"), { recursive: true });
553
+ if (hubBaseUrl && apiKey) {
554
+ try {
555
+ const res = await fetch(`${hubBaseUrl}/skills/system`, {
556
+ headers: { Authorization: `Bearer ${apiKey}` },
557
+ signal: AbortSignal.timeout(8e3)
558
+ });
559
+ if (res.ok) {
560
+ const packages = await res.json();
561
+ const globalClaudeSkillsDir = join(homedir(), ".claude", "skills");
562
+ for (const pkg of packages) {
563
+ const pkgDir = join(skillsDir, pkg.slug);
564
+ const globalPkgDir = join(globalClaudeSkillsDir, pkg.slug);
565
+ mkdirSync(pkgDir, { recursive: true });
566
+ mkdirSync(globalPkgDir, { recursive: true });
567
+ for (const file of pkg.files) {
568
+ writeFileSync(join(pkgDir, file.path), file.content, "utf-8");
569
+ writeFileSync(join(globalPkgDir, file.path), file.content, "utf-8");
570
+ }
571
+ }
572
+ return;
573
+ }
574
+ } catch {
575
+ }
576
+ }
577
+ const stubPath = join(skillsDir, "synap", "SKILL.md");
578
+ if (!existsSync(stubPath)) {
579
+ writeFileSync(stubPath, `---
580
+ name: synap
581
+ description: Use for memory, entities, channels, search, and governance via the Synap Hub Protocol.
582
+ ---
583
+
584
+ # Synap Hub Protocol
585
+
586
+ Base URL: $HUB_BASE_URL
587
+ Auth: Authorization: Bearer $SYNAP_API_KEY
588
+
589
+ Key endpoints:
590
+ GET /memory?query=... full-text search facts
591
+ POST /memory store a fact
592
+ GET /entities/search?q=... search entities
593
+ POST /entities create entity
594
+ GET /entities/:id
595
+ PUT /entities/:id update entity (merges)
596
+ GET /channels?workspaceId=... list channels
597
+ POST /skills/:id/execute run a user skill
598
+ GET /skills/system list system skill docs
599
+ GET /profiles list entity types
600
+
601
+ Run GET /users/me + GET /workspaces on startup to orient yourself.
602
+ `, "utf-8");
603
+ }
604
+ }
605
+ async function writeBuilderProjectEnv(projectDir, cwd = process.cwd()) {
606
+ const secrets = await readEveSecrets(cwd);
607
+ const hub = resolveHubBaseUrl(secrets);
608
+ const skillsDir = secrets?.builder?.skillsDir ?? defaultSkillsDir();
609
+ const synapApiKeyForSkills = await readAgentKeyOrLegacy("coder", cwd);
610
+ await ensureEveSkillsLayout(skillsDir, hub, synapApiKeyForSkills);
611
+ const synapApiKey = await readAgentKeyOrLegacy("coder", cwd);
612
+ const lines = [
613
+ "# Generated by Eve \u2014 Synap Hub + workspace wiring",
614
+ `SYNAP_API_URL=${resolveSynapUrl(secrets)}`,
615
+ `SYNAP_API_KEY=${synapApiKey}`,
616
+ `HUB_BASE_URL=${hub ?? ""}`,
617
+ `EVE_SKILLS_DIR=${skillsDir}`,
618
+ `DOKPLOY_WEBHOOK_URL=${secrets?.builder?.dokployWebhookUrl ?? process.env.DOKPLOY_WEBHOOK_URL ?? ""}`,
619
+ `DOKPLOY_API_URL=${secrets?.builder?.dokployApiUrl ?? ""}`,
620
+ `DOKPLOY_API_KEY=${secrets?.builder?.dokployApiKey ?? ""}`,
621
+ ""
622
+ ];
623
+ writeFileSync(join(projectDir, ".env"), lines.join("\n"), { mode: 384 });
624
+ }
625
+ async function writeSandboxEnvFile(cwd = process.cwd()) {
626
+ const secrets = await readEveSecrets(cwd);
627
+ const hub = resolveHubBaseUrl(secrets);
628
+ const skillsDir = secrets?.builder?.skillsDir ?? defaultSkillsDir();
629
+ const workspaceDir = secrets?.builder?.workspaceDir ?? join(cwd, ".eve", "workspace");
630
+ const synapApiKeyForSkills = await readAgentKeyOrLegacy("eve", cwd);
631
+ await ensureEveSkillsLayout(skillsDir, hub, synapApiKeyForSkills);
632
+ const synapApiKey = await readAgentKeyOrLegacy("eve", cwd);
633
+ const eveDir = join(cwd, ".eve");
634
+ mkdirSync(eveDir, { recursive: true });
635
+ const path = join(eveDir, "sandbox.env");
636
+ const lines = [
637
+ `SYNAP_API_URL=${resolveSynapUrl(secrets)}`,
638
+ `SYNAP_API_KEY=${synapApiKey}`,
639
+ `HUB_BASE_URL=${hub ?? ""}`,
640
+ `EVE_SKILLS_DIR=${skillsDir}`,
641
+ `EVE_WORKSPACE_DIR=${workspaceDir}`,
642
+ `DOKPLOY_WEBHOOK_URL=${secrets?.builder?.dokployWebhookUrl ?? process.env.DOKPLOY_WEBHOOK_URL ?? ""}`
643
+ ];
644
+ writeFileSync(path, lines.join("\n") + "\n", { mode: 384 });
645
+ return path;
646
+ }
647
+ function copySynapSkillIntoClaudeProject(projectDir, skillsDir = defaultSkillsDir()) {
648
+ const { readdirSync: _readdirSync } = __require("fs");
649
+ const skillPkgs = ["synap", "synap-schema", "synap-ui"];
650
+ const destRoots = [
651
+ join(projectDir, ".claude", "skills"),
652
+ join(homedir(), ".claude", "skills")
653
+ ];
654
+ for (const pkg of skillPkgs) {
655
+ const srcDir = join(skillsDir, pkg);
656
+ try {
657
+ const files = _readdirSync(srcDir);
658
+ for (const destRoot of destRoots) {
659
+ const destDir = join(destRoot, pkg);
660
+ mkdirSync(destDir, { recursive: true });
661
+ for (const file of files) {
662
+ if (file.endsWith(".md")) {
663
+ copyFileSync(join(srcDir, file), join(destDir, file));
664
+ }
665
+ }
666
+ }
667
+ } catch {
668
+ }
669
+ }
670
+ }
671
+ async function writeClaudeCodeSettings(projectDir, cwd = process.cwd()) {
672
+ const secrets = await readEveSecrets(cwd);
673
+ const hub = resolveHubBaseUrl(secrets);
674
+ const synapApiKey = await readAgentKeyOrLegacy("coder", cwd);
675
+ const skillsDir = secrets?.builder?.skillsDir ?? defaultSkillsDir();
676
+ await ensureEveSkillsLayout(skillsDir, hub ?? void 0, synapApiKey ?? void 0);
677
+ const dir = join(projectDir, ".claude");
678
+ mkdirSync(dir, { recursive: true });
679
+ const settings = {
680
+ env: {
681
+ SYNAP_API_URL: resolveSynapUrl(secrets),
682
+ SYNAP_API_KEY: synapApiKey,
683
+ HUB_BASE_URL: hub ?? "",
684
+ EVE_SKILLS_DIR: skillsDir
685
+ }
686
+ };
687
+ writeFileSync(join(dir, "settings.json"), JSON.stringify(settings, null, 2), "utf-8");
688
+ const hermesApiKey = secrets?.builder?.hermes?.apiServerKey;
689
+ if (hermesApiKey) {
690
+ const mcpServersDir = join(dir, "mcp-servers");
691
+ mkdirSync(mcpServersDir, { recursive: true });
692
+ const hermesMcp = {
693
+ type: "http",
694
+ url: "http://localhost:9120",
695
+ name: "hermes",
696
+ description: "Hermes agent \u2014 memory, channels, skills via Synap Hub Protocol",
697
+ headers: { Authorization: `Bearer ${hermesApiKey}` }
698
+ };
699
+ writeFileSync(join(mcpServersDir, "hermes.json"), JSON.stringify(hermesMcp, null, 2), "utf-8");
700
+ }
701
+ const synapUrl = resolveSynapUrl(secrets);
702
+ const synapMcpUrl = synapUrl ? `${synapUrl.replace(/\/$/, "")}/mcp` : "";
703
+ if (synapMcpUrl && synapApiKey) {
704
+ const mcpServersDir = join(dir, "mcp-servers");
705
+ mkdirSync(mcpServersDir, { recursive: true });
706
+ const synapMcp = {
707
+ type: "http",
708
+ url: synapMcpUrl,
709
+ name: "synap",
710
+ description: "Synap Data Pod \u2014 entities, memory, search, documents, channels, governance",
711
+ headers: { Authorization: `Bearer ${synapApiKey}` }
712
+ };
713
+ writeFileSync(join(mcpServersDir, "synap.json"), JSON.stringify(synapMcp, null, 2), "utf-8");
714
+ }
715
+ }
716
+ async function writeHermesEnvFile(cwd = process.cwd()) {
717
+ const secrets = await readEveSecrets(cwd);
718
+ const hub = resolveHubBaseUrl(secrets);
719
+ const hermesConfig = secrets?.builder?.hermes;
720
+ const synapApiKey = await readAgentKeyOrLegacy("hermes", cwd);
721
+ const skillsDir = secrets?.builder?.skillsDir ?? defaultSkillsDir();
722
+ await ensureEveSkillsLayout(skillsDir, hub ?? void 0, synapApiKey ?? void 0);
723
+ let apiServerKey = hermesConfig?.apiServerKey;
724
+ if (!apiServerKey) {
725
+ apiServerKey = randomBytes(32).toString("hex");
726
+ await writeEveSecrets({
727
+ builder: { hermes: { ...hermesConfig ?? {}, apiServerKey } }
728
+ });
729
+ }
730
+ const eveDir = join(homedir(), ".eve");
731
+ mkdirSync(eveDir, { recursive: true });
732
+ const path = join(eveDir, "hermes.env");
733
+ const lines = [
734
+ `SYNAP_API_URL=${resolveSynapUrl(secrets)}`,
735
+ `SYNAP_API_KEY=${synapApiKey}`,
736
+ `HUB_BASE_URL=${hub ?? ""}`,
737
+ `HERMES_ENABLED=${hermesConfig?.enabled ?? true}`,
738
+ `HERMES_POLL_INTERVAL_MS=${hermesConfig?.pollIntervalMs ?? 3e4}`,
739
+ `HERMES_MAX_CONCURRENT_TASKS=${hermesConfig?.maxConcurrentTasks ?? 1}`,
740
+ `EVE_WORKSPACE_DIR=${secrets?.builder?.workspaceDir ?? join(homedir(), ".eve", "workspace")}`,
741
+ // API server key — clients (OpenWebUI, LobeChat, etc.) use this to call
742
+ // the Hermes OpenAI-compat gateway at port 8642.
743
+ `API_SERVER_ENABLED=true`,
744
+ `API_SERVER_HOST=0.0.0.0`,
745
+ `API_SERVER_PORT=8642`,
746
+ `API_SERVER_KEY=${apiServerKey}`,
747
+ // Hermes gateway is already behind OpenWebUI's auth layer — OWUI enforces
748
+ // login before any request reaches the gateway. Allow all at the gateway
749
+ // level so OWUI-proxied requests aren't double-rejected.
750
+ `GATEWAY_ALLOW_ALL_USERS=true`
751
+ ];
752
+ const hermesProvider = secrets?.ai?.serviceProviders?.["hermes"] ?? secrets?.ai?.defaultProvider;
753
+ if (hermesProvider === "openai" || hermesProvider === "openrouter") {
754
+ lines.push(`SYNAP_EMBED_MODEL=text-embedding-3-small`);
755
+ }
756
+ const routing = secrets?.channels ? buildChannelRouting(secrets) : {};
757
+ const channels = secrets?.channels ?? {};
758
+ if (channels.telegram?.enabled && routing.telegram === "hermes") {
759
+ lines.push(`TELEGRAM_BOT_TOKEN=${channels.telegram.botToken ?? ""}`);
760
+ if (channels.telegram.webhookSecret) {
761
+ lines.push(`TELEGRAM_WEBHOOK_SECRET=${channels.telegram.webhookSecret}`);
762
+ }
763
+ }
764
+ if (channels.discord?.enabled && routing.discord === "hermes") {
765
+ lines.push(`DISCORD_BOT_TOKEN=${channels.discord.botToken ?? ""}`);
766
+ if (channels.discord.guildId) lines.push(`DISCORD_GUILD_ID=${channels.discord.guildId}`);
767
+ if (channels.discord.applicationId) lines.push(`DISCORD_APPLICATION_ID=${channels.discord.applicationId}`);
768
+ }
769
+ if (channels.whatsapp?.enabled && routing.whatsapp === "hermes") {
770
+ lines.push(`WHATSAPP_PHONE_NUMBER_ID=${channels.whatsapp.phoneNumberId ?? ""}`);
771
+ lines.push(`WHATSAPP_ACCESS_TOKEN=${channels.whatsapp.accessToken ?? ""}`);
772
+ if (channels.whatsapp.verifyToken) lines.push(`WHATSAPP_VERIFY_TOKEN=${channels.whatsapp.verifyToken}`);
773
+ }
774
+ if (channels.signal?.enabled && routing.signal === "hermes") {
775
+ lines.push(`SIGNAL_PHONE_NUMBER=${channels.signal.phoneNumber ?? ""}`);
776
+ lines.push(`SIGNAL_API_URL=${channels.signal.apiUrl ?? ""}`);
777
+ }
778
+ if (channels.matrix?.enabled && routing.matrix === "hermes") {
779
+ lines.push(`MATRIX_HOMESERVER_URL=${channels.matrix.homeserverUrl ?? ""}`);
780
+ lines.push(`MATRIX_ACCESS_TOKEN=${channels.matrix.accessToken ?? ""}`);
781
+ if (channels.matrix.roomId) lines.push(`MATRIX_ROOM_ID=${channels.matrix.roomId}`);
782
+ }
783
+ if (channels.slack?.enabled && routing.slack === "hermes") {
784
+ lines.push(`SLACK_BOT_TOKEN=${channels.slack.botToken ?? ""}`);
785
+ lines.push(`SLACK_SIGNING_SECRET=${channels.slack.signingSecret ?? ""}`);
786
+ if (channels.slack.appToken) lines.push(`SLACK_APP_TOKEN=${channels.slack.appToken}`);
787
+ }
788
+ lines.push("");
789
+ writeFileSync(path, lines.join("\n"), { mode: 384 });
790
+ return path;
791
+ }
792
+ function buildChannelRouting(secrets) {
793
+ const platforms = ["telegram", "discord", "whatsapp", "signal", "matrix", "slack"];
794
+ const result = {};
795
+ for (const p of platforms) {
796
+ result[p] = secrets.channelRouting?.[p] ?? "hermes";
797
+ }
798
+ return result;
799
+ }
800
+ var HERMES_CONFIG_YAML = (model, baseUrl, apiKey, mcpUrl) => `# Hermes config \u2014 managed by Eve (eve install / eve ai apply)
801
+ # Editing this file directly is fine; Eve will overwrite the managed sections on the next apply.
802
+
803
+ memory:
804
+ # Synap memory provider: 100% of memory syncs go to the Data Pod.
805
+ # The synap plugin lives at $HERMES_HOME/plugins/memory/synap/
806
+ provider: synap
807
+
808
+ # Named provider entry \u2014 Hermes resolves credentials from here, not from model.base_url/api_key.
809
+ # Both the main model and auxiliary clients (vision, web-extract) look up "custom/main".
810
+ providers:
811
+ main:
812
+ base_url: ${baseUrl}
813
+ api_key: ${apiKey}
814
+ model: ${model}
815
+
816
+ model:
817
+ provider: custom:main
818
+ default: ${model}
819
+
820
+ # MCP server \u2014 exposes Hermes tools to Claude Code, Cursor, and any MCP client.
821
+ # Connect at http://localhost:9120 (host) or http://eve-builder-hermes:9120 (docker network).
822
+ mcp:
823
+ server:
824
+ enabled: true
825
+ port: 9120
826
+ host: 0.0.0.0
827
+
828
+ # MCP clients \u2014 external MCP servers Hermes can call tools on.
829
+ # Synap Data Pod: all Synap tools available natively (search, entities, memory, etc.)
830
+ clients:
831
+ synap:
832
+ url: ${mcpUrl}
833
+ transport: http
834
+ auth:
835
+ type: bearer
836
+ token: \${SYNAP_API_KEY}
837
+
838
+ # Hermes dashboard \u2014 admin UI
839
+ dashboard:
840
+ port: 9119
841
+
842
+ # Skills \u2014 Synap Hub Protocol reference docs (mounted from ~/.eve/skills).
843
+ # Lives at /opt/data/synap-skills/ inside the container so it doesn't
844
+ # shadow Hermes's own /opt/data/skills/ bundled-skills directory (writable,
845
+ # managed by Hermes; we'd block its 91-skill bootstrap copy if we mounted
846
+ # the read-only Synap docs there).
847
+ skills:
848
+ dir: /opt/data/synap-skills
849
+ `;
850
+ async function writeHermesConfigYaml(cwd = process.cwd()) {
851
+ const secrets = await readEveSecrets(cwd);
852
+ return writeHermesConfigYamlSync(secrets);
853
+ }
854
+ function writeHermesConfigYamlSync(secrets) {
855
+ const providers = secrets?.ai?.providers ?? [];
856
+ const implicitDefault = providers.find((p) => p.enabled !== false)?.id ?? "";
857
+ const providerId = secrets?.ai?.serviceProviders?.["hermes"] ?? secrets?.ai?.defaultProvider ?? implicitDefault;
858
+ const provider = providers.find((p) => p.id === providerId);
859
+ const BUILTIN_URLS = {
860
+ openrouter: "https://openrouter.ai/api/v1",
861
+ openai: "https://api.openai.com/v1",
862
+ anthropic: "https://api.anthropic.com/v1",
863
+ ollama: "http://eve-brain-ollama:11434/v1"
864
+ };
865
+ let baseUrl = provider?.baseUrl?.replace(/\/$/, "") ?? BUILTIN_URLS[providerId] ?? "";
866
+ if (providerId === "ollama" && /localhost|127\.0\.0\.1/.test(baseUrl)) {
867
+ baseUrl = baseUrl.replace(/localhost|127\.0\.0\.1/, "eve-brain-ollama");
868
+ }
869
+ const serviceModels = secrets?.ai?.serviceModels ?? {};
870
+ let model;
871
+ let resolvedBaseUrl;
872
+ let modelApiKey;
873
+ if (baseUrl) {
874
+ model = serviceModels["hermes"] ?? provider?.defaultModel ?? "hermes-3-llama-3.1-8b";
875
+ resolvedBaseUrl = baseUrl;
876
+ modelApiKey = provider?.apiKey ?? "";
877
+ } else {
878
+ const synapUrl = resolveSynapUrl(secrets);
879
+ resolvedBaseUrl = synapUrl ? `${synapUrl.replace(/\/$/, "")}/v1` : "http://eve-brain-synap:4000/v1";
880
+ model = serviceModels["hermes"] ?? "synap/balanced";
881
+ modelApiKey = readAgentKeyOrLegacySync("hermes", secrets);
882
+ }
883
+ const mcpUrl = "http://eve-brain-synap:4000/mcp";
884
+ const hermesDir = join(homedir(), ".eve", "hermes");
885
+ mkdirSync(hermesDir, { recursive: true });
886
+ const configPath = join(hermesDir, "config.yaml");
887
+ writeFileSync(configPath, HERMES_CONFIG_YAML(model, resolvedBaseUrl, modelApiKey, mcpUrl), { mode: 384 });
888
+ return configPath;
889
+ }
890
+ var SYNAP_PLUGIN_YAML = `name: synap
891
+ version: 2.0.0
892
+ description: Synap Data Pod \u2014 memory sync plugin. Syncs conversation turns to the Data Pod via Hub Protocol. Tool suite is provided via the MCP client connection.
893
+ hooks:
894
+ - sync_turn
895
+ - on_session_end
896
+ - on_memory_write
897
+ - prefetch
898
+ `;
899
+ var SYNAP_PROVIDER_PY = `"""
900
+ Synap Data Pod \u2014 Hermes MemoryProvider plugin (memory sync).
901
+
902
+ Generated by Eve. Do not edit manually.
903
+
904
+ Syncs all conversation turns to the Synap Data Pod via Hub Protocol.
905
+ Tool suite (entity management, search, channels, etc.) is provided
906
+ via the MCP client connection configured in config.yaml \u2014 not this plugin.
907
+
908
+ Env vars:
909
+ SYNAP_API_KEY \u2014 Hub Protocol bearer token
910
+ HUB_BASE_URL \u2014 e.g. https://pod.example.com/api/hub
911
+ API_SERVER_KEY \u2014 Hermes gateway auth key (for embedding calls)
912
+ SYNAP_EMBED_MODEL \u2014 embedding model (empty = full-text only)
913
+ """
914
+ import os
915
+ import json
916
+ import urllib.request
917
+ import urllib.parse
918
+ import urllib.error
919
+ from typing import Any
920
+
921
+
922
+ # ---------------------------------------------------------------------------
923
+ # Module-level HTTP helpers
924
+ # ---------------------------------------------------------------------------
925
+
926
+ def _hub_url(path: str) -> str:
927
+ base = os.environ.get("HUB_BASE_URL", "").rstrip("/")
928
+ return f"{base}{path}"
929
+
930
+
931
+ def _headers() -> dict[str, str]:
932
+ return {
933
+ "Content-Type": "application/json",
934
+ "Authorization": f"Bearer {os.environ.get('SYNAP_API_KEY', '')}",
935
+ }
936
+
937
+
938
+ def _post(path: str, payload: dict) -> Any:
939
+ """POST JSON payload to Hub Protocol."""
940
+ try:
941
+ data = json.dumps(payload).encode()
942
+ req = urllib.request.Request(_hub_url(path), data=data, headers=_headers(), method="POST")
943
+ with urllib.request.urlopen(req, timeout=10) as resp:
944
+ return json.loads(resp.read())
945
+ except (urllib.error.URLError, Exception):
946
+ return None
947
+
948
+
949
+ def _get(path: str, params: dict | None = None) -> Any:
950
+ """GET request with optional query params dict."""
951
+ try:
952
+ url = _hub_url(path)
953
+ if params:
954
+ url = f"{url}?{urllib.parse.urlencode(params)}"
955
+ req = urllib.request.Request(url, headers=_headers(), method="GET")
956
+ with urllib.request.urlopen(req, timeout=10) as resp:
957
+ return json.loads(resp.read())
958
+ except (urllib.error.URLError, Exception):
959
+ return None
960
+
961
+
962
+ def _put(path: str, payload: dict) -> Any:
963
+ """PUT JSON payload to Hub Protocol."""
964
+ try:
965
+ data = json.dumps(payload).encode()
966
+ req = urllib.request.Request(_hub_url(path), data=data, headers=_headers(), method="PUT")
967
+ with urllib.request.urlopen(req, timeout=10) as resp:
968
+ return json.loads(resp.read())
969
+ except (urllib.error.URLError, Exception):
970
+ return None
971
+
972
+
973
+ def _patch(path: str, payload: dict) -> Any:
974
+ """PATCH JSON payload to Hub Protocol."""
975
+ try:
976
+ data = json.dumps(payload).encode()
977
+ req = urllib.request.Request(_hub_url(path), data=data, headers=_headers(), method="PATCH")
978
+ with urllib.request.urlopen(req, timeout=10) as resp:
979
+ return json.loads(resp.read())
980
+ except (urllib.error.URLError, Exception):
981
+ return None
982
+
983
+
984
+ def _delete(path: str) -> Any:
985
+ """DELETE request to Hub Protocol."""
986
+ try:
987
+ req = urllib.request.Request(_hub_url(path), headers=_headers(), method="DELETE")
988
+ with urllib.request.urlopen(req, timeout=10) as resp:
989
+ return json.loads(resp.read())
990
+ except (urllib.error.URLError, Exception):
991
+ return None
992
+
993
+
994
+ # ---------------------------------------------------------------------------
995
+ # Module-level response helpers
996
+ # ---------------------------------------------------------------------------
997
+
998
+ def _ok(data: Any) -> dict:
999
+ return {"status": "done", "data": data}
1000
+
1001
+
1002
+ def _err(message: str) -> dict:
1003
+ return {"status": "error", "message": message}
1004
+
1005
+
1006
+ def _write_result(result: Any) -> dict:
1007
+ """Normalize a Hub Protocol write response.
1008
+
1009
+ Returns _ok() on success, or pending_approval dict when the backend
1010
+ created a proposal instead of executing (granted=False). Agents should
1011
+ show the reviewUrl to the user and wait \u2014 never retry the same call.
1012
+ """
1013
+ if result is None:
1014
+ return _err("Hub Protocol request failed \u2014 check HUB_BASE_URL and SYNAP_API_KEY")
1015
+ if isinstance(result, dict) and result.get("granted") is False:
1016
+ return {
1017
+ "status": "pending_approval",
1018
+ "proposalId": result.get("proposalId"),
1019
+ "summary": result.get("summary", "Action requires human approval"),
1020
+ "reasoning": result.get("reasoning", ""),
1021
+ "reviewPath": result.get("reviewPath", ""),
1022
+ "reviewUrl": result.get("reviewUrl", ""),
1023
+ "message": f"This action needs approval. Review at: {result.get('reviewUrl', result.get('reviewPath', ''))}",
1024
+ }
1025
+ return _ok(result)
1026
+
1027
+
1028
+ # ---------------------------------------------------------------------------
1029
+ # SynapMemoryProvider
1030
+ # ---------------------------------------------------------------------------
1031
+
1032
+ class SynapMemoryProvider:
1033
+ """Synap Data Pod \u2014 Hermes MemoryProvider contract."""
1034
+
1035
+ def __init__(self):
1036
+ self._session_id: str | None = None
1037
+
1038
+ def is_available(self) -> bool:
1039
+ return bool(os.environ.get("SYNAP_API_KEY")) and bool(os.environ.get("HUB_BASE_URL"))
1040
+
1041
+ def initialize(self, session_id: str, **kwargs) -> None:
1042
+ self._session_id = session_id
1043
+
1044
+ def _embed(self, text: str) -> list[float] | None:
1045
+ """Get a 1536-d embedding via the Hermes internal gateway.
1046
+
1047
+ Calls http://127.0.0.1:8642/v1/embeddings (OpenAI-compat) using
1048
+ the model set in SYNAP_EMBED_MODEL. Returns None when the model
1049
+ is unset, unavailable, or returns a non-1536-d vector \u2014 callers
1050
+ degrade to full-text search without interrupting the session.
1051
+
1052
+ The 1536-d constraint matches the knowledge_facts pgvector column.
1053
+ Ollama embedding models (768-1024-d) are intentionally excluded.
1054
+ """
1055
+ model = os.environ.get("SYNAP_EMBED_MODEL", "")
1056
+ if not model:
1057
+ return None
1058
+ try:
1059
+ payload = json.dumps({"model": model, "input": text}).encode()
1060
+ key = os.environ.get("API_SERVER_KEY", "")
1061
+ req = urllib.request.Request(
1062
+ "http://127.0.0.1:8642/v1/embeddings",
1063
+ data=payload,
1064
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"},
1065
+ method="POST",
1066
+ )
1067
+ with urllib.request.urlopen(req, timeout=5) as resp:
1068
+ body = json.loads(resp.read())
1069
+ vec = body["data"][0]["embedding"]
1070
+ if isinstance(vec, list) and len(vec) == 1536:
1071
+ return vec
1072
+ except Exception:
1073
+ pass
1074
+ return None
1075
+
1076
+ def prefetch(self, query: str) -> list[dict]:
1077
+ """Return relevant memories for the given query.
1078
+
1079
+ Prefers semantic search (POST /memory/search with pgvector) when
1080
+ SYNAP_EMBED_MODEL is set and the gateway returns a valid 1536-d
1081
+ embedding. Falls back to full-text (GET /memory?query=) otherwise.
1082
+ """
1083
+ if not query:
1084
+ return []
1085
+ # Semantic path \u2014 try embedding via Hermes gateway
1086
+ embedding = self._embed(query)
1087
+ if embedding:
1088
+ results = _post("/memory/search", {"embedding": embedding, "limit": 10})
1089
+ if isinstance(results, list) and results:
1090
+ return results
1091
+ # Full-text fallback
1092
+ results = _get("/memory", {"query": query, "limit": "10"})
1093
+ if isinstance(results, list):
1094
+ return results
1095
+ return []
1096
+
1097
+ def sync_turn(self, user: str, assistant: str) -> None:
1098
+ """Persist a conversation turn as two memory facts.
1099
+
1100
+ Includes embeddings when SYNAP_EMBED_MODEL is set so facts are
1101
+ discoverable via semantic search in future prefetch() calls.
1102
+ """
1103
+ session_tag = f"[session:{self._session_id}]" if self._session_id else ""
1104
+ if user:
1105
+ fact = f"{session_tag}[user] {user}"
1106
+ payload: dict = {"fact": fact}
1107
+ emb = self._embed(fact)
1108
+ if emb:
1109
+ payload["embedding"] = emb
1110
+ _post("/memory", payload)
1111
+ if assistant:
1112
+ fact = f"{session_tag}[assistant] {assistant}"
1113
+ payload = {"fact": fact}
1114
+ emb = self._embed(fact)
1115
+ if emb:
1116
+ payload["embedding"] = emb
1117
+ _post("/memory", payload)
1118
+
1119
+ def on_session_end(self, messages: list[dict]) -> None:
1120
+ """Summarise the session as a single memory fact with embedding."""
1121
+ if not messages:
1122
+ return
1123
+ parts = []
1124
+ for m in messages[-20:]: # cap at last 20 to avoid huge facts
1125
+ role = m.get("role", "?")
1126
+ content = str(m.get("content", ""))[:300]
1127
+ parts.append(f"{role}: {content}")
1128
+ summary = " | ".join(parts)
1129
+ session_tag = f"[session:{self._session_id}] " if self._session_id else ""
1130
+ fact = f"{session_tag}[summary] {summary}"
1131
+ payload: dict = {"fact": fact, "confidence": 0.9}
1132
+ emb = self._embed(fact)
1133
+ if emb:
1134
+ payload["embedding"] = emb
1135
+ _post("/memory", payload)
1136
+
1137
+ def on_memory_write(self, action: str, target: str, content: str) -> None:
1138
+ """Intercept explicit remember/update commands \u2014 store as high-confidence facts."""
1139
+ if action in ("remember", "add", "update", "replace"):
1140
+ payload: dict = {"fact": content, "confidence": 1.0}
1141
+ emb = self._embed(content)
1142
+ if emb:
1143
+ payload["embedding"] = emb
1144
+ _post("/memory", payload)
1145
+
1146
+
1147
+ def register(ctx):
1148
+ ctx.register_memory_provider(SynapMemoryProvider())
1149
+ `;
1150
+ function generateSynapPlugin() {
1151
+ const pluginDir = join(homedir(), ".eve", "hermes", "plugins", "memory", "synap");
1152
+ mkdirSync(pluginDir, { recursive: true });
1153
+ writeFileSync(join(pluginDir, "plugin.yaml"), SYNAP_PLUGIN_YAML, "utf-8");
1154
+ writeFileSync(join(pluginDir, "__init__.py"), SYNAP_PROVIDER_PY, "utf-8");
1155
+ return { pluginDir };
1156
+ }
1157
+ var POD_DEPLOY_DIR_CANDIDATES = [
1158
+ "/opt/synap-backend/deploy",
1159
+ "/opt/synap-backend",
1160
+ "/opt/synap/deploy",
1161
+ "/opt/synap",
1162
+ "/opt/synap-pod/deploy",
1163
+ "/opt/synap-pod",
1164
+ "/srv/synap-backend/deploy",
1165
+ "/srv/synap/deploy"
1166
+ ];
1167
+ function findPodDeployDir() {
1168
+ if (process.env.SYNAP_DEPLOY_DIR && existsSync2(process.env.SYNAP_DEPLOY_DIR)) {
1169
+ return process.env.SYNAP_DEPLOY_DIR;
1170
+ }
1171
+ for (const dir of POD_DEPLOY_DIR_CANDIDATES) {
1172
+ if (!existsSync2(dir)) continue;
1173
+ if (existsSync2(join2(dir, "docker-compose.yml")) || existsSync2(join2(dir, "docker-compose.standalone.yml"))) {
1174
+ return dir;
1175
+ }
1176
+ }
1177
+ return null;
1178
+ }
1179
+ var SYNAP_BACKEND_CONTAINERS = [
1180
+ "synap-backend-backend-1",
1181
+ "synap-backend",
1182
+ "synap-backend-1"
1183
+ ];
1184
+ function restartBackendContainer(deployDir) {
1185
+ const mainFile = join2(deployDir, "docker-compose.yml");
1186
+ const standaloneFile = join2(deployDir, "docker-compose.standalone.yml");
1187
+ const overrideFile = join2(deployDir, "docker-compose.override.yml");
1188
+ const hasOverride = existsSync2(overrideFile);
1189
+ if (existsSync2(mainFile)) {
1190
+ try {
1191
+ execSync("docker compose up -d backend", {
1192
+ cwd: deployDir,
1193
+ stdio: ["ignore", "ignore", "ignore"],
1194
+ timeout: 3e4
1195
+ });
1196
+ return true;
1197
+ } catch {
1198
+ }
1199
+ }
1200
+ if (existsSync2(standaloneFile)) {
1201
+ try {
1202
+ const overrideArg = hasOverride ? ` -f ${overrideFile}` : "";
1203
+ execSync(`docker compose -f ${standaloneFile}${overrideArg} up -d backend`, {
1204
+ cwd: deployDir,
1205
+ stdio: ["ignore", "ignore", "ignore"],
1206
+ timeout: 3e4
1207
+ });
1208
+ return true;
1209
+ } catch {
1210
+ }
1211
+ }
1212
+ for (const container of SYNAP_BACKEND_CONTAINERS) {
1213
+ try {
1214
+ execSync(`docker restart ${container}`, {
1215
+ stdio: ["ignore", "ignore", "ignore"],
1216
+ timeout: 15e3
1217
+ });
1218
+ return true;
1219
+ } catch {
1220
+ }
1221
+ }
1222
+ return false;
1223
+ }
1224
+ function restartHermesIfRunning() {
1225
+ try {
1226
+ execSync('docker inspect --format "{{.State.Running}}" eve-builder-hermes', {
1227
+ stdio: "pipe"
1228
+ });
1229
+ execSync("docker restart eve-builder-hermes", { stdio: "pipe", timeout: 3e4 });
1230
+ return true;
1231
+ } catch {
1232
+ return false;
1233
+ }
1234
+ }
1235
+ var TRAEFIK_CANDIDATES = [
1236
+ "eve-legs-traefik",
1237
+ // Eve-managed standard install
1238
+ "traefik-traefik-1",
1239
+ // /opt/traefik compose project
1240
+ "traefik_traefik_1",
1241
+ // older Docker Compose naming
1242
+ "traefik"
1243
+ // bare hand-installed container
1244
+ ];
1245
+ function connectTraefikToEveNetwork() {
1246
+ let candidates = [...TRAEFIK_CANDIDATES];
1247
+ try {
1248
+ const out = execSync(
1249
+ 'docker ps --filter "ancestor=traefik" --filter "status=running" --format "{{.Names}}"',
1250
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 4e3 }
1251
+ ).trim();
1252
+ const imageMatch = out.split("\n")[0]?.trim();
1253
+ if (imageMatch) candidates = [imageMatch, ...candidates];
1254
+ } catch {
1255
+ }
1256
+ for (const name of candidates) {
1257
+ try {
1258
+ execSync(`docker inspect --format "{{.State.Running}}" ${name}`, {
1259
+ stdio: ["ignore", "ignore", "ignore"],
1260
+ timeout: 2e3
1261
+ });
1262
+ } catch {
1263
+ continue;
1264
+ }
1265
+ try {
1266
+ execSync(`docker network connect eve-network ${name}`, {
1267
+ stdio: ["ignore", "ignore", "ignore"],
1268
+ timeout: 5e3
1269
+ });
1270
+ return { connected: true, containerName: name, alreadyConnected: false };
1271
+ } catch (err) {
1272
+ const msg = err instanceof Error ? err.message : String(err);
1273
+ const isAlready = msg.includes("already exists") || msg.toLowerCase().includes("already connected");
1274
+ return { connected: true, containerName: name, alreadyConnected: isAlready };
1275
+ }
1276
+ }
1277
+ return { connected: false, alreadyConnected: false };
1278
+ }
1279
+ var cachedReachable;
1280
+ async function isSynapLoopbackReachable(timeoutMs = 200) {
1281
+ if (cachedReachable === true) return true;
1282
+ const result = await probeTcpPort("127.0.0.1", SYNAP_HOST_LOOPBACK_PORT, timeoutMs);
1283
+ if (result) cachedReachable = true;
1284
+ return result;
1285
+ }
1286
+ function resetSynapLoopbackProbeCache() {
1287
+ cachedReachable = void 0;
1288
+ }
1289
+ async function resolveSynapUrlOnHost(secrets) {
1290
+ if (await isSynapLoopbackReachable()) {
1291
+ return `http://127.0.0.1:${SYNAP_HOST_LOOPBACK_PORT}`;
1292
+ }
1293
+ const stored = secrets?.synap?.apiUrl?.trim();
1294
+ if (stored && !isLoopbackUrl(stored)) return stored;
1295
+ return resolveSynapUrl(secrets);
1296
+ }
1297
+ function probeTcpPort(host, port, timeoutMs) {
1298
+ return new Promise((resolve2) => {
1299
+ const socket = new Socket();
1300
+ let settled2 = false;
1301
+ const settle = (result) => {
1302
+ if (settled2) return;
1303
+ settled2 = true;
1304
+ try {
1305
+ socket.destroy();
1306
+ } catch {
1307
+ }
1308
+ resolve2(result);
1309
+ };
1310
+ const timer = setTimeout(() => settle(false), timeoutMs);
1311
+ timer.unref?.();
1312
+ socket.once("connect", () => {
1313
+ clearTimeout(timer);
1314
+ settle(true);
1315
+ });
1316
+ socket.once("error", () => {
1317
+ clearTimeout(timer);
1318
+ settle(false);
1319
+ });
1320
+ try {
1321
+ socket.connect(port, host);
1322
+ } catch {
1323
+ clearTimeout(timer);
1324
+ settle(false);
1325
+ }
1326
+ });
1327
+ }
1328
+ var require2 = createRequire(import.meta.url);
1329
+ var { createHmac, randomUUID } = require2("node:crypto");
1330
+ function resolveLiveAdminPort() {
1331
+ try {
1332
+ const out = execSync2(
1333
+ "docker port hestia-openwebui 8080",
1334
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }
1335
+ ).trim();
1336
+ const match = out.match(/:(\d+)\s*$/m);
1337
+ if (match) {
1338
+ const port = parseInt(match[1], 10);
1339
+ if (port > 0) return port;
1340
+ }
1341
+ } catch {
1342
+ }
1343
+ return null;
1344
+ }
1345
+ function resolveOpenwebuiAdminUrl(hostPort) {
1346
+ if (hostPort) return `http://127.0.0.1:${hostPort}`;
1347
+ const live = resolveLiveAdminPort();
1348
+ if (live) return `http://127.0.0.1:${live}`;
1349
+ const comp = COMPONENTS.find((c) => c.id === "openwebui");
1350
+ return `http://127.0.0.1:${comp?.service?.hostPort ?? 3011}`;
1351
+ }
1352
+ var resolveAdminUrl = resolveOpenwebuiAdminUrl;
1353
+ function probeHealthInternal() {
1354
+ try {
1355
+ const out = execSync2(
1356
+ `docker exec hestia-openwebui python3 -c "import urllib.request,sys
1357
+ try:
1358
+ r=urllib.request.urlopen('http://localhost:8080/api/version',timeout=5)
1359
+ sys.stdout.write(f'{r.status}\\n')
1360
+ sys.stdout.write(r.read(200).decode('utf-8','ignore'))
1361
+ except Exception as e:
1362
+ sys.stderr.write(str(e))
1363
+ sys.exit(2)"`,
1364
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
1365
+ );
1366
+ const text = out.toString();
1367
+ const lines = text.split("\n");
1368
+ const status = parseInt(lines[0] ?? "", 10);
1369
+ const body = lines.slice(1).join("\n").trim();
1370
+ if (status >= 200 && status < 300 && !body.startsWith("<")) {
1371
+ return { ok: true };
1372
+ }
1373
+ return { ok: false, reason: `HTTP ${status || "?"}, body=${body.slice(0, 100)}` };
1374
+ } catch (err) {
1375
+ const stderrBuf = err?.stderr;
1376
+ const stderrStr = stderrBuf?.toString().trim();
1377
+ const fallback = err instanceof Error ? err.message : String(err);
1378
+ return { ok: false, reason: stderrStr || fallback };
1379
+ }
1380
+ }
1381
+ async function waitUntilHealthy(baseUrl, maxAttempts = 12) {
1382
+ for (let i = 0; i < maxAttempts; i++) {
1383
+ try {
1384
+ const controller = new AbortController();
1385
+ const timer = setTimeout(() => controller.abort(), 5e3);
1386
+ const res = await fetch(`${baseUrl}/api/version`, { signal: controller.signal });
1387
+ clearTimeout(timer);
1388
+ if (res.ok) {
1389
+ const contentType = res.headers.get("content-type") || "";
1390
+ if (contentType.includes("json")) {
1391
+ return true;
1392
+ }
1393
+ const text = await res.text();
1394
+ if (!text.trimStart().startsWith("<")) return true;
1395
+ }
1396
+ } catch {
1397
+ }
1398
+ await new Promise((r) => setTimeout(r, 5e3));
1399
+ }
1400
+ return false;
1401
+ }
1402
+ function readWebuiSecretKey() {
1403
+ try {
1404
+ const envPath = "/opt/openwebui/.env";
1405
+ if (!existsSync3(envPath)) return null;
1406
+ const content = readFileSync(envPath, "utf-8");
1407
+ const match = content.split("\n").find((l) => l.startsWith("WEBUI_SECRET_KEY="));
1408
+ return match?.split("=", 2)[1]?.trim() ?? null;
1409
+ } catch {
1410
+ return null;
1411
+ }
1412
+ }
1413
+ function getAdminUserDetailed() {
1414
+ try {
1415
+ const out = execSync2(
1416
+ `docker exec hestia-openwebui python3 -c "import sqlite3; r=sqlite3.connect('/app/backend/data/webui.db').execute(\\"SELECT id,email FROM user WHERE role='admin' LIMIT 1\\").fetchone(); print(f'{r[0]}|{r[1]}') if r else print('')"`,
1417
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
1418
+ );
1419
+ const row = out.toString().trim();
1420
+ if (!row) {
1421
+ return { user: null, error: "admin row not found (no row where role='admin' in user table)" };
1422
+ }
1423
+ const [id, email] = row.split("|");
1424
+ if (!id || !email) return { user: null, error: `unexpected row shape: ${row}` };
1425
+ return { user: { id, email } };
1426
+ } catch (err) {
1427
+ const stderrBuf = err?.stderr;
1428
+ const stderrStr = stderrBuf?.toString().trim();
1429
+ const fallback = err instanceof Error ? err.message : String(err);
1430
+ return { user: null, error: `docker exec failed: ${(stderrStr || fallback).slice(0, 300)}` };
1431
+ }
1432
+ }
1433
+ function forgeAdminJwt(secretKey, admin) {
1434
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
1435
+ const payload = Buffer.from(JSON.stringify({
1436
+ id: admin.id,
1437
+ email: admin.email,
1438
+ jti: randomUUID(),
1439
+ iat: Math.floor(Date.now() / 1e3),
1440
+ exp: Math.floor(Date.now() / 1e3) + 300
1441
+ })).toString("base64url");
1442
+ const sig = createHmac("sha256", secretKey).update(`${header}.${payload}`).digest("base64url");
1443
+ return `${header}.${payload}.${sig}`;
1444
+ }
1445
+ async function waitForHealth(hostPort, maxAttempts = 12) {
1446
+ const baseUrl = resolveAdminUrl(hostPort);
1447
+ return waitUntilHealthy(baseUrl, maxAttempts);
1448
+ }
1449
+ async function waitForHealthDetailed(hostPort, maxAttempts = 12) {
1450
+ const baseUrl = resolveAdminUrl(hostPort);
1451
+ let lastErr = "";
1452
+ for (let i = 0; i < maxAttempts; i++) {
1453
+ try {
1454
+ const controller = new AbortController();
1455
+ const timer = setTimeout(() => controller.abort(), 5e3);
1456
+ const res = await fetch(`${baseUrl}/health`, { signal: controller.signal });
1457
+ clearTimeout(timer);
1458
+ if (res.ok) {
1459
+ const ct = res.headers.get("content-type") || "";
1460
+ if (ct.includes("json") || !ct.includes("html")) {
1461
+ return { ok: true, baseUrl };
1462
+ }
1463
+ const text = await res.text();
1464
+ if (!text.trimStart().startsWith("<")) {
1465
+ return { ok: true, baseUrl };
1466
+ }
1467
+ lastErr = "OWUI returned HTML at /health (still booting)";
1468
+ } else {
1469
+ lastErr = `HTTP ${res.status}`;
1470
+ }
1471
+ } catch (err) {
1472
+ lastErr = err instanceof Error ? err.message : String(err);
1473
+ }
1474
+ if (i < maxAttempts - 1) await new Promise((r) => setTimeout(r, 5e3));
1475
+ }
1476
+ const internal = probeHealthInternal();
1477
+ if (internal.ok) {
1478
+ return {
1479
+ ok: false,
1480
+ baseUrl,
1481
+ reason: `Host loopback ${baseUrl}/health timed out after ${maxAttempts * 5}s (last error: ${lastErr || "unknown"}), but the container's own /health responds. Host port mapping is broken \u2014 check \`docker port hestia-openwebui 8080\` and \`OPEN_WEBUI_PORT\` in /opt/openwebui/.env.`
1482
+ };
1483
+ }
1484
+ return {
1485
+ ok: false,
1486
+ baseUrl,
1487
+ reason: `Host loopback ${baseUrl}/health timed out after ${maxAttempts * 5}s (last error: ${lastErr || "unknown"}); internal probe also failed: ${internal.reason ?? "unknown"}.`
1488
+ };
1489
+ }
1490
+ async function getStatus(hostPort) {
1491
+ const baseUrl = resolveAdminUrl(hostPort);
1492
+ const healthy = await waitUntilHealthy(baseUrl, hostPort ? 12 : 6);
1493
+ if (!healthy) {
1494
+ try {
1495
+ const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(3e3) });
1496
+ if (res.ok) {
1497
+ const text = await res.text();
1498
+ if (text.trimStart().startsWith("<")) {
1499
+ return { status: "html-response" };
1500
+ }
1501
+ }
1502
+ } catch {
1503
+ }
1504
+ return { status: "not-reachable" };
1505
+ }
1506
+ const secretKey = readWebuiSecretKey();
1507
+ if (!secretKey) {
1508
+ return { status: "no-secret-key" };
1509
+ }
1510
+ const adminQ = getAdminUserDetailed();
1511
+ if (!adminQ.user) {
1512
+ return { status: "no-admin-user", adminUser: null, error: adminQ.error };
1513
+ }
1514
+ return { status: "healthy", adminUser: adminQ.user };
1515
+ }
1516
+ async function getAdminReadyStatus() {
1517
+ const secretKey = readWebuiSecretKey();
1518
+ if (!secretKey) {
1519
+ return { status: "no-secret-key" };
1520
+ }
1521
+ const adminQ = getAdminUserDetailed();
1522
+ if (!adminQ.user) {
1523
+ return { status: "no-admin-user", adminUser: null, error: adminQ.error };
1524
+ }
1525
+ return { status: "healthy", adminUser: adminQ.user };
1526
+ }
1527
+ async function getAdminJwt(hostPort) {
1528
+ const status = await getStatus(hostPort);
1529
+ if (status.status !== "healthy") return null;
1530
+ const secretKey = readWebuiSecretKey();
1531
+ if (!secretKey) return null;
1532
+ return forgeAdminJwt(secretKey, status.adminUser);
1533
+ }
1534
+ async function getAdminJwtPostHealth() {
1535
+ const status = await getAdminReadyStatus();
1536
+ if (status.status !== "healthy" || !status.adminUser) return null;
1537
+ const secretKey = readWebuiSecretKey();
1538
+ if (!secretKey) return null;
1539
+ return forgeAdminJwt(secretKey, status.adminUser);
1540
+ }
1541
+ async function getAdminJwtPostHealthDetailed() {
1542
+ const status = await getAdminReadyStatus();
1543
+ if (status.status === "no-secret-key") {
1544
+ return { ok: false, stage: "secret-key", reason: "WEBUI_SECRET_KEY missing in /opt/openwebui/.env" };
1545
+ }
1546
+ if (status.status !== "healthy" || !status.adminUser) {
1547
+ const reason = status.status === "no-admin-user" ? status.error ?? "admin user not found in user table" : `unexpected status: ${status.status}`;
1548
+ return { ok: false, stage: "admin-row", reason };
1549
+ }
1550
+ const secretKey = readWebuiSecretKey();
1551
+ if (!secretKey) {
1552
+ return { ok: false, stage: "secret-key", reason: "WEBUI_SECRET_KEY missing in /opt/openwebui/.env" };
1553
+ }
1554
+ return { ok: true, jwt: forgeAdminJwt(secretKey, status.adminUser) };
1555
+ }
1556
+ async function probeAdminAuth(jwt, hostPort) {
1557
+ const baseUrl = resolveAdminUrl(hostPort);
1558
+ try {
1559
+ const res = await fetch(`${baseUrl}/api/v1/configs/export`, {
1560
+ headers: { Authorization: `Bearer ${jwt}` },
1561
+ signal: AbortSignal.timeout(8e3)
1562
+ });
1563
+ const body = await res.text();
1564
+ return { ok: res.ok, status: res.status, body };
1565
+ } catch (err) {
1566
+ return { ok: false, status: 0, body: err instanceof Error ? err.message : String(err) };
1567
+ }
1568
+ }
1569
+ async function getConfig(jwt, hostPort) {
1570
+ const detailed = await getConfigDetailed(jwt, hostPort);
1571
+ return detailed.ok ? detailed.config : null;
1572
+ }
1573
+ async function getConfigDetailed(jwt, hostPort) {
1574
+ const baseUrl = resolveAdminUrl(hostPort);
1575
+ let status = 0;
1576
+ let body = "";
1577
+ try {
1578
+ const res = await fetch(`${baseUrl}/api/v1/configs/export`, {
1579
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }
1580
+ });
1581
+ status = res.status;
1582
+ body = await res.text();
1583
+ if (!res.ok) {
1584
+ return { ok: false, status, bodyPreview: body.slice(0, 200), reason: `HTTP ${status}` };
1585
+ }
1586
+ if (body.trimStart().startsWith("<")) {
1587
+ return {
1588
+ ok: false,
1589
+ status,
1590
+ bodyPreview: body.slice(0, 200),
1591
+ reason: "OWUI returned HTML at /api/v1/configs/export (admin endpoint not registered or SPA shell intercepted the request)"
1592
+ };
1593
+ }
1594
+ try {
1595
+ const config = JSON.parse(body);
1596
+ return { ok: true, config };
1597
+ } catch (err) {
1598
+ return {
1599
+ ok: false,
1600
+ status,
1601
+ bodyPreview: body.slice(0, 200),
1602
+ reason: `JSON parse failed: ${err instanceof Error ? err.message : String(err)}`
1603
+ };
1604
+ }
1605
+ } catch (err) {
1606
+ return {
1607
+ ok: false,
1608
+ status,
1609
+ bodyPreview: body.slice(0, 200),
1610
+ reason: err instanceof Error ? err.message : String(err)
1611
+ };
1612
+ }
1613
+ }
1614
+ async function saveConfig(jwt, config, hostPort) {
1615
+ const detailed = await saveConfigDetailed(jwt, config, hostPort);
1616
+ return detailed.ok;
1617
+ }
1618
+ async function saveConfigDetailed(jwt, config, hostPort) {
1619
+ const baseUrl = resolveAdminUrl(hostPort);
1620
+ let status = 0;
1621
+ let body = "";
1622
+ try {
1623
+ const res = await fetch(`${baseUrl}/api/v1/configs/import`, {
1624
+ method: "POST",
1625
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
1626
+ body: JSON.stringify({ config })
1627
+ });
1628
+ status = res.status;
1629
+ if (res.ok) return { ok: true };
1630
+ body = await res.text();
1631
+ return { ok: false, status, bodyPreview: body.slice(0, 200), reason: `HTTP ${status}` };
1632
+ } catch (err) {
1633
+ return {
1634
+ ok: false,
1635
+ status,
1636
+ bodyPreview: body.slice(0, 200),
1637
+ reason: err instanceof Error ? err.message : String(err)
1638
+ };
1639
+ }
1640
+ }
1641
+ var MANAGED_ROOT_KEY_BY_OPTION = {
1642
+ webuiUrl: "WEBUI_URL",
1643
+ webuiName: "WEBUI_NAME",
1644
+ enableSignup: "ENABLE_SIGNUP",
1645
+ defaultUserRole: "DEFAULT_USER_ROLE"
1646
+ };
1647
+ var MANAGED_UI_KEY_BY_OPTION = {
1648
+ enableSignup: "enable_signup",
1649
+ defaultUserRole: "default_user_role"
1650
+ };
1651
+ var DEFAULT_MODELS_KEYS = ["DEFAULT_MODELS", "default_models"];
1652
+ function isPlainConfigObject(value) {
1653
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1654
+ }
1655
+ function modelSourceMetadataFor(modelSource) {
1656
+ return {
1657
+ name: modelSource.displayName,
1658
+ models: modelSource.models?.join(";")
1659
+ };
1660
+ }
1661
+ function defaultModelsForExistingShape(desired, existing) {
1662
+ if (Array.isArray(existing)) {
1663
+ return Array.isArray(desired) ? [...desired] : [desired];
1664
+ }
1665
+ return Array.isArray(desired) ? desired.join(",") : desired;
1666
+ }
1667
+ function setIfChanged(target, key, value, changedKeys) {
1668
+ if (JSON.stringify(target[key]) === JSON.stringify(value)) return;
1669
+ target[key] = value;
1670
+ changedKeys.add(key);
1671
+ }
1672
+ function reconcileOpenwebuiManagedConfig(currentConfig, desired) {
1673
+ const config = { ...currentConfig };
1674
+ const changedKeys = /* @__PURE__ */ new Set();
1675
+ if (desired.modelSources?.length) {
1676
+ const existingOpenai = isPlainConfigObject(config.openai) ? config.openai : {};
1677
+ const openai = { ...existingOpenai };
1678
+ const urls = Array.isArray(openai.api_base_urls) ? [...openai.api_base_urls] : [];
1679
+ const keys = Array.isArray(openai.api_keys) ? [...openai.api_keys] : [];
1680
+ const existingMetadata = isPlainConfigObject(openai.metadata) ? openai.metadata : {};
1681
+ const metadata = { ...existingMetadata };
1682
+ for (const modelSource of desired.modelSources) {
1683
+ const idx = urls.indexOf(modelSource.url);
1684
+ if (idx === -1) {
1685
+ urls.push(modelSource.url);
1686
+ keys.push(modelSource.apiKey);
1687
+ } else {
1688
+ keys[idx] = modelSource.apiKey;
1689
+ }
1690
+ metadata[modelSource.url] = modelSourceMetadataFor(modelSource);
1691
+ }
1692
+ while (keys.length < urls.length) keys.push("");
1693
+ setIfChanged(openai, "api_base_urls", urls, changedKeys);
1694
+ setIfChanged(openai, "api_keys", keys, changedKeys);
1695
+ setIfChanged(openai, "metadata", metadata, changedKeys);
1696
+ if (JSON.stringify(config.openai) !== JSON.stringify(openai)) {
1697
+ config.openai = openai;
1698
+ changedKeys.add("openai");
1699
+ }
1700
+ }
1701
+ if (desired.defaultModels !== void 0) {
1702
+ const existingKeys = DEFAULT_MODELS_KEYS.filter((key) => key in config);
1703
+ const keysToSet = existingKeys.length ? existingKeys : ["DEFAULT_MODELS"];
1704
+ for (const key of keysToSet) {
1705
+ setIfChanged(
1706
+ config,
1707
+ key,
1708
+ defaultModelsForExistingShape(desired.defaultModels, config[key]),
1709
+ changedKeys
1710
+ );
1711
+ }
1712
+ }
1713
+ for (const [optionKey, configKey] of Object.entries(MANAGED_ROOT_KEY_BY_OPTION)) {
1714
+ const value = desired[optionKey];
1715
+ if (value !== void 0) {
1716
+ setIfChanged(config, configKey, value, changedKeys);
1717
+ }
1718
+ }
1719
+ if (Object.keys(MANAGED_UI_KEY_BY_OPTION).some(
1720
+ (k) => desired[k] !== void 0
1721
+ )) {
1722
+ const existingUi = isPlainConfigObject(config.ui) ? config.ui : {};
1723
+ const ui = { ...existingUi };
1724
+ for (const [optionKey, uiKey] of Object.entries(MANAGED_UI_KEY_BY_OPTION)) {
1725
+ const value = desired[optionKey];
1726
+ if (value !== void 0) {
1727
+ setIfChanged(ui, uiKey, value, changedKeys);
1728
+ }
1729
+ }
1730
+ if (JSON.stringify(config.ui) !== JSON.stringify(ui)) {
1731
+ config.ui = ui;
1732
+ changedKeys.add("ui");
1733
+ }
1734
+ }
1735
+ if ("tool_server" in config) {
1736
+ delete config.tool_server;
1737
+ changedKeys.add("tool_server");
1738
+ }
1739
+ return {
1740
+ config,
1741
+ changed: changedKeys.size > 0,
1742
+ changedKeys: [...changedKeys]
1743
+ };
1744
+ }
1745
+ async function reconcileOpenwebuiManagedConfigViaAdmin(jwt, desired, hostPort) {
1746
+ const detailed = await reconcileOpenwebuiManagedConfigViaAdminDetailed(jwt, desired, hostPort);
1747
+ return detailed.ok ? detailed.result : null;
1748
+ }
1749
+ async function reconcileOpenwebuiManagedConfigViaAdminDetailed(jwt, desired, hostPort) {
1750
+ const read = await getConfigDetailed(jwt, hostPort);
1751
+ if (!read.ok) {
1752
+ return { ok: false, step: "getConfig", status: read.status, bodyPreview: read.bodyPreview, reason: read.reason };
1753
+ }
1754
+ const result = reconcileOpenwebuiManagedConfig(read.config, desired);
1755
+ if (!result.changed) return { ok: true, result };
1756
+ const save = await saveConfigDetailed(jwt, result.config, hostPort);
1757
+ if (!save.ok) {
1758
+ return { ok: false, step: "saveConfig", status: save.status, bodyPreview: save.bodyPreview, reason: save.reason };
1759
+ }
1760
+ return { ok: true, result };
1761
+ }
1762
+ async function listModelSources(jwt, hostPort) {
1763
+ const config = await getConfig(jwt, hostPort);
1764
+ if (!config) return [];
1765
+ const openai = config.openai ?? {};
1766
+ const urls = Array.isArray(openai.api_base_urls) ? openai.api_base_urls : [];
1767
+ const keys = Array.isArray(openai.api_keys) ? openai.api_keys : [];
1768
+ const metadata = openai.metadata ?? {};
1769
+ return urls.map((url, i) => ({
1770
+ url,
1771
+ apiKey: keys[i] ?? "",
1772
+ displayName: metadata[url]?.name ?? url
1773
+ }));
1774
+ }
1775
+ async function registerModelSource(jwt, modelSource, hostPort) {
1776
+ const result = await reconcileOpenwebuiManagedConfigViaAdmin(
1777
+ jwt,
1778
+ { modelSources: [modelSource] },
1779
+ hostPort
1780
+ );
1781
+ return result !== null;
1782
+ }
1783
+ async function upsertAllModelSources(jwt, modelSources, hostPort) {
1784
+ if (modelSources.length === 0) return 0;
1785
+ const result = await reconcileOpenwebuiManagedConfigViaAdmin(
1786
+ jwt,
1787
+ { modelSources },
1788
+ hostPort
1789
+ );
1790
+ return result !== null ? modelSources.length : 0;
1791
+ }
1792
+ var SYNAP_SKILL_SLUGS = ["synap", "synap-schema", "synap-ui"];
1793
+ var EVE_MANAGED_TAG = "synap:system";
1794
+ var HTTP_TIMEOUT_MS = 3e4;
1795
+ function nameFor(slug) {
1796
+ switch (slug) {
1797
+ case "synap":
1798
+ return `Synap \u2014 Capture, Memory, Channels [${EVE_MANAGED_TAG}]`;
1799
+ case "synap-schema":
1800
+ return `Synap Schema \u2014 Profiles & Property Defs [${EVE_MANAGED_TAG}]`;
1801
+ case "synap-ui":
1802
+ return `Synap UI \u2014 Views & Dashboards [${EVE_MANAGED_TAG}]`;
1803
+ }
1804
+ }
1805
+ async function fetchSynapSkills(hubBaseUrl, apiKey) {
1806
+ const url = `${hubBaseUrl}/skills/system`;
1807
+ const res = await fetch(url, {
1808
+ headers: { Authorization: `Bearer ${apiKey}` },
1809
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
1810
+ });
1811
+ if (!res.ok) {
1812
+ const body = await res.text().catch(() => "");
1813
+ const keyPrefix = apiKey.slice(0, 12);
1814
+ throw new Error(
1815
+ `Hub /skills/system failed with HTTP ${res.status} (url=${url}, key=${keyPrefix}\u2026, body=${body.slice(0, 200)})`
1816
+ );
1817
+ }
1818
+ const packages = await res.json();
1819
+ if (!Array.isArray(packages)) {
1820
+ throw new Error("Hub /skills/system returned a non-array body");
1821
+ }
1822
+ return packages;
1823
+ }
1824
+ function extractSkillMarkdown(pkg) {
1825
+ const file = pkg.files.find((f) => f.path === "SKILL.md");
1826
+ return file?.content ?? null;
1827
+ }
1828
+ async function listPrompts(jwt, hostPort) {
1829
+ const baseUrl = resolveOpenwebuiAdminUrl(hostPort);
1830
+ const res = await fetch(`${baseUrl}/api/v1/prompts/`, {
1831
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
1832
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
1833
+ });
1834
+ if (!res.ok) {
1835
+ throw new Error(`OpenWebUI GET /api/v1/prompts/ failed with HTTP ${res.status}`);
1836
+ }
1837
+ const data = await res.json();
1838
+ return Array.isArray(data) ? data : [];
1839
+ }
1840
+ async function createPrompt(jwt, prompt, hostPort) {
1841
+ const baseUrl = resolveOpenwebuiAdminUrl(hostPort);
1842
+ const res = await fetch(`${baseUrl}/api/v1/prompts/create`, {
1843
+ method: "POST",
1844
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
1845
+ body: JSON.stringify(prompt),
1846
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
1847
+ });
1848
+ if (!res.ok) {
1849
+ throw new Error(`POST /api/v1/prompts/create failed with HTTP ${res.status}`);
1850
+ }
1851
+ }
1852
+ async function updatePrompt(jwt, promptId, prompt, hostPort) {
1853
+ const baseUrl = resolveOpenwebuiAdminUrl(hostPort);
1854
+ const path = `/api/v1/prompts/id/${encodeURIComponent(promptId)}/update`;
1855
+ const res = await fetch(`${baseUrl}${path}`, {
1856
+ method: "POST",
1857
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
1858
+ body: JSON.stringify(prompt),
1859
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
1860
+ });
1861
+ if (!res.ok) {
1862
+ throw new Error(`POST ${path} failed with HTTP ${res.status}`);
1863
+ }
1864
+ }
1865
+ async function pushSynapSkillsToOpenwebuiPrompts(cwd, hubBaseUrl, secrets) {
1866
+ let apiKey = readAgentKeyOrLegacySync("openwebui", secrets);
1867
+ if (!apiKey) apiKey = readAgentKeyOrLegacySync("eve", secrets);
1868
+ if (!apiKey) {
1869
+ throw new Error("No Hub API key available \u2014 run: eve auth provision --agent openwebui (or eve auth provision --agent eve)");
1870
+ }
1871
+ const packages = await fetchSynapSkills(hubBaseUrl.replace(/\/$/, ""), apiKey);
1872
+ const jwt = await getAdminJwt();
1873
+ if (!jwt) {
1874
+ throw new Error("OpenWebUI admin JWT unavailable \u2014 container down or no admin user");
1875
+ }
1876
+ const existing = await listPrompts(jwt);
1877
+ const existingByCommand = /* @__PURE__ */ new Map();
1878
+ for (const p of existing) {
1879
+ if (typeof p.command === "string") existingByCommand.set(p.command, p);
1880
+ }
1881
+ const synced = [];
1882
+ const skipped = [];
1883
+ let created = 0;
1884
+ let updated = 0;
1885
+ void secrets;
1886
+ for (const slug of SYNAP_SKILL_SLUGS) {
1887
+ const pkg = packages.find((p) => p.slug === slug);
1888
+ if (!pkg) {
1889
+ skipped.push({ command: slug, reason: "Hub did not return this skill package" });
1890
+ continue;
1891
+ }
1892
+ const content = extractSkillMarkdown(pkg);
1893
+ if (!content) {
1894
+ skipped.push({ command: slug, reason: "Skill package has no SKILL.md file" });
1895
+ continue;
1896
+ }
1897
+ const desired = {
1898
+ command: slug,
1899
+ name: nameFor(slug),
1900
+ content
1901
+ };
1902
+ try {
1903
+ const prior = existingByCommand.get(slug);
1904
+ if (!prior) {
1905
+ await createPrompt(jwt, desired);
1906
+ created++;
1907
+ } else if (prior.content !== content || prior.name !== desired.name) {
1908
+ if (!prior.id) {
1909
+ skipped.push({
1910
+ command: slug,
1911
+ reason: "existing prompt missing `id` field \u2014 cannot target id-keyed update endpoint"
1912
+ });
1913
+ continue;
1914
+ }
1915
+ await updatePrompt(jwt, prior.id, desired);
1916
+ updated++;
1917
+ }
1918
+ synced.push({
1919
+ command: slug,
1920
+ name: desired.name,
1921
+ content,
1922
+ source: slug
1923
+ });
1924
+ } catch (err) {
1925
+ skipped.push({
1926
+ command: slug,
1927
+ reason: err instanceof Error ? err.message : String(err)
1928
+ });
1929
+ }
1930
+ }
1931
+ return { synced, created, updated, skipped };
1932
+ }
1933
+ var DEFAULT_NAMESPACE = "openwebui";
1934
+ var DEFAULT_MAX_ENTRIES = 500;
1935
+ var SYNAP_PAGE_SIZE = 100;
1936
+ var FILE_EXTENSION = ".md";
1937
+ function filenameFor(entry) {
1938
+ const raw = entry.slug && entry.slug.trim() || entry.key;
1939
+ const safe = raw.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120);
1940
+ return `${safe || "entry"}${FILE_EXTENSION}`;
1941
+ }
1942
+ function sha256Hex(input) {
1943
+ return createHash("sha256").update(input).digest("hex");
1944
+ }
1945
+ function renderFileBody(entry, namespace) {
1946
+ const lines = [
1947
+ `# ${entry.key}`,
1948
+ "",
1949
+ entry.value ?? "",
1950
+ "",
1951
+ "---",
1952
+ "source: synap",
1953
+ `namespace: ${namespace}`
1954
+ ];
1955
+ if (entry.status) lines.push(`status: ${entry.status}`);
1956
+ if (entry.author) lines.push(`author: ${entry.author}`);
1957
+ return lines.join("\n");
1958
+ }
1959
+ function indexEntries(entries, maxEntries) {
1960
+ const byFilename = /* @__PURE__ */ new Map();
1961
+ const skipped = [];
1962
+ for (const entry of entries) {
1963
+ if (byFilename.size >= maxEntries) {
1964
+ skipped.push({ key: entry.key, reason: "maxEntries reached" });
1965
+ continue;
1966
+ }
1967
+ const filename = filenameFor(entry);
1968
+ if (byFilename.has(filename)) {
1969
+ skipped.push({ key: entry.key, reason: `filename collision with another entry: ${filename}` });
1970
+ continue;
1971
+ }
1972
+ byFilename.set(filename, entry);
1973
+ }
1974
+ return { byFilename, skipped };
1975
+ }
1976
+ async function fetchSynapKnowledge(hubBaseUrl, hubApiKey, namespace, maxEntries) {
1977
+ const out = [];
1978
+ let offset = 0;
1979
+ while (out.length < maxEntries) {
1980
+ const limit = Math.min(SYNAP_PAGE_SIZE, maxEntries - out.length);
1981
+ const url = new URL(`${hubBaseUrl.replace(/\/$/, "")}/knowledge`);
1982
+ url.searchParams.set("namespace", namespace);
1983
+ url.searchParams.set("limit", String(limit));
1984
+ url.searchParams.set("offset", String(offset));
1985
+ const res = await fetch(url, {
1986
+ headers: {
1987
+ Authorization: `Bearer ${hubApiKey}`,
1988
+ "Content-Type": "application/json"
1989
+ }
1990
+ });
1991
+ if (!res.ok) {
1992
+ const body = await res.text().catch(() => "");
1993
+ const keyPrefix = hubApiKey.slice(0, 12);
1994
+ throw new Error(
1995
+ `Synap knowledge fetch failed: HTTP ${res.status} ${res.statusText} (url=${url}, key=${keyPrefix}\u2026, body=${body.slice(0, 200)})`
1996
+ );
1997
+ }
1998
+ const data = await res.json();
1999
+ if (!Array.isArray(data)) {
2000
+ throw new Error("Synap knowledge fetch returned non-array payload");
2001
+ }
2002
+ const page = data;
2003
+ out.push(...page);
2004
+ if (page.length < limit) break;
2005
+ offset += page.length;
2006
+ }
2007
+ return out.slice(0, maxEntries);
2008
+ }
2009
+ function assertJsonText(path, text) {
2010
+ if (text.trimStart().startsWith("<")) {
2011
+ throw new Error(
2012
+ `OpenWebUI ${path} returned HTML \u2014 admin route not registered on this OWUI build (SPA shell shadowing the API)`
2013
+ );
2014
+ }
2015
+ }
2016
+ async function owuiGetJson(ctx, path) {
2017
+ const res = await fetch(`${ctx.baseUrl}${path}`, {
2018
+ headers: { Authorization: `Bearer ${ctx.jwt}`, "Content-Type": "application/json" }
2019
+ });
2020
+ if (!res.ok) {
2021
+ throw new Error(`OpenWebUI GET ${path} failed: HTTP ${res.status}`);
2022
+ }
2023
+ const text = await res.text();
2024
+ assertJsonText(`GET ${path}`, text);
2025
+ return JSON.parse(text);
2026
+ }
2027
+ async function owuiPostJson(ctx, path, body) {
2028
+ const res = await fetch(`${ctx.baseUrl}${path}`, {
2029
+ method: "POST",
2030
+ headers: { Authorization: `Bearer ${ctx.jwt}`, "Content-Type": "application/json" },
2031
+ body: JSON.stringify(body)
2032
+ });
2033
+ if (!res.ok) {
2034
+ throw new Error(`OpenWebUI POST ${path} failed: HTTP ${res.status}`);
2035
+ }
2036
+ const text = await res.text();
2037
+ assertJsonText(`POST ${path}`, text);
2038
+ return JSON.parse(text);
2039
+ }
2040
+ async function ensureCollection(ctx, name, description) {
2041
+ const list = await owuiGetJson(ctx, "/api/v1/knowledge/");
2042
+ const existing = Array.isArray(list) ? list.find((c) => c.name === name) : void 0;
2043
+ if (existing) {
2044
+ return owuiGetJson(
2045
+ ctx,
2046
+ `/api/v1/knowledge/${existing.id}`
2047
+ );
2048
+ }
2049
+ return owuiPostJson(ctx, "/api/v1/knowledge/create", {
2050
+ name,
2051
+ description
2052
+ });
2053
+ }
2054
+ function fileNameOf(file) {
2055
+ return file.filename ?? file.meta?.name ?? "";
2056
+ }
2057
+ async function fetchFileContent(ctx, fileId) {
2058
+ const res = await fetch(`${ctx.baseUrl}/api/v1/files/${fileId}/data/content`, {
2059
+ headers: { Authorization: `Bearer ${ctx.jwt}` }
2060
+ });
2061
+ if (!res.ok) {
2062
+ throw new Error(`OpenWebUI fetch file content failed: HTTP ${res.status}`);
2063
+ }
2064
+ return res.text();
2065
+ }
2066
+ async function getRemoteContentHash(ctx, fileId) {
2067
+ try {
2068
+ const body = await fetchFileContent(ctx, fileId);
2069
+ return sha256Hex(body);
2070
+ } catch {
2071
+ return null;
2072
+ }
2073
+ }
2074
+ async function uploadFile(ctx, filename, body) {
2075
+ const form = new FormData();
2076
+ form.append(
2077
+ "file",
2078
+ new Blob([body], { type: "text/markdown" }),
2079
+ filename
2080
+ );
2081
+ const res = await fetch(`${ctx.baseUrl}/api/v1/files/`, {
2082
+ method: "POST",
2083
+ headers: { Authorization: `Bearer ${ctx.jwt}` },
2084
+ body: form
2085
+ });
2086
+ if (!res.ok) {
2087
+ throw new Error(`OpenWebUI file upload failed: HTTP ${res.status}`);
2088
+ }
2089
+ const text = await res.text();
2090
+ assertJsonText("POST /api/v1/files/", text);
2091
+ const data = JSON.parse(text);
2092
+ if (!data.id) {
2093
+ throw new Error("OpenWebUI file upload returned no id");
2094
+ }
2095
+ return data.id;
2096
+ }
2097
+ async function attachFile(ctx, collectionId, fileId) {
2098
+ await owuiPostJson(ctx, `/api/v1/knowledge/${collectionId}/file/add`, {
2099
+ file_id: fileId
2100
+ });
2101
+ }
2102
+ async function removeFile(ctx, collectionId, fileId) {
2103
+ await owuiPostJson(ctx, `/api/v1/knowledge/${collectionId}/file/remove`, {
2104
+ file_id: fileId
2105
+ });
2106
+ }
2107
+ async function deleteFile(ctx, fileId) {
2108
+ const res = await fetch(`${ctx.baseUrl}/api/v1/files/${fileId}`, {
2109
+ method: "DELETE",
2110
+ headers: { Authorization: `Bearer ${ctx.jwt}` }
2111
+ });
2112
+ if (!res.ok && res.status !== 404) {
2113
+ throw new Error(`OpenWebUI file delete failed: HTTP ${res.status}`);
2114
+ }
2115
+ }
2116
+ async function syncSynapKnowledgeToOpenwebui(cwd, hubBaseUrl, secrets, opts = {}) {
2117
+ void cwd;
2118
+ const namespace = opts.namespace?.trim() || DEFAULT_NAMESPACE;
2119
+ const collectionName = opts.collectionName?.trim() || `Synap Knowledge (${namespace})`;
2120
+ const collectionDescription = opts.collectionDescription?.trim() || `Mirrored from Synap pod (namespace: ${namespace}). Managed by Eve.`;
2121
+ const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
2122
+ let hubApiKey = readAgentKeyOrLegacySync("openwebui", secrets);
2123
+ if (!hubApiKey) hubApiKey = readAgentKeyOrLegacySync("eve", secrets);
2124
+ if (!hubApiKey) {
2125
+ throw new Error("No Synap Hub API key available \u2014 run: eve auth provision --agent openwebui (or eve auth provision --agent eve)");
2126
+ }
2127
+ const jwt = await getAdminJwt();
2128
+ if (!jwt) {
2129
+ throw new Error("Could not forge OpenWebUI admin JWT (is the container up and an admin signed up?)");
2130
+ }
2131
+ const ctx = { baseUrl: resolveOpenwebuiAdminUrl(), jwt };
2132
+ const synapEntries = await fetchSynapKnowledge(
2133
+ hubBaseUrl,
2134
+ hubApiKey,
2135
+ namespace,
2136
+ maxEntries
2137
+ );
2138
+ if (synapEntries.length === 0) {
2139
+ return { collectionId: "", added: 0, updated: 0, removed: 0, skipped: [] };
2140
+ }
2141
+ const { byFilename: desired, skipped } = indexEntries(synapEntries, maxEntries);
2142
+ const collection = await ensureCollection(ctx, collectionName, collectionDescription);
2143
+ const remoteByFilename = /* @__PURE__ */ new Map();
2144
+ for (const file of collection.files ?? []) {
2145
+ const name = fileNameOf(file);
2146
+ if (name) remoteByFilename.set(name, file);
2147
+ }
2148
+ let added = 0;
2149
+ let updated = 0;
2150
+ let removed = 0;
2151
+ for (const [filename, entry] of desired.entries()) {
2152
+ const body = renderFileBody(entry, namespace);
2153
+ const desiredHash = sha256Hex(body);
2154
+ const remote = remoteByFilename.get(filename);
2155
+ try {
2156
+ if (!remote) {
2157
+ const fileId2 = await uploadFile(ctx, filename, body);
2158
+ await attachFile(ctx, collection.id, fileId2);
2159
+ added++;
2160
+ continue;
2161
+ }
2162
+ const remoteHash = await getRemoteContentHash(ctx, remote.id);
2163
+ if (remoteHash === desiredHash) {
2164
+ continue;
2165
+ }
2166
+ await removeFile(ctx, collection.id, remote.id);
2167
+ await deleteFile(ctx, remote.id);
2168
+ const fileId = await uploadFile(ctx, filename, body);
2169
+ await attachFile(ctx, collection.id, fileId);
2170
+ updated++;
2171
+ } catch (err) {
2172
+ skipped.push({
2173
+ key: entry.key,
2174
+ reason: err instanceof Error ? err.message : "unknown error"
2175
+ });
2176
+ }
2177
+ }
2178
+ for (const [filename, remote] of remoteByFilename.entries()) {
2179
+ if (desired.has(filename)) continue;
2180
+ try {
2181
+ await removeFile(ctx, collection.id, remote.id);
2182
+ await deleteFile(ctx, remote.id);
2183
+ removed++;
2184
+ } catch (err) {
2185
+ skipped.push({
2186
+ key: filename,
2187
+ reason: err instanceof Error ? err.message : "unknown error"
2188
+ });
2189
+ }
2190
+ }
2191
+ return {
2192
+ collectionId: collection.id,
2193
+ added,
2194
+ updated,
2195
+ removed,
2196
+ skipped
2197
+ };
2198
+ }
2199
+ var SYNAP_TOOL_SERVER_NAME = "Synap Hub Protocol";
2200
+ var SYNAP_MCP_SERVER_NAME = "Synap MCP";
2201
+ var HUB_OPENAPI_PATH = "/api/hub/openapi.json";
2202
+ var MCP_PATH = "/mcp";
2203
+ function synapOpenapiContainerUrl() {
2204
+ return `${SYNAP_BACKEND_INTERNAL_URL.replace(/\/+$/, "")}${HUB_OPENAPI_PATH}`;
2205
+ }
2206
+ function synapMcpContainerUrl() {
2207
+ return `${SYNAP_BACKEND_INTERNAL_URL.replace(/\/+$/, "")}${MCP_PATH}`;
2208
+ }
2209
+ async function fetchSynapOpenapi(hubBaseUrl) {
2210
+ const probeUrl = (() => {
2211
+ const trimmed = (hubBaseUrl || "").replace(/\/+$/, "");
2212
+ if (!trimmed) return null;
2213
+ if (trimmed.endsWith("/api/hub")) return `${trimmed}/openapi.json`;
2214
+ if (trimmed.includes("/api/hub/")) return `${trimmed}/openapi.json`;
2215
+ return `${trimmed}${HUB_OPENAPI_PATH}`;
2216
+ })();
2217
+ if (!probeUrl) return { ok: false };
2218
+ try {
2219
+ const controller = new AbortController();
2220
+ const timer = setTimeout(() => controller.abort(), 5e3);
2221
+ const res = await fetch(probeUrl, { signal: controller.signal });
2222
+ clearTimeout(timer);
2223
+ if (!res.ok) return { ok: false };
2224
+ const text = await res.text();
2225
+ if (!text || text.trimStart().startsWith("<")) return { ok: false };
2226
+ const parsed = JSON.parse(text);
2227
+ if (typeof parsed.openapi !== "string") return { ok: false };
2228
+ const paths = parsed.paths && typeof parsed.paths === "object" ? Object.keys(parsed.paths).length : 0;
2229
+ return { ok: true, paths };
2230
+ } catch {
2231
+ return { ok: false };
2232
+ }
2233
+ }
2234
+ async function getToolServerConnections(jwt, baseUrl) {
2235
+ const url = `${baseUrl}/api/v1/configs/tool_servers`;
2236
+ const res = await fetch(url, {
2237
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
2238
+ signal: AbortSignal.timeout(8e3)
2239
+ });
2240
+ if (!res.ok) {
2241
+ const body = await res.text().catch(() => "");
2242
+ throw new Error(`GET /api/v1/configs/tool_servers HTTP ${res.status}: ${body.slice(0, 200)}`);
2243
+ }
2244
+ const text = await res.text();
2245
+ if (text.trimStart().startsWith("<")) {
2246
+ throw new Error("GET /api/v1/configs/tool_servers returned HTML \u2014 admin route not registered on this OWUI build");
2247
+ }
2248
+ const parsed = JSON.parse(text);
2249
+ if (Array.isArray(parsed)) return parsed;
2250
+ return parsed.TOOL_SERVER_CONNECTIONS ?? [];
2251
+ }
2252
+ async function saveToolServerConnections(jwt, baseUrl, connections) {
2253
+ const url = `${baseUrl}/api/v1/configs/tool_servers`;
2254
+ const body = { TOOL_SERVER_CONNECTIONS: connections };
2255
+ const res = await fetch(url, {
2256
+ method: "POST",
2257
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
2258
+ body: JSON.stringify(body),
2259
+ signal: AbortSignal.timeout(8e3)
2260
+ });
2261
+ if (!res.ok) {
2262
+ const respBody = await res.text().catch(() => "");
2263
+ throw new Error(`POST /api/v1/configs/tool_servers HTTP ${res.status}: ${respBody.slice(0, 200)}`);
2264
+ }
2265
+ }
2266
+ async function registerSynapAsOpenwebuiToolServer(_cwd, hubBaseUrl, secrets) {
2267
+ const endpointUrl = synapOpenapiContainerUrl();
2268
+ const probe = await fetchSynapOpenapi(hubBaseUrl);
2269
+ if (!probe.ok) {
2270
+ console.log("Synap OpenAPI endpoint not found \u2014 skipping");
2271
+ return {
2272
+ registered: false,
2273
+ toolCount: 0,
2274
+ serverName: SYNAP_TOOL_SERVER_NAME,
2275
+ endpointUrl
2276
+ };
2277
+ }
2278
+ const baseUrl = resolveOpenwebuiAdminUrl();
2279
+ const jwt = await getAdminJwt();
2280
+ if (!jwt) {
2281
+ throw new Error(
2282
+ "OpenWebUI admin login failed \u2014 cannot register Synap tool server"
2283
+ );
2284
+ }
2285
+ const apiKey = readAgentKeyOrLegacySync("eve", secrets);
2286
+ if (!apiKey) {
2287
+ throw new Error(
2288
+ "No eve agent hubApiKey in secrets \u2014 cannot register Synap tool server"
2289
+ );
2290
+ }
2291
+ const connections = await getToolServerConnections(jwt, baseUrl);
2292
+ const existingIdx = connections.findIndex((c) => c.name === SYNAP_TOOL_SERVER_NAME);
2293
+ const existing = existingIdx >= 0 ? connections[existingIdx] : void 0;
2294
+ if (existing && existing.url === endpointUrl && existing.key === apiKey && (existing.path ?? "") === "") {
2295
+ return {
2296
+ registered: true,
2297
+ toolCount: probe.paths,
2298
+ serverName: SYNAP_TOOL_SERVER_NAME,
2299
+ endpointUrl
2300
+ };
2301
+ }
2302
+ const updatedEntry = {
2303
+ url: endpointUrl,
2304
+ path: "",
2305
+ type: "openapi",
2306
+ auth_type: "bearer",
2307
+ key: apiKey,
2308
+ name: SYNAP_TOOL_SERVER_NAME,
2309
+ config: {},
2310
+ info: {}
2311
+ };
2312
+ const next = [...connections];
2313
+ if (existingIdx >= 0) {
2314
+ next[existingIdx] = { ...existing, ...updatedEntry };
2315
+ } else {
2316
+ next.push(updatedEntry);
2317
+ }
2318
+ const mcpUrl = synapMcpContainerUrl();
2319
+ const mcpEntry = {
2320
+ url: mcpUrl,
2321
+ path: "",
2322
+ type: "mcp",
2323
+ auth_type: "bearer",
2324
+ key: apiKey,
2325
+ name: SYNAP_MCP_SERVER_NAME,
2326
+ config: {},
2327
+ info: {}
2328
+ };
2329
+ const mcpIdx = next.findIndex((c) => c.name === SYNAP_MCP_SERVER_NAME);
2330
+ if (mcpIdx >= 0) {
2331
+ const existingMcp = next[mcpIdx];
2332
+ if (existingMcp.url !== mcpUrl || existingMcp.key !== apiKey) {
2333
+ next[mcpIdx] = { ...existingMcp, ...mcpEntry };
2334
+ }
2335
+ } else {
2336
+ next.push(mcpEntry);
2337
+ }
2338
+ await saveToolServerConnections(jwt, baseUrl, next);
2339
+ return {
2340
+ registered: true,
2341
+ toolCount: probe.paths,
2342
+ serverName: SYNAP_TOOL_SERVER_NAME,
2343
+ endpointUrl
2344
+ };
2345
+ }
2346
+ var FUNCTION_DEFINITIONS = [
2347
+ {
2348
+ id: "synap_memory_filter",
2349
+ name: "Synap Memory Injection",
2350
+ file: "synap_memory_filter.py",
2351
+ description: "Pre-prompt RAG: pulls top memories + entities from Synap on every turn."
2352
+ },
2353
+ {
2354
+ id: "synap_channel_sync",
2355
+ name: "Synap Channel Sync",
2356
+ file: "synap_channel_sync.py",
2357
+ description: "One-way mirror: every Open WebUI chat is posted to a Synap thread."
2358
+ }
2359
+ ];
2360
+ var HTTP_TIMEOUT_MS2 = 8e3;
2361
+ function resolveAssetsDir() {
2362
+ const here = dirname(fileURLToPath(import.meta.url));
2363
+ const candidates = [
2364
+ join3(here, "..", "assets", "functions"),
2365
+ join3(here, "assets", "functions"),
2366
+ "/app/packages/@eve/dna/assets/functions"
2367
+ ];
2368
+ for (const c of candidates) {
2369
+ if (existsSync4(c)) return c;
2370
+ }
2371
+ return null;
2372
+ }
2373
+ function readFunctionSource(filename) {
2374
+ const dir = resolveAssetsDir();
2375
+ if (!dir) return null;
2376
+ const p = join3(dir, filename);
2377
+ if (!existsSync4(p)) return null;
2378
+ return readFileSync2(p, "utf-8");
2379
+ }
2380
+ function assertJsonText2(label, text) {
2381
+ if (text.trimStart().startsWith("<")) {
2382
+ throw new Error(
2383
+ `OpenWebUI ${label} returned HTML \u2014 admin route not registered on this OWUI build`
2384
+ );
2385
+ }
2386
+ }
2387
+ async function adminGet(ctx, path) {
2388
+ const res = await fetch(`${ctx.baseUrl}${path}`, {
2389
+ headers: { Authorization: `Bearer ${ctx.jwt}`, "Content-Type": "application/json" },
2390
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
2391
+ });
2392
+ if (!res.ok) throw new Error(`OpenWebUI GET ${path} failed: HTTP ${res.status}`);
2393
+ const text = await res.text();
2394
+ assertJsonText2(`GET ${path}`, text);
2395
+ return JSON.parse(text);
2396
+ }
2397
+ async function adminPost(ctx, path, body) {
2398
+ const res = await fetch(`${ctx.baseUrl}${path}`, {
2399
+ method: "POST",
2400
+ headers: { Authorization: `Bearer ${ctx.jwt}`, "Content-Type": "application/json" },
2401
+ body: JSON.stringify(body),
2402
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
2403
+ });
2404
+ if (!res.ok) {
2405
+ const respText = await res.text().catch(() => "");
2406
+ throw new Error(`OpenWebUI POST ${path} failed: HTTP ${res.status} ${respText.slice(0, 200)}`);
2407
+ }
2408
+ const text = await res.text();
2409
+ assertJsonText2(`POST ${path}`, text);
2410
+ return JSON.parse(text);
2411
+ }
2412
+ async function listFunctions(ctx) {
2413
+ const data = await adminGet(ctx, "/api/v1/functions/");
2414
+ return Array.isArray(data) ? data : [];
2415
+ }
2416
+ async function createFunction(ctx, form) {
2417
+ await adminPost(ctx, "/api/v1/functions/create", form);
2418
+ }
2419
+ async function updateFunction(ctx, id, form) {
2420
+ await adminPost(
2421
+ ctx,
2422
+ `/api/v1/functions/id/${encodeURIComponent(id)}/update`,
2423
+ form
2424
+ );
2425
+ }
2426
+ async function setValves(ctx, id, valves) {
2427
+ await adminPost(ctx, `/api/v1/functions/id/${encodeURIComponent(id)}/valves/update`, valves);
2428
+ }
2429
+ async function toggleActive(ctx, id) {
2430
+ await adminPost(ctx, `/api/v1/functions/id/${encodeURIComponent(id)}/toggle`, {});
2431
+ }
2432
+ async function toggleGlobal(ctx, id) {
2433
+ await adminPost(ctx, `/api/v1/functions/id/${encodeURIComponent(id)}/toggle/global`, {});
2434
+ }
2435
+ async function pushSynapFunctionsToOpenwebui(cwd, _hubBaseUrl, _secrets) {
2436
+ let apiKey = readAgentKeyOrLegacySync("openwebui", _secrets);
2437
+ if (!apiKey) apiKey = readAgentKeyOrLegacySync("eve", _secrets);
2438
+ if (!apiKey) {
2439
+ throw new Error("No Hub API key \u2014 run: eve auth provision --agent openwebui (or eve auth provision --agent eve)");
2440
+ }
2441
+ const jwt = await getAdminJwt();
2442
+ if (!jwt) {
2443
+ throw new Error("OpenWebUI admin JWT unavailable \u2014 container down or no admin user");
2444
+ }
2445
+ const ctx = { baseUrl: resolveOpenwebuiAdminUrl(), jwt };
2446
+ const existing = await listFunctions(ctx);
2447
+ const existingById = /* @__PURE__ */ new Map();
2448
+ for (const fn of existing) existingById.set(fn.id, fn);
2449
+ const valves = {
2450
+ SYNAP_API_URL: SYNAP_BACKEND_INTERNAL_URL,
2451
+ SYNAP_API_KEY: apiKey
2452
+ };
2453
+ const synced = [];
2454
+ const skipped = [];
2455
+ for (const def of FUNCTION_DEFINITIONS) {
2456
+ const content = readFunctionSource(def.file);
2457
+ if (!content) {
2458
+ skipped.push({ id: def.id, reason: `source file not found: assets/functions/${def.file}` });
2459
+ continue;
2460
+ }
2461
+ const desired = {
2462
+ id: def.id,
2463
+ name: def.name,
2464
+ content,
2465
+ meta: { description: def.description }
2466
+ };
2467
+ try {
2468
+ const prior = existingById.get(def.id);
2469
+ let action;
2470
+ if (!prior) {
2471
+ await createFunction(ctx, desired);
2472
+ action = "created";
2473
+ } else if (prior.content !== content || prior.name !== def.name) {
2474
+ await updateFunction(ctx, def.id, desired);
2475
+ action = "updated";
2476
+ } else {
2477
+ action = "unchanged";
2478
+ }
2479
+ await setValves(ctx, def.id, valves);
2480
+ if (!prior || prior.is_active !== true) await toggleActive(ctx, def.id);
2481
+ if (!prior || prior.is_global !== true) await toggleGlobal(ctx, def.id);
2482
+ synced.push({
2483
+ id: def.id,
2484
+ name: def.name,
2485
+ action,
2486
+ enabled: true,
2487
+ global: true
2488
+ });
2489
+ } catch (err) {
2490
+ skipped.push({
2491
+ id: def.id,
2492
+ reason: err instanceof Error ? err.message : String(err)
2493
+ });
2494
+ }
2495
+ }
2496
+ return { synced, skipped };
2497
+ }
2498
+ async function provisionOwuiUserInSynap(opts) {
2499
+ const podUrl = (opts.podUrl ?? process.env.SYNAP_POD_URL ?? "").replace(/\/+$/, "");
2500
+ const agentKey = opts.agentApiKey ?? process.env.SYNAP_AGENT_API_KEY ?? "";
2501
+ if (!podUrl) throw new Error("podUrl / SYNAP_POD_URL must be set");
2502
+ if (!agentKey) throw new Error("agentApiKey / SYNAP_AGENT_API_KEY must be set");
2503
+ const resp = await fetch(`${podUrl}/api/hub/setup/external-user`, {
2504
+ method: "POST",
2505
+ headers: {
2506
+ Authorization: `Bearer ${agentKey}`,
2507
+ "Content-Type": "application/json"
2508
+ },
2509
+ body: JSON.stringify({
2510
+ externalUserId: opts.owuiUserId,
2511
+ name: opts.owuiUserName,
2512
+ mintSubToken: true
2513
+ })
2514
+ });
2515
+ if (!resp.ok) {
2516
+ throw new Error(`Synap provisioning failed: ${resp.status} ${resp.statusText}`);
2517
+ }
2518
+ const data = await resp.json();
2519
+ return {
2520
+ token: data.token ?? null,
2521
+ synapUserId: data.synapUserId,
2522
+ reused: data.reused ?? false
2523
+ };
2524
+ }
2525
+ async function listOwuiUsers(jwt) {
2526
+ const baseUrl = resolveOpenwebuiAdminUrl();
2527
+ try {
2528
+ const res = await fetch(`${baseUrl}/api/v1/users/`, {
2529
+ headers: { Authorization: `Bearer ${jwt}` },
2530
+ signal: AbortSignal.timeout(1e4)
2531
+ });
2532
+ if (!res.ok) return [];
2533
+ const data = await res.json();
2534
+ if (!Array.isArray(data)) return [];
2535
+ return data;
2536
+ } catch {
2537
+ return [];
2538
+ }
2539
+ }
2540
+ async function syncOwuiUsersToSynap(cwd = process.cwd(), secrets) {
2541
+ const result = {
2542
+ total: 0,
2543
+ provisioned: 0,
2544
+ skipped: 0,
2545
+ errors: 0,
2546
+ errorMessages: []
2547
+ };
2548
+ const resolvedSecrets = secrets ?? await readEveSecrets(cwd);
2549
+ const podUrl = (resolvedSecrets?.synap?.apiUrl ?? process.env.SYNAP_POD_URL ?? "").replace(/\/+$/, "");
2550
+ const agentApiKey = resolvedSecrets?.agents?.["openwebui"]?.hubApiKey?.trim() || resolvedSecrets?.agents?.["eve"]?.hubApiKey?.trim() || process.env.SYNAP_AGENT_API_KEY || "";
2551
+ if (!podUrl || !agentApiKey) {
2552
+ result.errorMessages.push("syncOwuiUsersToSynap: podUrl or agentApiKey not available \u2014 skipping");
2553
+ result.errors = 1;
2554
+ return result;
2555
+ }
2556
+ const jwt = await getAdminJwtPostHealth();
2557
+ if (!jwt) {
2558
+ result.errorMessages.push("syncOwuiUsersToSynap: could not forge admin JWT \u2014 is OpenWebUI healthy?");
2559
+ result.errors = 1;
2560
+ return result;
2561
+ }
2562
+ const users = await listOwuiUsers(jwt);
2563
+ result.total = users.length;
2564
+ if (users.length === 0) return result;
2565
+ const existingTokens = resolvedSecrets?.builder?.openwebui?.userTokens ?? {};
2566
+ const newTokens = {};
2567
+ for (const user of users) {
2568
+ if (existingTokens[user.id]) {
2569
+ result.skipped++;
2570
+ continue;
2571
+ }
2572
+ try {
2573
+ const provision = await provisionOwuiUserInSynap({
2574
+ owuiUserId: user.id,
2575
+ owuiUserName: user.name,
2576
+ podUrl,
2577
+ agentApiKey
2578
+ });
2579
+ if (provision.token) {
2580
+ newTokens[user.id] = provision.token;
2581
+ result.provisioned++;
2582
+ } else {
2583
+ result.skipped++;
2584
+ }
2585
+ } catch (err) {
2586
+ result.errors++;
2587
+ result.errorMessages.push(
2588
+ `user ${user.id} (${user.email}): ${err instanceof Error ? err.message : String(err)}`
2589
+ );
2590
+ }
2591
+ }
2592
+ if (Object.keys(newTokens).length > 0) {
2593
+ const merged = { ...existingTokens, ...newTokens };
2594
+ await writeEveSecrets(
2595
+ {
2596
+ builder: {
2597
+ ...resolvedSecrets?.builder ?? {},
2598
+ openwebui: {
2599
+ ...resolvedSecrets?.builder?.openwebui ?? {},
2600
+ userTokens: merged
2601
+ }
2602
+ }
2603
+ },
2604
+ cwd
2605
+ );
2606
+ }
2607
+ return result;
2608
+ }
2609
+ async function resolveHubBaseUrlOnHost(secrets) {
2610
+ const synapBase = await resolveSynapUrlOnHost(secrets);
2611
+ if (!synapBase) return resolveHubBaseUrl(secrets);
2612
+ const isLoopback = synapBase.startsWith("http://127.") || synapBase.startsWith("http://localhost");
2613
+ const base = isLoopback ? synapBase : synapBase.replace(/^http:\/\//, "https://");
2614
+ return `${base.replace(/\/$/, "")}${DEFAULT_HUB_PATH}`;
2615
+ }
2616
+ async function syncOpenwebuiExtras(cwd, secrets, opts) {
2617
+ if (!secrets) return { skipped: true };
2618
+ const hubBaseUrl = await resolveHubBaseUrlOnHost(secrets);
2619
+ if (!hubBaseUrl) return { skipped: true };
2620
+ const [skills, knowledge, tools, functions, users] = await Promise.allSettled([
2621
+ pushSynapSkillsToOpenwebuiPrompts(cwd, hubBaseUrl, secrets),
2622
+ syncSynapKnowledgeToOpenwebui(cwd, hubBaseUrl, secrets, opts?.knowledge),
2623
+ registerSynapAsOpenwebuiToolServer(cwd, hubBaseUrl, secrets),
2624
+ pushSynapFunctionsToOpenwebui(cwd, hubBaseUrl, secrets),
2625
+ syncOwuiUsersToSynap(cwd, secrets)
2626
+ ]);
2627
+ return {
2628
+ skipped: false,
2629
+ skills: settled(skills),
2630
+ knowledge: settled(knowledge),
2631
+ tools: settled(tools),
2632
+ functions: settled(functions),
2633
+ users: settled(users)
2634
+ };
2635
+ }
2636
+ function formatExtrasSummary(r) {
2637
+ if (r.skipped) return "OpenWebUI extras: skipped (no Hub URL)";
2638
+ const parts = [];
2639
+ parts.push(formatPart("skills", r.skills, (v) => `created=${v.created} updated=${v.updated} skipped=${v.skipped.length}`));
2640
+ parts.push(formatPart("knowledge", r.knowledge, (v) => `+${v.added}/~${v.updated}/-${v.removed} skipped=${v.skipped.length}`));
2641
+ parts.push(formatPart("tools", r.tools, (v) => v.registered ? `registered ${v.toolCount} ops` : "not registered"));
2642
+ parts.push(formatPart("functions", r.functions, (v) => `synced=${v.synced.length} skipped=${v.skipped.length}`));
2643
+ parts.push(formatPart("users", r.users, (v) => `total=${v.total} provisioned=${v.provisioned} skipped=${v.skipped} errors=${v.errors}`));
2644
+ return `OpenWebUI extras: ${parts.join(" | ")}`;
2645
+ }
2646
+ function settled(p) {
2647
+ return p.status === "fulfilled" ? { ok: true, result: p.value } : { ok: false, error: errorMessage(p.reason) };
2648
+ }
2649
+ function formatPart(label, outcome, ok) {
2650
+ if (!outcome) return `${label}=\u2013`;
2651
+ return outcome.ok ? `${label}: ${ok(outcome.result)}` : `${label}: error (${outcome.error})`;
2652
+ }
2653
+ function errorMessage(err) {
2654
+ if (err instanceof Error) return err.message;
2655
+ return String(err);
2656
+ }
2657
+ function isContainerRunning(name) {
2658
+ try {
2659
+ const out = execSync3(`docker ps --filter "name=^${name}$" --format "{{.Names}}"`, {
2660
+ encoding: "utf-8",
2661
+ stdio: ["pipe", "pipe", "ignore"]
2662
+ }).trim();
2663
+ return out === name;
2664
+ } catch {
2665
+ return false;
2666
+ }
2667
+ }
2668
+ function dockerRestart(name) {
2669
+ try {
2670
+ execSync3(`docker restart ${name}`, { stdio: "ignore" });
2671
+ } catch {
2672
+ }
2673
+ }
2674
+ function pickPrimaryProvider(secrets, componentId) {
2675
+ const all = (secrets?.ai?.providers ?? []).filter((p) => p.enabled !== false);
2676
+ const usable = all.filter(
2677
+ (p) => p.id === "ollama" || p.apiKey && p.apiKey.trim().length > 0 || p.baseUrl && p.baseUrl.trim().length > 0
2678
+ // custom providers may not have API keys
2679
+ );
2680
+ if (usable.length === 0) return null;
2681
+ let base = null;
2682
+ if (componentId) {
2683
+ const override = secrets?.ai?.serviceProviders?.[componentId];
2684
+ if (override) {
2685
+ const hit = usable.find((p) => p.id === override);
2686
+ if (hit) base = hit;
2687
+ }
2688
+ }
2689
+ if (!base) {
2690
+ const def = secrets?.ai?.defaultProvider;
2691
+ const fallback = secrets?.ai?.fallbackProvider;
2692
+ base = usable.find((p) => p.id === def) ?? usable.find((p) => p.id === fallback) ?? usable.find((p) => p.enabled !== false) ?? usable[0];
2693
+ }
2694
+ if (!base) return null;
2695
+ if (componentId) {
2696
+ const modelOverride = secrets?.ai?.serviceModels?.[componentId];
2697
+ if (modelOverride) {
2698
+ return { ...base, defaultModel: modelOverride };
2699
+ }
2700
+ }
2701
+ return base;
2702
+ }
2703
+ function wireSynapIs(secrets) {
2704
+ const providers = secrets?.ai?.providers ?? [];
2705
+ if (providers.length === 0) {
2706
+ return { id: "synap", outcome: "skipped", summary: "no AI providers configured" };
2707
+ }
2708
+ const deployDir = process.env.SYNAP_DEPLOY_DIR ?? "/opt/synap-backend/deploy";
2709
+ if (!existsSync5(deployDir)) {
2710
+ return { id: "synap", outcome: "skipped", summary: `deploy dir not found: ${deployDir}` };
2711
+ }
2712
+ const envLines = ["# AI provider keys \u2014 managed by eve ai apply"];
2713
+ const skippedCustom = [];
2714
+ for (const p of providers) {
2715
+ if (!p.apiKey) continue;
2716
+ if (p.id === "openai") envLines.push(`OPENAI_API_KEY=${p.apiKey}`);
2717
+ if (p.id === "anthropic") envLines.push(`ANTHROPIC_API_KEY=${p.apiKey}`);
2718
+ if (p.id === "openrouter") envLines.push(`OPENROUTER_API_KEY=${p.apiKey}`);
2719
+ if (p.id.startsWith("custom-") || !["ollama", "openai", "anthropic", "openrouter"].includes(p.id)) {
2720
+ const idx = providers.filter((x) => x.id.startsWith("custom-")).indexOf(p) + 1;
2721
+ if (idx > 0) {
2722
+ envLines.push(`CUSTOM_PROVIDER_${idx}_BASE_URL=${p.baseUrl ?? ""}`);
2723
+ envLines.push(`CUSTOM_PROVIDER_${idx}_API_KEY=${p.apiKey}`);
2724
+ envLines.push(`CUSTOM_PROVIDER_${idx}_NAME=${p.name ?? p.id}`);
2725
+ if (p.defaultModel) envLines.push(`CUSTOM_PROVIDER_${idx}_DEFAULT_MODEL=${p.defaultModel}`);
2726
+ } else {
2727
+ skippedCustom.push(p.id);
2728
+ }
2729
+ }
2730
+ }
2731
+ const synapProvider = pickPrimaryProvider(secrets, "synap");
2732
+ if (synapProvider) {
2733
+ envLines.push(`DEFAULT_AI_PROVIDER=${synapProvider.id}`);
2734
+ if (synapProvider.defaultModel) {
2735
+ envLines.push(`DEFAULT_AI_MODEL=${synapProvider.defaultModel}`);
2736
+ }
2737
+ }
2738
+ const ollamaProvider = providers.find((p) => p.id === "ollama");
2739
+ if (ollamaProvider) {
2740
+ const ollamaUrl = secrets?.inference?.ollamaUrl ?? "http://eve-brain-ollama:11434";
2741
+ envLines.push(`OLLAMA_BASE_URL=${ollamaUrl}`);
2742
+ }
2743
+ const envPath = join4(deployDir, ".env");
2744
+ let existing = "";
2745
+ try {
2746
+ existing = readFileSync3(envPath, "utf-8");
2747
+ } catch {
2748
+ }
2749
+ const marker = "# AI provider keys \u2014 managed by eve ai apply";
2750
+ const before = existing.includes(marker) ? existing.split(marker)[0].trimEnd() : existing.trimEnd();
2751
+ const merged = (before ? before + "\n\n" : "") + envLines.join("\n") + "\n";
2752
+ try {
2753
+ writeFileSync2(envPath, merged, { mode: 384 });
2754
+ } catch (err) {
2755
+ return {
2756
+ id: "synap",
2757
+ outcome: "failed",
2758
+ summary: "could not write Synap IS env",
2759
+ detail: err instanceof Error ? err.message : String(err)
2760
+ };
2761
+ }
2762
+ try {
2763
+ const out = execSync3(
2764
+ `docker ps --filter "label=com.docker.compose.project=synap-backend" --filter "label=com.docker.compose.service=intelligence-hub" --format "{{.Names}}"`,
2765
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }
2766
+ ).trim();
2767
+ const isContainer = out.split("\n")[0]?.trim();
2768
+ if (isContainer) dockerRestart(isContainer);
2769
+ } catch {
2770
+ }
2771
+ const wiredCount = providers.length - skippedCustom.length;
2772
+ return {
2773
+ id: "synap",
2774
+ outcome: "ok",
2775
+ summary: `Synap IS env updated (${wiredCount} provider(s))` + (skippedCustom.length ? ` \u2014 ${skippedCustom.length} custom provider(s) not wired here (${skippedCustom.join(", ")}); they live in the pod's ai_providers table` : ""),
2776
+ detail: envPath,
2777
+ wiredModel: synapProvider?.defaultModel,
2778
+ wiredProvider: synapProvider?.id
2779
+ };
2780
+ }
2781
+ function wireOpenclaw(secrets) {
2782
+ const synapApiKey = readAgentKeyOrLegacySync("openclaw", secrets);
2783
+ if (!synapApiKey) {
2784
+ return { id: "openclaw", outcome: "skipped", summary: "no Synap pod API key \u2014 install Synap first" };
2785
+ }
2786
+ if (!isContainerRunning("eve-arms-openclaw")) {
2787
+ return { id: "openclaw", outcome: "skipped", summary: "eve-arms-openclaw container not running" };
2788
+ }
2789
+ const provider = pickPrimaryProvider(secrets, "openclaw");
2790
+ const preferredModel = provider?.defaultModel;
2791
+ const authProfile = {
2792
+ providers: {
2793
+ openai: {
2794
+ apiKey: synapApiKey,
2795
+ // Synap backend (eve-brain-synap:4000) is the AI hub for self-hosted Eve.
2796
+ // intelligence-hub:3001 is the proprietary IS — not available without managed pod.
2797
+ baseUrl: "http://eve-brain-synap:4000/v1",
2798
+ ...preferredModel ? { defaultModel: preferredModel } : {}
2799
+ }
2800
+ },
2801
+ ...preferredModel ? { defaultModel: preferredModel } : {}
2802
+ };
2803
+ const authJson = JSON.stringify(authProfile, null, 2);
2804
+ const containerPath = "/home/node/.openclaw/agents/main/agent/auth-profiles.json";
2805
+ try {
2806
+ execSync3(`docker exec eve-arms-openclaw sh -c 'mkdir -p ${dirname2(containerPath)}'`, {
2807
+ stdio: "ignore"
2808
+ });
2809
+ execSync3(
2810
+ `docker exec -i eve-arms-openclaw sh -c 'cat > ${containerPath}'`,
2811
+ { input: authJson, stdio: ["pipe", "pipe", "ignore"] }
2812
+ );
2813
+ execSync3(`docker exec eve-arms-openclaw sh -c 'chmod 600 ${containerPath}'`, {
2814
+ stdio: "ignore"
2815
+ });
2816
+ } catch (err) {
2817
+ return {
2818
+ id: "openclaw",
2819
+ outcome: "failed",
2820
+ summary: "could not write OpenClaw auth-profiles.json",
2821
+ detail: err instanceof Error ? err.message : String(err)
2822
+ };
2823
+ }
2824
+ dockerRestart("eve-arms-openclaw");
2825
+ return {
2826
+ id: "openclaw",
2827
+ outcome: "ok",
2828
+ summary: "OpenClaw wired to Synap IS as OpenAI provider",
2829
+ detail: containerPath,
2830
+ wiredModel: preferredModel,
2831
+ wiredProvider: "synap"
2832
+ };
2833
+ }
2834
+ async function wireOpenwebui(secrets) {
2835
+ let synapApiKey = readAgentKeyOrLegacySync("openwebui", secrets);
2836
+ if (!synapApiKey) {
2837
+ synapApiKey = readAgentKeyOrLegacySync("eve", secrets);
2838
+ }
2839
+ if (!synapApiKey) {
2840
+ return { id: "openwebui", outcome: "skipped", summary: "no Synap API key \u2014 run: eve auth provision --agent openwebui (or eve auth provision --agent eve)" };
2841
+ }
2842
+ const providers = secrets?.ai?.providers ?? [];
2843
+ const deployDir = "/opt/openwebui";
2844
+ const envPath = join4(deployDir, ".env");
2845
+ if (!existsSync5(deployDir)) {
2846
+ return { id: "openwebui", outcome: "skipped", summary: "Open WebUI deploy dir not found \u2014 install it first" };
2847
+ }
2848
+ let existing = "";
2849
+ try {
2850
+ existing = readFileSync3(envPath, "utf-8");
2851
+ } catch {
2852
+ }
2853
+ const marker = "# AI wiring \u2014 managed by eve ai apply";
2854
+ const aiManagedEnvKeys = /* @__PURE__ */ new Set([
2855
+ "SYNAP_API_KEY",
2856
+ "SYNAP_IS_URL",
2857
+ "OPENAI_API_BASE_URLS",
2858
+ "OPENAI_API_KEYS",
2859
+ "DEFAULT_MODELS"
2860
+ ]);
2861
+ const rawBefore = existing.includes(marker) ? existing.split(marker)[0] : existing;
2862
+ const before = rawBefore.split("\n").filter((line) => {
2863
+ const trimmed = line.trim();
2864
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) return true;
2865
+ const key = trimmed.slice(0, trimmed.indexOf("="));
2866
+ return !aiManagedEnvKeys.has(key);
2867
+ }).join("\n").trimEnd();
2868
+ const provider = pickPrimaryProvider(secrets, "openwebui");
2869
+ const preferredModel = provider?.defaultModel;
2870
+ const apiBaseUrls = ["http://eve-brain-synap:4000/v1"];
2871
+ const apiKeys = [synapApiKey];
2872
+ const hermesApiServerKey = secrets?.builder?.hermes?.apiServerKey;
2873
+ if (hermesApiServerKey && isContainerRunning("eve-builder-hermes")) {
2874
+ apiBaseUrls.push("http://eve-builder-hermes:8642/v1");
2875
+ apiKeys.push(hermesApiServerKey);
2876
+ }
2877
+ for (const p of providers) {
2878
+ if (p.enabled === false || !p.id.startsWith("custom-")) continue;
2879
+ if (!p.baseUrl) continue;
2880
+ const url = p.baseUrl.replace(/\/v1$/, "");
2881
+ apiBaseUrls.push(`${url}/v1`);
2882
+ if (p.apiKey && p.apiKey.trim()) {
2883
+ apiKeys.push(p.apiKey);
2884
+ } else {
2885
+ apiKeys.push("");
2886
+ }
2887
+ }
2888
+ const block = [
2889
+ marker,
2890
+ `SYNAP_API_KEY=${synapApiKey}`,
2891
+ // synap-backend (eve-brain-synap:4000) hosts /v1/chat/completions + /v1/models.
2892
+ // intelligence-hub (port 3001) is internal IS — it has no /v1 endpoints.
2893
+ `SYNAP_IS_URL=http://eve-brain-synap:4000`,
2894
+ // Plural form takes precedence when pipelines is also installed.
2895
+ // Hermes appears as a separate model source in the OpenWebUI model picker.
2896
+ `OPENAI_API_BASE_URLS=${apiBaseUrls.join(";")}`,
2897
+ `OPENAI_API_KEYS=${apiKeys.join(";")}`,
2898
+ ...preferredModel ? [`DEFAULT_MODELS=${preferredModel}`] : []
2899
+ ].join("\n");
2900
+ try {
2901
+ writeFileSync2(envPath, (before ? before + "\n\n" : "") + block + "\n", { mode: 384 });
2902
+ } catch (err) {
2903
+ return {
2904
+ id: "openwebui",
2905
+ outcome: "failed",
2906
+ summary: "could not write Open WebUI env",
2907
+ detail: err instanceof Error ? err.message : String(err)
2908
+ };
2909
+ }
2910
+ try {
2911
+ execSync3("docker compose up -d", {
2912
+ cwd: "/opt/openwebui",
2913
+ stdio: ["pipe", "pipe", "pipe"]
2914
+ });
2915
+ } catch (err) {
2916
+ const errMsg = err instanceof Error ? err.message.split("\n").pop()?.trim() ?? String(err) : String(err);
2917
+ if (isContainerRunning("hestia-openwebui")) {
2918
+ try {
2919
+ dockerRestart("hestia-openwebui");
2920
+ } catch {
2921
+ }
2922
+ }
2923
+ return {
2924
+ id: "openwebui",
2925
+ outcome: "failed",
2926
+ summary: `docker compose failed (${errMsg})`,
2927
+ detail: errMsg
2928
+ };
2929
+ }
2930
+ const hermesNote = hermesApiServerKey ? " + Hermes gateway" : "";
2931
+ try {
2932
+ const { modelSources } = buildOpenwebuiModelSources(secrets, deployDir);
2933
+ if (modelSources.length > 0) {
2934
+ modelSources[0].apiKey = synapApiKey;
2935
+ }
2936
+ const hermesIdx = modelSources.findIndex((m) => m.displayName === "Hermes Gateway");
2937
+ if (hermesIdx >= 0) {
2938
+ modelSources[hermesIdx].apiKey = hermesApiServerKey ?? "";
2939
+ }
2940
+ const outcome = await registerOpenwebuiAdminApi(modelSources, {
2941
+ managedConfig: buildOpenwebuiManagedConfig(secrets)
2942
+ });
2943
+ if (!outcome.ok) {
2944
+ return {
2945
+ id: "openwebui",
2946
+ outcome: "ok",
2947
+ summary: `Open WebUI wired to Synap IS${hermesNote} (registration warning: ${outcome.stage} \u2014 ${outcome.reason} \u2014 run \`eve openwebui sync\` to retry)`,
2948
+ detail: envPath,
2949
+ wiredModel: preferredModel,
2950
+ wiredProvider: "synap"
2951
+ };
2952
+ }
2953
+ const extras = await syncOpenwebuiExtras(process.cwd(), secrets);
2954
+ const extrasSummary = formatExtrasSummary(extras);
2955
+ const extrasHas401 = /\b401\b|Unauthorized/i.test(extrasSummary);
2956
+ return {
2957
+ id: "openwebui",
2958
+ outcome: "ok",
2959
+ summary: extrasHas401 ? `Open WebUI wired to Synap IS${hermesNote} (extras 401 \u2014 eve key likely stale: ${extrasSummary})` : `Open WebUI wired to Synap IS${hermesNote} (${extrasSummary})`,
2960
+ detail: envPath,
2961
+ wiredModel: preferredModel,
2962
+ wiredProvider: "synap"
2963
+ };
2964
+ } catch (err) {
2965
+ const msg = err instanceof Error ? err.message : String(err);
2966
+ return {
2967
+ id: "openwebui",
2968
+ outcome: "ok",
2969
+ summary: `Open WebUI wired to Synap IS${hermesNote} (registration warning: ${msg} \u2014 run \`eve openwebui sync\` to retry)`,
2970
+ detail: envPath,
2971
+ wiredModel: preferredModel,
2972
+ wiredProvider: "synap"
2973
+ };
2974
+ }
2975
+ }
2976
+ function wireHermes(secrets) {
2977
+ const home = homedir2();
2978
+ const eveDir = join4(home, ".eve");
2979
+ const hermesEnvPath = join4(home, ".eve", "hermes.env");
2980
+ if (!existsSync5(eveDir)) {
2981
+ return { id: "hermes", outcome: "skipped", summary: ".eve dir not found \u2014 install Hermes first" };
2982
+ }
2983
+ const synapApiKey = readAgentKeyOrLegacySync("hermes", secrets);
2984
+ if (!synapApiKey) {
2985
+ return { id: "hermes", outcome: "skipped", summary: "no Hermes Hub API key \u2014 run provisioning first" };
2986
+ }
2987
+ const provider = pickPrimaryProvider(secrets, "hermes");
2988
+ const preferredModel = provider?.defaultModel ?? "synap/balanced";
2989
+ const BUILTIN_URLS = {
2990
+ openrouter: "https://openrouter.ai/api/v1",
2991
+ openai: "https://api.openai.com/v1",
2992
+ anthropic: "https://api.anthropic.com/v1",
2993
+ ollama: "http://eve-brain-ollama:11434/v1"
2994
+ };
2995
+ let aiBaseUrl = provider?.baseUrl?.replace(/\/$/, "") ?? BUILTIN_URLS[provider?.id ?? ""] ?? "";
2996
+ if (provider?.id === "ollama" && /localhost|127\.0\.0\.1/.test(aiBaseUrl)) {
2997
+ aiBaseUrl = aiBaseUrl.replace(/localhost|127\.0\.0\.1/, "eve-brain-ollama");
2998
+ }
2999
+ if (!aiBaseUrl) {
3000
+ aiBaseUrl = "http://eve-brain-synap:4000/v1";
3001
+ }
3002
+ const aiApiKey = provider && provider.id !== "ollama" && provider.apiKey ? provider.apiKey : synapApiKey;
3003
+ const aiBlock = [
3004
+ "# AI wiring \u2014 managed by eve ai apply",
3005
+ `AI_BASE_URL=${aiBaseUrl}`,
3006
+ `AI_API_KEY=${aiApiKey}`,
3007
+ `AI_DEFAULT_MODEL=${preferredModel}`
3008
+ ].join("\n");
3009
+ let existing = "";
3010
+ try {
3011
+ existing = readFileSync3(hermesEnvPath, "utf-8");
3012
+ } catch {
3013
+ }
3014
+ const marker = "# AI wiring \u2014 managed by eve ai apply";
3015
+ const before = existing.includes(marker) ? existing.split(marker)[0].trimEnd() : existing.trimEnd();
3016
+ const merged = (before ? before + "\n\n" : "") + aiBlock + "\n";
3017
+ try {
3018
+ writeFileSync2(hermesEnvPath, merged, { mode: 384 });
3019
+ } catch (err) {
3020
+ return {
3021
+ id: "hermes",
3022
+ outcome: "failed",
3023
+ summary: "could not write hermes.env AI block",
3024
+ detail: err instanceof Error ? err.message : String(err)
3025
+ };
3026
+ }
3027
+ try {
3028
+ writeHermesConfigYamlSync(secrets);
3029
+ generateSynapPlugin();
3030
+ } catch {
3031
+ }
3032
+ const hermesWasRunning = isContainerRunning("eve-builder-hermes");
3033
+ if (hermesWasRunning) {
3034
+ void appendOperationalEvent({
3035
+ type: "repair.started",
3036
+ target: "eve-builder-hermes",
3037
+ componentId: "hermes",
3038
+ summary: "Recreating Hermes container to pick up env/config changes",
3039
+ details: { trigger: "wireHermes", op: "docker rm -f + docker run" }
3040
+ }).catch(() => {
3041
+ });
3042
+ try {
3043
+ execSync3("docker rm -f eve-builder-hermes", { stdio: "ignore" });
3044
+ } catch {
3045
+ }
3046
+ }
3047
+ try {
3048
+ const home2 = homedir2();
3049
+ const hermesHome = join4(home2, ".eve", "hermes");
3050
+ const hermesEnv = join4(home2, ".eve", "hermes.env");
3051
+ const skillsDir = join4(home2, ".eve", "skills");
3052
+ mkdirSync2(hermesHome, { recursive: true });
3053
+ mkdirSync2(skillsDir, { recursive: true });
3054
+ try {
3055
+ execSync3(`chown -R 10000:10000 ${JSON.stringify(hermesHome)}`, { stdio: "ignore" });
3056
+ } catch {
3057
+ }
3058
+ const args = [
3059
+ "run",
3060
+ "-d",
3061
+ "--name",
3062
+ "eve-builder-hermes",
3063
+ "--network",
3064
+ "eve-network",
3065
+ "--restart",
3066
+ "unless-stopped",
3067
+ "-p",
3068
+ "8642:8642",
3069
+ "-p",
3070
+ "9119:9119",
3071
+ "-p",
3072
+ "9120:9120",
3073
+ "-v",
3074
+ `${hermesHome}:/opt/data`,
3075
+ "-v",
3076
+ `${skillsDir}:/opt/data/synap-skills:ro`,
3077
+ "--env-file",
3078
+ hermesEnv,
3079
+ "-e",
3080
+ "HERMES_HOME=/opt/data",
3081
+ "nousresearch/hermes-agent:latest",
3082
+ "gateway",
3083
+ "run"
3084
+ ];
3085
+ execSync3(`docker ${args.join(" ")}`, { stdio: "pipe", timeout: 3e4 });
3086
+ if (hermesWasRunning) {
3087
+ void appendOperationalEvent({
3088
+ type: "repair.succeeded",
3089
+ target: "eve-builder-hermes",
3090
+ componentId: "hermes",
3091
+ ok: true,
3092
+ summary: "Hermes container recreated with fresh env/config"
3093
+ }).catch(() => {
3094
+ });
3095
+ }
3096
+ } catch (err) {
3097
+ if (hermesWasRunning) {
3098
+ void appendOperationalEvent({
3099
+ type: "repair.failed",
3100
+ target: "eve-builder-hermes",
3101
+ componentId: "hermes",
3102
+ ok: false,
3103
+ summary: "Hermes container recreate failed",
3104
+ error: err instanceof Error ? err.message : String(err)
3105
+ }).catch(() => {
3106
+ });
3107
+ }
3108
+ }
3109
+ return {
3110
+ id: "hermes",
3111
+ outcome: "ok",
3112
+ summary: `Hermes wired \u2192 ${preferredModel} (config + plugin regenerated)`,
3113
+ detail: hermesEnvPath,
3114
+ wiredModel: preferredModel,
3115
+ wiredProvider: "synap"
3116
+ };
3117
+ }
3118
+ function wireBuilder(_secrets, componentId) {
3119
+ return {
3120
+ id: componentId,
3121
+ outcome: "skipped",
3122
+ summary: "builder organ uses Synap IS via Hub Protocol \u2014 wired during install"
3123
+ };
3124
+ }
3125
+ var AI_CONSUMERS = /* @__PURE__ */ new Set([
3126
+ "synap",
3127
+ "openclaw",
3128
+ "openwebui",
3129
+ "hermes"
3130
+ ]);
3131
+ var AI_CONSUMERS_NEEDING_RECREATE = /* @__PURE__ */ new Set([
3132
+ "openclaw",
3133
+ // Hermes is started via `docker run --env-file ~/.eve/hermes.env`. Docker
3134
+ // bakes env vars into the container spec at creation time — `docker restart`
3135
+ // reuses those stale values. Any channel credential or AI config change
3136
+ // requires a full recreate so the updated hermes.env is re-read.
3137
+ "hermes",
3138
+ // OpenWebUI reads OPENAI_API_BASE_URLS from .env on first boot. `docker
3139
+ // restart` won't re-read the file — compose up -d is required. The admin
3140
+ // API can update config without restart, but the primary path uses recreate.
3141
+ "openwebui"
3142
+ ]);
3143
+ async function wireComponentAi(componentId, secrets) {
3144
+ const comp = COMPONENTS.find((c) => c.id === componentId);
3145
+ if (!comp) {
3146
+ return { id: componentId, outcome: "failed", summary: `unknown component: ${componentId}` };
3147
+ }
3148
+ switch (componentId) {
3149
+ case "synap":
3150
+ return wireSynapIs(secrets);
3151
+ case "openclaw":
3152
+ return wireOpenclaw(secrets);
3153
+ case "openwebui":
3154
+ return wireOpenwebui(secrets);
3155
+ case "hermes":
3156
+ return wireHermes(secrets);
3157
+ case "opencode":
3158
+ case "openclaude":
3159
+ return wireBuilder(secrets, componentId);
3160
+ default:
3161
+ return {
3162
+ id: componentId,
3163
+ outcome: "skipped",
3164
+ summary: "no AI wiring needed"
3165
+ };
3166
+ }
3167
+ }
3168
+ async function wireAllInstalledComponents(secrets, installedComponents) {
3169
+ return Promise.all(installedComponents.map((id) => wireComponentAi(id, secrets)));
3170
+ }
3171
+ function buildOpenwebuiModelSources(secrets, _deployDir) {
3172
+ const providers = secrets?.ai?.providers ?? [];
3173
+ const modelSources = [];
3174
+ modelSources.push({
3175
+ url: "http://eve-brain-synap:4000/v1",
3176
+ apiKey: "",
3177
+ displayName: "Synap IS"
3178
+ });
3179
+ const hermesApiKey = secrets?.builder?.hermes?.apiServerKey ?? "";
3180
+ if (hermesApiKey && isContainerRunning("eve-builder-hermes")) {
3181
+ modelSources.push({
3182
+ url: "http://eve-builder-hermes:8642/v1",
3183
+ apiKey: hermesApiKey,
3184
+ displayName: "Hermes Gateway"
3185
+ });
3186
+ }
3187
+ for (const p of providers) {
3188
+ if (p.enabled === false || !p.id.startsWith("custom-")) continue;
3189
+ const hasLiveUrl = !!(p.baseUrl && p.apiKey);
3190
+ if (!hasLiveUrl) continue;
3191
+ modelSources.push({
3192
+ url: p.baseUrl.replace(/\/v1$/, "") + "/v1",
3193
+ apiKey: p.apiKey ?? "",
3194
+ displayName: p.name ?? p.id,
3195
+ models: p.models
3196
+ });
3197
+ }
3198
+ return { modelSources };
3199
+ }
3200
+ function buildOpenwebuiManagedConfig(secrets) {
3201
+ const provider = pickPrimaryProvider(secrets, "openwebui");
3202
+ const defaultModels = secrets?.ai?.serviceModels?.openwebui ?? provider?.defaultModel ?? "synap/auto";
3203
+ const domain = secrets?.domain?.primary?.trim();
3204
+ const webuiUrl = domain && domain !== "localhost" ? `${secrets?.domain?.ssl ? "https" : "http"}://chat.${domain}` : "";
3205
+ return {
3206
+ defaultModels,
3207
+ webuiUrl,
3208
+ webuiName: "Eve",
3209
+ enableSignup: true,
3210
+ defaultUserRole: "pending"
3211
+ };
3212
+ }
3213
+ async function registerOpenwebuiAdminApi(modelSources, options) {
3214
+ const healthAttempts = options.maxRetries ?? 12;
3215
+ const health = await waitForHealthDetailed(void 0, healthAttempts);
3216
+ if (!health.ok) {
3217
+ return { ok: false, stage: "health", reason: health.reason ?? `health timed out at ${health.baseUrl}` };
3218
+ }
3219
+ const jwtResult = await getAdminJwtPostHealthDetailed();
3220
+ if (!jwtResult.ok) {
3221
+ return { ok: false, stage: jwtResult.stage, reason: jwtResult.reason };
3222
+ }
3223
+ const jwt = jwtResult.jwt;
3224
+ const auth = await probeAdminAuth(jwt);
3225
+ if (!auth.ok) {
3226
+ if (auth.status === 401 || auth.status === 403) {
3227
+ return {
3228
+ ok: false,
3229
+ stage: "jwt-rejected",
3230
+ reason: `admin /api/v1/configs/ rejected forged JWT with HTTP ${auth.status}: ${auth.body.slice(0, 200)}`
3231
+ };
3232
+ }
3233
+ return {
3234
+ ok: false,
3235
+ stage: "reconcile",
3236
+ reason: `admin /api/v1/configs/ probe returned HTTP ${auth.status || "?"}: ${auth.body.slice(0, 200)}`
3237
+ };
3238
+ }
3239
+ try {
3240
+ const detailed = await reconcileOpenwebuiManagedConfigViaAdminDetailed(jwt, {
3241
+ ...options.managedConfig ?? {},
3242
+ modelSources
3243
+ });
3244
+ if (!detailed.ok) {
3245
+ return {
3246
+ ok: false,
3247
+ stage: "reconcile",
3248
+ reason: `${detailed.step} failed (HTTP ${detailed.status || "?"}): ${detailed.reason}` + (detailed.bodyPreview ? ` | body: ${detailed.bodyPreview}` : "")
3249
+ };
3250
+ }
3251
+ } catch (err) {
3252
+ return {
3253
+ ok: false,
3254
+ stage: "reconcile",
3255
+ reason: err instanceof Error ? err.message : String(err)
3256
+ };
3257
+ }
3258
+ return { ok: true };
3259
+ }
3260
+ function hasAnyProvider(secrets) {
3261
+ const providers = secrets?.ai?.providers ?? [];
3262
+ return providers.some((p) => p.apiKey && p.apiKey.trim().length > 0);
3263
+ }
3264
+ function changed(changedSections, section) {
3265
+ return changedSections.some((s) => s === section || s.startsWith(`${section}.`));
3266
+ }
3267
+ function installedAiConsumers(secrets) {
3268
+ const status = secrets.ai?.wiringStatus ?? {};
3269
+ const wired = Object.keys(status).filter((id) => status[id]?.lastApplied);
3270
+ if (wired.length > 0) return wired;
3271
+ const ids = new Set(COMPONENTS.map((component) => component.id));
3272
+ return Array.from(AI_CONSUMERS_NEEDING_RECREATE).filter((id) => ids.has(id));
3273
+ }
3274
+ async function tryWriteHermesEnv() {
3275
+ try {
3276
+ await writeHermesEnvFile();
3277
+ return true;
3278
+ } catch {
3279
+ return false;
3280
+ }
3281
+ }
3282
+ async function reconcile(secrets, changedSections, options = {}) {
3283
+ const result = {
3284
+ envSync: false,
3285
+ aiWiring: [],
3286
+ containerRecreates: [],
3287
+ traefikUpdate: false
3288
+ };
3289
+ if (changed(changedSections, "ai")) {
3290
+ const consumers = installedAiConsumers(secrets);
3291
+ const wiring = await wireAllInstalledComponents(secrets, consumers);
3292
+ result.aiWiring = wiring.map(({ id, outcome, summary }) => ({ id, outcome, summary }));
3293
+ result.containerRecreates.push(
3294
+ ...wiring.filter((item) => item.outcome === "ok" && AI_CONSUMERS_NEEDING_RECREATE.has(item.id)).map((item) => item.id)
3295
+ );
3296
+ }
3297
+ if (!options.skipEnvSync && (changed(changedSections, "channels") || changed(changedSections, "channelRouting") || changed(changedSections, "builder") || changed(changedSections, "inference"))) {
3298
+ result.envSync = await tryWriteHermesEnv();
3299
+ if (changed(changedSections, "builder")) {
3300
+ result.containerRecreates.push("hermes");
3301
+ }
3302
+ }
3303
+ if (changed(changedSections, "domain") || changed(changedSections, "synap") || changed(changedSections, "pod")) {
3304
+ const deployDir = findPodDeployDir();
3305
+ if (deployDir) {
3306
+ result.envSync = true;
3307
+ if (restartBackendContainer(deployDir)) {
3308
+ result.containerRecreates.push("synap");
3309
+ }
3310
+ }
3311
+ }
3312
+ if (options.recreateComponents) {
3313
+ result.containerRecreates.push(...options.recreateComponents);
3314
+ }
3315
+ result.containerRecreates = Array.from(new Set(result.containerRecreates));
3316
+ return result;
3317
+ }
3318
+
3319
+ export {
3320
+ COMPONENTS,
3321
+ EVE_DASHBOARD_SERVICE,
3322
+ resolveComponent,
3323
+ allComponentIds,
3324
+ addonComponentIds,
3325
+ selectedIds,
3326
+ serviceComponents,
3327
+ validateBaseDomain,
3328
+ publicComponentUrl,
3329
+ isLoopbackUrl,
3330
+ SYNAP_BACKEND_INTERNAL_URL,
3331
+ SYNAP_HOST_LOOPBACK_PORT,
3332
+ resolveSynapUrl,
3333
+ DEFAULT_HUB_PATH,
3334
+ resolveHubBaseUrl,
3335
+ defaultSkillsDir,
3336
+ ensureEveSkillsLayout,
3337
+ writeBuilderProjectEnv,
3338
+ writeSandboxEnvFile,
3339
+ copySynapSkillIntoClaudeProject,
3340
+ writeClaudeCodeSettings,
3341
+ writeHermesEnvFile,
3342
+ writeHermesConfigYaml,
3343
+ generateSynapPlugin,
3344
+ POD_DEPLOY_DIR_CANDIDATES,
3345
+ findPodDeployDir,
3346
+ SYNAP_BACKEND_CONTAINERS,
3347
+ restartBackendContainer,
3348
+ restartHermesIfRunning,
3349
+ connectTraefikToEveNetwork,
3350
+ isSynapLoopbackReachable,
3351
+ resetSynapLoopbackProbeCache,
3352
+ resolveSynapUrlOnHost,
3353
+ resolveOpenwebuiAdminUrl,
3354
+ waitForHealth,
3355
+ waitForHealthDetailed,
3356
+ getStatus,
3357
+ getAdminReadyStatus,
3358
+ getAdminJwt,
3359
+ getAdminJwtPostHealth,
3360
+ getAdminJwtPostHealthDetailed,
3361
+ probeAdminAuth,
3362
+ getConfig,
3363
+ getConfigDetailed,
3364
+ saveConfig,
3365
+ saveConfigDetailed,
3366
+ reconcileOpenwebuiManagedConfig,
3367
+ reconcileOpenwebuiManagedConfigViaAdmin,
3368
+ reconcileOpenwebuiManagedConfigViaAdminDetailed,
3369
+ listModelSources,
3370
+ registerModelSource,
3371
+ upsertAllModelSources,
3372
+ pushSynapSkillsToOpenwebuiPrompts,
3373
+ syncSynapKnowledgeToOpenwebui,
3374
+ registerSynapAsOpenwebuiToolServer,
3375
+ pushSynapFunctionsToOpenwebui,
3376
+ provisionOwuiUserInSynap,
3377
+ syncOwuiUsersToSynap,
3378
+ syncOpenwebuiExtras,
3379
+ formatExtrasSummary,
3380
+ pickPrimaryProvider,
3381
+ AI_CONSUMERS,
3382
+ AI_CONSUMERS_NEEDING_RECREATE,
3383
+ wireComponentAi,
3384
+ wireAllInstalledComponents,
3385
+ buildOpenwebuiModelSources,
3386
+ buildOpenwebuiManagedConfig,
3387
+ registerOpenwebuiAdminApi,
3388
+ hasAnyProvider,
3389
+ reconcile
3390
+ };
3391
+ //# sourceMappingURL=chunk-O5WTCVMZ.js.map