@smart-tinker/kayla 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +34 -0
- package/dist/bot/bot.js +12 -0
- package/dist/bot/handlers.js +69 -0
- package/dist/cli.js +188 -0
- package/dist/core/config.js +314 -0
- package/dist/core/doctor.js +175 -0
- package/dist/core/kayla.js +255 -0
- package/dist/core/logging.js +14 -0
- package/dist/core/parser.js +91 -0
- package/dist/core/runner.js +132 -0
- package/dist/core/security.js +6 -0
- package/dist/core/sessions.js +59 -0
- package/dist/core/setup.js +221 -0
- package/dist/core/storage.js +259 -0
- package/dist/core/telegram.js +89 -0
- package/dist/index.js +7 -0
- package/dist/service.js +22 -0
- package/package.json +53 -0
- package/scripts/systemd/kayla.service.template +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 smart-tinker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Kayla
|
|
2
|
+
|
|
3
|
+
Kayla is a Telegram bot service that orchestrates a locally installed Claude Code CLI (`claude`).
|
|
4
|
+
|
|
5
|
+
## Quick start (local)
|
|
6
|
+
|
|
7
|
+
1) Install dependencies:
|
|
8
|
+
```bash
|
|
9
|
+
npm install
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
2) Configure:
|
|
13
|
+
- `config/config.yaml` (set `telegram.allowlist.user_ids`)
|
|
14
|
+
- export Telegram token:
|
|
15
|
+
```bash
|
|
16
|
+
export KAYLA_TELEGRAM_TOKEN="..."
|
|
17
|
+
export KAYLA_CONFIG="config/config.yaml"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
3) Run:
|
|
21
|
+
```bash
|
|
22
|
+
npm run dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Verification
|
|
26
|
+
```bash
|
|
27
|
+
npm test
|
|
28
|
+
npm run typecheck
|
|
29
|
+
npm run lint
|
|
30
|
+
npm run build
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
More details: `docs/`.
|
|
34
|
+
|
package/dist/bot/bot.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildBot = buildBot;
|
|
4
|
+
const grammy_1 = require("grammy");
|
|
5
|
+
const handlers_1 = require("./handlers");
|
|
6
|
+
const kayla_1 = require("../core/kayla");
|
|
7
|
+
function buildBot(deps) {
|
|
8
|
+
const bot = new grammy_1.Bot(deps.config.telegram.token);
|
|
9
|
+
const service = new kayla_1.KaylaService({ cfg: deps.config, logger: deps.logger, storage: deps.storage, api: bot.api });
|
|
10
|
+
(0, handlers_1.registerHandlers)(bot, { ...deps, service });
|
|
11
|
+
return bot;
|
|
12
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerHandlers = registerHandlers;
|
|
4
|
+
function registerHandlers(bot, deps) {
|
|
5
|
+
bot.command("start", async (ctx) => {
|
|
6
|
+
if (ctx.chat.type !== "private")
|
|
7
|
+
return;
|
|
8
|
+
const userId = ctx.from?.id;
|
|
9
|
+
if (!userId)
|
|
10
|
+
return;
|
|
11
|
+
await deps.service.handleStartWithUser(ctx.chat.id, userId);
|
|
12
|
+
});
|
|
13
|
+
bot.command("status", async (ctx) => {
|
|
14
|
+
if (ctx.chat.type !== "private")
|
|
15
|
+
return;
|
|
16
|
+
const userId = ctx.from?.id;
|
|
17
|
+
if (!userId)
|
|
18
|
+
return;
|
|
19
|
+
if (!deps.storage.isAllowedUser("telegram", String(userId)))
|
|
20
|
+
return;
|
|
21
|
+
await deps.service.handleStatus(ctx.chat.id);
|
|
22
|
+
});
|
|
23
|
+
bot.command("new", async (ctx) => {
|
|
24
|
+
if (ctx.chat.type !== "private")
|
|
25
|
+
return;
|
|
26
|
+
const userId = ctx.from?.id;
|
|
27
|
+
if (!userId)
|
|
28
|
+
return;
|
|
29
|
+
if (!deps.storage.isAllowedUser("telegram", String(userId)))
|
|
30
|
+
return;
|
|
31
|
+
await deps.service.handleNew(ctx.chat.id, userId);
|
|
32
|
+
});
|
|
33
|
+
bot.command("reset", async (ctx) => {
|
|
34
|
+
if (ctx.chat.type !== "private")
|
|
35
|
+
return;
|
|
36
|
+
const userId = ctx.from?.id;
|
|
37
|
+
if (!userId)
|
|
38
|
+
return;
|
|
39
|
+
if (!deps.storage.isAllowedUser("telegram", String(userId)))
|
|
40
|
+
return;
|
|
41
|
+
const text = ctx.message?.text ?? "";
|
|
42
|
+
const confirm = text.trim().toLowerCase() === "/reset yes";
|
|
43
|
+
await deps.service.handleReset(ctx.chat.id, userId, confirm);
|
|
44
|
+
});
|
|
45
|
+
bot.command("cancel", async (ctx) => {
|
|
46
|
+
if (ctx.chat.type !== "private")
|
|
47
|
+
return;
|
|
48
|
+
const userId = ctx.from?.id;
|
|
49
|
+
if (!userId)
|
|
50
|
+
return;
|
|
51
|
+
if (!deps.storage.isAllowedUser("telegram", String(userId)))
|
|
52
|
+
return;
|
|
53
|
+
await deps.service.handleCancel(ctx.chat.id);
|
|
54
|
+
});
|
|
55
|
+
bot.on("message:text", async (ctx) => {
|
|
56
|
+
if (ctx.chat.type !== "private")
|
|
57
|
+
return;
|
|
58
|
+
const userId = ctx.from?.id;
|
|
59
|
+
if (!userId)
|
|
60
|
+
return;
|
|
61
|
+
if (!deps.storage.isAllowedUser("telegram", String(userId)))
|
|
62
|
+
return;
|
|
63
|
+
const text = ctx.message.text;
|
|
64
|
+
// Commands are handled separately.
|
|
65
|
+
if (text.startsWith("/"))
|
|
66
|
+
return;
|
|
67
|
+
await deps.service.enqueueUserMessage(ctx.chat.id, userId, text);
|
|
68
|
+
});
|
|
69
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_child_process_1 = require("node:child_process");
|
|
10
|
+
const promises_1 = __importDefault(require("node:readline/promises"));
|
|
11
|
+
const storage_1 = require("./core/storage");
|
|
12
|
+
const service_1 = require("./service");
|
|
13
|
+
const setup_1 = require("./core/setup");
|
|
14
|
+
const doctor_1 = require("./core/doctor");
|
|
15
|
+
function usage() {
|
|
16
|
+
return [
|
|
17
|
+
"Usage:",
|
|
18
|
+
" kayla run",
|
|
19
|
+
" kayla setup",
|
|
20
|
+
" kayla doctor",
|
|
21
|
+
" kayla telegram users approve <code>",
|
|
22
|
+
" kayla telegram users pending",
|
|
23
|
+
" kayla telegram users list"
|
|
24
|
+
].join("\n");
|
|
25
|
+
}
|
|
26
|
+
function fail(msg, code = 1) {
|
|
27
|
+
console.error(msg);
|
|
28
|
+
process.exit(code);
|
|
29
|
+
}
|
|
30
|
+
function createPrompt() {
|
|
31
|
+
const rl = promises_1.default.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
32
|
+
const askText = async (message) => rl.question(message);
|
|
33
|
+
const askSecret = async (message) => {
|
|
34
|
+
// Best-effort: hide user input for secrets while still showing the prompt.
|
|
35
|
+
process.stdout.write(message);
|
|
36
|
+
const anyRl = rl;
|
|
37
|
+
const prev = anyRl._writeToOutput;
|
|
38
|
+
anyRl._writeToOutput = () => { };
|
|
39
|
+
try {
|
|
40
|
+
const ans = await rl.question("");
|
|
41
|
+
process.stdout.write("\n");
|
|
42
|
+
return ans;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
anyRl._writeToOutput = prev;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
prompt: async (q) => (q.kind === "secret" ? askSecret(q.message) : askText(q.message)),
|
|
50
|
+
close: () => rl.close()
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async function main(argv) {
|
|
54
|
+
const args = argv.slice(2);
|
|
55
|
+
if (args.length < 1)
|
|
56
|
+
fail(usage(), 2);
|
|
57
|
+
const top = args[0];
|
|
58
|
+
if (top === "run") {
|
|
59
|
+
await (0, service_1.runService)();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (top === "setup") {
|
|
63
|
+
const home = node_os_1.default.homedir();
|
|
64
|
+
const username = node_os_1.default.userInfo().username;
|
|
65
|
+
const paths = (0, setup_1.computeDefaultSetupPaths)(home);
|
|
66
|
+
(0, setup_1.ensureXdgDirs)(paths);
|
|
67
|
+
if (!node_fs_1.default.existsSync(paths.configPath)) {
|
|
68
|
+
const isTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
69
|
+
const p = isTty ? createPrompt() : null;
|
|
70
|
+
try {
|
|
71
|
+
const res = await (0, setup_1.bootstrapConfig)(paths, process.env, { isTty, prompt: p?.prompt });
|
|
72
|
+
(0, setup_1.writeConfigIfMissing)(paths.configPath, res.config);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
p?.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Validate config exists and has required fields.
|
|
79
|
+
(0, setup_1.ensureConfigValidOrThrow)(paths.configPath);
|
|
80
|
+
// Render unit template.
|
|
81
|
+
const templatePath = node_path_1.default.join(__dirname, "..", "scripts", "systemd", "kayla.service.template");
|
|
82
|
+
const tpl = node_fs_1.default.readFileSync(templatePath, "utf8");
|
|
83
|
+
const nodePath = process.execPath;
|
|
84
|
+
const cliPath = node_fs_1.default.realpathSync(process.argv[1]);
|
|
85
|
+
const execStart = `${nodePath} ${cliPath} run`;
|
|
86
|
+
const unitPath = "/etc/systemd/system/kayla.service";
|
|
87
|
+
const rendered = (0, setup_1.renderSystemdTemplate)(tpl, {
|
|
88
|
+
KAYLA_USER: username,
|
|
89
|
+
KAYLA_HOME: home,
|
|
90
|
+
KAYLA_CONFIG: paths.configPath,
|
|
91
|
+
KAYLA_PATH: (0, setup_1.ensurePathHasLocalBin)(home, process.env.PATH),
|
|
92
|
+
KAYLA_EXEC_START: execStart
|
|
93
|
+
});
|
|
94
|
+
const tmp = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "kayla-unit-"));
|
|
95
|
+
const tmpUnit = node_path_1.default.join(tmp, "kayla.service");
|
|
96
|
+
node_fs_1.default.writeFileSync(tmpUnit, rendered);
|
|
97
|
+
try {
|
|
98
|
+
(0, node_child_process_1.execFileSync)("sudo", ["install", "-m", "0644", tmpUnit, unitPath], { stdio: "inherit" });
|
|
99
|
+
(0, node_child_process_1.execFileSync)("sudo", ["systemctl", "daemon-reload"], { stdio: "inherit" });
|
|
100
|
+
(0, node_child_process_1.execFileSync)("sudo", ["systemctl", "enable", "--now", "kayla.service"], { stdio: "inherit" });
|
|
101
|
+
console.log("Kayla service installed and started: kayla.service");
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
try {
|
|
105
|
+
node_fs_1.default.rmSync(tmp, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// ignore cleanup errors
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (top === "doctor") {
|
|
114
|
+
const cfgPath = (0, doctor_1.resolveConfigPath)(process.env);
|
|
115
|
+
const checks = [...(0, doctor_1.checkConfig)(cfgPath)];
|
|
116
|
+
// Best-effort runtime dirs: prefer config values; otherwise default to XDG paths.
|
|
117
|
+
const home = node_os_1.default.homedir();
|
|
118
|
+
const paths = (0, setup_1.computeDefaultSetupPaths)(home);
|
|
119
|
+
const cfgInfo = (0, doctor_1.readDoctorConfigInfo)(cfgPath);
|
|
120
|
+
const dataDir = cfgInfo.runtime?.dataDir ?? paths.dataDir;
|
|
121
|
+
const workspacesDir = cfgInfo.runtime?.workspacesDir ?? node_path_1.default.join(dataDir, "workspaces");
|
|
122
|
+
const uploadsDir = cfgInfo.runtime?.uploadsDir ?? node_path_1.default.join(dataDir, "uploads");
|
|
123
|
+
const runtime = { dataDir, workspacesDir, uploadsDir };
|
|
124
|
+
checks.push(...(0, doctor_1.checkDirs)(runtime));
|
|
125
|
+
checks.push((0, doctor_1.checkSqlite)(runtime.dataDir));
|
|
126
|
+
checks.push((0, doctor_1.checkClaude)(cfgInfo.claudeBinary ?? "claude", process.env));
|
|
127
|
+
let ok = true;
|
|
128
|
+
for (const c of checks) {
|
|
129
|
+
if (!c.ok)
|
|
130
|
+
ok = false;
|
|
131
|
+
const tag = c.ok ? "OK" : "FAIL";
|
|
132
|
+
console.log(`[${tag}] ${c.summary}`);
|
|
133
|
+
if (!c.ok && c.details)
|
|
134
|
+
console.log(` ${c.details}`);
|
|
135
|
+
}
|
|
136
|
+
if (!ok)
|
|
137
|
+
process.exitCode = 2;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (top !== "telegram")
|
|
141
|
+
fail(`Unsupported command: ${top}\n\n${usage()}`, 2);
|
|
142
|
+
if (args.length < 3)
|
|
143
|
+
fail(usage(), 2);
|
|
144
|
+
const channel = args[0];
|
|
145
|
+
const resource = args[1];
|
|
146
|
+
const action = args[2];
|
|
147
|
+
if (channel !== "telegram")
|
|
148
|
+
fail(`Unsupported channel: ${channel}\n\n${usage()}`, 2);
|
|
149
|
+
if (resource !== "users")
|
|
150
|
+
fail(`Unsupported resource: ${resource}\n\n${usage()}`, 2);
|
|
151
|
+
const dataDir = (0, doctor_1.resolveRuntimeDataDir)(process.env);
|
|
152
|
+
const storage = (0, storage_1.openStorage)(dataDir);
|
|
153
|
+
if (action === "approve") {
|
|
154
|
+
const code = args[3];
|
|
155
|
+
if (!code)
|
|
156
|
+
fail("Missing <code>\n\n" + usage(), 2);
|
|
157
|
+
const res = storage.approveOnboardingCode("telegram", String(code), "cli");
|
|
158
|
+
console.log(`Approved telegram user_id=${res.userId} (chat_id=${res.chatId})`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (action === "pending") {
|
|
162
|
+
const rows = storage.listPendingCodes("telegram");
|
|
163
|
+
if (rows.length === 0) {
|
|
164
|
+
console.log("No pending codes.");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
for (const r of rows) {
|
|
168
|
+
console.log(`${r.code}\tuser_id=${r.userId}\tchat_id=${r.chatId}\texpires_at=${new Date(r.expiresAt).toISOString()}`);
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (action === "list") {
|
|
173
|
+
const rows = storage.listAllowedUsers("telegram");
|
|
174
|
+
if (rows.length === 0) {
|
|
175
|
+
console.log("No allowlisted users.");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
for (const r of rows) {
|
|
179
|
+
console.log(`${r.userId}\tcreated_at=${new Date(r.createdAt).toISOString()}\tadded_by=${r.addedBy ?? ""}`);
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
fail(`Unknown action: ${action}\n\n${usage()}`, 2);
|
|
184
|
+
}
|
|
185
|
+
main(process.argv).catch((err) => {
|
|
186
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
187
|
+
fail(msg, 1);
|
|
188
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.loadConfig = loadConfig;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const yaml_1 = __importDefault(require("yaml"));
|
|
10
|
+
function defaultConfigPath(env) {
|
|
11
|
+
if (env.KAYLA_CONFIG && env.KAYLA_CONFIG.trim().length)
|
|
12
|
+
return env.KAYLA_CONFIG.trim();
|
|
13
|
+
const home = env.HOME?.trim();
|
|
14
|
+
if (home && home.length)
|
|
15
|
+
return node_path_1.default.join(home, ".config", "kayla", "config.yaml");
|
|
16
|
+
return node_path_1.default.join(process.cwd(), "config", "config.yaml");
|
|
17
|
+
}
|
|
18
|
+
function defaultDataDir(env) {
|
|
19
|
+
const home = env.HOME?.trim();
|
|
20
|
+
if (home && home.length)
|
|
21
|
+
return node_path_1.default.join(home, ".local", "share", "kayla");
|
|
22
|
+
return ".kayla-data";
|
|
23
|
+
}
|
|
24
|
+
const DEFAULT_CONFIG = {
|
|
25
|
+
telegram: {
|
|
26
|
+
token: "",
|
|
27
|
+
mode: "polling",
|
|
28
|
+
admin_user_ids: [],
|
|
29
|
+
allowlist: { user_ids: [] }
|
|
30
|
+
},
|
|
31
|
+
runtime: {
|
|
32
|
+
data_dir: "",
|
|
33
|
+
workspaces_dir: "",
|
|
34
|
+
uploads_dir: ""
|
|
35
|
+
},
|
|
36
|
+
claude: {
|
|
37
|
+
binary: "claude",
|
|
38
|
+
default_model: "",
|
|
39
|
+
max_turns: 10,
|
|
40
|
+
timeout_seconds: 900,
|
|
41
|
+
streaming: true,
|
|
42
|
+
output_format: "stream-json",
|
|
43
|
+
tools: "default",
|
|
44
|
+
allowed_tools: [],
|
|
45
|
+
disallowed_tools: [],
|
|
46
|
+
mcp: { strict: false }
|
|
47
|
+
},
|
|
48
|
+
jobs: {
|
|
49
|
+
per_chat_concurrency: 1,
|
|
50
|
+
global_concurrency: 2,
|
|
51
|
+
queue_size_per_chat: 20
|
|
52
|
+
},
|
|
53
|
+
logging: {
|
|
54
|
+
level: "info",
|
|
55
|
+
json: true
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
function isRecord(v) {
|
|
59
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
60
|
+
}
|
|
61
|
+
function optionalPath(v) {
|
|
62
|
+
if (typeof v !== "string")
|
|
63
|
+
return undefined;
|
|
64
|
+
const trimmed = v.trim();
|
|
65
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
66
|
+
}
|
|
67
|
+
function optionalString(v) {
|
|
68
|
+
if (typeof v !== "string")
|
|
69
|
+
return undefined;
|
|
70
|
+
const trimmed = v.trim();
|
|
71
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
72
|
+
}
|
|
73
|
+
function parseBool(v) {
|
|
74
|
+
const s = v.trim().toLowerCase();
|
|
75
|
+
if (s === "1" || s === "true" || s === "yes" || s === "on")
|
|
76
|
+
return true;
|
|
77
|
+
if (s === "0" || s === "false" || s === "no" || s === "off")
|
|
78
|
+
return false;
|
|
79
|
+
throw new Error(`Invalid boolean env value: ${v}`);
|
|
80
|
+
}
|
|
81
|
+
function parseNumber(v) {
|
|
82
|
+
const n = Number(v.trim());
|
|
83
|
+
if (!Number.isFinite(n))
|
|
84
|
+
throw new Error(`Invalid number env value: ${v}`);
|
|
85
|
+
return n;
|
|
86
|
+
}
|
|
87
|
+
function parseCsv(v) {
|
|
88
|
+
const raw = v.split(",").map((x) => x.trim()).filter((x) => x.length > 0);
|
|
89
|
+
return raw;
|
|
90
|
+
}
|
|
91
|
+
function parseCsvNumbers(v) {
|
|
92
|
+
return parseCsv(v).map((x) => {
|
|
93
|
+
const n = Number(x);
|
|
94
|
+
if (!Number.isFinite(n))
|
|
95
|
+
throw new Error(`Invalid number in CSV: ${x}`);
|
|
96
|
+
return n;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function readYamlIfExists(p) {
|
|
100
|
+
try {
|
|
101
|
+
if (!node_fs_1.default.existsSync(p))
|
|
102
|
+
return undefined;
|
|
103
|
+
const raw = node_fs_1.default.readFileSync(p, "utf8");
|
|
104
|
+
return yaml_1.default.parse(raw);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new Error(`Failed to read config file: ${p}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function applyEnv(cfg, env) {
|
|
111
|
+
const get = (k) => env[k];
|
|
112
|
+
// Telegram
|
|
113
|
+
const t = get("KAYLA_TELEGRAM_TOKEN");
|
|
114
|
+
if (t && t.trim().length)
|
|
115
|
+
cfg.telegram.token = t.trim();
|
|
116
|
+
const mode = get("KAYLA_TELEGRAM_MODE");
|
|
117
|
+
if (mode && mode.trim().length)
|
|
118
|
+
cfg.telegram.mode = mode.trim() === "webhook" ? "webhook" : "polling";
|
|
119
|
+
const allowCsv = get("KAYLA_TELEGRAM_ALLOWLIST_USER_IDS");
|
|
120
|
+
if (allowCsv && allowCsv.trim().length)
|
|
121
|
+
cfg.telegram.allowlist.user_ids = parseCsvNumbers(allowCsv);
|
|
122
|
+
const adminCsv = get("KAYLA_TELEGRAM_ADMIN_USER_IDS");
|
|
123
|
+
if (adminCsv && adminCsv.trim().length)
|
|
124
|
+
cfg.telegram.admin_user_ids = parseCsvNumbers(adminCsv);
|
|
125
|
+
// Runtime
|
|
126
|
+
const dataDir = get("KAYLA_RUNTIME_DATA_DIR");
|
|
127
|
+
const wDir = get("KAYLA_RUNTIME_WORKSPACES_DIR");
|
|
128
|
+
const uDir = get("KAYLA_RUNTIME_UPLOADS_DIR");
|
|
129
|
+
const dataDirSet = !!(dataDir && dataDir.trim().length);
|
|
130
|
+
const wDirSet = !!(wDir && wDir.trim().length);
|
|
131
|
+
const uDirSet = !!(uDir && uDir.trim().length);
|
|
132
|
+
if (dataDirSet)
|
|
133
|
+
cfg.runtime.data_dir = dataDir.trim();
|
|
134
|
+
if (wDirSet)
|
|
135
|
+
cfg.runtime.workspaces_dir = wDir.trim();
|
|
136
|
+
if (uDirSet)
|
|
137
|
+
cfg.runtime.uploads_dir = uDir.trim();
|
|
138
|
+
if (dataDirSet && !wDirSet)
|
|
139
|
+
cfg.runtime.workspaces_dir = node_path_1.default.join(cfg.runtime.data_dir, "workspaces");
|
|
140
|
+
if (dataDirSet && !uDirSet)
|
|
141
|
+
cfg.runtime.uploads_dir = node_path_1.default.join(cfg.runtime.data_dir, "uploads");
|
|
142
|
+
// Claude (no backend/auth settings, only runner-level knobs)
|
|
143
|
+
const cb = get("KAYLA_CLAUDE_BINARY");
|
|
144
|
+
if (cb && cb.trim().length)
|
|
145
|
+
cfg.claude.binary = cb.trim();
|
|
146
|
+
const dm = get("KAYLA_CLAUDE_DEFAULT_MODEL");
|
|
147
|
+
if (dm && dm.trim().length)
|
|
148
|
+
cfg.claude.default_model = dm.trim();
|
|
149
|
+
const mt = get("KAYLA_CLAUDE_MAX_TURNS");
|
|
150
|
+
if (mt && mt.trim().length)
|
|
151
|
+
cfg.claude.max_turns = parseNumber(mt);
|
|
152
|
+
const ts = get("KAYLA_CLAUDE_TIMEOUT_SECONDS");
|
|
153
|
+
if (ts && ts.trim().length)
|
|
154
|
+
cfg.claude.timeout_seconds = parseNumber(ts);
|
|
155
|
+
const st = get("KAYLA_CLAUDE_STREAMING");
|
|
156
|
+
if (st && st.trim().length)
|
|
157
|
+
cfg.claude.streaming = parseBool(st);
|
|
158
|
+
const tools = get("KAYLA_CLAUDE_TOOLS");
|
|
159
|
+
if (tools && tools.trim().length)
|
|
160
|
+
cfg.claude.tools = tools.trim();
|
|
161
|
+
const at = get("KAYLA_CLAUDE_ALLOWED_TOOLS");
|
|
162
|
+
if (at && at.trim().length)
|
|
163
|
+
cfg.claude.allowed_tools = parseCsv(at);
|
|
164
|
+
const dt = get("KAYLA_CLAUDE_DISALLOWED_TOOLS");
|
|
165
|
+
if (dt && dt.trim().length)
|
|
166
|
+
cfg.claude.disallowed_tools = parseCsv(dt);
|
|
167
|
+
const ap = get("KAYLA_CLAUDE_APPEND_SYSTEM_PROMPT_FILE");
|
|
168
|
+
if (ap && ap.trim().length)
|
|
169
|
+
cfg.claude.append_system_prompt_file = ap.trim();
|
|
170
|
+
const af = get("KAYLA_CLAUDE_AGENTS_FILE");
|
|
171
|
+
if (af && af.trim().length)
|
|
172
|
+
cfg.claude.agents_file = af.trim();
|
|
173
|
+
const mcpStrict = get("KAYLA_CLAUDE_MCP_STRICT");
|
|
174
|
+
if (mcpStrict && mcpStrict.trim().length) {
|
|
175
|
+
cfg.claude.mcp = cfg.claude.mcp ?? { strict: false };
|
|
176
|
+
cfg.claude.mcp.strict = parseBool(mcpStrict);
|
|
177
|
+
}
|
|
178
|
+
const mcpCfg = get("KAYLA_CLAUDE_MCP_CONFIG_FILE");
|
|
179
|
+
if (mcpCfg && mcpCfg.trim().length) {
|
|
180
|
+
cfg.claude.mcp = cfg.claude.mcp ?? { strict: false };
|
|
181
|
+
cfg.claude.mcp.config_file = mcpCfg.trim();
|
|
182
|
+
}
|
|
183
|
+
// Jobs
|
|
184
|
+
const pcc = get("KAYLA_JOBS_PER_CHAT_CONCURRENCY");
|
|
185
|
+
if (pcc && pcc.trim().length)
|
|
186
|
+
cfg.jobs.per_chat_concurrency = parseNumber(pcc);
|
|
187
|
+
const gcc = get("KAYLA_JOBS_GLOBAL_CONCURRENCY");
|
|
188
|
+
if (gcc && gcc.trim().length)
|
|
189
|
+
cfg.jobs.global_concurrency = parseNumber(gcc);
|
|
190
|
+
const qsz = get("KAYLA_JOBS_QUEUE_SIZE_PER_CHAT");
|
|
191
|
+
if (qsz && qsz.trim().length)
|
|
192
|
+
cfg.jobs.queue_size_per_chat = parseNumber(qsz);
|
|
193
|
+
// Logging
|
|
194
|
+
const lvl = get("KAYLA_LOGGING_LEVEL");
|
|
195
|
+
if (lvl && lvl.trim().length)
|
|
196
|
+
cfg.logging.level = lvl.trim();
|
|
197
|
+
const js = get("KAYLA_LOGGING_JSON");
|
|
198
|
+
if (js && js.trim().length)
|
|
199
|
+
cfg.logging.json = parseBool(js);
|
|
200
|
+
}
|
|
201
|
+
function applyYaml(cfg, doc) {
|
|
202
|
+
if (!isRecord(doc))
|
|
203
|
+
return;
|
|
204
|
+
const t = isRecord(doc.telegram) ? doc.telegram : undefined;
|
|
205
|
+
if (t) {
|
|
206
|
+
const token = optionalString(t.token);
|
|
207
|
+
if (token)
|
|
208
|
+
cfg.telegram.token = token;
|
|
209
|
+
const mode = optionalString(t.mode);
|
|
210
|
+
if (mode)
|
|
211
|
+
cfg.telegram.mode = mode === "webhook" ? "webhook" : "polling";
|
|
212
|
+
if (isRecord(t.allowlist) && Array.isArray(t.allowlist.user_ids) && t.allowlist.user_ids.every((x) => typeof x === "number")) {
|
|
213
|
+
cfg.telegram.allowlist.user_ids = t.allowlist.user_ids;
|
|
214
|
+
}
|
|
215
|
+
if (Array.isArray(t.admin_user_ids) && t.admin_user_ids.every((x) => typeof x === "number")) {
|
|
216
|
+
cfg.telegram.admin_user_ids = t.admin_user_ids;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const r = isRecord(doc.runtime) ? doc.runtime : undefined;
|
|
220
|
+
if (r) {
|
|
221
|
+
const dd = optionalString(r.data_dir);
|
|
222
|
+
const wd = optionalString(r.workspaces_dir);
|
|
223
|
+
const ud = optionalString(r.uploads_dir);
|
|
224
|
+
if (dd)
|
|
225
|
+
cfg.runtime.data_dir = dd;
|
|
226
|
+
if (wd)
|
|
227
|
+
cfg.runtime.workspaces_dir = wd;
|
|
228
|
+
if (ud)
|
|
229
|
+
cfg.runtime.uploads_dir = ud;
|
|
230
|
+
if (dd && !wd)
|
|
231
|
+
cfg.runtime.workspaces_dir = node_path_1.default.join(cfg.runtime.data_dir, "workspaces");
|
|
232
|
+
if (dd && !ud)
|
|
233
|
+
cfg.runtime.uploads_dir = node_path_1.default.join(cfg.runtime.data_dir, "uploads");
|
|
234
|
+
}
|
|
235
|
+
const c = isRecord(doc.claude) ? doc.claude : undefined;
|
|
236
|
+
if (c) {
|
|
237
|
+
const bin = optionalString(c.binary);
|
|
238
|
+
if (bin)
|
|
239
|
+
cfg.claude.binary = bin;
|
|
240
|
+
const dm = optionalString(c.default_model);
|
|
241
|
+
if (dm)
|
|
242
|
+
cfg.claude.default_model = dm;
|
|
243
|
+
if (typeof c.max_turns === "number")
|
|
244
|
+
cfg.claude.max_turns = c.max_turns;
|
|
245
|
+
if (typeof c.timeout_seconds === "number")
|
|
246
|
+
cfg.claude.timeout_seconds = c.timeout_seconds;
|
|
247
|
+
if (typeof c.streaming === "boolean")
|
|
248
|
+
cfg.claude.streaming = c.streaming;
|
|
249
|
+
const tools = optionalString(c.tools);
|
|
250
|
+
if (tools)
|
|
251
|
+
cfg.claude.tools = tools;
|
|
252
|
+
if (Array.isArray(c.allowed_tools) && c.allowed_tools.every((x) => typeof x === "string"))
|
|
253
|
+
cfg.claude.allowed_tools = c.allowed_tools;
|
|
254
|
+
if (Array.isArray(c.disallowed_tools) && c.disallowed_tools.every((x) => typeof x === "string"))
|
|
255
|
+
cfg.claude.disallowed_tools = c.disallowed_tools;
|
|
256
|
+
cfg.claude.append_system_prompt_file = optionalPath(c.append_system_prompt_file) ?? cfg.claude.append_system_prompt_file;
|
|
257
|
+
cfg.claude.agents_file = optionalPath(c.agents_file) ?? cfg.claude.agents_file;
|
|
258
|
+
if (isRecord(c.mcp)) {
|
|
259
|
+
cfg.claude.mcp = cfg.claude.mcp ?? { strict: false };
|
|
260
|
+
if (typeof c.mcp.strict === "boolean")
|
|
261
|
+
cfg.claude.mcp.strict = c.mcp.strict;
|
|
262
|
+
cfg.claude.mcp.config_file = optionalPath(c.mcp.config_file) ?? cfg.claude.mcp.config_file;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const j = isRecord(doc.jobs) ? doc.jobs : undefined;
|
|
266
|
+
if (j) {
|
|
267
|
+
if (typeof j.per_chat_concurrency === "number")
|
|
268
|
+
cfg.jobs.per_chat_concurrency = j.per_chat_concurrency;
|
|
269
|
+
if (typeof j.global_concurrency === "number")
|
|
270
|
+
cfg.jobs.global_concurrency = j.global_concurrency;
|
|
271
|
+
if (typeof j.queue_size_per_chat === "number")
|
|
272
|
+
cfg.jobs.queue_size_per_chat = j.queue_size_per_chat;
|
|
273
|
+
}
|
|
274
|
+
const l = isRecord(doc.logging) ? doc.logging : undefined;
|
|
275
|
+
if (l) {
|
|
276
|
+
const level = optionalString(l.level);
|
|
277
|
+
if (level)
|
|
278
|
+
cfg.logging.level = level;
|
|
279
|
+
if (typeof l.json === "boolean")
|
|
280
|
+
cfg.logging.json = l.json;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function validate(cfg) {
|
|
284
|
+
if (cfg.jobs.per_chat_concurrency !== 1)
|
|
285
|
+
throw new Error("Invalid config: jobs.per_chat_concurrency must be 1 (MVP constraint)");
|
|
286
|
+
if (cfg.jobs.global_concurrency < 1)
|
|
287
|
+
throw new Error("Invalid config: jobs.global_concurrency must be >= 1");
|
|
288
|
+
// Derive output_format from streaming by default (kept for compatibility).
|
|
289
|
+
cfg.claude.output_format = cfg.claude.streaming ? "stream-json" : "json";
|
|
290
|
+
if (!cfg.telegram.token || cfg.telegram.token.trim().length === 0) {
|
|
291
|
+
throw new Error("Missing telegram token: set telegram.token in config.yaml or KAYLA_TELEGRAM_TOKEN in env");
|
|
292
|
+
}
|
|
293
|
+
if (cfg.telegram.admin_user_ids.length === 0 && cfg.telegram.allowlist.user_ids.length === 0) {
|
|
294
|
+
throw new Error("Missing bootstrap allowlist: set telegram.admin_user_ids (or telegram.allowlist.user_ids) in config/env");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function loadConfig(opts) {
|
|
298
|
+
const env = opts?.env ?? process.env;
|
|
299
|
+
const configPath = opts?.configPath ?? defaultConfigPath(env);
|
|
300
|
+
const cfg = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
|
|
301
|
+
// HOME-aware defaults (XDG-ish) for production; can be overridden by env/yaml.
|
|
302
|
+
const dd = defaultDataDir(env);
|
|
303
|
+
cfg.runtime.data_dir = dd;
|
|
304
|
+
cfg.runtime.workspaces_dir = node_path_1.default.join(dd, "workspaces");
|
|
305
|
+
cfg.runtime.uploads_dir = node_path_1.default.join(dd, "uploads");
|
|
306
|
+
// 1) env defaults
|
|
307
|
+
applyEnv(cfg, env);
|
|
308
|
+
// 2) yaml overrides
|
|
309
|
+
const doc = readYamlIfExists(configPath);
|
|
310
|
+
if (doc !== undefined)
|
|
311
|
+
applyYaml(cfg, doc);
|
|
312
|
+
validate(cfg);
|
|
313
|
+
return cfg;
|
|
314
|
+
}
|