clankie 0.2.2 → 0.2.3

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/src/config.ts DELETED
@@ -1,261 +0,0 @@
1
- /**
2
- * clankie configuration management
3
- *
4
- * Reads an optional JSON5 config from ~/.clankie/clankie.json (comments + trailing commas allowed).
5
- * Structure mirrors OpenClaw's ~/.openclaw/openclaw.json where applicable.
6
- *
7
- * Authentication credentials are managed by pi's AuthStorage
8
- * at ~/.pi/agent/auth.json — shared between `pi` and `clankie`.
9
- */
10
-
11
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
- import { homedir } from "node:os";
13
- import { join } from "node:path";
14
- import JSON5 from "json5";
15
-
16
- // ─── Config types ────────────────────────────────────────────────────────────
17
-
18
- export interface AppConfig {
19
- /** Agent runtime settings */
20
- agent?: {
21
- /** Working directory for the agent (default: ~/.clankie/workspace) */
22
- workspace?: string;
23
- /** Override for pi's agent dir (default: ~/.clankie) */
24
- agentDir?: string;
25
- /** Restrict agent to workspace directory (default: true) */
26
- restrictToWorkspace?: boolean;
27
- /** Additional paths outside workspace that are allowed (e.g. ["/tmp"]) */
28
- allowedPaths?: string[];
29
- /** Model configuration */
30
- model?: {
31
- /** Primary model in provider/model format (e.g. "anthropic/claude-sonnet-4-5") */
32
- primary?: string;
33
- /** Fallback models tried in order if primary fails */
34
- fallbacks?: string[];
35
- };
36
- };
37
-
38
- /** Channel configuration — each channel starts when its section exists */
39
- channels?: {
40
- slack?: {
41
- enabled?: boolean;
42
- /** App token from Slack app settings (xapp-...) */
43
- appToken?: string;
44
- /** Bot token from Slack app settings (xoxb-...) */
45
- botToken?: string;
46
- /** Allowed Slack user IDs */
47
- allowFrom?: string[];
48
- /** Allowed Slack channel IDs (empty = allow all) */
49
- allowedChannelIds?: string[];
50
- };
51
- web?: {
52
- enabled?: boolean;
53
- /** Port to listen on (default: 3100) */
54
- port?: number;
55
- /** Required shared secret for authentication */
56
- authToken?: string;
57
- /** Allowed origins for CORS-like validation (empty = allow all) */
58
- allowedOrigins?: string[];
59
- /** Path to built web-ui static files (enables same-origin serving) */
60
- staticDir?: string;
61
- };
62
- };
63
- }
64
-
65
- // ─── Paths ────────────────────────────────────────────────────────────────────
66
-
67
- const APP_DIR = join(homedir(), ".clankie");
68
- const CONFIG_PATH = join(APP_DIR, "clankie.json");
69
- /** Legacy path — migrated automatically */
70
- const LEGACY_CONFIG_PATH = join(APP_DIR, "config.json");
71
-
72
- /** Returns the path to the app's config directory, creating it if needed. */
73
- export function getAppDir(): string {
74
- if (!existsSync(APP_DIR)) {
75
- mkdirSync(APP_DIR, { recursive: true, mode: 0o700 });
76
- }
77
- return APP_DIR;
78
- }
79
-
80
- /** Resolves the workspace directory, creating it if needed. */
81
- export function getWorkspace(config?: AppConfig): string {
82
- const workspace = config?.agent?.workspace ?? join(homedir(), ".clankie", "workspace");
83
- const resolved = workspace.replace(/^~/, homedir());
84
- if (!existsSync(resolved)) {
85
- mkdirSync(resolved, { recursive: true, mode: 0o755 });
86
- }
87
- return resolved;
88
- }
89
-
90
- /** Resolves pi's agent directory (defaults to ~/.clankie to keep the app self-contained). */
91
- export function getAgentDir(config?: AppConfig): string {
92
- return config?.agent?.agentDir ?? join(homedir(), ".clankie");
93
- }
94
-
95
- /** Returns the path to the app's auth file (~/.clankie/auth.json). */
96
- export function getAuthPath(): string {
97
- return join(getAppDir(), "auth.json");
98
- }
99
-
100
- /** Path to the config file */
101
- export function getConfigPath(): string {
102
- return CONFIG_PATH;
103
- }
104
-
105
- /**
106
- * Resolve the path to the bundled web-ui static files.
107
- * When installed via npm, these live at <package>/web-ui-dist/ alongside src/.
108
- * Returns the path if found, undefined otherwise.
109
- */
110
- export function getBundledWebUiDir(): string | undefined {
111
- // import.meta.dirname → <package>/src/ at runtime (Node 21+)
112
- const packageRoot = join(import.meta.dirname, "..");
113
- const bundledDir = join(packageRoot, "web-ui-dist");
114
- if (existsSync(bundledDir) && existsSync(join(bundledDir, "_shell.html"))) {
115
- return bundledDir;
116
- }
117
- return undefined;
118
- }
119
-
120
- // ─── Loading & saving ─────────────────────────────────────────────────────────
121
-
122
- /** Load config from ~/.clankie/clankie.json (JSON5). Returns empty config if missing. */
123
- export function loadConfig(): AppConfig {
124
- getAppDir();
125
-
126
- // Auto-migrate legacy config.json → clankie.json
127
- if (!existsSync(CONFIG_PATH) && existsSync(LEGACY_CONFIG_PATH)) {
128
- migrateFromLegacy();
129
- }
130
-
131
- if (!existsSync(CONFIG_PATH)) {
132
- return {};
133
- }
134
- try {
135
- const raw = readFileSync(CONFIG_PATH, "utf-8");
136
- return JSON5.parse(raw) as AppConfig;
137
- } catch (err) {
138
- console.error(`Warning: failed to parse ${CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`);
139
- return {};
140
- }
141
- }
142
-
143
- /** Save config to ~/.clankie/clankie.json (JSON5-formatted with 2-space indent). */
144
- export function saveConfig(config: AppConfig): void {
145
- getAppDir();
146
- // JSON5.stringify produces valid JSON5 with trailing commas when possible
147
- writeFileSync(CONFIG_PATH, `${JSON5.stringify(config, null, 2)}\n`, "utf-8");
148
- try {
149
- chmodSync(CONFIG_PATH, 0o600);
150
- } catch {
151
- // chmod may not be supported on all platforms; non-fatal
152
- }
153
- }
154
-
155
- /** Deep-merge partial updates into the config. */
156
- export function updateConfig(partial: Partial<AppConfig>): AppConfig {
157
- const current = loadConfig();
158
- const updated = deepMerge(
159
- current as unknown as Record<string, unknown>,
160
- partial as unknown as Record<string, unknown>,
161
- );
162
- saveConfig(updated as unknown as AppConfig);
163
- return updated as unknown as AppConfig;
164
- }
165
-
166
- // ─── Dot-path accessors (for `clankie config get/set`) ───────────────────────
167
-
168
- /** Get a value from the config by dot-separated path (e.g. "channels.telegram.botToken") */
169
- export function getByPath(config: AppConfig, path: string): unknown {
170
- const parts = path.split(".");
171
- let current: unknown = config;
172
- for (const part of parts) {
173
- if (current == null || typeof current !== "object") return undefined;
174
- current = (current as Record<string, unknown>)[part];
175
- }
176
- return current;
177
- }
178
-
179
- /** Set a value in the config by dot-separated path. Returns the updated config. */
180
- export function setByPath(config: AppConfig, path: string, value: unknown): AppConfig {
181
- const parts = path.split(".");
182
- const clone = structuredClone(config) as Record<string, unknown>;
183
-
184
- let current: Record<string, unknown> = clone;
185
- for (let i = 0; i < parts.length - 1; i++) {
186
- const part = parts[i];
187
- if (current[part] == null || typeof current[part] !== "object") {
188
- current[part] = {};
189
- }
190
- current = current[part] as Record<string, unknown>;
191
- }
192
-
193
- const lastKey = parts[parts.length - 1];
194
- current[lastKey] = value;
195
-
196
- return clone as unknown as AppConfig;
197
- }
198
-
199
- /** Unset (delete) a value from the config by dot-separated path. Returns the updated config. */
200
- export function unsetByPath(config: AppConfig, path: string): AppConfig {
201
- const parts = path.split(".");
202
- const clone = structuredClone(config) as Record<string, unknown>;
203
-
204
- let current: Record<string, unknown> = clone;
205
- for (let i = 0; i < parts.length - 1; i++) {
206
- const part = parts[i];
207
- if (current[part] == null || typeof current[part] !== "object") return clone as unknown as AppConfig;
208
- current = current[part] as Record<string, unknown>;
209
- }
210
-
211
- delete current[parts[parts.length - 1]];
212
- return clone as unknown as AppConfig;
213
- }
214
-
215
- // ─── Helpers ──────────────────────────────────────────────────────────────────
216
-
217
- function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
218
- const result = { ...target };
219
- for (const key of Object.keys(source)) {
220
- const sourceVal = source[key];
221
- const targetVal = target[key];
222
- if (
223
- sourceVal != null &&
224
- typeof sourceVal === "object" &&
225
- !Array.isArray(sourceVal) &&
226
- targetVal != null &&
227
- typeof targetVal === "object" &&
228
- !Array.isArray(targetVal)
229
- ) {
230
- result[key] = deepMerge(targetVal as Record<string, unknown>, sourceVal as Record<string, unknown>);
231
- } else {
232
- result[key] = sourceVal;
233
- }
234
- }
235
- return result;
236
- }
237
-
238
- /** Migrate legacy ~/.clankie/config.json (flat keys) to new ~/.clankie/clankie.json (nested). */
239
- function migrateFromLegacy(): void {
240
- try {
241
- const raw = readFileSync(LEGACY_CONFIG_PATH, "utf-8");
242
- const legacy = JSON.parse(raw) as Record<string, unknown>;
243
-
244
- const config: AppConfig = {};
245
-
246
- // Map flat keys → nested structure
247
- if (legacy.workspace || legacy.agentDir || legacy.provider || legacy.model) {
248
- config.agent = {};
249
- if (legacy.workspace) config.agent.workspace = legacy.workspace as string;
250
- if (legacy.agentDir) config.agent.agentDir = legacy.agentDir as string;
251
- if (legacy.model) {
252
- config.agent.model = { primary: legacy.model as string };
253
- }
254
- }
255
-
256
- saveConfig(config);
257
- console.log(`Migrated config: ${LEGACY_CONFIG_PATH} → ${CONFIG_PATH}`);
258
- } catch {
259
- // Migration failed — non-fatal
260
- }
261
- }
package/src/daemon.ts DELETED
@@ -1,380 +0,0 @@
1
- /**
2
- * clankie daemon — always-on process that connects channels to the agent.
3
- *
4
- * Receives messages from channels (Telegram, etc.), routes them to
5
- * a pi agent session, collects the response, and sends it back.
6
- *
7
- * Each chat gets its own persistent session (keyed by channel+chatId).
8
- */
9
-
10
- import { existsSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
11
- import { join } from "node:path";
12
- import type { Channel, InboundMessage } from "./channels/channel.ts";
13
- import { SlackChannel } from "./channels/slack.ts";
14
- import { WebChannel } from "./channels/web.ts";
15
- import { getAppDir, getBundledWebUiDir, getConfigPath, getWorkspace, loadConfig } from "./config.ts";
16
- import {
17
- getActiveSessionName,
18
- getOrCreateSession,
19
- listSessionNames,
20
- saveNonImageAttachments,
21
- setActiveSessionName,
22
- toImageContents,
23
- withChatLock,
24
- } from "./sessions.ts";
25
-
26
- // ─── PID file management ──────────────────────────────────────────────────────
27
-
28
- const PID_FILE = join(getAppDir(), "daemon.pid");
29
-
30
- export function isRunning(): { running: boolean; pid?: number } {
31
- if (!existsSync(PID_FILE)) return { running: false };
32
-
33
- try {
34
- const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
35
- // Check if process is alive
36
- process.kill(pid, 0);
37
- return { running: true, pid };
38
- } catch {
39
- // Process not found — stale PID file
40
- cleanupPidFile();
41
- return { running: false };
42
- }
43
- }
44
-
45
- function writePidFile(): void {
46
- writeFileSync(PID_FILE, String(process.pid), "utf-8");
47
- }
48
-
49
- function cleanupPidFile(): void {
50
- try {
51
- unlinkSync(PID_FILE);
52
- } catch {
53
- // ignore
54
- }
55
- }
56
-
57
- // ─── Daemon state tracking (for config reload) ────────────────────────────────
58
-
59
- let activeChannels: Channel[] = [];
60
- let configWatcher: ReturnType<typeof watch> | null = null;
61
-
62
- // ─── Message handling ──────────────────────────────────────────────────────────
63
-
64
- async function handleMessage(message: InboundMessage, channel: Channel): Promise<void> {
65
- // Determine session name:
66
- // 1. For forum topics (threadId present) → use threadId
67
- // 2. For regular chats → use active session name or "default"
68
- const chatIdentifier = `${message.channel}_${message.chatId}`;
69
-
70
- let sessionName: string;
71
- if (message.threadId) {
72
- // Forum topic — use threadId as session name
73
- sessionName = message.threadId;
74
- } else {
75
- // Regular chat — use active session name
76
- sessionName = getActiveSessionName(chatIdentifier);
77
- }
78
-
79
- const chatKey = `${chatIdentifier}_${sessionName}`;
80
-
81
- // Serialize messages per chat — wait for previous message to finish
82
- await withChatLock(chatKey, () => processMessage(message, channel, chatKey, chatIdentifier, sessionName));
83
- }
84
-
85
- async function processMessage(
86
- message: InboundMessage,
87
- channel: Channel,
88
- chatKey: string,
89
- chatIdentifier: string,
90
- sessionName: string,
91
- ): Promise<void> {
92
- const config = loadConfig();
93
-
94
- const attachCount = message.attachments?.length ?? 0;
95
- const preview = message.text.slice(0, 100) || (attachCount > 0 ? `[${attachCount} attachment(s)]` : "[empty]");
96
- const sessionInfo = sessionName !== "default" ? ` [session:${sessionName}]` : "";
97
- console.log(`[daemon] ${message.channel}/${message.chatId}${sessionInfo} (${message.senderName}): ${preview}`);
98
-
99
- // Prepare send options for thread-aware responses
100
- // Always reply in a thread: use existing thread or create new one with message.id as parent
101
- const sendOptions = { threadId: message.threadId || message.id };
102
-
103
- try {
104
- const trimmed = message.text.trim();
105
-
106
- // Handle /switch <name> command — switch to a different session
107
- if (trimmed.startsWith("/switch ")) {
108
- const newSessionName = trimmed.substring(8).trim();
109
- if (!newSessionName || newSessionName.includes(" ")) {
110
- await channel.send(message.chatId, "⚠️ Usage: /switch <session-name>\n\nExample: /switch coding", sendOptions);
111
- return;
112
- }
113
-
114
- setActiveSessionName(chatIdentifier, newSessionName);
115
- console.log(`[daemon] Switched ${chatIdentifier} to session "${newSessionName}"`);
116
- await channel.send(
117
- message.chatId,
118
- `💬 Switched to session "${newSessionName}"\n\nUse /sessions to see all sessions.`,
119
- sendOptions,
120
- );
121
- return;
122
- }
123
-
124
- // Handle /sessions command — list all sessions for this chat
125
- if (trimmed === "/sessions") {
126
- const chatSessions = listSessionNames(chatIdentifier);
127
-
128
- if (chatSessions.length === 0) {
129
- await channel.send(
130
- message.chatId,
131
- "No sessions found yet. Send a message to create the first one!",
132
- sendOptions,
133
- );
134
- return;
135
- }
136
-
137
- const currentSession = getActiveSessionName(chatIdentifier);
138
- const sessionList = chatSessions
139
- .map((name) => (name === currentSession ? `• ${name} ✓ (active)` : `• ${name}`))
140
- .join("\n");
141
-
142
- await channel.send(
143
- message.chatId,
144
- `📋 Available sessions:\n\n${sessionList}\n\nSwitch with: /switch <name>`,
145
- sendOptions,
146
- );
147
- return;
148
- }
149
-
150
- // Handle /new command — reset current session
151
- if (trimmed === "/new") {
152
- const session = await getOrCreateSession(chatKey, config);
153
- await session.newSession();
154
- console.log(`[daemon] Session reset for ${chatKey}`);
155
- await channel.send(
156
- message.chatId,
157
- `✨ Started a fresh session in "${sessionName}". Previous context cleared.`,
158
- sendOptions,
159
- );
160
- return;
161
- }
162
-
163
- const session = await getOrCreateSession(chatKey, config);
164
-
165
- // Build image attachments for the agent (vision-capable models)
166
- const images = toImageContents(message.attachments);
167
-
168
- // For non-image attachments, save to temp files and note paths in the prompt
169
- const filePaths = await saveNonImageAttachments(message.attachments, chatKey);
170
-
171
- let promptText = message.text;
172
- if (filePaths.length > 0) {
173
- const fileList = filePaths.map((f) => ` - ${f.fileName}: ${f.path}`).join("\n");
174
- const prefix = promptText ? `${promptText}\n\n` : "";
175
- promptText = `${prefix}[Attached files saved to disk]\n${fileList}`;
176
- }
177
-
178
- if (!promptText && images.length === 0) {
179
- // Nothing to send — likely an unsupported attachment type that failed download
180
- await channel.send(message.chatId, "⚠️ Received an empty message with no processable content.", sendOptions);
181
- return;
182
- }
183
-
184
- // Send message to agent and wait for completion
185
- await session.prompt(promptText || "Describe this image.", {
186
- source: "rpc",
187
- images: images.length > 0 ? images : undefined,
188
- });
189
-
190
- // Extract the assistant's response
191
- const state = session.state;
192
- const lastMessage = state.messages[state.messages.length - 1];
193
-
194
- if (lastMessage?.role === "assistant") {
195
- const textParts: string[] = [];
196
- for (const content of lastMessage.content) {
197
- if (content.type === "text" && content.text.trim()) {
198
- textParts.push(content.text);
199
- }
200
- }
201
-
202
- const responseText = textParts.join("\n").trim();
203
- if (responseText) {
204
- await channel.send(message.chatId, responseText, sendOptions);
205
- } else {
206
- await channel.send(message.chatId, "(No text response)", sendOptions);
207
- }
208
- }
209
- } catch (err) {
210
- console.error(`[daemon] Error processing message:`, err);
211
- try {
212
- await channel.send(message.chatId, `⚠️ Error: ${err instanceof Error ? err.message : String(err)}`, sendOptions);
213
- } catch {
214
- // Failed to send error — ignore
215
- }
216
- }
217
- }
218
-
219
- // ─── Daemon lifecycle ──────────────────────────────────────────────────────────
220
-
221
- /**
222
- * Initialize channels from current config.
223
- * Stores references in module-level state for restart capability.
224
- */
225
- async function initializeChannels(): Promise<void> {
226
- const config = loadConfig();
227
-
228
- const channels: Channel[] = [];
229
-
230
- // Slack
231
- const slack = config.channels?.slack;
232
- if (slack?.appToken && slack.botToken && slack.enabled !== false) {
233
- channels.push(
234
- new SlackChannel({
235
- appToken: slack.appToken,
236
- botToken: slack.botToken,
237
- allowedUsers: slack.allowFrom ?? [],
238
- allowedChannelIds: slack.allowedChannelIds,
239
- }),
240
- );
241
- }
242
-
243
- // Web
244
- const web = config.channels?.web;
245
- if (web?.authToken && web.enabled !== false) {
246
- // Resolve static dir: explicit config > bundled web-ui > none
247
- const staticDir = web.staticDir ?? getBundledWebUiDir();
248
- if (staticDir) {
249
- console.log(`[daemon] Serving web-ui from: ${staticDir}`);
250
- }
251
-
252
- channels.push(
253
- new WebChannel({
254
- port: web.port ?? 3100,
255
- authToken: web.authToken,
256
- allowedOrigins: web.allowedOrigins,
257
- staticDir,
258
- }),
259
- );
260
- }
261
-
262
- if (channels.length === 0) {
263
- console.error(
264
- "No channels configured. Set up Slack or Web:\n\n" +
265
- "Slack:\n" +
266
- " clankie config set channels.slack.appToken <xapp-...>\n" +
267
- " clankie config set channels.slack.botToken <xoxb-...>\n" +
268
- ' clankie config set channels.slack.allowFrom ["U12345678"]\n' +
269
- "\nWeb:\n" +
270
- ' clankie config set channels.web.authToken "your-secret-token"\n' +
271
- " clankie config set channels.web.port 3100\n" +
272
- "\nOr edit ~/.clankie/clankie.json directly.\n",
273
- );
274
- process.exit(1);
275
- }
276
-
277
- console.log(`[daemon] Workspace: ${getWorkspace(config)}`);
278
- console.log(`[daemon] Channels: ${channels.length > 0 ? channels.map((c) => c.name).join(", ") : "(none)"}`);
279
-
280
- for (const ch of channels) {
281
- await ch.start((msg) => handleMessage(msg, ch));
282
- }
283
-
284
- // Store in module state
285
- activeChannels = channels;
286
-
287
- console.log("[daemon] Ready. Waiting for messages...");
288
- }
289
-
290
- /**
291
- * Restart the daemon by stopping all channels, clearing cache, and reinitializing.
292
- */
293
- async function restartDaemon(): Promise<void> {
294
- console.log("[daemon] Config changed — restarting...");
295
-
296
- // Stop existing channels
297
- for (const ch of activeChannels) {
298
- await ch.stop().catch((err) => {
299
- console.error(`[daemon] Error stopping channel ${ch.name}:`, err);
300
- });
301
- }
302
- activeChannels = [];
303
-
304
- // Sessions remain cached in sessions.ts and will be reused across restarts
305
- // Reinitialize with fresh config
306
- await initializeChannels();
307
-
308
- console.log("[daemon] Restart complete.");
309
- }
310
-
311
- export async function startDaemon(): Promise<void> {
312
- // Write PID file
313
- writePidFile();
314
-
315
- console.log(`[daemon] Starting clankie daemon (pid ${process.pid})...`);
316
-
317
- // Initial startup
318
- await initializeChannels();
319
-
320
- // ─── Config file watcher ──────────────────────────────────────────────
321
-
322
- const configPath = getConfigPath();
323
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
324
-
325
- try {
326
- configWatcher = watch(configPath, (_eventType) => {
327
- // Debounce: config writes often trigger multiple events (write + chmod)
328
- if (debounceTimer) clearTimeout(debounceTimer);
329
- debounceTimer = setTimeout(() => {
330
- restartDaemon().catch((err) => {
331
- console.error("[daemon] Restart failed:", err instanceof Error ? err.message : String(err));
332
- });
333
- }, 1000);
334
- });
335
- console.log(`[daemon] Watching config file: ${configPath}`);
336
- } catch (err) {
337
- console.warn(`[daemon] Could not watch config file: ${err instanceof Error ? err.message : String(err)}`);
338
- }
339
-
340
- // ─── Graceful shutdown ────────────────────────────────────────────────
341
-
342
- const shutdown = async (signal: string) => {
343
- console.log(`\n[daemon] Received ${signal}, shutting down...`);
344
-
345
- // Close config watcher
346
- if (configWatcher) {
347
- configWatcher.close();
348
- configWatcher = null;
349
- }
350
-
351
- // Stop channels
352
- for (const ch of activeChannels) {
353
- await ch.stop().catch(() => {});
354
- }
355
-
356
- cleanupPidFile();
357
- process.exit(0);
358
- };
359
-
360
- process.on("SIGINT", () => shutdown("SIGINT"));
361
- process.on("SIGTERM", () => shutdown("SIGTERM"));
362
- }
363
-
364
- export function stopDaemon(): boolean {
365
- const status = isRunning();
366
- if (!status.running || !status.pid) {
367
- console.log("Daemon is not running.");
368
- return false;
369
- }
370
-
371
- try {
372
- process.kill(status.pid, "SIGTERM");
373
- console.log(`Stopped daemon (pid ${status.pid}).`);
374
- cleanupPidFile();
375
- return true;
376
- } catch (err) {
377
- console.error(`Failed to stop daemon: ${err instanceof Error ? err.message : String(err)}`);
378
- return false;
379
- }
380
- }