twinclaw 1.0.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 (132) hide show
  1. package/README.md +66 -0
  2. package/bin/npm-twinclaw.js +17 -0
  3. package/bin/run-twinbot-cli.js +36 -0
  4. package/bin/twinbot.js +4 -0
  5. package/bin/twinclaw.js +4 -0
  6. package/dist/api/handlers/browser.js +160 -0
  7. package/dist/api/handlers/callback.js +80 -0
  8. package/dist/api/handlers/config-validate.js +19 -0
  9. package/dist/api/handlers/health.js +117 -0
  10. package/dist/api/handlers/local-state-backup.js +118 -0
  11. package/dist/api/handlers/persona-state.js +59 -0
  12. package/dist/api/handlers/skill-packages.js +94 -0
  13. package/dist/api/router.js +278 -0
  14. package/dist/api/runtime-event-producer.js +99 -0
  15. package/dist/api/shared.js +82 -0
  16. package/dist/api/websocket-hub.js +305 -0
  17. package/dist/config/config-loader.js +2 -0
  18. package/dist/config/env-schema.js +202 -0
  19. package/dist/config/env-validator.js +223 -0
  20. package/dist/config/identity-bootstrap.js +115 -0
  21. package/dist/config/json-config.js +344 -0
  22. package/dist/config/workspace.js +186 -0
  23. package/dist/core/channels-cli.js +77 -0
  24. package/dist/core/cli.js +119 -0
  25. package/dist/core/context-assembly.js +33 -0
  26. package/dist/core/doctor.js +365 -0
  27. package/dist/core/gateway-cli.js +323 -0
  28. package/dist/core/gateway.js +416 -0
  29. package/dist/core/heartbeat.js +54 -0
  30. package/dist/core/install-cli.js +320 -0
  31. package/dist/core/lane-executor.js +134 -0
  32. package/dist/core/logs-cli.js +70 -0
  33. package/dist/core/onboarding.js +760 -0
  34. package/dist/core/pairing-cli.js +78 -0
  35. package/dist/core/secret-vault-cli.js +204 -0
  36. package/dist/core/types.js +1 -0
  37. package/dist/index.js +404 -0
  38. package/dist/interfaces/dispatcher.js +214 -0
  39. package/dist/interfaces/telegram_handler.js +82 -0
  40. package/dist/interfaces/tui-dashboard.js +53 -0
  41. package/dist/interfaces/whatsapp_handler.js +94 -0
  42. package/dist/release/cli.js +97 -0
  43. package/dist/release/mvp-gate-cli.js +118 -0
  44. package/dist/release/twinbot-config-schema.js +162 -0
  45. package/dist/release/twinclaw-config-schema.js +162 -0
  46. package/dist/services/block-chunker.js +174 -0
  47. package/dist/services/browser-service.js +334 -0
  48. package/dist/services/context-lifecycle.js +314 -0
  49. package/dist/services/db.js +1055 -0
  50. package/dist/services/delivery-tracker.js +110 -0
  51. package/dist/services/dm-pairing.js +245 -0
  52. package/dist/services/embedding-service.js +125 -0
  53. package/dist/services/file-watcher.js +125 -0
  54. package/dist/services/inbound-debounce.js +92 -0
  55. package/dist/services/incident-manager.js +516 -0
  56. package/dist/services/job-scheduler.js +176 -0
  57. package/dist/services/local-state-backup.js +682 -0
  58. package/dist/services/mcp-client-adapter.js +291 -0
  59. package/dist/services/mcp-server-manager.js +143 -0
  60. package/dist/services/model-router.js +927 -0
  61. package/dist/services/mvp-gate.js +845 -0
  62. package/dist/services/orchestration-service.js +422 -0
  63. package/dist/services/persona-state.js +256 -0
  64. package/dist/services/policy-engine.js +92 -0
  65. package/dist/services/proactive-notifier.js +94 -0
  66. package/dist/services/queue-service.js +146 -0
  67. package/dist/services/release-pipeline.js +652 -0
  68. package/dist/services/runtime-budget-governor.js +415 -0
  69. package/dist/services/secret-vault.js +704 -0
  70. package/dist/services/semantic-memory.js +249 -0
  71. package/dist/services/skill-package-manager.js +806 -0
  72. package/dist/services/skill-registry.js +122 -0
  73. package/dist/services/streaming-output.js +75 -0
  74. package/dist/services/stt-service.js +39 -0
  75. package/dist/services/tts-service.js +44 -0
  76. package/dist/skills/builtin.js +250 -0
  77. package/dist/skills/shell.js +87 -0
  78. package/dist/skills/types.js +1 -0
  79. package/dist/types/api.js +1 -0
  80. package/dist/types/context-budget.js +1 -0
  81. package/dist/types/doctor.js +1 -0
  82. package/dist/types/file-watcher.js +1 -0
  83. package/dist/types/incident.js +1 -0
  84. package/dist/types/local-state-backup.js +1 -0
  85. package/dist/types/mcp.js +1 -0
  86. package/dist/types/messaging.js +1 -0
  87. package/dist/types/model-routing.js +1 -0
  88. package/dist/types/mvp-gate.js +2 -0
  89. package/dist/types/orchestration.js +1 -0
  90. package/dist/types/persona-state.js +22 -0
  91. package/dist/types/policy.js +1 -0
  92. package/dist/types/reasoning-graph.js +1 -0
  93. package/dist/types/release.js +1 -0
  94. package/dist/types/reliability.js +1 -0
  95. package/dist/types/runtime-budget.js +1 -0
  96. package/dist/types/scheduler.js +1 -0
  97. package/dist/types/secret-vault.js +1 -0
  98. package/dist/types/skill-packages.js +1 -0
  99. package/dist/types/websocket.js +14 -0
  100. package/dist/utils/logger.js +57 -0
  101. package/dist/utils/retry.js +61 -0
  102. package/dist/utils/secret-scan.js +208 -0
  103. package/mcp-servers.json +179 -0
  104. package/package.json +81 -0
  105. package/skill-packages.json +92 -0
  106. package/skill-packages.lock.json +5 -0
  107. package/src/skills/builtin.ts +275 -0
  108. package/src/skills/shell.ts +118 -0
  109. package/src/skills/types.ts +30 -0
  110. package/src/types/api.ts +252 -0
  111. package/src/types/blessed-contrib.d.ts +4 -0
  112. package/src/types/context-budget.ts +76 -0
  113. package/src/types/doctor.ts +29 -0
  114. package/src/types/file-watcher.ts +26 -0
  115. package/src/types/incident.ts +57 -0
  116. package/src/types/local-state-backup.ts +121 -0
  117. package/src/types/mcp.ts +106 -0
  118. package/src/types/messaging.ts +35 -0
  119. package/src/types/model-routing.ts +61 -0
  120. package/src/types/mvp-gate.ts +99 -0
  121. package/src/types/orchestration.ts +65 -0
  122. package/src/types/persona-state.ts +61 -0
  123. package/src/types/policy.ts +27 -0
  124. package/src/types/reasoning-graph.ts +58 -0
  125. package/src/types/release.ts +115 -0
  126. package/src/types/reliability.ts +43 -0
  127. package/src/types/runtime-budget.ts +85 -0
  128. package/src/types/scheduler.ts +47 -0
  129. package/src/types/secret-vault.ts +62 -0
  130. package/src/types/skill-packages.ts +81 -0
  131. package/src/types/sqlite-vec.d.ts +5 -0
  132. package/src/types/websocket.ts +122 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Unified runtime configuration validator.
