@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
|
@@ -0,0 +1,175 @@
|
|
|
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.resolveConfigPath = resolveConfigPath;
|
|
7
|
+
exports.resolveRuntimeDataDir = resolveRuntimeDataDir;
|
|
8
|
+
exports.readDoctorConfigInfo = readDoctorConfigInfo;
|
|
9
|
+
exports.checkConfig = checkConfig;
|
|
10
|
+
exports.checkDirs = checkDirs;
|
|
11
|
+
exports.checkSqlite = checkSqlite;
|
|
12
|
+
exports.checkClaude = checkClaude;
|
|
13
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
14
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
15
|
+
const node_child_process_1 = require("node:child_process");
|
|
16
|
+
const yaml_1 = __importDefault(require("yaml"));
|
|
17
|
+
const storage_1 = require("./storage");
|
|
18
|
+
function isRecord(v) {
|
|
19
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
20
|
+
}
|
|
21
|
+
function resolveConfigPath(env) {
|
|
22
|
+
const p = env.KAYLA_CONFIG?.trim();
|
|
23
|
+
if (p)
|
|
24
|
+
return p;
|
|
25
|
+
const home = env.HOME?.trim();
|
|
26
|
+
if (home)
|
|
27
|
+
return node_path_1.default.join(home, ".config", "kayla", "config.yaml");
|
|
28
|
+
return node_path_1.default.join(process.cwd(), "config", "config.yaml");
|
|
29
|
+
}
|
|
30
|
+
function resolveRuntimeDataDir(env) {
|
|
31
|
+
const dd = env.KAYLA_RUNTIME_DATA_DIR?.trim();
|
|
32
|
+
if (dd)
|
|
33
|
+
return dd;
|
|
34
|
+
const cfgPath = resolveConfigPath(env);
|
|
35
|
+
const info = readDoctorConfigInfo(cfgPath);
|
|
36
|
+
const dataDir = info.runtime?.dataDir?.trim();
|
|
37
|
+
if (dataDir)
|
|
38
|
+
return dataDir;
|
|
39
|
+
const home = env.HOME?.trim();
|
|
40
|
+
if (home)
|
|
41
|
+
return node_path_1.default.join(home, ".local", "share", "kayla");
|
|
42
|
+
return ".kayla-data";
|
|
43
|
+
}
|
|
44
|
+
function readDoctorConfigInfo(configPath) {
|
|
45
|
+
if (!node_fs_1.default.existsSync(configPath))
|
|
46
|
+
return {};
|
|
47
|
+
try {
|
|
48
|
+
const doc = yaml_1.default.parse(node_fs_1.default.readFileSync(configPath, "utf8"));
|
|
49
|
+
if (!isRecord(doc))
|
|
50
|
+
return {};
|
|
51
|
+
const r = isRecord(doc.runtime) ? doc.runtime : undefined;
|
|
52
|
+
const dataDir = typeof r?.data_dir === "string" ? r.data_dir.trim() : "";
|
|
53
|
+
const workspacesDir = typeof r?.workspaces_dir === "string" ? r.workspaces_dir.trim() : "";
|
|
54
|
+
const uploadsDir = typeof r?.uploads_dir === "string" ? r.uploads_dir.trim() : "";
|
|
55
|
+
const c = isRecord(doc.claude) ? doc.claude : undefined;
|
|
56
|
+
const binary = typeof c?.binary === "string" ? c.binary.trim() : "";
|
|
57
|
+
const out = {};
|
|
58
|
+
if (dataDir || workspacesDir || uploadsDir) {
|
|
59
|
+
out.runtime = {
|
|
60
|
+
dataDir: dataDir || undefined,
|
|
61
|
+
workspacesDir: workspacesDir || undefined,
|
|
62
|
+
uploadsDir: uploadsDir || undefined
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (binary)
|
|
66
|
+
out.claudeBinary = binary;
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function checkConfig(configPath) {
|
|
74
|
+
if (!node_fs_1.default.existsSync(configPath)) {
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
id: "config.file",
|
|
78
|
+
ok: false,
|
|
79
|
+
summary: `Missing config file: ${configPath}`,
|
|
80
|
+
details: "Create config and set telegram.token + telegram.admin_user_ids (or telegram.allowlist.user_ids)."
|
|
81
|
+
}
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
let doc;
|
|
85
|
+
try {
|
|
86
|
+
doc = yaml_1.default.parse(node_fs_1.default.readFileSync(configPath, "utf8"));
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
id: "config.parse",
|
|
92
|
+
ok: false,
|
|
93
|
+
summary: `Failed to parse config: ${configPath}`,
|
|
94
|
+
details: err instanceof Error ? err.message : String(err)
|
|
95
|
+
}
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
if (!isRecord(doc)) {
|
|
99
|
+
return [{ id: "config.schema", ok: false, summary: "Invalid config: expected YAML object" }];
|
|
100
|
+
}
|
|
101
|
+
const t = isRecord(doc.telegram) ? doc.telegram : undefined;
|
|
102
|
+
if (!t)
|
|
103
|
+
return [{ id: "config.telegram", ok: false, summary: "Invalid config: missing telegram section" }];
|
|
104
|
+
const token = typeof t.token === "string" ? t.token.trim() : "";
|
|
105
|
+
const hasToken = token.length > 0;
|
|
106
|
+
const admins = Array.isArray(t.admin_user_ids) ? t.admin_user_ids : [];
|
|
107
|
+
const allow = isRecord(t.allowlist) && Array.isArray(t.allowlist.user_ids) ? t.allowlist.user_ids : [];
|
|
108
|
+
const hasBootstrap = admins.length + allow.length > 0;
|
|
109
|
+
const out = [];
|
|
110
|
+
out.push({ id: "config.file", ok: true, summary: `Config file found: ${configPath}` });
|
|
111
|
+
out.push({ id: "config.telegram.token", ok: hasToken, summary: hasToken ? "telegram.token: present" : "telegram.token: missing" });
|
|
112
|
+
out.push({
|
|
113
|
+
id: "config.telegram.bootstrap",
|
|
114
|
+
ok: hasBootstrap,
|
|
115
|
+
summary: hasBootstrap ? "bootstrap allowlist: present" : "bootstrap allowlist: missing",
|
|
116
|
+
details: hasBootstrap ? undefined : "Set telegram.admin_user_ids (or telegram.allowlist.user_ids)."
|
|
117
|
+
});
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
function checkDirs(paths) {
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const [id, p] of [
|
|
123
|
+
["runtime.data_dir", paths.dataDir],
|
|
124
|
+
["runtime.workspaces_dir", paths.workspacesDir],
|
|
125
|
+
["runtime.uploads_dir", paths.uploadsDir]
|
|
126
|
+
]) {
|
|
127
|
+
try {
|
|
128
|
+
node_fs_1.default.mkdirSync(p, { recursive: true });
|
|
129
|
+
node_fs_1.default.accessSync(p, node_fs_1.default.constants.W_OK);
|
|
130
|
+
out.push({ id: `dirs.${id}`, ok: true, summary: `${id}: writable (${p})` });
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
out.push({
|
|
134
|
+
id: `dirs.${id}`,
|
|
135
|
+
ok: false,
|
|
136
|
+
summary: `${id}: not writable (${p})`,
|
|
137
|
+
details: err instanceof Error ? err.message : String(err)
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
function checkSqlite(dataDir) {
|
|
144
|
+
try {
|
|
145
|
+
const storage = (0, storage_1.openStorage)(dataDir);
|
|
146
|
+
return { id: "sqlite.open", ok: true, summary: `SQLite OK (${storage.dbPath() ?? ""})`.trim() };
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
return {
|
|
150
|
+
id: "sqlite.open",
|
|
151
|
+
ok: false,
|
|
152
|
+
summary: "Failed to open SQLite",
|
|
153
|
+
details: err instanceof Error ? err.message : String(err)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function checkClaude(binary, env) {
|
|
158
|
+
const b = binary.trim() || "claude";
|
|
159
|
+
try {
|
|
160
|
+
(0, node_child_process_1.execFileSync)(b, ["--version"], {
|
|
161
|
+
env: { ...process.env, ...env },
|
|
162
|
+
stdio: "ignore",
|
|
163
|
+
timeout: 5000
|
|
164
|
+
});
|
|
165
|
+
return { id: "claude", ok: true, summary: `claude OK (${b})` };
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return {
|
|
169
|
+
id: "claude",
|
|
170
|
+
ok: false,
|
|
171
|
+
summary: `claude failed (${b})`,
|
|
172
|
+
details: "Ensure Claude Code CLI is installed and available in PATH for the service user."
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KaylaService = void 0;
|
|
4
|
+
const sessions_1 = require("./sessions");
|
|
5
|
+
const runner_1 = require("./runner");
|
|
6
|
+
const telegram_1 = require("./telegram");
|
|
7
|
+
class Semaphore {
|
|
8
|
+
constructor(size) {
|
|
9
|
+
this.waiters = [];
|
|
10
|
+
this.available = size;
|
|
11
|
+
}
|
|
12
|
+
async acquire() {
|
|
13
|
+
if (this.available > 0) {
|
|
14
|
+
this.available -= 1;
|
|
15
|
+
return () => this.release();
|
|
16
|
+
}
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
this.waiters.push(resolve);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
release() {
|
|
22
|
+
this.available += 1;
|
|
23
|
+
const next = this.waiters.shift();
|
|
24
|
+
if (next && this.available > 0) {
|
|
25
|
+
this.available -= 1;
|
|
26
|
+
next(() => this.release());
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
class KaylaService {
|
|
31
|
+
constructor(deps) {
|
|
32
|
+
this.deps = deps;
|
|
33
|
+
this.chats = new Map();
|
|
34
|
+
this.semaphore = new Semaphore(this.deps.cfg.jobs.global_concurrency);
|
|
35
|
+
this.runClaudeImpl = this.deps.runClaudeImpl ?? runner_1.runClaude;
|
|
36
|
+
}
|
|
37
|
+
isAllowedTelegramUser(userId) {
|
|
38
|
+
return this.deps.storage.isAllowedUser("telegram", String(userId));
|
|
39
|
+
}
|
|
40
|
+
async handleStartWithUser(chatId, userId) {
|
|
41
|
+
if (this.isAllowedTelegramUser(userId)) {
|
|
42
|
+
await this.deps.api.sendMessage(chatId, "Kayla: ready.\n\nCommands: /status /new /reset yes /cancel\nSend a message to run it via Claude Code CLI.");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const { code } = this.deps.storage.getOrCreateOnboardingCode("telegram", String(chatId), String(userId));
|
|
46
|
+
await this.deps.api.sendMessage(chatId, `Access not granted.\n\nCode: ${code}\n\nAsk admin to run:\nkayla telegram users approve ${code}`);
|
|
47
|
+
}
|
|
48
|
+
async handleStatus(chatId) {
|
|
49
|
+
// Status is only for allowlisted users.
|
|
50
|
+
// Handlers enforce allowlist and ignore non-allowlisted updates.
|
|
51
|
+
const running = this.deps.storage.getRunningJob(String(chatId));
|
|
52
|
+
const queued = this.deps.storage.countQueuedJobs(String(chatId));
|
|
53
|
+
const chat = this.deps.storage.getChat(String(chatId));
|
|
54
|
+
const lines = [];
|
|
55
|
+
lines.push(`chat_id: ${chatId}`);
|
|
56
|
+
lines.push(`running: ${running ? running.job_id : "none"}`);
|
|
57
|
+
lines.push(`queued: ${queued}`);
|
|
58
|
+
if (chat) {
|
|
59
|
+
lines.push(`workspace: ${chat.workspace_path}`);
|
|
60
|
+
lines.push(`streaming: ${chat.settings.streaming ?? this.deps.cfg.claude.streaming}`);
|
|
61
|
+
lines.push(`tools: ${chat.settings.tools ?? this.deps.cfg.claude.tools}`);
|
|
62
|
+
lines.push(`model: ${(chat.settings.model ?? this.deps.cfg.claude.default_model) || "(default)"}`);
|
|
63
|
+
}
|
|
64
|
+
await this.deps.api.sendMessage(chatId, lines.join("\n"));
|
|
65
|
+
}
|
|
66
|
+
async handleNew(chatId, userId) {
|
|
67
|
+
if (this.isChatBusy(chatId)) {
|
|
68
|
+
await this.deps.api.sendMessage(chatId, "Busy: cancel the running job first with /cancel.");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const s = (0, sessions_1.createNewSession)(this.deps.storage, this.deps.cfg, chatId, userId);
|
|
72
|
+
await this.deps.api.sendMessage(chatId, `New session started.\nworkspace: ${s.workspacePath}`);
|
|
73
|
+
}
|
|
74
|
+
async handleReset(chatId, userId, confirm) {
|
|
75
|
+
if (!confirm) {
|
|
76
|
+
await this.deps.api.sendMessage(chatId, "Reset is destructive. Confirm with: /reset yes");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (this.isChatBusy(chatId)) {
|
|
80
|
+
await this.deps.api.sendMessage(chatId, "Busy: cancel the running job first with /cancel.");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const s = (0, sessions_1.resetSession)(this.deps.storage, this.deps.cfg, chatId, userId);
|
|
84
|
+
await this.deps.api.sendMessage(chatId, `Session reset.\nworkspace: ${s.workspacePath}`);
|
|
85
|
+
}
|
|
86
|
+
async handleCancel(chatId) {
|
|
87
|
+
const state = this.getChatState(chatId);
|
|
88
|
+
if (!state.active) {
|
|
89
|
+
await this.deps.api.sendMessage(chatId, "Nothing to cancel.");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
state.active.cancel();
|
|
93
|
+
await this.deps.api.sendMessage(chatId, `Cancel requested: ${state.active.jobId}`);
|
|
94
|
+
}
|
|
95
|
+
async enqueueUserMessage(chatId, userId, requestText) {
|
|
96
|
+
const state = this.getChatState(chatId);
|
|
97
|
+
const queuedCount = state.queue.length + (state.active ? 1 : 0);
|
|
98
|
+
if (queuedCount >= this.deps.cfg.jobs.queue_size_per_chat) {
|
|
99
|
+
await this.deps.api.sendMessage(chatId, "Queue is full for this chat. Please wait or /cancel.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Ensure chat exists + workspace created.
|
|
103
|
+
(0, sessions_1.getOrCreateSession)(this.deps.storage, this.deps.cfg, chatId, userId);
|
|
104
|
+
const job = this.deps.storage.insertJob({ chatId: String(chatId), requestText });
|
|
105
|
+
state.queue.push({ jobId: job.job_id, chatId, userId, requestText });
|
|
106
|
+
this.kick(chatId).catch((err) => {
|
|
107
|
+
this.deps.logger.error({ err, msg: "kick failed", chatId });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
getChatState(chatId) {
|
|
111
|
+
const existing = this.chats.get(chatId);
|
|
112
|
+
if (existing)
|
|
113
|
+
return existing;
|
|
114
|
+
const s = { queue: [], processing: false };
|
|
115
|
+
this.chats.set(chatId, s);
|
|
116
|
+
return s;
|
|
117
|
+
}
|
|
118
|
+
isChatBusy(chatId) {
|
|
119
|
+
const s = this.getChatState(chatId);
|
|
120
|
+
return !!s.active || s.queue.length > 0 || this.deps.storage.getRunningJob(String(chatId)) !== undefined;
|
|
121
|
+
}
|
|
122
|
+
async kick(chatId) {
|
|
123
|
+
const state = this.getChatState(chatId);
|
|
124
|
+
if (state.processing)
|
|
125
|
+
return;
|
|
126
|
+
state.processing = true;
|
|
127
|
+
try {
|
|
128
|
+
while (state.queue.length > 0) {
|
|
129
|
+
const item = state.queue.shift();
|
|
130
|
+
const release = await this.semaphore.acquire();
|
|
131
|
+
try {
|
|
132
|
+
await this.runOne(item, state);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
this.deps.logger.error({ err, msg: "job processing failed", chatId: item.chatId, jobId: item.jobId });
|
|
136
|
+
// Best-effort mark job as failed so /status isn't stuck.
|
|
137
|
+
try {
|
|
138
|
+
this.deps.storage.setJobFinished({
|
|
139
|
+
jobId: item.jobId,
|
|
140
|
+
status: "failed",
|
|
141
|
+
error: err instanceof Error ? err.message : "unknown error"
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// ignore secondary failures
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
release();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
state.processing = false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async runOne(item, state) {
|
|
158
|
+
const { chatId, userId, jobId, requestText } = item;
|
|
159
|
+
const logger = this.deps.logger.child({ chatId, jobId });
|
|
160
|
+
logger.info({ phase: "enqueue", msg: "starting job" });
|
|
161
|
+
const chat = this.deps.storage.getChat(String(chatId));
|
|
162
|
+
const session = (0, sessions_1.getOrCreateSession)(this.deps.storage, this.deps.cfg, chatId, userId);
|
|
163
|
+
const streaming = chat?.settings.streaming ?? this.deps.cfg.claude.streaming;
|
|
164
|
+
let messageId;
|
|
165
|
+
let editor;
|
|
166
|
+
let assistantSoFar = "";
|
|
167
|
+
if (streaming) {
|
|
168
|
+
const placeholder = await this.deps.api.sendMessage(chatId, "...");
|
|
169
|
+
messageId = placeholder.message_id;
|
|
170
|
+
this.deps.storage.setJobTelegramMessageId(jobId, messageId);
|
|
171
|
+
editor = new telegram_1.ThrottledTelegramEditor(this.deps.api, chatId, messageId, 1000, logger);
|
|
172
|
+
}
|
|
173
|
+
this.deps.storage.setJobRunning(jobId);
|
|
174
|
+
const running = this.runClaudeImpl({
|
|
175
|
+
cfg: this.deps.cfg,
|
|
176
|
+
cwd: session.workspacePath,
|
|
177
|
+
prompt: requestText,
|
|
178
|
+
chatSettings: chat?.settings,
|
|
179
|
+
onTextDelta: (delta) => {
|
|
180
|
+
assistantSoFar += delta;
|
|
181
|
+
if (editor)
|
|
182
|
+
editor.request(assistantSoFar);
|
|
183
|
+
},
|
|
184
|
+
logger
|
|
185
|
+
});
|
|
186
|
+
state.active = { jobId, cancel: running.cancel };
|
|
187
|
+
let resultStatus = "succeeded";
|
|
188
|
+
let resultError = null;
|
|
189
|
+
let exitCode = null;
|
|
190
|
+
let stderr = "";
|
|
191
|
+
try {
|
|
192
|
+
const res = await running.promise;
|
|
193
|
+
exitCode = res.exitCode;
|
|
194
|
+
stderr = res.stderr;
|
|
195
|
+
if (res.timedOut) {
|
|
196
|
+
resultStatus = "timeout";
|
|
197
|
+
resultError = "timeout";
|
|
198
|
+
}
|
|
199
|
+
else if (res.canceled) {
|
|
200
|
+
resultStatus = "canceled";
|
|
201
|
+
resultError = "canceled";
|
|
202
|
+
}
|
|
203
|
+
else if (res.exitCode !== 0) {
|
|
204
|
+
resultStatus = "failed";
|
|
205
|
+
resultError = `exit_code=${res.exitCode}`;
|
|
206
|
+
}
|
|
207
|
+
if (!assistantSoFar)
|
|
208
|
+
assistantSoFar = res.assistantText;
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
resultStatus = "failed";
|
|
212
|
+
resultError = err instanceof Error ? err.message : "unknown error";
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
state.active = undefined;
|
|
216
|
+
}
|
|
217
|
+
logger.info({ phase: "finish", status: resultStatus, exitCode, msg: "job finished" });
|
|
218
|
+
if (stderr)
|
|
219
|
+
logger.debug({ stderr, msg: "claude stderr" });
|
|
220
|
+
this.deps.storage.setJobFinished({ jobId, status: resultStatus, responseText: assistantSoFar, error: resultError, exitCode });
|
|
221
|
+
if (streaming && messageId && editor) {
|
|
222
|
+
editor.request(assistantSoFar || "(no output)");
|
|
223
|
+
await editor.flush();
|
|
224
|
+
// If too long, send remaining chunks as additional messages.
|
|
225
|
+
const finalText = assistantSoFar || "(no output)";
|
|
226
|
+
const parts = (0, telegram_1.splitTelegramText)(finalText);
|
|
227
|
+
if (parts.length > 1) {
|
|
228
|
+
// Keep the edited message readable (first chunk), send the rest.
|
|
229
|
+
await this.deps.api.editMessageText(chatId, messageId, (0, telegram_1.truncateForEdit)(parts[0]));
|
|
230
|
+
for (const p of parts.slice(1)) {
|
|
231
|
+
await this.deps.api.sendMessage(chatId, p);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (resultStatus === "failed") {
|
|
235
|
+
await this.deps.api.sendMessage(chatId, `Job failed (${jobId}).`);
|
|
236
|
+
}
|
|
237
|
+
else if (resultStatus === "timeout") {
|
|
238
|
+
await this.deps.api.sendMessage(chatId, `Job timed out (${jobId}).`);
|
|
239
|
+
}
|
|
240
|
+
else if (resultStatus === "canceled") {
|
|
241
|
+
await this.deps.api.sendMessage(chatId, `Canceled (${jobId}).`);
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// Non-streaming: send one or multiple messages after completion.
|
|
246
|
+
const finalText = assistantSoFar || "(no output)";
|
|
247
|
+
for (const p of (0, telegram_1.splitTelegramText)(finalText)) {
|
|
248
|
+
await this.deps.api.sendMessage(chatId, p);
|
|
249
|
+
}
|
|
250
|
+
if (resultStatus !== "succeeded") {
|
|
251
|
+
await this.deps.api.sendMessage(chatId, `Job ${resultStatus} (${jobId}).`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
exports.KaylaService = KaylaService;
|
|
@@ -0,0 +1,14 @@
|
|
|
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.createLogger = createLogger;
|
|
7
|
+
const pino_1 = __importDefault(require("pino"));
|
|
8
|
+
function createLogger(cfg) {
|
|
9
|
+
return (0, pino_1.default)({
|
|
10
|
+
level: cfg.level,
|
|
11
|
+
// If json=false, we still log JSON objects; pino-pretty can be added later.
|
|
12
|
+
base: null
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tryParseJson = tryParseJson;
|
|
4
|
+
exports.extractTextPieces = extractTextPieces;
|
|
5
|
+
exports.appendStreamJsonLine = appendStreamJsonLine;
|
|
6
|
+
exports.extractAssistantTextFromJsonOutput = extractAssistantTextFromJsonOutput;
|
|
7
|
+
function tryParseJson(line) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(line);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function isRecord(v) {
|
|
16
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
17
|
+
}
|
|
18
|
+
function extractTextFromContentArray(v) {
|
|
19
|
+
if (!Array.isArray(v))
|
|
20
|
+
return undefined;
|
|
21
|
+
const parts = [];
|
|
22
|
+
for (const item of v) {
|
|
23
|
+
if (!isRecord(item))
|
|
24
|
+
continue;
|
|
25
|
+
const type = item.type;
|
|
26
|
+
if (type === "text" && typeof item.text === "string") {
|
|
27
|
+
parts.push(item.text);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (typeof item.text === "string")
|
|
31
|
+
parts.push(item.text);
|
|
32
|
+
if (typeof item.value === "string")
|
|
33
|
+
parts.push(item.value);
|
|
34
|
+
}
|
|
35
|
+
return parts.length ? parts.join("") : undefined;
|
|
36
|
+
}
|
|
37
|
+
function extractTextPieces(event) {
|
|
38
|
+
if (!isRecord(event))
|
|
39
|
+
return [];
|
|
40
|
+
// Priority-ish search (best-effort) based on the task requirements.
|
|
41
|
+
const msg = event.message;
|
|
42
|
+
if (isRecord(msg)) {
|
|
43
|
+
const content = msg.content;
|
|
44
|
+
if (typeof content === "string")
|
|
45
|
+
return [content];
|
|
46
|
+
const fromArray = extractTextFromContentArray(content);
|
|
47
|
+
if (typeof fromArray === "string" && fromArray.length)
|
|
48
|
+
return [fromArray];
|
|
49
|
+
}
|
|
50
|
+
const delta = event.delta;
|
|
51
|
+
if (isRecord(delta) && typeof delta.text === "string" && delta.text.length)
|
|
52
|
+
return [delta.text];
|
|
53
|
+
const content = event.content;
|
|
54
|
+
if (isRecord(content) && typeof content.text === "string" && content.text.length)
|
|
55
|
+
return [content.text];
|
|
56
|
+
const contentFromArray = extractTextFromContentArray(content);
|
|
57
|
+
if (typeof contentFromArray === "string" && contentFromArray.length)
|
|
58
|
+
return [contentFromArray];
|
|
59
|
+
// Fallback: crawl a few common shapes without being too clever.
|
|
60
|
+
const parts = [];
|
|
61
|
+
for (const k of ["text", "value"]) {
|
|
62
|
+
const v = event[k];
|
|
63
|
+
if (typeof v === "string")
|
|
64
|
+
parts.push(v);
|
|
65
|
+
}
|
|
66
|
+
return parts;
|
|
67
|
+
}
|
|
68
|
+
function appendStreamJsonLine(opts) {
|
|
69
|
+
const parsed = tryParseJson(opts.line);
|
|
70
|
+
if (parsed === undefined) {
|
|
71
|
+
opts.onWarn?.("non-json line");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const pieces = extractTextPieces(parsed);
|
|
75
|
+
for (const p of pieces) {
|
|
76
|
+
if (p)
|
|
77
|
+
opts.onText(p);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function extractAssistantTextFromJsonOutput(v) {
|
|
81
|
+
const pieces = extractTextPieces(v);
|
|
82
|
+
if (pieces.length)
|
|
83
|
+
return pieces.join("");
|
|
84
|
+
// Some CLIs wrap the final content in { message: { content: [...] } } etc.
|
|
85
|
+
if (isRecord(v) && isRecord(v.result)) {
|
|
86
|
+
const inner = extractTextPieces(v.result);
|
|
87
|
+
if (inner.length)
|
|
88
|
+
return inner.join("");
|
|
89
|
+
}
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
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.buildClaudeArgs = buildClaudeArgs;
|
|
7
|
+
exports.runClaude = runClaude;
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_child_process_1 = require("node:child_process");
|
|
10
|
+
const node_readline_1 = __importDefault(require("node:readline"));
|
|
11
|
+
const parser_1 = require("./parser");
|
|
12
|
+
function fileExists(p) {
|
|
13
|
+
try {
|
|
14
|
+
return node_fs_1.default.existsSync(p);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function buildClaudeArgs(cfg, prompt, chatSettings) {
|
|
21
|
+
const args = [];
|
|
22
|
+
args.push("-c", "-p");
|
|
23
|
+
const outputFormat = cfg.claude.streaming ? "stream-json" : "json";
|
|
24
|
+
args.push("--output-format", outputFormat);
|
|
25
|
+
// Chat-level overrides (optional).
|
|
26
|
+
const tools = chatSettings?.tools ?? cfg.claude.tools;
|
|
27
|
+
const model = chatSettings?.model ?? cfg.claude.default_model;
|
|
28
|
+
if (model && model.length)
|
|
29
|
+
args.push("--model", model);
|
|
30
|
+
args.push("--max-turns", String(cfg.claude.max_turns));
|
|
31
|
+
args.push("--tools", tools);
|
|
32
|
+
if (cfg.claude.mcp?.config_file) {
|
|
33
|
+
args.push("--mcp-config", cfg.claude.mcp.config_file);
|
|
34
|
+
if (cfg.claude.mcp.strict)
|
|
35
|
+
args.push("--strict-mcp-config");
|
|
36
|
+
}
|
|
37
|
+
if (cfg.claude.append_system_prompt_file) {
|
|
38
|
+
args.push("--append-system-prompt-file", cfg.claude.append_system_prompt_file);
|
|
39
|
+
}
|
|
40
|
+
if (cfg.claude.agents_file) {
|
|
41
|
+
const agentsJson = node_fs_1.default.readFileSync(cfg.claude.agents_file, "utf8");
|
|
42
|
+
args.push("--agents", agentsJson);
|
|
43
|
+
}
|
|
44
|
+
args.push(prompt);
|
|
45
|
+
return args;
|
|
46
|
+
}
|
|
47
|
+
function runClaude(opts) {
|
|
48
|
+
// Validate optional files early for clearer errors.
|
|
49
|
+
if (opts.cfg.claude.append_system_prompt_file && !fileExists(opts.cfg.claude.append_system_prompt_file)) {
|
|
50
|
+
throw new Error(`append_system_prompt_file not found: ${opts.cfg.claude.append_system_prompt_file}`);
|
|
51
|
+
}
|
|
52
|
+
if (opts.cfg.claude.agents_file && !fileExists(opts.cfg.claude.agents_file)) {
|
|
53
|
+
throw new Error(`agents_file not found: ${opts.cfg.claude.agents_file}`);
|
|
54
|
+
}
|
|
55
|
+
if (opts.cfg.claude.mcp?.config_file && !fileExists(opts.cfg.claude.mcp.config_file)) {
|
|
56
|
+
throw new Error(`mcp config_file not found: ${opts.cfg.claude.mcp.config_file}`);
|
|
57
|
+
}
|
|
58
|
+
const args = buildClaudeArgs(opts.cfg, opts.prompt, opts.chatSettings);
|
|
59
|
+
const child = (0, node_child_process_1.spawn)(opts.cfg.claude.binary, args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
60
|
+
let assistantText = "";
|
|
61
|
+
let stderr = "";
|
|
62
|
+
let canceled = false;
|
|
63
|
+
let timedOut = false;
|
|
64
|
+
let killTimer;
|
|
65
|
+
let hardKillTimer;
|
|
66
|
+
let settled = false;
|
|
67
|
+
let spawnError = null;
|
|
68
|
+
child.stderr.on("data", (buf) => {
|
|
69
|
+
stderr += buf.toString("utf8");
|
|
70
|
+
});
|
|
71
|
+
child.on("error", (err) => {
|
|
72
|
+
spawnError = err instanceof Error ? err.message : "spawn error";
|
|
73
|
+
// Close might not fire on spawn errors; resolve via promise handler below.
|
|
74
|
+
});
|
|
75
|
+
const outputFormat = opts.cfg.claude.streaming ? "stream-json" : "json";
|
|
76
|
+
if (outputFormat === "stream-json") {
|
|
77
|
+
const rl = node_readline_1.default.createInterface({ input: child.stdout });
|
|
78
|
+
rl.on("line", (line) => {
|
|
79
|
+
(0, parser_1.appendStreamJsonLine)({
|
|
80
|
+
line,
|
|
81
|
+
onText: (delta) => {
|
|
82
|
+
assistantText += delta;
|
|
83
|
+
opts.onTextDelta?.(delta);
|
|
84
|
+
},
|
|
85
|
+
onWarn: (msg) => opts.logger?.debug({ msg, line }, "stream-json parse warn")
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
let stdout = "";
|
|
91
|
+
child.stdout.on("data", (buf) => {
|
|
92
|
+
stdout += buf.toString("utf8");
|
|
93
|
+
});
|
|
94
|
+
child.on("close", () => {
|
|
95
|
+
// Parse at end (best effort).
|
|
96
|
+
const parsed = (0, parser_1.tryParseJson)(stdout);
|
|
97
|
+
if (parsed !== undefined)
|
|
98
|
+
assistantText = (0, parser_1.extractAssistantTextFromJsonOutput)(parsed);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const cancel = () => {
|
|
102
|
+
if (child.killed)
|
|
103
|
+
return;
|
|
104
|
+
canceled = true;
|
|
105
|
+
child.kill("SIGTERM");
|
|
106
|
+
hardKillTimer = setTimeout(() => {
|
|
107
|
+
if (!child.killed)
|
|
108
|
+
child.kill("SIGKILL");
|
|
109
|
+
}, 5000);
|
|
110
|
+
};
|
|
111
|
+
killTimer = setTimeout(() => {
|
|
112
|
+
timedOut = true;
|
|
113
|
+
cancel();
|
|
114
|
+
}, opts.cfg.claude.timeout_seconds * 1000);
|
|
115
|
+
const promise = new Promise((resolve) => {
|
|
116
|
+
const finalize = (code) => {
|
|
117
|
+
if (settled)
|
|
118
|
+
return;
|
|
119
|
+
settled = true;
|
|
120
|
+
if (killTimer)
|
|
121
|
+
clearTimeout(killTimer);
|
|
122
|
+
if (hardKillTimer)
|
|
123
|
+
clearTimeout(hardKillTimer);
|
|
124
|
+
if (spawnError && !stderr)
|
|
125
|
+
stderr = spawnError;
|
|
126
|
+
resolve({ assistantText, exitCode: code, timedOut, canceled, stderr });
|
|
127
|
+
};
|
|
128
|
+
child.on("close", (code) => finalize(code));
|
|
129
|
+
child.on("error", () => finalize(null));
|
|
130
|
+
});
|
|
131
|
+
return { promise, cancel };
|
|
132
|
+
}
|