zoe-agent 0.3.1

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 (267) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/LICENSE +96 -0
  3. package/README.md +568 -0
  4. package/dist/adapters/cli/agent.d.ts +59 -0
  5. package/dist/adapters/cli/agent.js +232 -0
  6. package/dist/adapters/cli/bootstrap.d.ts +25 -0
  7. package/dist/adapters/cli/bootstrap.js +204 -0
  8. package/dist/adapters/cli/commands/build-registry.d.ts +14 -0
  9. package/dist/adapters/cli/commands/build-registry.js +88 -0
  10. package/dist/adapters/cli/commands/clear.d.ts +7 -0
  11. package/dist/adapters/cli/commands/clear.js +10 -0
  12. package/dist/adapters/cli/commands/compact.d.ts +13 -0
  13. package/dist/adapters/cli/commands/compact.js +96 -0
  14. package/dist/adapters/cli/commands/exit.d.ts +7 -0
  15. package/dist/adapters/cli/commands/exit.js +9 -0
  16. package/dist/adapters/cli/commands/gateway.d.ts +7 -0
  17. package/dist/adapters/cli/commands/gateway.js +152 -0
  18. package/dist/adapters/cli/commands/help.d.ts +9 -0
  19. package/dist/adapters/cli/commands/help.js +12 -0
  20. package/dist/adapters/cli/commands/models.d.ts +10 -0
  21. package/dist/adapters/cli/commands/models.js +32 -0
  22. package/dist/adapters/cli/commands/registry.d.ts +70 -0
  23. package/dist/adapters/cli/commands/registry.js +111 -0
  24. package/dist/adapters/cli/commands/settings-utils.d.ts +38 -0
  25. package/dist/adapters/cli/commands/settings-utils.js +182 -0
  26. package/dist/adapters/cli/commands/settings.d.ts +9 -0
  27. package/dist/adapters/cli/commands/settings.js +395 -0
  28. package/dist/adapters/cli/commands/skills.d.ts +7 -0
  29. package/dist/adapters/cli/commands/skills.js +21 -0
  30. package/dist/adapters/cli/config-loader.d.ts +27 -0
  31. package/dist/adapters/cli/config-loader.js +48 -0
  32. package/dist/adapters/cli/docker-utils.d.ts +37 -0
  33. package/dist/adapters/cli/docker-utils.js +90 -0
  34. package/dist/adapters/cli/index.d.ts +2 -0
  35. package/dist/adapters/cli/index.js +88 -0
  36. package/dist/adapters/cli/repl.d.ts +22 -0
  37. package/dist/adapters/cli/repl.js +256 -0
  38. package/dist/adapters/cli/setup.d.ts +19 -0
  39. package/dist/adapters/cli/setup.js +613 -0
  40. package/dist/adapters/cli/system-prompts.d.ts +56 -0
  41. package/dist/adapters/cli/system-prompts.js +131 -0
  42. package/dist/adapters/cli/tui/app.d.ts +58 -0
  43. package/dist/adapters/cli/tui/app.js +314 -0
  44. package/dist/adapters/cli/tui/components/assistant-message.d.ts +5 -0
  45. package/dist/adapters/cli/tui/components/assistant-message.js +9 -0
  46. package/dist/adapters/cli/tui/components/autocomplete.d.ts +19 -0
  47. package/dist/adapters/cli/tui/components/autocomplete.js +75 -0
  48. package/dist/adapters/cli/tui/components/command-palette.d.ts +15 -0
  49. package/dist/adapters/cli/tui/components/command-palette.js +50 -0
  50. package/dist/adapters/cli/tui/components/diff-viewer.d.ts +5 -0
  51. package/dist/adapters/cli/tui/components/diff-viewer.js +109 -0
  52. package/dist/adapters/cli/tui/components/error-message.d.ts +5 -0
  53. package/dist/adapters/cli/tui/components/error-message.js +8 -0
  54. package/dist/adapters/cli/tui/components/footer.d.ts +20 -0
  55. package/dist/adapters/cli/tui/components/footer.js +19 -0
  56. package/dist/adapters/cli/tui/components/goal-status.d.ts +12 -0
  57. package/dist/adapters/cli/tui/components/goal-status.js +22 -0
  58. package/dist/adapters/cli/tui/components/info-message.d.ts +5 -0
  59. package/dist/adapters/cli/tui/components/info-message.js +8 -0
  60. package/dist/adapters/cli/tui/components/logo-banner.d.ts +7 -0
  61. package/dist/adapters/cli/tui/components/logo-banner.js +33 -0
  62. package/dist/adapters/cli/tui/components/markdown.d.ts +9 -0
  63. package/dist/adapters/cli/tui/components/markdown.js +92 -0
  64. package/dist/adapters/cli/tui/components/message-area.d.ts +19 -0
  65. package/dist/adapters/cli/tui/components/message-area.js +55 -0
  66. package/dist/adapters/cli/tui/components/permission-prompt.d.ts +13 -0
  67. package/dist/adapters/cli/tui/components/permission-prompt.js +32 -0
  68. package/dist/adapters/cli/tui/components/prompt-area.d.ts +22 -0
  69. package/dist/adapters/cli/tui/components/prompt-area.js +68 -0
  70. package/dist/adapters/cli/tui/components/text-input.d.ts +27 -0
  71. package/dist/adapters/cli/tui/components/text-input.js +142 -0
  72. package/dist/adapters/cli/tui/components/tool-call-block.d.ts +11 -0
  73. package/dist/adapters/cli/tui/components/tool-call-block.js +68 -0
  74. package/dist/adapters/cli/tui/components/user-message.d.ts +5 -0
  75. package/dist/adapters/cli/tui/components/user-message.js +8 -0
  76. package/dist/adapters/cli/tui/diff/file-write-meta.d.ts +11 -0
  77. package/dist/adapters/cli/tui/diff/file-write-meta.js +11 -0
  78. package/dist/adapters/cli/tui/diff/line-diff.d.ts +17 -0
  79. package/dist/adapters/cli/tui/diff/line-diff.js +44 -0
  80. package/dist/adapters/cli/tui/feed-serializer.d.ts +29 -0
  81. package/dist/adapters/cli/tui/feed-serializer.js +70 -0
  82. package/dist/adapters/cli/tui/file-index.d.ts +8 -0
  83. package/dist/adapters/cli/tui/file-index.js +41 -0
  84. package/dist/adapters/cli/tui/hooks/use-agent.d.ts +54 -0
  85. package/dist/adapters/cli/tui/hooks/use-agent.js +177 -0
  86. package/dist/adapters/cli/tui/hooks/use-feed.d.ts +16 -0
  87. package/dist/adapters/cli/tui/hooks/use-feed.js +25 -0
  88. package/dist/adapters/cli/tui/hooks/use-file-watcher.d.ts +10 -0
  89. package/dist/adapters/cli/tui/hooks/use-file-watcher.js +43 -0
  90. package/dist/adapters/cli/tui/hooks/use-keybindings.d.ts +16 -0
  91. package/dist/adapters/cli/tui/hooks/use-keybindings.js +25 -0
  92. package/dist/adapters/cli/tui/hooks/use-theme.d.ts +8 -0
  93. package/dist/adapters/cli/tui/hooks/use-theme.js +12 -0
  94. package/dist/adapters/cli/tui/index.d.ts +19 -0
  95. package/dist/adapters/cli/tui/index.js +206 -0
  96. package/dist/adapters/cli/tui/ink-reset.d.ts +29 -0
  97. package/dist/adapters/cli/tui/ink-reset.js +57 -0
  98. package/dist/adapters/cli/tui/layout.d.ts +15 -0
  99. package/dist/adapters/cli/tui/layout.js +15 -0
  100. package/dist/adapters/cli/tui/logo/gradient.d.ts +11 -0
  101. package/dist/adapters/cli/tui/logo/gradient.js +31 -0
  102. package/dist/adapters/cli/tui/overlays/help-dialog.d.ts +4 -0
  103. package/dist/adapters/cli/tui/overlays/help-dialog.js +26 -0
  104. package/dist/adapters/cli/tui/overlays/model-selector.d.ts +14 -0
  105. package/dist/adapters/cli/tui/overlays/model-selector.js +43 -0
  106. package/dist/adapters/cli/tui/overlays/session-selector.d.ts +35 -0
  107. package/dist/adapters/cli/tui/overlays/session-selector.js +162 -0
  108. package/dist/adapters/cli/tui/overlays/settings-overlay.d.ts +24 -0
  109. package/dist/adapters/cli/tui/overlays/settings-overlay.js +126 -0
  110. package/dist/adapters/cli/tui/session-export.d.ts +21 -0
  111. package/dist/adapters/cli/tui/session-export.js +63 -0
  112. package/dist/adapters/cli/tui/theme.d.ts +23 -0
  113. package/dist/adapters/cli/tui/theme.js +22 -0
  114. package/dist/adapters/cli/tui/types.d.ts +52 -0
  115. package/dist/adapters/cli/tui/types.js +12 -0
  116. package/dist/adapters/sdk/agent.d.ts +20 -0
  117. package/dist/adapters/sdk/agent.js +356 -0
  118. package/dist/adapters/sdk/http.d.ts +43 -0
  119. package/dist/adapters/sdk/http.js +61 -0
  120. package/dist/adapters/sdk/index.d.ts +58 -0
  121. package/dist/adapters/sdk/index.js +209 -0
  122. package/dist/adapters/sdk/settings.d.ts +18 -0
  123. package/dist/adapters/sdk/settings.js +57 -0
  124. package/dist/adapters/sdk/tools.d.ts +7 -0
  125. package/dist/adapters/sdk/tools.js +13 -0
  126. package/dist/adapters/server/auth.d.ts +53 -0
  127. package/dist/adapters/server/auth.js +168 -0
  128. package/dist/adapters/server/index.d.ts +40 -0
  129. package/dist/adapters/server/index.js +255 -0
  130. package/dist/adapters/server/rest-gateway.d.ts +13 -0
  131. package/dist/adapters/server/rest-gateway.js +218 -0
  132. package/dist/adapters/server/rest.d.ts +37 -0
  133. package/dist/adapters/server/rest.js +341 -0
  134. package/dist/adapters/server/server-core.d.ts +55 -0
  135. package/dist/adapters/server/server-core.js +121 -0
  136. package/dist/adapters/server/session-store.d.ts +81 -0
  137. package/dist/adapters/server/session-store.js +272 -0
  138. package/dist/adapters/server/settings-handlers.d.ts +24 -0
  139. package/dist/adapters/server/settings-handlers.js +360 -0
  140. package/dist/adapters/server/standalone.d.ts +19 -0
  141. package/dist/adapters/server/standalone.js +113 -0
  142. package/dist/adapters/server/websocket.d.ts +26 -0
  143. package/dist/adapters/server/websocket.js +68 -0
  144. package/dist/adapters/server/ws-handlers.d.ts +32 -0
  145. package/dist/adapters/server/ws-handlers.js +523 -0
  146. package/dist/adapters/server/ws-types.d.ts +304 -0
  147. package/dist/adapters/server/ws-types.js +7 -0
  148. package/dist/core/agent-loop.d.ts +68 -0
  149. package/dist/core/agent-loop.js +423 -0
  150. package/dist/core/config.d.ts +115 -0
  151. package/dist/core/config.js +189 -0
  152. package/dist/core/errors.d.ts +58 -0
  153. package/dist/core/errors.js +88 -0
  154. package/dist/core/hooks.d.ts +35 -0
  155. package/dist/core/hooks.js +49 -0
  156. package/dist/core/index.d.ts +23 -0
  157. package/dist/core/index.js +29 -0
  158. package/dist/core/message-convert.d.ts +41 -0
  159. package/dist/core/message-convert.js +94 -0
  160. package/dist/core/middleware/auth.d.ts +24 -0
  161. package/dist/core/middleware/auth.js +28 -0
  162. package/dist/core/middleware/logging.d.ts +23 -0
  163. package/dist/core/middleware/logging.js +28 -0
  164. package/dist/core/middleware/rate-limit.d.ts +27 -0
  165. package/dist/core/middleware/rate-limit.js +38 -0
  166. package/dist/core/middleware/semantic-tools.d.ts +10 -0
  167. package/dist/core/middleware/semantic-tools.js +43 -0
  168. package/dist/core/middleware.d.ts +48 -0
  169. package/dist/core/middleware.js +38 -0
  170. package/dist/core/permission.d.ts +25 -0
  171. package/dist/core/permission.js +50 -0
  172. package/dist/core/provider-config.d.ts +129 -0
  173. package/dist/core/provider-config.js +273 -0
  174. package/dist/core/provider-env.d.ts +39 -0
  175. package/dist/core/provider-env.js +142 -0
  176. package/dist/core/provider-resolver.d.ts +12 -0
  177. package/dist/core/provider-resolver.js +12 -0
  178. package/dist/core/session-store.d.ts +75 -0
  179. package/dist/core/session-store.js +245 -0
  180. package/dist/core/settings-manager.d.ts +57 -0
  181. package/dist/core/settings-manager.js +359 -0
  182. package/dist/core/settings-schema.d.ts +38 -0
  183. package/dist/core/settings-schema.js +171 -0
  184. package/dist/core/skill-catalog.d.ts +6 -0
  185. package/dist/core/skill-catalog.js +17 -0
  186. package/dist/core/skill-invoker.d.ts +127 -0
  187. package/dist/core/skill-invoker.js +182 -0
  188. package/dist/core/stream-accumulator.d.ts +21 -0
  189. package/dist/core/stream-accumulator.js +51 -0
  190. package/dist/core/stream-manager.d.ts +58 -0
  191. package/dist/core/stream-manager.js +212 -0
  192. package/dist/core/tool-executor.d.ts +84 -0
  193. package/dist/core/tool-executor.js +256 -0
  194. package/dist/core/types.d.ts +259 -0
  195. package/dist/core/types.js +11 -0
  196. package/dist/gateway/gateway.d.ts +52 -0
  197. package/dist/gateway/gateway.js +537 -0
  198. package/dist/gateway/index.d.ts +21 -0
  199. package/dist/gateway/index.js +31 -0
  200. package/dist/gateway/openapi-importer.d.ts +15 -0
  201. package/dist/gateway/openapi-importer.js +66 -0
  202. package/dist/gateway/semantic-scorer.d.ts +7 -0
  203. package/dist/gateway/semantic-scorer.js +24 -0
  204. package/dist/gateway/settings-adapter.d.ts +49 -0
  205. package/dist/gateway/settings-adapter.js +137 -0
  206. package/dist/gateway/tool-factory.d.ts +9 -0
  207. package/dist/gateway/tool-factory.js +414 -0
  208. package/dist/gateway/types.d.ts +68 -0
  209. package/dist/gateway/types.js +7 -0
  210. package/dist/models-catalog.js +46 -0
  211. package/dist/providers/anthropic.d.ts +22 -0
  212. package/dist/providers/anthropic.js +148 -0
  213. package/dist/providers/factory.d.ts +10 -0
  214. package/dist/providers/factory.js +25 -0
  215. package/dist/providers/openai.d.ts +15 -0
  216. package/dist/providers/openai.js +71 -0
  217. package/dist/providers/types.d.ts +48 -0
  218. package/dist/providers/types.js +1 -0
  219. package/dist/skills/args.d.ts +37 -0
  220. package/dist/skills/args.js +99 -0
  221. package/dist/skills/index.d.ts +11 -0
  222. package/dist/skills/index.js +23 -0
  223. package/dist/skills/loader.d.ts +3 -0
  224. package/dist/skills/loader.js +59 -0
  225. package/dist/skills/parser.d.ts +7 -0
  226. package/dist/skills/parser.js +152 -0
  227. package/dist/skills/registry.d.ts +13 -0
  228. package/dist/skills/registry.js +74 -0
  229. package/dist/skills/resolver.d.ts +19 -0
  230. package/dist/skills/resolver.js +116 -0
  231. package/dist/skills/types.d.ts +74 -0
  232. package/dist/skills/types.js +50 -0
  233. package/dist/tools/browser.d.ts +2 -0
  234. package/dist/tools/browser.js +68 -0
  235. package/dist/tools/core.d.ts +20 -0
  236. package/dist/tools/core.js +244 -0
  237. package/dist/tools/email.d.ts +2 -0
  238. package/dist/tools/email.js +61 -0
  239. package/dist/tools/image.d.ts +2 -0
  240. package/dist/tools/image.js +257 -0
  241. package/dist/tools/index.d.ts +2 -0
  242. package/dist/tools/index.js +88 -0
  243. package/dist/tools/interface.d.ts +22 -0
  244. package/dist/tools/interface.js +1 -0
  245. package/dist/tools/notify.d.ts +2 -0
  246. package/dist/tools/notify.js +100 -0
  247. package/dist/tools/prompt-optimizer.d.ts +2 -0
  248. package/dist/tools/prompt-optimizer.js +65 -0
  249. package/dist/tools/screenshot.d.ts +2 -0
  250. package/dist/tools/screenshot.js +184 -0
  251. package/dist/tools/search.d.ts +2 -0
  252. package/dist/tools/search.js +78 -0
  253. package/dist/tools/todos.d.ts +10 -0
  254. package/dist/tools/todos.js +50 -0
  255. package/package.json +119 -0
  256. package/skills/docker-ops/SKILL.md +329 -0
  257. package/skills/k8s-deploy/SKILL.md +397 -0
  258. package/skills/log-analyzer/SKILL.md +331 -0
  259. package/skills/speckit-analyze/SKILL.md +260 -0
  260. package/skills/speckit-checklist/SKILL.md +374 -0
  261. package/skills/speckit-clarify/SKILL.md +286 -0
  262. package/skills/speckit-constitution/SKILL.md +157 -0
  263. package/skills/speckit-implement/SKILL.md +224 -0
  264. package/skills/speckit-plan/SKILL.md +171 -0
  265. package/skills/speckit-specify/SKILL.md +346 -0
  266. package/skills/speckit-tasks/SKILL.md +215 -0
  267. package/skills/speckit-taskstoissues/SKILL.md +107 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Zoe Server — Server-side Session Management