3
+ *
4
+ * Produces structured, redaction-safe diagnostics for:
5
+ * - Missing required config keys.
6
+ * - Active features whose conditional keys are absent.
7
+ * - Format/type violations on plain env vars.
8
+ * - A machine-readable summary suitable for API responses and operator tooling.
9
+ *
10
+ * No secret values are ever included in the output.
11
+ */
12
+ import { CONFIG_SCHEMA } from './env-schema.js';
13
+ import { getSecretVaultService } from '../services/secret-vault.js';
14
+ import { getConfigValue } from './config-loader.js';
15
+ // ── Internal helpers ─────────────────────────────────────────────────────────
16
+ /**
17
+ * Resolve a config key's value without leaking it.
18
+ * Returns true when a non-empty value exists (vault → env), false otherwise.
19
+ */
20
+ function hasValue(spec) {
21
+ if (spec.type === 'secret') {
22
+ return getSecretVaultService().readSecret(spec.key) !== null;
23
+ }
24
+ const raw = getConfigValue(spec.key);
25
+ return typeof raw === 'string' && raw.trim().length > 0;
26
+ }
27
+ /**
28
+ * Validate additional format constraints for known env vars.
29
+ * Returns an issue string if invalid, null if ok.
30
+ */
31
+ function formatError(spec) {
32
+ if (spec.type !== 'env') {
33
+ return null;
34
+ }
35
+ const raw = getConfigValue(spec.key);
36
+ if (!raw) {
37
+ return null; // missing handled separately
38
+ }
39
+ switch (spec.key) {
40
+ case 'TELEGRAM_USER_ID': {
41
+ const parsed = Number(raw.trim());
42
+ if (!Number.isInteger(parsed) || parsed <= 0) {
43
+ return `TELEGRAM_USER_ID must be a positive integer, got '${raw.trim()}'.`;
44
+ }
45
+ break;
46
+ }
47
+ case 'MEMORY_EMBEDDING_DIM': {
48
+ const parsed = Number(raw.trim());
49
+ if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) {
50
+ return `MEMORY_EMBEDDING_DIM must be a positive integer, got '${raw.trim()}'.`;
51
+ }
52
+ break;
53
+ }
54
+ case 'API_PORT': {
55
+ const parsed = Number(raw.trim());
56
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
57
+ return `API_PORT must be an integer in range 1–65535, got '${raw.trim()}'.`;
58
+ }
59
+ break;
60
+ }
61
+ case 'EMBEDDING_PROVIDER': {
62
+ const v = raw.trim().toLowerCase();
63
+ if (v !== 'openai' && v !== 'ollama') {
64
+ return `EMBEDDING_PROVIDER must be 'openai' or 'ollama', got '${raw.trim()}'.`;
65
+ }
66
+ break;
67
+ }
68
+ default:
69
+ break;
70
+ }
71
+ return null;
72
+ }
73
+ /**
74
+ * Detect which feature gates are currently active based on present keys.
75
+ *
76
+ * A feature gate is considered "active" if its paired conditional key is present.
77
+ */
78
+ function detectActiveFeatures() {
79
+ const active = new Set();
80
+ for (const spec of CONFIG_SCHEMA) {
81
+ if (spec.class !== 'conditional' || !spec.condition) {
82
+ continue;
83
+ }
84
+ if (hasValue(spec)) {
85
+ active.add(spec.condition);
86
+ }
87
+ }
88
+ return active;
89
+ }
90
+ /**
91
+ * Resolve the model feature gates that are active.
92
+ *
93
+ * The model subsystem is considered "active" when at least one model API key is present.
94
+ * Individual model conditions only generate issues when no model key is available at all.
95
+ */
96
+ function hasAnyModelKey() {
97
+ const modelKeys = ['MODAL_API_KEY', 'OPENROUTER_API_KEY', 'GEMINI_API_KEY'];
98
+ return modelKeys.some((key) => getSecretVaultService().readSecret(key) !== null);
99
+ }
100
+ // ── Public API ───────────────────────────────────────────────────────────────
101
+ /**
102
+ * Validate the runtime configuration against the schema.
103
+ *
104
+ * @param now - Injectable clock (ISO-8601 source). Defaults to `new Date()`.
105
+ */
106
+ export function validateRuntimeConfig(now = () => new Date()) {
107
+ const issues = [];
108
+ const presentKeys = [];
109
+ const activeFeatures = detectActiveFeatures();
110
+ const anyModelKey = hasAnyModelKey();
111
+ for (const spec of CONFIG_SCHEMA) {
112
+ const present = hasValue(spec);
113
+ if (present) {
114
+ presentKeys.push(spec.key);
115
+ }
116
+ // ── Format validation (even for present keys) ───────────────────────────
117
+ const formatErr = formatError(spec);
118
+ if (formatErr) {
119
+ issues.push({
120
+ key: spec.key,
121
+ class: 'format_error',
122
+ message: formatErr,
123
+ remediation: spec.remediation,
124
+ });
125
+ continue;
126
+ }
127
+ if (present) {
128
+ continue; // value is present and valid
129
+ }
130
+ // ── Missing key handling ────────────────────────────────────────────────
131
+ switch (spec.class) {
132
+ case 'required':
133
+ issues.push({
134
+ key: spec.key,
135
+ class: 'missing_required',
136
+ message: `Required config key '${spec.key}' is missing. ${spec.description}`,
137
+ remediation: spec.remediation,
138
+ });
139
+ break;
140
+ case 'conditional': {
141
+ // Model keys: only flag as an issue when no model key is configured at all.
142
+ if (spec.condition === 'model:primary' ||
143
+ spec.condition === 'model:fallback_1' ||
144
+ spec.condition === 'model:fallback_2') {
145
+ if (!anyModelKey) {
146
+ // Only emit this issue once (for the first model key that is checked).
147
+ // Deduplicate by checking if a model issue was already emitted.
148
+ const alreadyReported = issues.some((i) => i.key === 'MODAL_API_KEY' ||
149
+ i.key === 'OPENROUTER_API_KEY' ||
150
+ i.key === 'GEMINI_API_KEY');
151
+ if (!alreadyReported) {
152
+ issues.push({
153
+ key: spec.key,
154
+ class: 'missing_conditional',
155
+ message: `No model API key is configured (MODAL_API_KEY, OPENROUTER_API_KEY, GEMINI_API_KEY). ` +
156
+ `At least one is required for AI functionality.`,
157
+ remediation: spec.remediation,
158
+ });
159
+ }
160
+ }
161
+ break;
162
+ }
163
+ // Telegram: both parts must be set together.
164
+ if (spec.condition === 'messaging:telegram' &&
165
+ activeFeatures.has('messaging:telegram')) {
166
+ issues.push({
167
+ key: spec.key,
168
+ class: 'missing_conditional',
169
+ message: `Telegram integration is partially configured — '${spec.key}' is missing. ${spec.description}`,
170
+ remediation: spec.remediation,
171
+ });
172
+ break;
173
+ }
174
+ // Voice: flag if any messaging platform is active but GROQ_API_KEY is absent.
175
+ if (spec.condition === 'messaging:voice') {
176
+ const messagingActive = activeFeatures.has('messaging:telegram') ||
177
+ activeFeatures.has('messaging:whatsapp');
178
+ if (messagingActive) {
179
+ issues.push({
180
+ key: spec.key,
181
+ class: 'missing_conditional',
182
+ message: `GROQ_API_KEY is required for voice messaging but is not configured. ` +
183
+ `Messaging dispatcher will start in text-only mode.`,
184
+ remediation: spec.remediation,
185
+ });
186
+ }
187
+ break;
188
+ }
189
+ // All other conditional keys: only flag when both parts of a pair are partially set.
190
+ // Otherwise, silently skip (feature is simply not enabled).
191
+ break;
192
+ }
193
+ case 'optional':
194
+ // Optional keys are never surfaced as issues when absent.
195
+ break;
196
+ }
197
+ }
198
+ const fatalIssues = issues.filter((i) => i.class === 'missing_required');
199
+ return {
200
+ ok: fatalIssues.length === 0 && !issues.some((i) => i.class === 'format_error'),
201
+ presentKeys: presentKeys.sort(),
202
+ issues,
203
+ activeFeatures: [...activeFeatures].sort(),
204
+ fatalIssues,
205
+ validatedAt: now().toISOString(),
206
+ };
207
+ }
208
+ /**
209
+ * Run the runtime config validation and throw when fatal issues exist.
210
+ *
211
+ * Safe to call during startup. Never exposes secret values in the thrown error.
212
+ *
213
+ * @returns The full `ConfigValidationResult` when validation passes.
214
+ * @throws Error with actionable, redaction-safe message when fatal issues are found.
215
+ */
216
+ export function assertRuntimeConfig(now = () => new Date()) {
217
+ const result = validateRuntimeConfig(now);
218
+ if (result.fatalIssues.length > 0) {
219
+ const reasons = result.fatalIssues.map((i) => i.message).join(' | ');
220
+ throw new Error(`Runtime config validation failed: ${reasons}`);
221
+ }
222
+ return result;
223
+ }
@@ -0,0 +1,115 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { getIdentityDir, getWorkspaceSubdir } from './workspace.js';
4
+ const DEFAULT_SOUL_TEMPLATE = `# TwinBot Core Directives
5
+
6
+ ## Operational Tone
7
+ - Be direct, concise, and practical
8
+ - Prioritize solving problems over being agreeable
9
+ - Ask clarifying questions when instructions are ambiguous
10
+
11
+ ## Behavioral Boundaries
12
+ - Never execute commands that could cause irreversible harm to the system
13
+ - Always confirm before executing destructive operations
14
+ - Maintain user privacy and never log sensitive information
15
+
16
+ ## Core Principles
17
+ - Zero-cost infrastructure: Prefer local solutions over paid services
18
+ - Local-first: Use local databases and file storage before external services
19
+ - Proactive: Anticipate user needs and offer suggestions
20
+ - Transparent: Be clear about limitations and uncertainties
21
+
22
+ ## Communication Style
23
+ - Use natural, conversational language
24
+ - Provide context for important decisions
25
+ - Admit when you don't know something
26
+ `;
27
+ const DEFAULT_IDENTITY_TEMPLATE = `# TwinBot Identity
28
+
29
+ ## Basic Info
30
+ - **Name:** TwinBot
31
+ - **Role:** Local AI Assistant
32
+ - **Version:** 1.0.0
33
+
34
+ ## Persona
35
+ TwinBot is a local-first AI assistant that runs on your machine. It has access to your filesystem, can execute commands, and help with various tasks.
36
+
37
+ ## Capabilities
38
+ - File system operations
39
+ - Command execution
40
+ - Web browsing via Playwright
41
+ - Messaging via WhatsApp and Telegram
42
+ - Long-term memory via semantic search
43
+
44
+ ## Limitations
45
+ - Depends on local resources (CPU, memory, disk)
46
+ - Requires API keys for cloud AI models
47
+ - Cannot access the internet without configured channels
48
+ `;
49
+ const DEFAULT_MEMORY_TEMPLATE = `# TwinBot Memory
50
+
51
+ This file stores long-term facts, preferences, and important information that should persist across sessions.
52
+
53
+ ## User Preferences
54
+ - (Add user preferences here)
55
+
56
+ ## Important Facts
57
+ - (Add important facts about the user or context here)
58
+
59
+ ## Learned Information
60
+ - (Information learned from conversations that should be remembered)
61
+ `;
62
+ export function ensureIdentityFiles() {
63
+ const identityDir = getIdentityDir();
64
+ const memoryDir = getWorkspaceSubdir('memory');
65
+ if (!fs.existsSync(identityDir)) {
66
+ fs.mkdirSync(identityDir, { recursive: true });
67
+ }
68
+ if (!fs.existsSync(memoryDir)) {
69
+ fs.mkdirSync(memoryDir, { recursive: true });
70
+ }
71
+ const soulPath = path.join(identityDir, 'soul.md');
72
+ if (!fs.existsSync(soulPath)) {
73
+ fs.writeFileSync(soulPath, DEFAULT_SOUL_TEMPLATE, { encoding: 'utf-8' });
74
+ }
75
+ const identityPath = path.join(identityDir, 'identity.md');
76
+ if (!fs.existsSync(identityPath)) {
77
+ fs.writeFileSync(identityPath, DEFAULT_IDENTITY_TEMPLATE, { encoding: 'utf-8' });
78
+ }
79
+ const memoryPath = path.join(memoryDir, 'memory.md');
80
+ if (!fs.existsSync(memoryPath)) {
81
+ fs.writeFileSync(memoryPath, DEFAULT_MEMORY_TEMPLATE, { encoding: 'utf-8' });
82
+ }
83
+ }
84
+ export function getIdentityFilesStatus() {
85
+ const identityDir = getIdentityDir();
86
+ const memoryDir = getWorkspaceSubdir('memory');
87
+ return {
88
+ soul: fs.existsSync(path.join(identityDir, 'soul.md')),
89
+ identity: fs.existsSync(path.join(identityDir, 'identity.md')),
90
+ memory: fs.existsSync(path.join(memoryDir, 'memory.md')),
91
+ };
92
+ }
93
+ export const IDENTITY_FILE_CHECKS = [
94
+ {
95
+ kind: 'filesystem',
96
+ name: 'identity-soul',
97
+ description: 'Identity soul.md constitution file',
98
+ severity: 'critical',
99
+ remediation: 'Run `node src/index.ts onboard` to initialize identity files.',
100
+ },
101
+ {
102
+ kind: 'filesystem',
103
+ name: 'identity-identity',
104
+ description: 'Identity persona file',
105
+ severity: 'critical',
106
+ remediation: 'Run `node src/index.ts onboard` to initialize identity files.',
107
+ },
108
+ {
109
+ kind: 'filesystem',
110
+ name: 'identity-memory',
111
+ description: 'Long-term memory file',
112
+ severity: 'warning',
113
+ remediation: 'Run `node src/index.ts onboard` to initialize memory file.',
114
+ },
115
+ ];
@@ -0,0 +1,344 @@
1
+ import * as fs from 'fs/promises';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import * as path from 'path';
4
+ import { getConfigPath as getWorkspaceConfigPath, hasLegacyConfig, migrateLegacyConfig, ensureWorkspaceDir, } from './workspace.js';
5
+ export const DEFAULT_CONFIG = {
6
+ runtime: {
7
+ apiSecret: '',
8
+ apiPort: 3100,
9
+ secretVaultRequired: [],
10
+ },
11
+ models: {
12
+ modalApiKey: '',
13
+ openRouterApiKey: '',
14
+ geminiApiKey: '',
15
+ },
16
+ messaging: {
17
+ telegram: {
18
+ enabled: false,
19
+ botToken: '',
20
+ userId: null,
21
+ },
22
+ whatsapp: {
23
+ enabled: false,
24
+ phoneNumber: '',
25
+ },
26
+ voice: {
27
+ groqApiKey: '',
28
+ },
29
+ inbound: {
30
+ enabled: true,
31
+ debounceMs: 1500,
32
+ },
33
+ streaming: {
34
+ blockStreamingDefault: true,
35
+ blockStreamingBreak: 'paragraph',
36
+ blockStreamingMinChars: 50,
37
+ blockStreamingMaxChars: 800,
38
+ blockStreamingCoalesce: true,
39
+ humanDelayMs: 800,
40
+ },
41
+ },
42
+ storage: {
43
+ embeddingDim: 1536,
44
+ },
45
+ integration: {
46
+ embeddingProvider: '',
47
+ embeddingApiKey: '',
48
+ openaiApiKey: '',
49
+ embeddingApiUrl: 'https://api.openai.com/v1/embeddings',
50
+ embeddingModel: 'text-embedding-3-small',
51
+ ollamaBaseUrl: 'http://localhost:11434',
52
+ ollamaEmbeddingModel: 'mxbai-embed-large',
53
+ },
54
+ tools: {
55
+ allow: [],
56
+ deny: [],
57
+ },
58
+ };
59
+ export function getConfigPath(overridePath) {
60
+ if (overridePath)
61
+ return path.resolve(overridePath);
62
+ const envPath = process.env.TWINBOT_CONFIG_PATH || process.env.TWINCLAW_CONFIG_PATH;
63
+ if (envPath) {
64
+ return path.resolve(envPath);
65
+ }
66
+ ensureWorkspaceDir();
67
+ return getWorkspaceConfigPath();
68
+ }
69
+ export async function ensureConfigDir(configPath) {
70
+ const dir = path.dirname(configPath);
71
+ if (!existsSync(dir)) {
72
+ await fs.mkdir(dir, { recursive: true });
73
+ }
74
+ }
75
+ export async function readConfig(overridePath) {
76
+ const targetPath = getConfigPath(overridePath);
77
+ try {
78
+ const rawData = await fs.readFile(targetPath, 'utf-8');
79
+ const parsed = JSON.parse(rawData);
80
+ return mergeWithDefaults(parsed);
81
+ }
82
+ catch (error) {
83
+ const fsError = error;
84
+ if (fsError.code === 'ENOENT')
85
+ return mergeWithDefaults({});
86
+ throw new Error(`Failed to parse config file at ${targetPath}: ${fsError.message}`);
87
+ }
88
+ }
89
+ export async function writeConfig(config, overridePath) {
90
+ const targetPath = getConfigPath(overridePath);
91
+ await ensureConfigDir(targetPath);
92
+ const tempPath = `${targetPath}.${Date.now()}.tmp`;
93
+ try {
94
+ const serialized = JSON.stringify(config, null, 2);
95
+ await fs.writeFile(tempPath, serialized, { encoding: 'utf-8', mode: 0o600 });
96
+ await fs.rename(tempPath, targetPath);
97
+ }
98
+ catch (error) {
99
+ const fsError = error;
100
+ try {
101
+ if (existsSync(tempPath))
102
+ await fs.unlink(tempPath);
103
+ }
104
+ catch (_) { }
105
+ throw new Error(`Failed to save config to ${targetPath}: ${fsError.message}`);
106
+ }
107
+ }
108
+ function mergeWithDefaults(loaded) {
109
+ const loadedRecord = (typeof loaded === 'object' && loaded !== null
110
+ ? loaded
111
+ : {});
112
+ const config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
113
+ if (!loadedRecord)
114
+ return config;
115
+ const runtime = loadedRecord.runtime;
116
+ const models = loadedRecord.models;
117
+ const messaging = loadedRecord.messaging;
118
+ const storage = loadedRecord.storage;
119
+ const integration = loadedRecord.integration;
120
+ const tools = loadedRecord.tools;
121
+ if (runtime)
122
+ config.runtime = { ...config.runtime, ...runtime };
123
+ if (models)
124
+ config.models = { ...config.models, ...models };
125
+ if (messaging) {
126
+ const telegram = messaging.telegram;
127
+ const whatsapp = messaging.whatsapp;
128
+ const voice = messaging.voice;
129
+ const inbound = messaging.inbound;
130
+ const streaming = messaging.streaming;
131
+ if (telegram)
132
+ config.messaging.telegram = { ...config.messaging.telegram, ...telegram };
133
+ if (whatsapp)
134
+ config.messaging.whatsapp = { ...config.messaging.whatsapp, ...whatsapp };
135
+ if (voice)
136
+ config.messaging.voice = { ...config.messaging.voice, ...voice };
137
+ if (inbound)
138
+ config.messaging.inbound = { ...config.messaging.inbound, ...inbound };
139
+ if (streaming)
140
+ config.messaging.streaming = { ...config.messaging.streaming, ...streaming };
141
+ }
142
+ if (storage)
143
+ config.storage = { ...config.storage, ...storage };
144
+ if (integration)
145
+ config.integration = { ...config.integration, ...integration };
146
+ if (tools) {
147
+ config.tools = {
148
+ allow: Array.isArray(tools.allow)
149
+ ? tools.allow.filter((value) => typeof value === 'string')
150
+ : config.tools.allow,
151
+ deny: Array.isArray(tools.deny)
152
+ ? tools.deny.filter((value) => typeof value === 'string')
153
+ : config.tools.deny,
154
+ };
155
+ }
156
+ return config;
157
+ }
158
+ // ── Legacy Flat KV Adapter ──────────────────────────────────────────────────
159
+ let cachedConfig = null;
160
+ const legacyWarningEmitted = new Set();
161
+ export function clearConfigCacheForTests() {
162
+ cachedConfig = null;
163
+ legacyWarningEmitted.clear();
164
+ }
165
+ export function reloadConfigSync() {
166
+ const configPath = getConfigPath();
167
+ try {
168
+ if (existsSync(configPath)) {
169
+ const content = readFileSync(configPath, 'utf8');
170
+ cachedConfig = mergeWithDefaults(JSON.parse(content));
171
+ return;
172
+ }
173
+ }
174
+ catch (error) {
175
+ console.error(`[TwinBot Config] Failed to parse JSON config at ${configPath}:`, error);
176
+ }
177
+ cachedConfig = mergeWithDefaults({});
178
+ }
179
+ /**
180
+ * Gets a configured value either from `twinbot.json` (mapped) or `process.env`.
181
+ */
182
+ export function getConfigValue(key, sensitive = false) {
183
+ if (cachedConfig === null) {
184
+ reloadConfigSync();
185
+ }
186
+ const config = cachedConfig;
187
+ let jsonValue = undefined;
188
+ switch (key) {
189
+ case 'API_SECRET':
190
+ jsonValue = config.runtime.apiSecret;
191
+ break;
192
+ case 'API_PORT':
193
+ jsonValue = config.runtime.apiPort;
194
+ break;
195
+ case 'SECRET_VAULT_REQUIRED':
196
+ jsonValue = config.runtime.secretVaultRequired?.join(',');
197
+ break;
198
+ case 'LOCAL_STATE_SNAPSHOT_CRON':
199
+ jsonValue = config.runtime.localStateSnapshotCron;
200
+ break;
201
+ case 'INCIDENT_POLL_CRON':
202
+ jsonValue = config.runtime.incidentPollCron;
203
+ break;
204
+ case 'HEARTBEAT_CRON':
205
+ jsonValue = config.runtime.heartbeatCron;
206
+ break;
207
+ case 'HEARTBEAT_MESSAGE':
208
+ jsonValue = config.runtime.heartbeatMessage;
209
+ break;
210
+ case 'MODAL_API_KEY':
211
+ jsonValue = config.models.modalApiKey;
212
+ break;
213
+ case 'OPENROUTER_API_KEY':
214
+ jsonValue = config.models.openRouterApiKey;
215
+ break;
216
+ case 'GEMINI_API_KEY':
217
+ jsonValue = config.models.geminiApiKey;
218
+ break;
219
+ case 'TELEGRAM_BOT_TOKEN':
220
+ jsonValue = config.messaging.telegram.botToken;
221
+ break;
222
+ case 'TELEGRAM_USER_ID':
223
+ jsonValue = config.messaging.telegram.userId;
224
+ break;
225
+ case 'WHATSAPP_PHONE_NUMBER':
226
+ jsonValue = config.messaging.whatsapp.phoneNumber;
227
+ break;
228
+ case 'GROQ_API_KEY':
229
+ jsonValue = config.messaging.voice.groqApiKey;
230
+ break;
231
+ case 'MEMORY_EMBEDDING_DIM':
232
+ jsonValue = config.storage.embeddingDim;
233
+ break;
234
+ case 'EMBEDDING_PROVIDER':
235
+ jsonValue = config.integration.embeddingProvider;
236
+ break;
237
+ case 'EMBEDDING_API_KEY':
238
+ jsonValue = config.integration.embeddingApiKey;
239
+ break;
240
+ case 'OPENAI_API_KEY':
241
+ jsonValue = config.integration.openaiApiKey;
242
+ break;
243
+ case 'EMBEDDING_API_URL':
244
+ jsonValue = config.integration.embeddingApiUrl;
245
+ break;
246
+ case 'EMBEDDING_MODEL':
247
+ jsonValue = config.integration.embeddingModel;
248
+ break;
249
+ case 'OLLAMA_BASE_URL':
250
+ jsonValue = config.integration.ollamaBaseUrl;
251
+ break;
252
+ case 'OLLAMA_EMBEDDING_MODEL':
253
+ jsonValue = config.integration.ollamaEmbeddingModel;
254
+ break;
255
+ case 'TOOLS_ALLOW':
256
+ jsonValue = config.tools.allow?.join(',');
257
+ break;
258
+ case 'TOOLS_DENY':
259
+ jsonValue = config.tools.deny?.join(',');
260
+ break;
261
+ case 'INBOUND_DEBOUNCE_ENABLED':
262
+ jsonValue = config.messaging.inbound.enabled;
263
+ break;
264
+ case 'INBOUND_DEBOUNCE_MS':
265
+ jsonValue = config.messaging.inbound.debounceMs;
266
+ break;
267
+ case 'BLOCK_STREAMING_DEFAULT':
268
+ jsonValue = config.messaging.streaming.blockStreamingDefault;
269
+ break;
270
+ case 'BLOCK_STREAMING_BREAK':
271
+ jsonValue = config.messaging.streaming.blockStreamingBreak;
272
+ break;
273
+ case 'BLOCK_STREAMING_MIN_CHARS':
274
+ jsonValue = config.messaging.streaming.blockStreamingMinChars;
275
+ break;
276
+ case 'BLOCK_STREAMING_MAX_CHARS':
277
+ jsonValue = config.messaging.streaming.blockStreamingMaxChars;
278
+ break;
279
+ case 'BLOCK_STREAMING_COALESCE':
280
+ jsonValue = config.messaging.streaming.blockStreamingCoalesce;
281
+ break;
282
+ case 'HUMAN_DELAY_MS':
283
+ jsonValue = config.messaging.streaming.humanDelayMs;
284
+ break;
285
+ }
286
+ // 1. Process explicit environment variables that act as overrides
287
+ if (isAllowedOverride(key) && process.env[key] !== undefined && String(process.env[key]).trim() !== '') {
288
+ return String(process.env[key]);
289
+ }
290
+ // 2. Return the parsed config value (which merges twinbot.json with defaults)
291
+ if (jsonValue !== undefined && jsonValue !== null && String(jsonValue).trim() !== '') {
292
+ return String(jsonValue);
293
+ }
294
+ // 3. Fallback to process.env for legacy non-overrides, emitting a deprecation warning
295
+ const envValue = process.env[key];
296
+ if (envValue !== undefined && envValue !== null && String(envValue).trim() !== '') {
297
+ if (!sensitive && !legacyWarningEmitted.has(key)) {
298
+ console.warn(`[TwinBot Config Migration] Deprecation Warning: Loaded configuration key '${key}' from process.env (or .env). Please re-run 'twinbot onboard' or generate a twinbot.json file.`);
299
+ legacyWarningEmitted.add(key);
300
+ }
301
+ return String(envValue);
302
+ }
303
+ return undefined;
304
+ }
305
+ function isAllowedOverride(key) {
306
+ return [
307
+ 'TWINBOT_CONFIG_PATH',
308
+ 'TWINBOT_PROFILE',
309
+ 'TWINCLAW_CONFIG_PATH',
310
+ 'TWINCLAW_PROFILE',
311
+ 'RUNTIME_BUDGET_DEFAULT_PROFILE',
312
+ 'RUNTIME_BUDGET_PREFER_LOCAL_MODEL',
313
+ 'RUNTIME_BUDGET_LOCAL_MODEL_ID',
314
+ 'API_PORT',
315
+ 'LOCAL_STATE_SNAPSHOT_CRON',
316
+ 'INCIDENT_POLL_CRON',
317
+ 'TOOLS_ALLOW',
318
+ 'TOOLS_DENY',
319
+ 'NODE_ENV',
320
+ 'SECRET_VAULT_MASTER_KEY',
321
+ 'API_SECRET',
322
+ 'MODEL_ROUTING_FALLBACK_MODE'
323
+ ].includes(key);
324
+ }
325
+ let migrationPerformed = false;
326
+ export function checkAndMigrateWorkspace() {
327
+ if (migrationPerformed) {
328
+ return { migrated: false, sourcePath: null, targetPath: null };
329
+ }
330
+ migrationPerformed = true;
331
+ if (hasLegacyConfig()) {
332
+ console.log('[TwinBot] Detected legacy ~/.twinbot configuration. Migrating to workspace structure...');
333
+ const result = migrateLegacyConfig();
334
+ if (result.migrated) {
335
+ console.log(`[TwinBot] Successfully migrated config to ${result.targetPath}`);
336
+ }
337
+ else if (result.error) {
338
+ console.error(`[TwinBot] Migration failed: ${result.error}`);
339
+ }
340
+ return result;
341
+ }
342
+ return { migrated: false, sourcePath: null, targetPath: null };
343
+ }
344
+ export { hasLegacyConfig, migrateLegacyConfig };