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.
- package/CHANGELOG.md +154 -0
- package/LICENSE +96 -0
- package/README.md +568 -0
- package/dist/adapters/cli/agent.d.ts +59 -0
- package/dist/adapters/cli/agent.js +232 -0
- package/dist/adapters/cli/bootstrap.d.ts +25 -0
- package/dist/adapters/cli/bootstrap.js +204 -0
- package/dist/adapters/cli/commands/build-registry.d.ts +14 -0
- package/dist/adapters/cli/commands/build-registry.js +88 -0
- package/dist/adapters/cli/commands/clear.d.ts +7 -0
- package/dist/adapters/cli/commands/clear.js +10 -0
- package/dist/adapters/cli/commands/compact.d.ts +13 -0
- package/dist/adapters/cli/commands/compact.js +96 -0
- package/dist/adapters/cli/commands/exit.d.ts +7 -0
- package/dist/adapters/cli/commands/exit.js +9 -0
- package/dist/adapters/cli/commands/gateway.d.ts +7 -0
- package/dist/adapters/cli/commands/gateway.js +152 -0
- package/dist/adapters/cli/commands/help.d.ts +9 -0
- package/dist/adapters/cli/commands/help.js +12 -0
- package/dist/adapters/cli/commands/models.d.ts +10 -0
- package/dist/adapters/cli/commands/models.js +32 -0
- package/dist/adapters/cli/commands/registry.d.ts +70 -0
- package/dist/adapters/cli/commands/registry.js +111 -0
- package/dist/adapters/cli/commands/settings-utils.d.ts +38 -0
- package/dist/adapters/cli/commands/settings-utils.js +182 -0
- package/dist/adapters/cli/commands/settings.d.ts +9 -0
- package/dist/adapters/cli/commands/settings.js +395 -0
- package/dist/adapters/cli/commands/skills.d.ts +7 -0
- package/dist/adapters/cli/commands/skills.js +21 -0
- package/dist/adapters/cli/config-loader.d.ts +27 -0
- package/dist/adapters/cli/config-loader.js +48 -0
- package/dist/adapters/cli/docker-utils.d.ts +37 -0
- package/dist/adapters/cli/docker-utils.js +90 -0
- package/dist/adapters/cli/index.d.ts +2 -0
- package/dist/adapters/cli/index.js +88 -0
- package/dist/adapters/cli/repl.d.ts +22 -0
- package/dist/adapters/cli/repl.js +256 -0
- package/dist/adapters/cli/setup.d.ts +19 -0
- package/dist/adapters/cli/setup.js +613 -0
- package/dist/adapters/cli/system-prompts.d.ts +56 -0
- package/dist/adapters/cli/system-prompts.js +131 -0
- package/dist/adapters/cli/tui/app.d.ts +58 -0
- package/dist/adapters/cli/tui/app.js +314 -0
- package/dist/adapters/cli/tui/components/assistant-message.d.ts +5 -0
- package/dist/adapters/cli/tui/components/assistant-message.js +9 -0
- package/dist/adapters/cli/tui/components/autocomplete.d.ts +19 -0
- package/dist/adapters/cli/tui/components/autocomplete.js +75 -0
- package/dist/adapters/cli/tui/components/command-palette.d.ts +15 -0
- package/dist/adapters/cli/tui/components/command-palette.js +50 -0
- package/dist/adapters/cli/tui/components/diff-viewer.d.ts +5 -0
- package/dist/adapters/cli/tui/components/diff-viewer.js +109 -0
- package/dist/adapters/cli/tui/components/error-message.d.ts +5 -0
- package/dist/adapters/cli/tui/components/error-message.js +8 -0
- package/dist/adapters/cli/tui/components/footer.d.ts +20 -0
- package/dist/adapters/cli/tui/components/footer.js +19 -0
- package/dist/adapters/cli/tui/components/goal-status.d.ts +12 -0
- package/dist/adapters/cli/tui/components/goal-status.js +22 -0
- package/dist/adapters/cli/tui/components/info-message.d.ts +5 -0
- package/dist/adapters/cli/tui/components/info-message.js +8 -0
- package/dist/adapters/cli/tui/components/logo-banner.d.ts +7 -0
- package/dist/adapters/cli/tui/components/logo-banner.js +33 -0
- package/dist/adapters/cli/tui/components/markdown.d.ts +9 -0
- package/dist/adapters/cli/tui/components/markdown.js +92 -0
- package/dist/adapters/cli/tui/components/message-area.d.ts +19 -0
- package/dist/adapters/cli/tui/components/message-area.js +55 -0
- package/dist/adapters/cli/tui/components/permission-prompt.d.ts +13 -0
- package/dist/adapters/cli/tui/components/permission-prompt.js +32 -0
- package/dist/adapters/cli/tui/components/prompt-area.d.ts +22 -0
- package/dist/adapters/cli/tui/components/prompt-area.js +68 -0
- package/dist/adapters/cli/tui/components/text-input.d.ts +27 -0
- package/dist/adapters/cli/tui/components/text-input.js +142 -0
- package/dist/adapters/cli/tui/components/tool-call-block.d.ts +11 -0
- package/dist/adapters/cli/tui/components/tool-call-block.js +68 -0
- package/dist/adapters/cli/tui/components/user-message.d.ts +5 -0
- package/dist/adapters/cli/tui/components/user-message.js +8 -0
- package/dist/adapters/cli/tui/diff/file-write-meta.d.ts +11 -0
- package/dist/adapters/cli/tui/diff/file-write-meta.js +11 -0
- package/dist/adapters/cli/tui/diff/line-diff.d.ts +17 -0
- package/dist/adapters/cli/tui/diff/line-diff.js +44 -0
- package/dist/adapters/cli/tui/feed-serializer.d.ts +29 -0
- package/dist/adapters/cli/tui/feed-serializer.js +70 -0
- package/dist/adapters/cli/tui/file-index.d.ts +8 -0
- package/dist/adapters/cli/tui/file-index.js +41 -0
- package/dist/adapters/cli/tui/hooks/use-agent.d.ts +54 -0
- package/dist/adapters/cli/tui/hooks/use-agent.js +177 -0
- package/dist/adapters/cli/tui/hooks/use-feed.d.ts +16 -0
- package/dist/adapters/cli/tui/hooks/use-feed.js +25 -0
- package/dist/adapters/cli/tui/hooks/use-file-watcher.d.ts +10 -0
- package/dist/adapters/cli/tui/hooks/use-file-watcher.js +43 -0
- package/dist/adapters/cli/tui/hooks/use-keybindings.d.ts +16 -0
- package/dist/adapters/cli/tui/hooks/use-keybindings.js +25 -0
- package/dist/adapters/cli/tui/hooks/use-theme.d.ts +8 -0
- package/dist/adapters/cli/tui/hooks/use-theme.js +12 -0
- package/dist/adapters/cli/tui/index.d.ts +19 -0
- package/dist/adapters/cli/tui/index.js +206 -0
- package/dist/adapters/cli/tui/ink-reset.d.ts +29 -0
- package/dist/adapters/cli/tui/ink-reset.js +57 -0
- package/dist/adapters/cli/tui/layout.d.ts +15 -0
- package/dist/adapters/cli/tui/layout.js +15 -0
- package/dist/adapters/cli/tui/logo/gradient.d.ts +11 -0
- package/dist/adapters/cli/tui/logo/gradient.js +31 -0
- package/dist/adapters/cli/tui/overlays/help-dialog.d.ts +4 -0
- package/dist/adapters/cli/tui/overlays/help-dialog.js +26 -0
- package/dist/adapters/cli/tui/overlays/model-selector.d.ts +14 -0
- package/dist/adapters/cli/tui/overlays/model-selector.js +43 -0
- package/dist/adapters/cli/tui/overlays/session-selector.d.ts +35 -0
- package/dist/adapters/cli/tui/overlays/session-selector.js +162 -0
- package/dist/adapters/cli/tui/overlays/settings-overlay.d.ts +24 -0
- package/dist/adapters/cli/tui/overlays/settings-overlay.js +126 -0
- package/dist/adapters/cli/tui/session-export.d.ts +21 -0
- package/dist/adapters/cli/tui/session-export.js +63 -0
- package/dist/adapters/cli/tui/theme.d.ts +23 -0
- package/dist/adapters/cli/tui/theme.js +22 -0
- package/dist/adapters/cli/tui/types.d.ts +52 -0
- package/dist/adapters/cli/tui/types.js +12 -0
- package/dist/adapters/sdk/agent.d.ts +20 -0
- package/dist/adapters/sdk/agent.js +356 -0
- package/dist/adapters/sdk/http.d.ts +43 -0
- package/dist/adapters/sdk/http.js +61 -0
- package/dist/adapters/sdk/index.d.ts +58 -0
- package/dist/adapters/sdk/index.js +209 -0
- package/dist/adapters/sdk/settings.d.ts +18 -0
- package/dist/adapters/sdk/settings.js +57 -0
- package/dist/adapters/sdk/tools.d.ts +7 -0
- package/dist/adapters/sdk/tools.js +13 -0
- package/dist/adapters/server/auth.d.ts +53 -0
- package/dist/adapters/server/auth.js +168 -0
- package/dist/adapters/server/index.d.ts +40 -0
- package/dist/adapters/server/index.js +255 -0
- package/dist/adapters/server/rest-gateway.d.ts +13 -0
- package/dist/adapters/server/rest-gateway.js +218 -0
- package/dist/adapters/server/rest.d.ts +37 -0
- package/dist/adapters/server/rest.js +341 -0
- package/dist/adapters/server/server-core.d.ts +55 -0
- package/dist/adapters/server/server-core.js +121 -0
- package/dist/adapters/server/session-store.d.ts +81 -0
- package/dist/adapters/server/session-store.js +272 -0
- package/dist/adapters/server/settings-handlers.d.ts +24 -0
- package/dist/adapters/server/settings-handlers.js +360 -0
- package/dist/adapters/server/standalone.d.ts +19 -0
- package/dist/adapters/server/standalone.js +113 -0
- package/dist/adapters/server/websocket.d.ts +26 -0
- package/dist/adapters/server/websocket.js +68 -0
- package/dist/adapters/server/ws-handlers.d.ts +32 -0
- package/dist/adapters/server/ws-handlers.js +523 -0
- package/dist/adapters/server/ws-types.d.ts +304 -0
- package/dist/adapters/server/ws-types.js +7 -0
- package/dist/core/agent-loop.d.ts +68 -0
- package/dist/core/agent-loop.js +423 -0
- package/dist/core/config.d.ts +115 -0
- package/dist/core/config.js +189 -0
- package/dist/core/errors.d.ts +58 -0
- package/dist/core/errors.js +88 -0
- package/dist/core/hooks.d.ts +35 -0
- package/dist/core/hooks.js +49 -0
- package/dist/core/index.d.ts +23 -0
- package/dist/core/index.js +29 -0
- package/dist/core/message-convert.d.ts +41 -0
- package/dist/core/message-convert.js +94 -0
- package/dist/core/middleware/auth.d.ts +24 -0
- package/dist/core/middleware/auth.js +28 -0
- package/dist/core/middleware/logging.d.ts +23 -0
- package/dist/core/middleware/logging.js +28 -0
- package/dist/core/middleware/rate-limit.d.ts +27 -0
- package/dist/core/middleware/rate-limit.js +38 -0
- package/dist/core/middleware/semantic-tools.d.ts +10 -0
- package/dist/core/middleware/semantic-tools.js +43 -0
- package/dist/core/middleware.d.ts +48 -0
- package/dist/core/middleware.js +38 -0
- package/dist/core/permission.d.ts +25 -0
- package/dist/core/permission.js +50 -0
- package/dist/core/provider-config.d.ts +129 -0
- package/dist/core/provider-config.js +273 -0
- package/dist/core/provider-env.d.ts +39 -0
- package/dist/core/provider-env.js +142 -0
- package/dist/core/provider-resolver.d.ts +12 -0
- package/dist/core/provider-resolver.js +12 -0
- package/dist/core/session-store.d.ts +75 -0
- package/dist/core/session-store.js +245 -0
- package/dist/core/settings-manager.d.ts +57 -0
- package/dist/core/settings-manager.js +359 -0
- package/dist/core/settings-schema.d.ts +38 -0
- package/dist/core/settings-schema.js +171 -0
- package/dist/core/skill-catalog.d.ts +6 -0
- package/dist/core/skill-catalog.js +17 -0
- package/dist/core/skill-invoker.d.ts +127 -0
- package/dist/core/skill-invoker.js +182 -0
- package/dist/core/stream-accumulator.d.ts +21 -0
- package/dist/core/stream-accumulator.js +51 -0
- package/dist/core/stream-manager.d.ts +58 -0
- package/dist/core/stream-manager.js +212 -0
- package/dist/core/tool-executor.d.ts +84 -0
- package/dist/core/tool-executor.js +256 -0
- package/dist/core/types.d.ts +259 -0
- package/dist/core/types.js +11 -0
- package/dist/gateway/gateway.d.ts +52 -0
- package/dist/gateway/gateway.js +537 -0
- package/dist/gateway/index.d.ts +21 -0
- package/dist/gateway/index.js +31 -0
- package/dist/gateway/openapi-importer.d.ts +15 -0
- package/dist/gateway/openapi-importer.js +66 -0
- package/dist/gateway/semantic-scorer.d.ts +7 -0
- package/dist/gateway/semantic-scorer.js +24 -0
- package/dist/gateway/settings-adapter.d.ts +49 -0
- package/dist/gateway/settings-adapter.js +137 -0
- package/dist/gateway/tool-factory.d.ts +9 -0
- package/dist/gateway/tool-factory.js +414 -0
- package/dist/gateway/types.d.ts +68 -0
- package/dist/gateway/types.js +7 -0
- package/dist/models-catalog.js +46 -0
- package/dist/providers/anthropic.d.ts +22 -0
- package/dist/providers/anthropic.js +148 -0
- package/dist/providers/factory.d.ts +10 -0
- package/dist/providers/factory.js +25 -0
- package/dist/providers/openai.d.ts +15 -0
- package/dist/providers/openai.js +71 -0
- package/dist/providers/types.d.ts +48 -0
- package/dist/providers/types.js +1 -0
- package/dist/skills/args.d.ts +37 -0
- package/dist/skills/args.js +99 -0
- package/dist/skills/index.d.ts +11 -0
- package/dist/skills/index.js +23 -0
- package/dist/skills/loader.d.ts +3 -0
- package/dist/skills/loader.js +59 -0
- package/dist/skills/parser.d.ts +7 -0
- package/dist/skills/parser.js +152 -0
- package/dist/skills/registry.d.ts +13 -0
- package/dist/skills/registry.js +74 -0
- package/dist/skills/resolver.d.ts +19 -0
- package/dist/skills/resolver.js +116 -0
- package/dist/skills/types.d.ts +74 -0
- package/dist/skills/types.js +50 -0
- package/dist/tools/browser.d.ts +2 -0
- package/dist/tools/browser.js +68 -0
- package/dist/tools/core.d.ts +20 -0
- package/dist/tools/core.js +244 -0
- package/dist/tools/email.d.ts +2 -0
- package/dist/tools/email.js +61 -0
- package/dist/tools/image.d.ts +2 -0
- package/dist/tools/image.js +257 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +88 -0
- package/dist/tools/interface.d.ts +22 -0
- package/dist/tools/interface.js +1 -0
- package/dist/tools/notify.d.ts +2 -0
- package/dist/tools/notify.js +100 -0
- package/dist/tools/prompt-optimizer.d.ts +2 -0
- package/dist/tools/prompt-optimizer.js +65 -0
- package/dist/tools/screenshot.d.ts +2 -0
- package/dist/tools/screenshot.js +184 -0
- package/dist/tools/search.d.ts +2 -0
- package/dist/tools/search.js +78 -0
- package/dist/tools/todos.d.ts +10 -0
- package/dist/tools/todos.js +50 -0
- package/package.json +119 -0
- package/skills/docker-ops/SKILL.md +329 -0
- package/skills/k8s-deploy/SKILL.md +397 -0
- package/skills/log-analyzer/SKILL.md +331 -0
- package/skills/speckit-analyze/SKILL.md +260 -0
- package/skills/speckit-checklist/SKILL.md +374 -0
- package/skills/speckit-clarify/SKILL.md +286 -0
- package/skills/speckit-constitution/SKILL.md +157 -0
- package/skills/speckit-implement/SKILL.md +224 -0
- package/skills/speckit-plan/SKILL.md +171 -0
- package/skills/speckit-specify/SKILL.md +346 -0
- package/skills/speckit-tasks/SKILL.md +215 -0
- package/skills/speckit-taskstoissues/SKILL.md +107 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zoe SDK — Session persistence
|
|
3
|
+
*
|
|
4
|
+
* Provides composable persistence backends for storing conversation history.
|
|
5
|
+
* Built-in "file" and "memory" backends are registered by default. Custom
|
|
6
|
+
* backends (Redis, SQLite, etc.) can be registered via `registerBackend()`.
|
|
7
|
+
*
|
|
8
|
+
* Legacy `SessionStore`-based API is preserved for backward compatibility.
|
|
9
|
+
*/
|
|
10
|
+
import { promises as fs } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
// ── Session ID validation ───────────────────────────────────────────────
|
|
14
|
+
const SESSION_ID_RE = /^[a-zA-Z0-9-]+$/;
|
|
15
|
+
function validateSessionId(sessionId) {
|
|
16
|
+
if (!SESSION_ID_RE.test(sessionId)) {
|
|
17
|
+
throw new Error(`Invalid session ID "${sessionId}". Only alphanumeric characters and dashes are allowed.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// ── Default path ────────────────────────────────────────────────────────
|
|
21
|
+
function defaultSessionPath() {
|
|
22
|
+
return join(homedir(), ".zoe", "sessions");
|
|
23
|
+
}
|
|
24
|
+
// ── File-based PersistenceBackend ───────────────────────────────────────
|
|
25
|
+
/**
|
|
26
|
+
* File-backed persistence backend. Each session is stored as a JSON file
|
|
27
|
+
* at `{basePath}/{sessionId}.json`.
|
|
28
|
+
*/
|
|
29
|
+
export class FilePersistenceBackend {
|
|
30
|
+
__persistenceBackend = true;
|
|
31
|
+
basePath;
|
|
32
|
+
constructor(basePath) {
|
|
33
|
+
this.basePath = basePath;
|
|
34
|
+
}
|
|
35
|
+
filePath(id) {
|
|
36
|
+
return join(this.basePath, `${id}.json`);
|
|
37
|
+
}
|
|
38
|
+
async ensureDir() {
|
|
39
|
+
await fs.mkdir(this.basePath, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
async save(id, data) {
|
|
42
|
+
validateSessionId(id);
|
|
43
|
+
await this.ensureDir();
|
|
44
|
+
const existing = await this.loadFromDisk(id);
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const full = existing
|
|
47
|
+
? {
|
|
48
|
+
id,
|
|
49
|
+
messages: data.messages,
|
|
50
|
+
createdAt: existing.createdAt,
|
|
51
|
+
updatedAt: now,
|
|
52
|
+
provider: data.provider ?? existing.provider,
|
|
53
|
+
model: data.model ?? existing.model,
|
|
54
|
+
metadata: data.metadata ?? existing.metadata,
|
|
55
|
+
}
|
|
56
|
+
: {
|
|
57
|
+
id,
|
|
58
|
+
messages: data.messages,
|
|
59
|
+
createdAt: now,
|
|
60
|
+
updatedAt: now,
|
|
61
|
+
provider: data.provider,
|
|
62
|
+
model: data.model,
|
|
63
|
+
metadata: data.metadata,
|
|
64
|
+
};
|
|
65
|
+
const filePath = this.filePath(id);
|
|
66
|
+
const tmpPath = filePath + ".tmp." + Date.now();
|
|
67
|
+
try {
|
|
68
|
+
await fs.writeFile(tmpPath, JSON.stringify(full, null, 2), "utf-8");
|
|
69
|
+
await fs.rename(tmpPath, filePath);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
// Clean up orphaned temp file on rename failure (e.g. cross-device move)
|
|
73
|
+
try {
|
|
74
|
+
await fs.unlink(tmpPath);
|
|
75
|
+
}
|
|
76
|
+
catch { /* best effort */ }
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async load(id) {
|
|
81
|
+
return this.loadFromDisk(id);
|
|
82
|
+
}
|
|
83
|
+
async delete(id) {
|
|
84
|
+
validateSessionId(id);
|
|
85
|
+
try {
|
|
86
|
+
await fs.unlink(this.filePath(id));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// File doesn't exist — nothing to delete
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async list() {
|
|
93
|
+
await this.ensureDir();
|
|
94
|
+
const entries = await fs.readdir(this.basePath);
|
|
95
|
+
return entries
|
|
96
|
+
.filter((name) => name.endsWith(".json"))
|
|
97
|
+
.map((name) => name.slice(0, -".json".length));
|
|
98
|
+
}
|
|
99
|
+
async loadFromDisk(id) {
|
|
100
|
+
try {
|
|
101
|
+
const raw = await fs.readFile(this.filePath(id), "utf-8");
|
|
102
|
+
return JSON.parse(raw);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// ── In-memory PersistenceBackend ────────────────────────────────────────
|
|
110
|
+
/**
|
|
111
|
+
* In-memory persistence backend backed by a Map. Useful for testing.
|
|
112
|
+
*/
|
|
113
|
+
export class MemoryPersistenceBackend {
|
|
114
|
+
__persistenceBackend = true;
|
|
115
|
+
store = new Map();
|
|
116
|
+
async save(id, data) {
|
|
117
|
+
validateSessionId(id);
|
|
118
|
+
const existing = this.store.get(id);
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
this.store.set(id, {
|
|
121
|
+
id,
|
|
122
|
+
messages: data.messages,
|
|
123
|
+
createdAt: existing?.createdAt ?? now,
|
|
124
|
+
updatedAt: now,
|
|
125
|
+
provider: data.provider ?? existing?.provider,
|
|
126
|
+
model: data.model ?? existing?.model,
|
|
127
|
+
metadata: data.metadata ?? existing?.metadata,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async load(id) {
|
|
131
|
+
return this.store.get(id) ?? null;
|
|
132
|
+
}
|
|
133
|
+
async delete(id) {
|
|
134
|
+
this.store.delete(id);
|
|
135
|
+
}
|
|
136
|
+
async list() {
|
|
137
|
+
return Array.from(this.store.keys());
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const registry = new Map();
|
|
141
|
+
// Register built-in backends
|
|
142
|
+
registry.set("file", (config) => new FilePersistenceBackend(config.path ?? defaultSessionPath()));
|
|
143
|
+
registry.set("memory", () => new MemoryPersistenceBackend());
|
|
144
|
+
/**
|
|
145
|
+
* Register a custom persistence backend factory.
|
|
146
|
+
*
|
|
147
|
+
* @param type Unique backend identifier (e.g., "redis", "sqlite")
|
|
148
|
+
* @param factory Factory function that creates a `PersistenceBackend` from config
|
|
149
|
+
*/
|
|
150
|
+
export function registerBackend(type, factory) {
|
|
151
|
+
registry.set(type, factory);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Create a persistence backend from a config object.
|
|
155
|
+
* Uses the `type` field to look up the registered factory.
|
|
156
|
+
*
|
|
157
|
+
* @throws Error if `type` is not registered
|
|
158
|
+
*/
|
|
159
|
+
export function createPersistenceBackend(config) {
|
|
160
|
+
const factory = registry.get(config.type);
|
|
161
|
+
if (!factory) {
|
|
162
|
+
throw new Error(`Unknown persistence backend type "${config.type}". Registered types: ${Array.from(registry.keys()).join(", ")}`);
|
|
163
|
+
}
|
|
164
|
+
return factory(config);
|
|
165
|
+
}
|
|
166
|
+
// ── Save orchestration ──────────────────────────────────────────────────
|
|
167
|
+
/**
|
|
168
|
+
* Persist a session's messages to the backend.
|
|
169
|
+
*
|
|
170
|
+
* Single source of truth for the save step shared by all adapters (SDK, CLI,
|
|
171
|
+
* Server). The backend owns `createdAt` (assigns it on first save, preserves
|
|
172
|
+
* it on overwrite — see FilePersistenceBackend / MemoryPersistenceBackend) and
|
|
173
|
+
* merges optional `provider`/`model`/`metadata` fields, so callers only pass
|
|
174
|
+
* what they know. Adapters that don't track provider/model (the SDK) omit them
|
|
175
|
+
* and the persisted values are left untouched.
|
|
176
|
+
*/
|
|
177
|
+
export async function persistSession(backend, sessionId, messages, opts) {
|
|
178
|
+
await backend.save(sessionId, {
|
|
179
|
+
id: sessionId,
|
|
180
|
+
messages,
|
|
181
|
+
// createdAt is required by the SessionData type but ignored by the
|
|
182
|
+
// backends — they assign it on first save and preserve it on overwrite.
|
|
183
|
+
updatedAt: Date.now(),
|
|
184
|
+
provider: opts?.provider,
|
|
185
|
+
model: opts?.model,
|
|
186
|
+
metadata: opts?.metadata,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// ── Deprecated legacy API ───────────────────────────────────────────────
|
|
190
|
+
/**
|
|
191
|
+
* @deprecated Use `FilePersistenceBackend` or `createPersistenceBackend({ type: "file", path })` instead.
|
|
192
|
+
*/
|
|
193
|
+
class FileSessionStore {
|
|
194
|
+
backend;
|
|
195
|
+
constructor(basePath) {
|
|
196
|
+
this.backend = new FilePersistenceBackend(basePath);
|
|
197
|
+
}
|
|
198
|
+
async save(sessionId, messages) {
|
|
199
|
+
await this.backend.save(sessionId, { id: sessionId, messages, createdAt: Date.now(), updatedAt: Date.now() });
|
|
200
|
+
}
|
|
201
|
+
async load(sessionId) {
|
|
202
|
+
const data = await this.backend.load(sessionId);
|
|
203
|
+
return data?.messages ?? null;
|
|
204
|
+
}
|
|
205
|
+
async delete(sessionId) {
|
|
206
|
+
await this.backend.delete(sessionId);
|
|
207
|
+
}
|
|
208
|
+
async list() {
|
|
209
|
+
return this.backend.list();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* @deprecated Use `MemoryPersistenceBackend` or `createPersistenceBackend({ type: "memory" })` instead.
|
|
214
|
+
*/
|
|
215
|
+
class MemorySessionStore {
|
|
216
|
+
backend;
|
|
217
|
+
constructor() {
|
|
218
|
+
this.backend = new MemoryPersistenceBackend();
|
|
219
|
+
}
|
|
220
|
+
async save(sessionId, messages) {
|
|
221
|
+
await this.backend.save(sessionId, { id: sessionId, messages, createdAt: Date.now(), updatedAt: Date.now() });
|
|
222
|
+
}
|
|
223
|
+
async load(sessionId) {
|
|
224
|
+
const data = await this.backend.load(sessionId);
|
|
225
|
+
return data?.messages ?? null;
|
|
226
|
+
}
|
|
227
|
+
async delete(sessionId) {
|
|
228
|
+
await this.backend.delete(sessionId);
|
|
229
|
+
}
|
|
230
|
+
async list() {
|
|
231
|
+
return this.backend.list();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* @deprecated Use `createPersistenceBackend({ type: "file", path })` instead.
|
|
236
|
+
*/
|
|
237
|
+
export function createSessionStore(path) {
|
|
238
|
+
return new FileSessionStore(path ?? defaultSessionPath());
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* @deprecated Use `createPersistenceBackend({ type: "memory" })` instead.
|
|
242
|
+
*/
|
|
243
|
+
export function createMemoryStore() {
|
|
244
|
+
return new MemorySessionStore();
|
|
245
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zoe Core — Settings Manager
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for reading, writing, validating, and persisting
|
|
5
|
+
* settings. Adapters (CLI, SDK, Server) delegate to this class.
|
|
6
|
+
*/
|
|
7
|
+
import { ZoeError } from './errors.js';
|
|
8
|
+
import { SettingsCategory } from './settings-schema.js';
|
|
9
|
+
export declare class SettingsError extends ZoeError {
|
|
10
|
+
constructor(message: string, code?: string);
|
|
11
|
+
}
|
|
12
|
+
export interface SettingValue {
|
|
13
|
+
value: unknown;
|
|
14
|
+
origin: string;
|
|
15
|
+
masked: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface SettingEntry extends SettingValue {
|
|
18
|
+
dotKey: string;
|
|
19
|
+
category: SettingsCategory;
|
|
20
|
+
restartRequired: boolean;
|
|
21
|
+
label: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SettingsManagerOptions {
|
|
24
|
+
config: Record<string, any>;
|
|
25
|
+
projectConfigPath?: string;
|
|
26
|
+
globalConfigPath?: string;
|
|
27
|
+
projectConfig?: Record<string, any>;
|
|
28
|
+
globalConfig?: Record<string, any>;
|
|
29
|
+
}
|
|
30
|
+
export declare class SettingsManager {
|
|
31
|
+
private config;
|
|
32
|
+
private projectConfigPath?;
|
|
33
|
+
private globalConfigPath?;
|
|
34
|
+
private projectConfig;
|
|
35
|
+
private globalConfig;
|
|
36
|
+
private listeners;
|
|
37
|
+
constructor(options: SettingsManagerOptions);
|
|
38
|
+
get(dotKey: string): SettingValue;
|
|
39
|
+
list(): SettingEntry[];
|
|
40
|
+
listByCategory(): Record<string, SettingEntry[]>;
|
|
41
|
+
set(dotKey: string, rawValue: string): Promise<void>;
|
|
42
|
+
reset(dotKey: string): Promise<void>;
|
|
43
|
+
resetAll(): Promise<void>;
|
|
44
|
+
onChange(callback: (changedKeys: string[]) => void): () => void;
|
|
45
|
+
private emitChange;
|
|
46
|
+
private validateValue;
|
|
47
|
+
private resolveConfigOrigin;
|
|
48
|
+
private parseEnvValue;
|
|
49
|
+
private resolveWriteTarget;
|
|
50
|
+
private readConfigFile;
|
|
51
|
+
private persist;
|
|
52
|
+
private getValueByPath;
|
|
53
|
+
private applyValueToConfig;
|
|
54
|
+
private removeValueFromConfig;
|
|
55
|
+
private hasPath;
|
|
56
|
+
private maskValue;
|
|
57
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zoe Core — Settings Manager
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for reading, writing, validating, and persisting
|
|
5
|
+
* settings. Adapters (CLI, SDK, Server) delegate to this class.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'fs/promises';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { ZoeError } from './errors.js';
|
|
10
|
+
import { SETTINGS_MAP, SETTINGS_SCHEMA, ENV_VAR_MAP, isSecretField, } from './settings-schema.js';
|
|
11
|
+
// ── Settings Error ────────────────────────────────────────────────────────
|
|
12
|
+
export class SettingsError extends ZoeError {
|
|
13
|
+
constructor(message, code = 'SETTINGS_ERROR') {
|
|
14
|
+
super(message, code, false);
|
|
15
|
+
this.name = 'SettingsError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// ── SettingsManager ───────────────────────────────────────────────────────
|
|
19
|
+
export class SettingsManager {
|
|
20
|
+
config;
|
|
21
|
+
projectConfigPath;
|
|
22
|
+
globalConfigPath;
|
|
23
|
+
projectConfig;
|
|
24
|
+
globalConfig;
|
|
25
|
+
listeners = [];
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.config = { ...options.config };
|
|
28
|
+
this.projectConfigPath = options.projectConfigPath;
|
|
29
|
+
this.globalConfigPath = options.globalConfigPath;
|
|
30
|
+
this.projectConfig = options.projectConfig ?? {};
|
|
31
|
+
this.globalConfig = options.globalConfig ?? {};
|
|
32
|
+
}
|
|
33
|
+
// ── Read ───────────────────────────────────────────────────────────────
|
|
34
|
+
get(dotKey) {
|
|
35
|
+
const entry = SETTINGS_MAP.get(dotKey);
|
|
36
|
+
if (!entry) {
|
|
37
|
+
throw new SettingsError(`Unknown setting: ${dotKey}. Use /settings list to see available keys.`, 'SETTINGS_INVALID_KEY');
|
|
38
|
+
}
|
|
39
|
+
const schema = SETTINGS_SCHEMA.get(dotKey);
|
|
40
|
+
const secret = isSecretField(dotKey);
|
|
41
|
+
// Check env var first (empty string = not explicitly set → fall through to default)
|
|
42
|
+
const envVar = ENV_VAR_MAP.get(dotKey);
|
|
43
|
+
if (envVar && process.env[envVar]) {
|
|
44
|
+
const raw = this.parseEnvValue(process.env[envVar], schema);
|
|
45
|
+
const value = secret && raw != null ? this.maskValue(String(raw)) : raw;
|
|
46
|
+
return { value, origin: `env: ${envVar}`, masked: secret };
|
|
47
|
+
}
|
|
48
|
+
// Check config, falling back to schema default
|
|
49
|
+
const raw = this.getValueByPath(this.config, entry.configPath);
|
|
50
|
+
const effectiveValue = raw ?? schema?.default;
|
|
51
|
+
const value = secret && effectiveValue != null ? this.maskValue(String(effectiveValue)) : effectiveValue;
|
|
52
|
+
const origin = raw !== undefined && raw !== null
|
|
53
|
+
? this.resolveConfigOrigin(entry.configPath)
|
|
54
|
+
: schema?.default !== undefined ? 'default' : 'default';
|
|
55
|
+
return { value, origin, masked: secret };
|
|
56
|
+
}
|
|
57
|
+
list() {
|
|
58
|
+
const results = [];
|
|
59
|
+
for (const [dotKey, mapEntry] of SETTINGS_MAP) {
|
|
60
|
+
const schema = SETTINGS_SCHEMA.get(dotKey);
|
|
61
|
+
const { value, origin, masked } = this.get(dotKey);
|
|
62
|
+
results.push({
|
|
63
|
+
dotKey,
|
|
64
|
+
value,
|
|
65
|
+
origin,
|
|
66
|
+
masked,
|
|
67
|
+
category: mapEntry.category,
|
|
68
|
+
restartRequired: schema?.restartRequired ?? false,
|
|
69
|
+
label: mapEntry.label,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
listByCategory() {
|
|
75
|
+
const result = {};
|
|
76
|
+
for (const entry of this.list()) {
|
|
77
|
+
if (!result[entry.category])
|
|
78
|
+
result[entry.category] = [];
|
|
79
|
+
result[entry.category].push(entry);
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
// ── Write ──────────────────────────────────────────────────────────────
|
|
84
|
+
async set(dotKey, rawValue) {
|
|
85
|
+
const mapEntry = SETTINGS_MAP.get(dotKey);
|
|
86
|
+
if (!mapEntry) {
|
|
87
|
+
throw new SettingsError(`Unknown setting: ${dotKey}. Use /settings list to see available keys.`, 'SETTINGS_INVALID_KEY');
|
|
88
|
+
}
|
|
89
|
+
const schema = SETTINGS_SCHEMA.get(dotKey);
|
|
90
|
+
const value = this.validateValue(dotKey, rawValue, schema);
|
|
91
|
+
// Determine write target
|
|
92
|
+
const writePath = this.resolveWriteTarget(dotKey);
|
|
93
|
+
if (!writePath) {
|
|
94
|
+
throw new SettingsError('No config file path available for writing.', 'SETTINGS_WRITE_FAILED');
|
|
95
|
+
}
|
|
96
|
+
// Check env var override
|
|
97
|
+
const envVar = ENV_VAR_MAP.get(dotKey);
|
|
98
|
+
if (envVar && process.env[envVar]) {
|
|
99
|
+
console.warn(`Note: This key is overridden by env var ${envVar}. Saving to config. The env var takes precedence until unset.`);
|
|
100
|
+
}
|
|
101
|
+
// Read current file, apply change, write
|
|
102
|
+
const fileConfig = await this.readConfigFile(writePath);
|
|
103
|
+
this.applyValueToConfig(fileConfig, mapEntry.configPath, value);
|
|
104
|
+
await this.persist(writePath, fileConfig);
|
|
105
|
+
// Update in-memory
|
|
106
|
+
this.applyValueToConfig(this.config, mapEntry.configPath, value);
|
|
107
|
+
this.emitChange([dotKey]);
|
|
108
|
+
}
|
|
109
|
+
async reset(dotKey) {
|
|
110
|
+
const mapEntry = SETTINGS_MAP.get(dotKey);
|
|
111
|
+
if (!mapEntry) {
|
|
112
|
+
throw new SettingsError(`Unknown setting: ${dotKey}. Use /settings list to see available keys.`, 'SETTINGS_INVALID_KEY');
|
|
113
|
+
}
|
|
114
|
+
const envVar = ENV_VAR_MAP.get(dotKey);
|
|
115
|
+
if (envVar && process.env[envVar]) {
|
|
116
|
+
throw new SettingsError(`Cannot reset: this value is set by env var ${envVar}. Unset the environment variable to use the default.`, 'SETTINGS_WRITE_FAILED');
|
|
117
|
+
}
|
|
118
|
+
const writePath = this.resolveWriteTarget(dotKey);
|
|
119
|
+
if (!writePath) {
|
|
120
|
+
throw new SettingsError('No config file path available for writing.', 'SETTINGS_WRITE_FAILED');
|
|
121
|
+
}
|
|
122
|
+
const fileConfig = await this.readConfigFile(writePath);
|
|
123
|
+
this.removeValueFromConfig(fileConfig, mapEntry.configPath);
|
|
124
|
+
await this.persist(writePath, fileConfig);
|
|
125
|
+
// Reset in-memory to default
|
|
126
|
+
const schema = SETTINGS_SCHEMA.get(dotKey);
|
|
127
|
+
if (schema?.default !== undefined) {
|
|
128
|
+
this.applyValueToConfig(this.config, mapEntry.configPath, schema.default);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
this.removeValueFromConfig(this.config, mapEntry.configPath);
|
|
132
|
+
}
|
|
133
|
+
this.emitChange([dotKey]);
|
|
134
|
+
}
|
|
135
|
+
async resetAll() {
|
|
136
|
+
const writePath = this.projectConfigPath ?? this.globalConfigPath;
|
|
137
|
+
if (!writePath)
|
|
138
|
+
return;
|
|
139
|
+
await this.persist(writePath, {});
|
|
140
|
+
// Rebuild config from env vars (preserving env overrides)
|
|
141
|
+
const rebuilt = {};
|
|
142
|
+
for (const [dotKey, envVar] of ENV_VAR_MAP) {
|
|
143
|
+
const val = process.env[envVar];
|
|
144
|
+
if (val != null) {
|
|
145
|
+
const entry = SETTINGS_MAP.get(dotKey);
|
|
146
|
+
if (entry)
|
|
147
|
+
this.applyValueToConfig(rebuilt, entry.configPath, val);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
this.config = rebuilt;
|
|
151
|
+
this.emitChange(['*']);
|
|
152
|
+
}
|
|
153
|
+
// ── Events ─────────────────────────────────────────────────────────────
|
|
154
|
+
onChange(callback) {
|
|
155
|
+
this.listeners.push(callback);
|
|
156
|
+
return () => {
|
|
157
|
+
this.listeners = this.listeners.filter(l => l !== callback);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// ── Internals ──────────────────────────────────────────────────────────
|
|
161
|
+
emitChange(changedKeys) {
|
|
162
|
+
for (const listener of this.listeners) {
|
|
163
|
+
try {
|
|
164
|
+
listener(changedKeys);
|
|
165
|
+
}
|
|
166
|
+
catch { /* non-fatal */ }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
validateValue(dotKey, raw, schema) {
|
|
170
|
+
if (!schema)
|
|
171
|
+
return raw;
|
|
172
|
+
switch (schema.type) {
|
|
173
|
+
case 'number': {
|
|
174
|
+
const num = Number(raw);
|
|
175
|
+
if (isNaN(num)) {
|
|
176
|
+
throw new SettingsError(`${dotKey} must be a number. Got: "${raw}"`, 'SETTINGS_VALIDATION_FAILED');
|
|
177
|
+
}
|
|
178
|
+
if (schema.min !== undefined && num < schema.min) {
|
|
179
|
+
throw new SettingsError(`${dotKey} must be >= ${schema.min}. Got: ${num}`, 'SETTINGS_VALIDATION_FAILED');
|
|
180
|
+
}
|
|
181
|
+
if (schema.max !== undefined && num > schema.max) {
|
|
182
|
+
throw new SettingsError(`${dotKey} must be <= ${schema.max}. Got: ${num}`, 'SETTINGS_VALIDATION_FAILED');
|
|
183
|
+
}
|
|
184
|
+
return num;
|
|
185
|
+
}
|
|
186
|
+
case 'boolean': {
|
|
187
|
+
const lower = raw.toLowerCase();
|
|
188
|
+
if (lower === 'true' || lower === '1')
|
|
189
|
+
return true;
|
|
190
|
+
if (lower === 'false' || lower === '0')
|
|
191
|
+
return false;
|
|
192
|
+
throw new SettingsError(`${dotKey} must be true or false. Got: "${raw}"`, 'SETTINGS_VALIDATION_FAILED');
|
|
193
|
+
}
|
|
194
|
+
case 'enum': {
|
|
195
|
+
if (!schema.enumValues?.includes(raw)) {
|
|
196
|
+
throw new SettingsError(`${dotKey} must be one of: ${schema.enumValues?.join(', ')}. Got: "${raw}"`, 'SETTINGS_VALIDATION_FAILED');
|
|
197
|
+
}
|
|
198
|
+
return raw;
|
|
199
|
+
}
|
|
200
|
+
case 'string': {
|
|
201
|
+
// URL validation for baseUrl/webhook fields
|
|
202
|
+
if (dotKey.includes('baseUrl') || dotKey.includes('.webhook')) {
|
|
203
|
+
try {
|
|
204
|
+
const parsed = new URL(raw);
|
|
205
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
206
|
+
throw new SettingsError(`${dotKey} must be a valid URL starting with http:// or https://.`, 'SETTINGS_VALIDATION_FAILED');
|
|
207
|
+
}
|
|
208
|
+
if (parsed.username || parsed.password) {
|
|
209
|
+
throw new SettingsError(`${dotKey} must not contain embedded credentials.`, 'SETTINGS_VALIDATION_FAILED');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
if (e instanceof SettingsError)
|
|
214
|
+
throw e;
|
|
215
|
+
throw new SettingsError(`${dotKey} must be a valid URL starting with http:// or https://.`, 'SETTINGS_VALIDATION_FAILED');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Hostname validation
|
|
219
|
+
if (dotKey === 'smtp.host') {
|
|
220
|
+
if (!/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(raw)) {
|
|
221
|
+
throw new SettingsError(`${dotKey} must be a valid hostname.`, 'SETTINGS_VALIDATION_FAILED');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return raw;
|
|
225
|
+
}
|
|
226
|
+
default:
|
|
227
|
+
return raw;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
resolveConfigOrigin(configPath) {
|
|
231
|
+
if (this.hasPath(this.projectConfig, configPath))
|
|
232
|
+
return 'project config';
|
|
233
|
+
if (this.hasPath(this.globalConfig, configPath))
|
|
234
|
+
return 'global config';
|
|
235
|
+
return 'default';
|
|
236
|
+
}
|
|
237
|
+
parseEnvValue(raw, schema) {
|
|
238
|
+
const val = raw ?? '';
|
|
239
|
+
if (!schema)
|
|
240
|
+
return val;
|
|
241
|
+
switch (schema.type) {
|
|
242
|
+
case 'boolean':
|
|
243
|
+
return val.toLowerCase() === 'true' || val === '1';
|
|
244
|
+
case 'number':
|
|
245
|
+
return Number(val);
|
|
246
|
+
default:
|
|
247
|
+
return val;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
resolveWriteTarget(dotKey) {
|
|
251
|
+
const envVar = ENV_VAR_MAP.get(dotKey);
|
|
252
|
+
if (envVar && process.env[envVar]) {
|
|
253
|
+
// Write to project config when env var overrides
|
|
254
|
+
return this.projectConfigPath ?? this.globalConfigPath;
|
|
255
|
+
}
|
|
256
|
+
// Write to whichever config file currently owns the key
|
|
257
|
+
const entry = SETTINGS_MAP.get(dotKey);
|
|
258
|
+
if (!entry)
|
|
259
|
+
return this.projectConfigPath ?? this.globalConfigPath;
|
|
260
|
+
if (this.hasPath(this.projectConfig, entry.configPath) && this.projectConfigPath) {
|
|
261
|
+
return this.projectConfigPath;
|
|
262
|
+
}
|
|
263
|
+
if (this.hasPath(this.globalConfig, entry.configPath) && this.globalConfigPath) {
|
|
264
|
+
return this.globalConfigPath;
|
|
265
|
+
}
|
|
266
|
+
// New key — prefer project config if it exists
|
|
267
|
+
return this.projectConfigPath ?? this.globalConfigPath;
|
|
268
|
+
}
|
|
269
|
+
async readConfigFile(filePath) {
|
|
270
|
+
try {
|
|
271
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
272
|
+
return JSON.parse(content);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return {};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async persist(configPath, data) {
|
|
279
|
+
try {
|
|
280
|
+
const dir = path.dirname(configPath);
|
|
281
|
+
await fs.mkdir(dir, { recursive: true });
|
|
282
|
+
// Backup existing file
|
|
283
|
+
try {
|
|
284
|
+
await fs.rename(configPath, configPath + '.bak');
|
|
285
|
+
}
|
|
286
|
+
catch { /* no existing file to back up */ }
|
|
287
|
+
// Atomic write via temp file
|
|
288
|
+
const tmpPath = configPath + `.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
289
|
+
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
290
|
+
await fs.rename(tmpPath, configPath);
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
console.warn(`Warning: could not save to ${configPath}: ${e.message}. Change applied in-memory only.`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
getValueByPath(obj, pathParts) {
|
|
297
|
+
let current = obj;
|
|
298
|
+
for (const part of pathParts) {
|
|
299
|
+
if (current == null || typeof current !== 'object')
|
|
300
|
+
return undefined;
|
|
301
|
+
current = current[part];
|
|
302
|
+
}
|
|
303
|
+
return current;
|
|
304
|
+
}
|
|
305
|
+
applyValueToConfig(obj, pathParts, value) {
|
|
306
|
+
if (pathParts.length === 0)
|
|
307
|
+
return;
|
|
308
|
+
// Deep merge for models map
|
|
309
|
+
if (pathParts[0] === 'models' && pathParts.length === 3) {
|
|
310
|
+
if (!obj.models)
|
|
311
|
+
obj.models = {};
|
|
312
|
+
const provider = pathParts[1];
|
|
313
|
+
if (!obj.models[provider])
|
|
314
|
+
obj.models[provider] = {};
|
|
315
|
+
obj.models[provider][pathParts[2]] = value;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// Standard path
|
|
319
|
+
let current = obj;
|
|
320
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
321
|
+
if (current[pathParts[i]] == null || typeof current[pathParts[i]] !== 'object') {
|
|
322
|
+
current[pathParts[i]] = {};
|
|
323
|
+
}
|
|
324
|
+
current = current[pathParts[i]];
|
|
325
|
+
}
|
|
326
|
+
current[pathParts[pathParts.length - 1]] = value;
|
|
327
|
+
}
|
|
328
|
+
removeValueFromConfig(obj, pathParts) {
|
|
329
|
+
if (pathParts.length === 0)
|
|
330
|
+
return;
|
|
331
|
+
if (pathParts.length === 1) {
|
|
332
|
+
delete obj[pathParts[0]];
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
let current = obj;
|
|
336
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
337
|
+
if (current[pathParts[i]] == null)
|
|
338
|
+
return;
|
|
339
|
+
current = current[pathParts[i]];
|
|
340
|
+
}
|
|
341
|
+
delete current[pathParts[pathParts.length - 1]];
|
|
342
|
+
}
|
|
343
|
+
hasPath(obj, pathParts) {
|
|
344
|
+
let current = obj;
|
|
345
|
+
for (const part of pathParts) {
|
|
346
|
+
if (current == null || typeof current !== 'object')
|
|
347
|
+
return false;
|
|
348
|
+
if (!(part in current))
|
|
349
|
+
return false;
|
|
350
|
+
current = current[part];
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
maskValue(value) {
|
|
355
|
+
if (!value || value.length < 8)
|
|
356
|
+
return '******';
|
|
357
|
+
return `${value.slice(0, 3)}...${value.slice(-4)}`;
|
|
358
|
+
}
|
|
359
|
+
}
|