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.
- package/README.md +66 -0
- package/bin/npm-twinclaw.js +17 -0
- package/bin/run-twinbot-cli.js +36 -0
- package/bin/twinbot.js +4 -0
- package/bin/twinclaw.js +4 -0
- package/dist/api/handlers/browser.js +160 -0
- package/dist/api/handlers/callback.js +80 -0
- package/dist/api/handlers/config-validate.js +19 -0
- package/dist/api/handlers/health.js +117 -0
- package/dist/api/handlers/local-state-backup.js +118 -0
- package/dist/api/handlers/persona-state.js +59 -0
- package/dist/api/handlers/skill-packages.js +94 -0
- package/dist/api/router.js +278 -0
- package/dist/api/runtime-event-producer.js +99 -0
- package/dist/api/shared.js +82 -0
- package/dist/api/websocket-hub.js +305 -0
- package/dist/config/config-loader.js +2 -0
- package/dist/config/env-schema.js +202 -0
- package/dist/config/env-validator.js +223 -0
- package/dist/config/identity-bootstrap.js +115 -0
- package/dist/config/json-config.js +344 -0
- package/dist/config/workspace.js +186 -0
- package/dist/core/channels-cli.js +77 -0
- package/dist/core/cli.js +119 -0
- package/dist/core/context-assembly.js +33 -0
- package/dist/core/doctor.js +365 -0
- package/dist/core/gateway-cli.js +323 -0
- package/dist/core/gateway.js +416 -0
- package/dist/core/heartbeat.js +54 -0
- package/dist/core/install-cli.js +320 -0
- package/dist/core/lane-executor.js +134 -0
- package/dist/core/logs-cli.js +70 -0
- package/dist/core/onboarding.js +760 -0
- package/dist/core/pairing-cli.js +78 -0
- package/dist/core/secret-vault-cli.js +204 -0
- package/dist/core/types.js +1 -0
- package/dist/index.js +404 -0
- package/dist/interfaces/dispatcher.js +214 -0
- package/dist/interfaces/telegram_handler.js +82 -0
- package/dist/interfaces/tui-dashboard.js +53 -0
- package/dist/interfaces/whatsapp_handler.js +94 -0
- package/dist/release/cli.js +97 -0
- package/dist/release/mvp-gate-cli.js +118 -0
- package/dist/release/twinbot-config-schema.js +162 -0
- package/dist/release/twinclaw-config-schema.js +162 -0
- package/dist/services/block-chunker.js +174 -0
- package/dist/services/browser-service.js +334 -0
- package/dist/services/context-lifecycle.js +314 -0
- package/dist/services/db.js +1055 -0
- package/dist/services/delivery-tracker.js +110 -0
- package/dist/services/dm-pairing.js +245 -0
- package/dist/services/embedding-service.js +125 -0
- package/dist/services/file-watcher.js +125 -0
- package/dist/services/inbound-debounce.js +92 -0
- package/dist/services/incident-manager.js +516 -0
- package/dist/services/job-scheduler.js +176 -0
- package/dist/services/local-state-backup.js +682 -0
- package/dist/services/mcp-client-adapter.js +291 -0
- package/dist/services/mcp-server-manager.js +143 -0
- package/dist/services/model-router.js +927 -0
- package/dist/services/mvp-gate.js +845 -0
- package/dist/services/orchestration-service.js +422 -0
- package/dist/services/persona-state.js +256 -0
- package/dist/services/policy-engine.js +92 -0
- package/dist/services/proactive-notifier.js +94 -0
- package/dist/services/queue-service.js +146 -0
- package/dist/services/release-pipeline.js +652 -0
- package/dist/services/runtime-budget-governor.js +415 -0
- package/dist/services/secret-vault.js +704 -0
- package/dist/services/semantic-memory.js +249 -0
- package/dist/services/skill-package-manager.js +806 -0
- package/dist/services/skill-registry.js +122 -0
- package/dist/services/streaming-output.js +75 -0
- package/dist/services/stt-service.js +39 -0
- package/dist/services/tts-service.js +44 -0
- package/dist/skills/builtin.js +250 -0
- package/dist/skills/shell.js +87 -0
- package/dist/skills/types.js +1 -0
- package/dist/types/api.js +1 -0
- package/dist/types/context-budget.js +1 -0
- package/dist/types/doctor.js +1 -0
- package/dist/types/file-watcher.js +1 -0
- package/dist/types/incident.js +1 -0
- package/dist/types/local-state-backup.js +1 -0
- package/dist/types/mcp.js +1 -0
- package/dist/types/messaging.js +1 -0
- package/dist/types/model-routing.js +1 -0
- package/dist/types/mvp-gate.js +2 -0
- package/dist/types/orchestration.js +1 -0
- package/dist/types/persona-state.js +22 -0
- package/dist/types/policy.js +1 -0
- package/dist/types/reasoning-graph.js +1 -0
- package/dist/types/release.js +1 -0
- package/dist/types/reliability.js +1 -0
- package/dist/types/runtime-budget.js +1 -0
- package/dist/types/scheduler.js +1 -0
- package/dist/types/secret-vault.js +1 -0
- package/dist/types/skill-packages.js +1 -0
- package/dist/types/websocket.js +14 -0
- package/dist/utils/logger.js +57 -0
- package/dist/utils/retry.js +61 -0
- package/dist/utils/secret-scan.js +208 -0
- package/mcp-servers.json +179 -0
- package/package.json +81 -0
- package/skill-packages.json +92 -0
- package/skill-packages.lock.json +5 -0
- package/src/skills/builtin.ts +275 -0
- package/src/skills/shell.ts +118 -0
- package/src/skills/types.ts +30 -0
- package/src/types/api.ts +252 -0
- package/src/types/blessed-contrib.d.ts +4 -0
- package/src/types/context-budget.ts +76 -0
- package/src/types/doctor.ts +29 -0
- package/src/types/file-watcher.ts +26 -0
- package/src/types/incident.ts +57 -0
- package/src/types/local-state-backup.ts +121 -0
- package/src/types/mcp.ts +106 -0
- package/src/types/messaging.ts +35 -0
- package/src/types/model-routing.ts +61 -0
- package/src/types/mvp-gate.ts +99 -0
- package/src/types/orchestration.ts +65 -0
- package/src/types/persona-state.ts +61 -0
- package/src/types/policy.ts +27 -0
- package/src/types/reasoning-graph.ts +58 -0
- package/src/types/release.ts +115 -0
- package/src/types/reliability.ts +43 -0
- package/src/types/runtime-budget.ts +85 -0
- package/src/types/scheduler.ts +47 -0
- package/src/types/secret-vault.ts +62 -0
- package/src/types/skill-packages.ts +81 -0
- package/src/types/sqlite-vec.d.ts +5 -0
- 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 };
|