3
+ *
4
+ * Wraps a PersistenceBackend for server-specific needs:
5
+ * - TTL-based session expiration
6
+ * - Per-API-key concurrency limits
7
+ * - Periodic cleanup of stale sessions
8
+ *
9
+ * Raw storage is delegated to a PersistenceBackend (default: file-based).
10
+ * Server metadata (apiKeyHash, lastActivityAt) lives in memory and in
11
+ * the `metadata` field of SessionData.
12
+ */
13
+ import { createPersistenceBackend } from "../../core/session-store.js";
14
+ // ── Defaults ───────────────────────────────────────────────────────────
15
+ const DEFAULT_SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours
16
+ const DEFAULT_INACTIVITY_TIMEOUT = 30 * 60 * 1000; // 30 minutes
17
+ const DEFAULT_MAX_SESSIONS = 5;
18
+ const DEFAULT_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
19
+ // ── Helpers ────────────────────────────────────────────────────────────
20
+ import * as crypto from "crypto";
21
+ export function hashKey(key) {
22
+ return crypto.createHash("sha256").update(key).digest("hex").slice(0, 16);
23
+ }
24
+ // ── ServerSessionManager ───────────────────────────────────────────────
25
+ export class ServerSessionManager {
26
+ sessions = new Map();
27
+ sessionTTL;
28
+ inactivityTimeout;
29
+ maxSessionsPerKey;
30
+ cleanupInterval;
31
+ backend;
32
+ cleanupTimer = null;
33
+ constructor(options) {
34
+ this.sessionTTL = options?.sessionTTL ?? DEFAULT_SESSION_TTL;
35
+ this.inactivityTimeout = options?.inactivityTimeout ?? DEFAULT_INACTIVITY_TIMEOUT;
36
+ this.maxSessionsPerKey = options?.maxSessionsPerKey ?? DEFAULT_MAX_SESSIONS;
37
+ this.cleanupInterval = options?.cleanupInterval ?? DEFAULT_CLEANUP_INTERVAL;
38
+ if (options?.backend) {
39
+ this.backend = options.backend;
40
+ }
41
+ else {
42
+ this.backend = createPersistenceBackend({
43
+ type: "file",
44
+ path: options?.sessionDir,
45
+ });
46
+ }
47
+ }
48
+ // ── Lifecycle ────────────────────────────────────────────────────────
49
+ /**
50
+ * Start the periodic cleanup timer.
51
+ */
52
+ startCleanup() {
53
+ if (this.cleanupTimer)
54
+ return;
55
+ this.cleanupTimer = setInterval(() => this.cleanup(), this.cleanupInterval);
56
+ // Prevent the timer from keeping the process alive
57
+ if (this.cleanupTimer.unref) {
58
+ this.cleanupTimer.unref();
59
+ }
60
+ }
61
+ /**
62
+ * Stop the periodic cleanup timer.
63
+ */
64
+ stopCleanup() {
65
+ if (this.cleanupTimer) {
66
+ clearInterval(this.cleanupTimer);
67
+ this.cleanupTimer = null;
68
+ }
69
+ }
70
+ // ── CRUD ─────────────────────────────────────────────────────────────
71
+ /**
72
+ * Create a new session. Enforces per-API-key session limits.
73
+ * Returns the new SessionData or throws if the limit is exceeded.
74
+ * Awaits persistence — callers should await to ensure backend errors propagate.
75
+ */
76
+ async createSession(apiKey, provider, model) {
77
+ const keyHash = hashKey(apiKey);
78
+ // Enforce per-key limit
79
+ const existing = this.getSessionsByKey(keyHash);
80
+ if (existing.length >= this.maxSessionsPerKey) {
81
+ throw new Error(`Maximum concurrent sessions (${this.maxSessionsPerKey}) reached for this API key.`);
82
+ }
83
+ const id = crypto.randomUUID();
84
+ const now = Date.now();
85
+ const session = {
86
+ id,
87
+ messages: [],
88
+ createdAt: now,
89
+ updatedAt: now,
90
+ lastActivityAt: now,
91
+ apiKeyHash: keyHash,
92
+ provider,
93
+ model,
94
+ };
95
+ this.sessions.set(id, session);
96
+ await this.persistSessionAsync(session);
97
+ return {
98
+ id: session.id,
99
+ messages: session.messages,
100
+ createdAt: session.createdAt,
101
+ updatedAt: session.updatedAt,
102
+ provider: session.provider,
103
+ model: session.model,
104
+ };
105
+ }
106
+ /**
107
+ * Get a session by its ID, verifying ownership via API key hash.
108
+ * Returns null if the session does not exist, has expired, or is not owned
109
+ * by the provided API key.
110
+ */
111
+ async getSession(id, apiKeyHash) {
112
+ let session = this.sessions.get(id);
113
+ if (!session) {
114
+ // Try loading from persistence backend
115
+ session = await this.loadSessionFromBackend(id);
116
+ if (!session)
117
+ return null;
118
+ this.sessions.set(id, session);
119
+ }
120
+ // Check expiration
121
+ if (this.isExpired(session)) {
122
+ this.deleteSession(id);
123
+ return null;
124
+ }
125
+ // Ownership verification — constant-time comparison to prevent timing attacks
126
+ if (!this.verifyOwnership(session, apiKeyHash)) {
127
+ return null;
128
+ }
129
+ return {
130
+ id: session.id,
131
+ messages: session.messages,
132
+ createdAt: session.createdAt,
133
+ updatedAt: session.updatedAt,
134
+ provider: session.provider,
135
+ model: session.model,
136
+ };
137
+ }
138
+ /**
139
+ * Add a message to an existing session.
140
+ * Updates the last-activity timestamp.
141
+ */
142
+ addMessage(sessionId, message) {
143
+ const session = this.sessions.get(sessionId);
144
+ if (!session)
145
+ return;
146
+ session.messages.push(message);
147
+ session.updatedAt = Date.now();
148
+ session.lastActivityAt = Date.now();
149
+ this.persistSession(session);
150
+ }
151
+ /**
152
+ * Delete a session by ID.
153
+ */
154
+ deleteSession(id) {
155
+ this.sessions.delete(id);
156
+ this.backend.delete(id).catch(() => {
157
+ // Best-effort — don't crash on delete errors
158
+ });
159
+ }
160
+ /**
161
+ * Get all active (non-expired) sessions.
162
+ */
163
+ getActiveSessions() {
164
+ const active = [];
165
+ for (const [id, session] of this.sessions) {
166
+ if (!this.isExpired(session)) {
167
+ active.push({
168
+ id: session.id,
169
+ messages: session.messages,
170
+ createdAt: session.createdAt,
171
+ updatedAt: session.updatedAt,
172
+ provider: session.provider,
173
+ model: session.model,
174
+ });
175
+ }
176
+ }
177
+ return active;
178
+ }
179
+ /**
180
+ * Remove expired sessions from memory and backend.
181
+ */
182
+ cleanup() {
183
+ for (const [id, session] of this.sessions) {
184
+ if (this.isExpired(session)) {
185
+ this.deleteSession(id);
186
+ }
187
+ }
188
+ }
189
+ // ── Internal helpers ─────────────────────────────────────────────────
190
+ getSessionsByKey(keyHash) {
191
+ const result = [];
192
+ for (const session of this.sessions.values()) {
193
+ if (session.apiKeyHash === keyHash && !this.isExpired(session)) {
194
+ result.push(session);
195
+ }
196
+ }
197
+ return result;
198
+ }
199
+ isExpired(session) {
200
+ const now = Date.now();
201
+ // Absolute TTL
202
+ if (now - session.createdAt > this.sessionTTL) {
203
+ return true;
204
+ }
205
+ // Inactivity timeout
206
+ if (now - session.lastActivityAt > this.inactivityTimeout) {
207
+ return true;
208
+ }
209
+ return false;
210
+ }
211
+ persistSession(session) {
212
+ const data = {
213
+ id: session.id,
214
+ messages: session.messages,
215
+ createdAt: session.createdAt,
216
+ updatedAt: session.updatedAt,
217
+ provider: session.provider,
218
+ model: session.model,
219
+ metadata: {
220
+ apiKeyHash: session.apiKeyHash,
221
+ lastActivityAt: session.lastActivityAt,
222
+ },
223
+ };
224
+ this.backend.save(session.id, data).catch(() => {
225
+ // Best-effort persistence — don't crash on write errors
226
+ });
227
+ }
228
+ async persistSessionAsync(session) {
229
+ const data = {
230
+ id: session.id,
231
+ messages: session.messages,
232
+ createdAt: session.createdAt,
233
+ updatedAt: session.updatedAt,
234
+ provider: session.provider,
235
+ model: session.model,
236
+ metadata: {
237
+ apiKeyHash: session.apiKeyHash,
238
+ lastActivityAt: session.lastActivityAt,
239
+ },
240
+ };
241
+ await this.backend.save(session.id, data);
242
+ }
243
+ async loadSessionFromBackend(id) {
244
+ try {
245
+ const data = await this.backend.load(id);
246
+ if (!data)
247
+ return null;
248
+ const metadata = data.metadata ?? {};
249
+ return {
250
+ id: data.id,
251
+ messages: data.messages,
252
+ createdAt: data.createdAt,
253
+ updatedAt: data.updatedAt,
254
+ provider: data.provider,
255
+ model: data.model,
256
+ apiKeyHash: metadata.apiKeyHash ?? "",
257
+ lastActivityAt: metadata.lastActivityAt ?? data.updatedAt,
258
+ };
259
+ }
260
+ catch (err) {
261
+ console.warn(`[session-store] Failed to load session ${id} from backend:`, err);
262
+ return null;
263
+ }
264
+ }
265
+ verifyOwnership(session, apiKeyHash) {
266
+ const a = Buffer.from(session.apiKeyHash, "utf-8");
267
+ const b = Buffer.from(apiKeyHash, "utf-8");
268
+ if (a.length !== b.length)
269
+ return false;
270
+ return crypto.timingSafeEqual(a, b);
271
+ }
272
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Zoe Server — Settings REST & WebSocket Handlers
3
+ *
4
+ * REST endpoints and WS message handlers for settings management.
5
+ */
6
+ import type { IncomingMessage, ServerResponse } from 'http';
7
+ import { SettingsManager } from '../../core/settings-manager.js';
8
+ import type { WebSocket, ConnectionState } from './ws-types.js';
9
+ export interface SettingsHandlerContext {
10
+ settingsManager: SettingsManager;
11
+ /** Get all connected WS clients (excluding sender) */
12
+ getOtherClients: (excludeWs?: WebSocket) => Array<{
13
+ ws: WebSocket;
14
+ state: ConnectionState;
15
+ }>;
16
+ }
17
+ export declare function handleGetSettings(req: IncomingMessage, res: ServerResponse, ctx: SettingsHandlerContext, category?: string): Promise<void>;
18
+ export declare function handlePatchSettings(req: IncomingMessage, res: ServerResponse, ctx: SettingsHandlerContext, category?: string): Promise<void>;
19
+ export declare function handleGetSettingsSchema(req: IncomingMessage, res: ServerResponse, ctx: SettingsHandlerContext): Promise<void>;
20
+ export declare function handlePostProvider(req: IncomingMessage, res: ServerResponse, ctx: SettingsHandlerContext): Promise<void>;
21
+ export declare function handlePatchProvider(req: IncomingMessage, res: ServerResponse, ctx: SettingsHandlerContext, providerType: string): Promise<void>;
22
+ export declare function handleDeleteProvider(req: IncomingMessage, res: ServerResponse, ctx: SettingsHandlerContext, providerType: string): Promise<void>;
23
+ export declare function handleWsGetSettings(msg: any, ws: WebSocket, state: ConnectionState, ctx: SettingsHandlerContext): void;
24
+ export declare function handleWsUpdateSettings(msg: any, ws: WebSocket, state: ConnectionState, ctx: SettingsHandlerContext): Promise<void>;
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Zoe Server — Settings REST & WebSocket Handlers
3
+ *
4
+ * REST endpoints and WS message handlers for settings management.
5
+ */
6
+ import { SETTINGS_SCHEMA, SETTINGS_CATEGORIES } from '../../core/settings-schema.js';
7
+ import { hasScope } from './auth.js';
8
+ // ── Mutex ─────────────────────────────────────────────────────────────────
9
+ class AsyncMutex {
10
+ queue = [];
11
+ locked = false;
12
+ async acquire() {
13
+ return new Promise((resolve) => {
14
+ const tryAcquire = () => {
15
+ if (!this.locked) {
16
+ this.locked = true;
17
+ resolve(() => {
18
+ this.locked = false;
19
+ if (this.queue.length > 0)
20
+ this.queue.shift()();
21
+ });
22
+ }
23
+ else {
24
+ this.queue.push(tryAcquire);
25
+ }
26
+ };
27
+ tryAcquire();
28
+ });
29
+ }
30
+ }
31
+ const writeMutex = new AsyncMutex();
32
+ // ── Helpers ───────────────────────────────────────────────────────────────
33
+ function sendJSON(res, statusCode, data) {
34
+ const body = JSON.stringify(data);
35
+ res.writeHead(statusCode, {
36
+ 'Content-Type': 'application/json',
37
+ 'Content-Length': Buffer.byteLength(body),
38
+ });
39
+ res.end(body);
40
+ }
41
+ function sendError(res, statusCode, code, message) {
42
+ sendJSON(res, statusCode, { error: { code, message } });
43
+ }
44
+ function requireScope(res, apiKey, scope) {
45
+ if (!apiKey || !hasScope(apiKey, scope)) {
46
+ sendError(res, 403, 'FORBIDDEN', `Requires ${scope} scope`);
47
+ return false;
48
+ }
49
+ return true;
50
+ }
51
+ function requireWsScope(state, scope) {
52
+ const apiKey = state.apiKey;
53
+ return !!apiKey && hasScope(apiKey, scope);
54
+ }
55
+ async function readBody(req) {
56
+ return new Promise((resolve, reject) => {
57
+ let data = '';
58
+ req.on('data', (chunk) => { data += chunk; });
59
+ req.on('end', () => {
60
+ try {
61
+ resolve(JSON.parse(data));
62
+ }
63
+ catch {
64
+ reject(new Error('Invalid JSON'));
65
+ }
66
+ });
67
+ req.on('error', reject);
68
+ });
69
+ }
70
+ const VALID_CATEGORIES = new Set(SETTINGS_CATEGORIES.map(c => c.key));
71
+ const VALID_PROVIDER_TYPES = new Set(['openai', 'anthropic', 'glm', 'openai-compatible']);
72
+ // ── REST Handlers ─────────────────────────────────────────────────────────
73
+ export async function handleGetSettings(req, res, ctx, category) {
74
+ const apiKey = req.apiKey;
75
+ if (!requireScope(res, apiKey, 'agent:read'))
76
+ return;
77
+ if (category) {
78
+ if (!VALID_CATEGORIES.has(category)) {
79
+ sendError(res, 404, 'NOT_FOUND', `Unknown category: ${category}`);
80
+ return;
81
+ }
82
+ const all = ctx.settingsManager.listByCategory();
83
+ sendJSON(res, 200, { [category]: all[category] ?? [] });
84
+ return;
85
+ }
86
+ const all = ctx.settingsManager.listByCategory();
87
+ sendJSON(res, 200, all);
88
+ }
89
+ export async function handlePatchSettings(req, res, ctx, category) {
90
+ const apiKey = req.apiKey;
91
+ if (!requireScope(res, apiKey, 'admin'))
92
+ return;
93
+ const body = await readBody(req);
94
+ const updates = category ? { [category]: body } : body;
95
+ const applied = {};
96
+ const errors = [];
97
+ let requiresRestart = false;
98
+ const restartAffected = [];
99
+ const release = await writeMutex.acquire();
100
+ try {
101
+ for (const [cat, fields] of Object.entries(updates)) {
102
+ if (typeof fields !== 'object' || fields === null)
103
+ continue;
104
+ applied[cat] = {};
105
+ for (const [key, value] of Object.entries(fields)) {
106
+ const dotKey = category ? `${category}.${key}` : `${cat}.${key}`;
107
+ try {
108
+ await ctx.settingsManager.set(dotKey, String(value));
109
+ applied[cat][key] = ctx.settingsManager.get(dotKey).value;
110
+ // Check restart requirement via schema
111
+ const schema = SETTINGS_SCHEMA.get(dotKey);
112
+ if (schema?.restartRequired) {
113
+ requiresRestart = true;
114
+ restartAffected.push(dotKey);
115
+ }
116
+ }
117
+ catch (e) {
118
+ errors.push({ field: dotKey, message: e.message });
119
+ }
120
+ }
121
+ }
122
+ }
123
+ finally {
124
+ release();
125
+ }
126
+ if (errors.length > 0) {
127
+ sendJSON(res, 422, {
128
+ error: { code: 'VALIDATION_ERROR', message: `${errors.length} field(s) failed validation`, details: errors },
129
+ });
130
+ return;
131
+ }
132
+ // Broadcast change notification
133
+ broadcastSettingsChange(ctx, Object.keys(updates), requiresRestart, restartAffected);
134
+ sendJSON(res, 200, { applied, requiresRestart, restartAffected });
135
+ }
136
+ export async function handleGetSettingsSchema(req, res, ctx) {
137
+ const apiKey = req.apiKey;
138
+ if (!requireScope(res, apiKey, 'agent:read'))
139
+ return;
140
+ // Build a simple JSON schema from SETTINGS_SCHEMA
141
+ const properties = {};
142
+ for (const [dotKey, schema] of SETTINGS_SCHEMA) {
143
+ const prop = {};
144
+ if (schema.type === 'number')
145
+ prop.type = 'integer';
146
+ else if (schema.type === 'boolean')
147
+ prop.type = 'boolean';
148
+ else if (schema.type === 'enum') {
149
+ prop.type = 'string';
150
+ prop.enum = schema.enumValues;
151
+ }
152
+ else
153
+ prop.type = 'string';
154
+ if (schema.secret)
155
+ prop.writeOnly = true;
156
+ if (schema.default !== undefined)
157
+ prop.default = schema.default;
158
+ properties[dotKey] = prop;
159
+ }
160
+ sendJSON(res, 200, {
161
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
162
+ title: 'Zoe Agent Settings',
163
+ type: 'object',
164
+ properties,
165
+ });
166
+ }
167
+ export async function handlePostProvider(req, res, ctx) {
168
+ const apiKey = req.apiKey;
169
+ if (!requireScope(res, apiKey, 'admin'))
170
+ return;
171
+ const body = await readBody(req);
172
+ const providerType = body.type;
173
+ if (!providerType || !VALID_PROVIDER_TYPES.has(providerType)) {
174
+ sendError(res, 422, 'VALIDATION_ERROR', 'Invalid or missing provider type');
175
+ return;
176
+ }
177
+ // Check if already exists
178
+ const existingKey = `providers.${providerType === 'openai-compatible' ? 'openai-compat' : providerType}.apiKey`;
179
+ const existing = ctx.settingsManager.get(existingKey);
180
+ if (existing.value != null) {
181
+ sendError(res, 409, 'CONFLICT', `Provider "${providerType}" is already configured. Use PATCH to update.`);
182
+ return;
183
+ }
184
+ if (!body.apiKey) {
185
+ sendError(res, 422, 'VALIDATION_ERROR', 'apiKey is required');
186
+ return;
187
+ }
188
+ if (providerType === 'openai-compatible' && !body.baseUrl) {
189
+ sendError(res, 422, 'VALIDATION_ERROR', 'baseUrl is required for openai-compatible');
190
+ return;
191
+ }
192
+ const release = await writeMutex.acquire();
193
+ try {
194
+ await ctx.settingsManager.set(existingKey, body.apiKey);
195
+ if (body.model) {
196
+ const modelKey = `providers.${providerType === 'openai-compatible' ? 'openai-compat' : providerType}.model`;
197
+ await ctx.settingsManager.set(modelKey, body.model);
198
+ }
199
+ if (body.baseUrl && providerType === 'openai-compatible') {
200
+ await ctx.settingsManager.set('providers.openai-compat.baseUrl', body.baseUrl);
201
+ }
202
+ }
203
+ finally {
204
+ release();
205
+ }
206
+ broadcastSettingsChange(ctx, ['providers'], false, []);
207
+ sendJSON(res, 201, {
208
+ provider: { type: providerType, apiKey: ctx.settingsManager.get(existingKey).value },
209
+ requiresRestart: false,
210
+ restartAffected: [],
211
+ });
212
+ }
213
+ export async function handlePatchProvider(req, res, ctx, providerType) {
214
+ const apiKey = req.apiKey;
215
+ if (!requireScope(res, apiKey, 'admin'))
216
+ return;
217
+ if (!VALID_PROVIDER_TYPES.has(providerType)) {
218
+ sendError(res, 404, 'NOT_FOUND', `Unknown provider type: ${providerType}`);
219
+ return;
220
+ }
221
+ const body = await readBody(req);
222
+ const prefix = `providers.${providerType === 'openai-compatible' ? 'openai-compat' : providerType}`;
223
+ const release = await writeMutex.acquire();
224
+ try {
225
+ if (body.apiKey)
226
+ await ctx.settingsManager.set(`${prefix}.apiKey`, body.apiKey);
227
+ if (body.model)
228
+ await ctx.settingsManager.set(`${prefix}.model`, body.model);
229
+ if (body.baseUrl && providerType === 'openai-compatible')
230
+ await ctx.settingsManager.set(`${prefix}.baseUrl`, body.baseUrl);
231
+ }
232
+ finally {
233
+ release();
234
+ }
235
+ broadcastSettingsChange(ctx, ['providers'], false, []);
236
+ const result = { type: providerType };
237
+ result.apiKey = ctx.settingsManager.get(`${prefix}.apiKey`).value;
238
+ if (providerType === 'openai-compatible')
239
+ result.baseUrl = ctx.settingsManager.get(`${prefix}.baseUrl`).value;
240
+ if (body.model)
241
+ result.model = body.model;
242
+ sendJSON(res, 200, { provider: result, requiresRestart: false, restartAffected: [] });
243
+ }
244
+ export async function handleDeleteProvider(req, res, ctx, providerType) {
245
+ const apiKey = req.apiKey;
246
+ if (!requireScope(res, apiKey, 'admin'))
247
+ return;
248
+ if (!VALID_PROVIDER_TYPES.has(providerType)) {
249
+ sendError(res, 404, 'NOT_FOUND', `Unknown provider type: ${providerType}`);
250
+ return;
251
+ }
252
+ const prefix = `providers.${providerType === 'openai-compatible' ? 'openai-compat' : providerType}`;
253
+ // Check how many providers have keys configured
254
+ let configuredCount = 0;
255
+ for (const pType of ['openai', 'anthropic', 'glm', 'openai-compatible']) {
256
+ const p = `providers.${pType === 'openai-compatible' ? 'openai-compat' : pType}.apiKey`;
257
+ const val = ctx.settingsManager.get(p);
258
+ if (val.value != null)
259
+ configuredCount++;
260
+ }
261
+ if (configuredCount <= 1) {
262
+ sendError(res, 422, 'VALIDATION_ERROR', 'Cannot remove the last configured provider.');
263
+ return;
264
+ }
265
+ const release = await writeMutex.acquire();
266
+ try {
267
+ await ctx.settingsManager.reset(`${prefix}.apiKey`);
268
+ try {
269
+ await ctx.settingsManager.reset(`${prefix}.model`);
270
+ }
271
+ catch { /* may not exist */ }
272
+ try {
273
+ await ctx.settingsManager.reset(`${prefix}.baseUrl`);
274
+ }
275
+ catch { /* may not exist */ }
276
+ }
277
+ finally {
278
+ release();
279
+ }
280
+ broadcastSettingsChange(ctx, ['providers'], false, []);
281
+ sendJSON(res, 200, { removed: providerType, requiresRestart: false, restartAffected: [] });
282
+ }
283
+ // ── WS Handlers ───────────────────────────────────────────────────────────
284
+ export function handleWsGetSettings(msg, ws, state, ctx) {
285
+ if (!requireWsScope(state, 'agent:read')) {
286
+ ws.send(JSON.stringify({ type: 'settings', id: msg.id, error: { code: 'FORBIDDEN', message: 'Requires agent:read scope' } }));
287
+ return;
288
+ }
289
+ const all = ctx.settingsManager.listByCategory();
290
+ const filtered = msg.category ? { [msg.category]: all[msg.category] ?? [] } : all;
291
+ ws.send(JSON.stringify({ type: 'settings', id: msg.id, settings: filtered }));
292
+ }
293
+ export async function handleWsUpdateSettings(msg, ws, state, ctx) {
294
+ if (!requireWsScope(state, 'admin')) {
295
+ ws.send(JSON.stringify({ type: 'settings_updated', id: msg.id, error: { code: 'FORBIDDEN', message: 'Requires admin scope' } }));
296
+ return;
297
+ }
298
+ const settings = msg.settings ?? {};
299
+ const applied = {};
300
+ const changedFields = [];
301
+ const errors = [];
302
+ let requiresRestart = false;
303
+ const restartAffected = [];
304
+ const release = await writeMutex.acquire();
305
+ try {
306
+ for (const [cat, fields] of Object.entries(settings)) {
307
+ if (typeof fields !== 'object' || fields === null)
308
+ continue;
309
+ applied[cat] = {};
310
+ for (const [key, value] of Object.entries(fields)) {
311
+ const dotKey = `${cat}.${key}`;
312
+ try {
313
+ await ctx.settingsManager.set(dotKey, String(value));
314
+ applied[cat][key] = ctx.settingsManager.get(dotKey).value;
315
+ changedFields.push(dotKey);
316
+ const schema = SETTINGS_SCHEMA.get(dotKey);
317
+ if (schema?.restartRequired) {
318
+ requiresRestart = true;
319
+ restartAffected.push(dotKey);
320
+ }
321
+ }
322
+ catch (e) {
323
+ errors.push({ field: dotKey, message: e.message });
324
+ }
325
+ }
326
+ }
327
+ }
328
+ finally {
329
+ release();
330
+ }
331
+ if (errors.length > 0) {
332
+ ws.send(JSON.stringify({
333
+ type: 'settings_updated', id: msg.id,
334
+ error: { code: 'VALIDATION_ERROR', message: `${errors.length} field(s) failed validation`, details: errors },
335
+ }));
336
+ return;
337
+ }
338
+ // Respond to sender
339
+ ws.send(JSON.stringify({ type: 'settings_updated', id: msg.id, applied, requiresRestart, restartAffected }));
340
+ // Broadcast to others
341
+ broadcastSettingsChange(ctx, changedFields, requiresRestart, restartAffected, ws);
342
+ }
343
+ // ── Broadcast ─────────────────────────────────────────────────────────────
344
+ function broadcastSettingsChange(ctx, changedFields, requiresRestart, restartAffected, excludeWs) {
345
+ const categories = new Set(changedFields.map(f => f.split('.')[0]));
346
+ const message = JSON.stringify({
347
+ type: 'settings_changed',
348
+ changedCategories: [...categories],
349
+ changedFields,
350
+ requiresRestart,
351
+ restartAffected,
352
+ timestamp: new Date().toISOString(),
353
+ });
354
+ for (const client of ctx.getOtherClients(excludeWs)) {
355
+ try {
356
+ client.ws.send(message);
357
+ }
358
+ catch { /* best-effort */ }
359
+ }
360
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Zoe Server — Standalone Entry Point
4
+ *
5
+ * Starts the Zoe remote server as a standalone process.
6
+ * Suitable as a Docker CMD/ENTRYPOINT or direct CLI invocation.
7
+ *
8
+ * Usage:
9
+ * node dist/adapters/server/standalone.js
10
+ * node dist/adapters/server/standalone.js --generate-api-key
11
+ *
12
+ * Environment variables:
13
+ * ZOE_PORT / PORT — Port to listen on (default: 7337)
14
+ * ZOE_HOST — Host to bind to (default: "0.0.0.0")
15
+ * ZOE_SESSION_DIR — Directory for session storage
16
+ * ZOE_SESSION_TTL — Session TTL in seconds (default: 86400)
17
+ * ZOE_API_KEYS_FILE — Path to API key store file
18
+ */
19
+ export {};