clankie 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/README.md +578 -0
- package/package.json +54 -0
- package/src/agent.ts +137 -0
- package/src/channels/channel.ts +57 -0
- package/src/channels/slack.ts +273 -0
- package/src/cli.ts +943 -0
- package/src/config.ts +380 -0
- package/src/daemon.ts +721 -0
- package/src/extensions/cron/index.ts +548 -0
- package/src/extensions/persona/index.ts +564 -0
- package/src/extensions/persona/memory-db.ts +460 -0
- package/src/extensions/security.ts +158 -0
- package/src/heartbeat.ts +191 -0
- package/src/service.ts +371 -0
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
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, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { ImageContent } from "@mariozechner/pi-ai";
|
|
13
|
+
import {
|
|
14
|
+
type AgentSession,
|
|
15
|
+
AuthStorage,
|
|
16
|
+
type CreateAgentSessionResult,
|
|
17
|
+
createAgentSession,
|
|
18
|
+
DefaultResourceLoader,
|
|
19
|
+
ModelRegistry,
|
|
20
|
+
SessionManager,
|
|
21
|
+
} from "@mariozechner/pi-coding-agent";
|
|
22
|
+
import type { Attachment, Channel, InboundMessage } from "./channels/channel.ts";
|
|
23
|
+
import { SlackChannel } from "./channels/slack.ts";
|
|
24
|
+
import {
|
|
25
|
+
type AppConfig,
|
|
26
|
+
getAgentDir,
|
|
27
|
+
getAppDir,
|
|
28
|
+
getAuthPath,
|
|
29
|
+
getPersonaDir,
|
|
30
|
+
getWorkspace,
|
|
31
|
+
loadChannelPersonaOverrides,
|
|
32
|
+
loadConfig,
|
|
33
|
+
resolvePersonaModel,
|
|
34
|
+
saveChannelPersonaOverrides,
|
|
35
|
+
} from "./config.ts";
|
|
36
|
+
import cronExtension from "./extensions/cron/index.ts";
|
|
37
|
+
import { createPersonaExtension } from "./extensions/persona/index.ts";
|
|
38
|
+
import securityExtension from "./extensions/security.ts";
|
|
39
|
+
import { HeartbeatService } from "./heartbeat.ts";
|
|
40
|
+
|
|
41
|
+
// ─── PID file management ──────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const PID_FILE = join(getAppDir(), "daemon.pid");
|
|
44
|
+
|
|
45
|
+
export function isRunning(): { running: boolean; pid?: number } {
|
|
46
|
+
if (!existsSync(PID_FILE)) return { running: false };
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
50
|
+
// Check if process is alive
|
|
51
|
+
process.kill(pid, 0);
|
|
52
|
+
return { running: true, pid };
|
|
53
|
+
} catch {
|
|
54
|
+
// Process not found — stale PID file
|
|
55
|
+
cleanupPidFile();
|
|
56
|
+
return { running: false };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writePidFile(): void {
|
|
61
|
+
writeFileSync(PID_FILE, String(process.pid), "utf-8");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cleanupPidFile(): void {
|
|
65
|
+
try {
|
|
66
|
+
unlinkSync(PID_FILE);
|
|
67
|
+
} catch {
|
|
68
|
+
// ignore
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Session cache (one session per chat) ──────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const sessionCache = new Map<string, AgentSession>();
|
|
75
|
+
|
|
76
|
+
// Track active session name per chat (for /switch command)
|
|
77
|
+
const activeSessionNames = new Map<string, string>();
|
|
78
|
+
|
|
79
|
+
// Track channel-specific persona overrides (runtime, persisted to ~/.clankie/channel-personas.json)
|
|
80
|
+
const channelPersonaOverrides = new Map<string, string>();
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the persona name for a given channel and chat ID.
|
|
84
|
+
* Resolution order:
|
|
85
|
+
* 1. Runtime override (channelPersonaOverrides)
|
|
86
|
+
* 2. Config per-channel mapping (channels.slack.channelPersonas[chatId])
|
|
87
|
+
* 3. Config channel-level default (channels.slack.persona)
|
|
88
|
+
* 4. Config global default (agent.persona)
|
|
89
|
+
* 5. Hardcoded fallback ("default")
|
|
90
|
+
*/
|
|
91
|
+
function resolveChannelPersona(channel: string, chatId: string, config: AppConfig): string {
|
|
92
|
+
const channelKey = `${channel}_${chatId}`;
|
|
93
|
+
|
|
94
|
+
// 1. Runtime override
|
|
95
|
+
const override = channelPersonaOverrides.get(channelKey);
|
|
96
|
+
if (override) return override;
|
|
97
|
+
|
|
98
|
+
// 2. Config per-channel mapping (Slack only for now)
|
|
99
|
+
if (channel === "slack") {
|
|
100
|
+
const slackConfig = config.channels?.slack;
|
|
101
|
+
const channelMapping = slackConfig?.channelPersonas?.[chatId];
|
|
102
|
+
if (channelMapping) return channelMapping;
|
|
103
|
+
|
|
104
|
+
// 3. Config channel-level default
|
|
105
|
+
if (slackConfig?.persona) return slackConfig.persona;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 4. Config global default
|
|
109
|
+
if (config.agent?.persona) return config.agent.persona;
|
|
110
|
+
|
|
111
|
+
// 5. Hardcoded fallback
|
|
112
|
+
return "default";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function getOrCreateSession(
|
|
116
|
+
chatKey: string,
|
|
117
|
+
config: AppConfig,
|
|
118
|
+
personaName: string = "default",
|
|
119
|
+
): Promise<AgentSession> {
|
|
120
|
+
const cached = sessionCache.get(chatKey);
|
|
121
|
+
if (cached) return cached;
|
|
122
|
+
|
|
123
|
+
// Validate persona name early
|
|
124
|
+
try {
|
|
125
|
+
getPersonaDir(personaName);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error(
|
|
128
|
+
`[daemon] Invalid persona name "${personaName}": ${err instanceof Error ? err.message : String(err)}`,
|
|
129
|
+
);
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const agentDir = getAgentDir(config);
|
|
134
|
+
const cwd = getWorkspace(config);
|
|
135
|
+
|
|
136
|
+
const authStorage = AuthStorage.create(getAuthPath());
|
|
137
|
+
const modelRegistry = new ModelRegistry(authStorage);
|
|
138
|
+
|
|
139
|
+
const loader = new DefaultResourceLoader({
|
|
140
|
+
cwd,
|
|
141
|
+
agentDir,
|
|
142
|
+
extensionFactories: [securityExtension, createPersonaExtension(personaName), cronExtension],
|
|
143
|
+
});
|
|
144
|
+
await loader.reload();
|
|
145
|
+
|
|
146
|
+
// Use a stable session directory per chat so conversations persist across restarts
|
|
147
|
+
const sessionDir = join(getAppDir(), "sessions", chatKey);
|
|
148
|
+
|
|
149
|
+
const sessionManager = SessionManager.continueRecent(cwd, sessionDir);
|
|
150
|
+
|
|
151
|
+
// Resolve model: persona config → global config → pi auto-detection
|
|
152
|
+
const personaModel = resolvePersonaModel(personaName);
|
|
153
|
+
const modelSpec = personaModel ?? config.agent?.model?.primary;
|
|
154
|
+
let model: ReturnType<typeof modelRegistry.find> | undefined;
|
|
155
|
+
if (modelSpec) {
|
|
156
|
+
const slash = modelSpec.indexOf("/");
|
|
157
|
+
if (slash !== -1) {
|
|
158
|
+
const provider = modelSpec.substring(0, slash);
|
|
159
|
+
const modelId = modelSpec.substring(slash + 1);
|
|
160
|
+
model = modelRegistry.find(provider, modelId);
|
|
161
|
+
if (!model) {
|
|
162
|
+
const source = personaModel ? `persona "${personaName}"` : "config";
|
|
163
|
+
console.warn(`[daemon] Warning: model "${modelSpec}" from ${source} not found, falling back to auto-detection`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const result: CreateAgentSessionResult = await createAgentSession({
|
|
169
|
+
cwd,
|
|
170
|
+
agentDir,
|
|
171
|
+
authStorage,
|
|
172
|
+
modelRegistry,
|
|
173
|
+
resourceLoader: loader,
|
|
174
|
+
sessionManager,
|
|
175
|
+
model,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const { session } = result;
|
|
179
|
+
|
|
180
|
+
// Bind extensions (headless — no UI)
|
|
181
|
+
await session.bindExtensions({
|
|
182
|
+
commandContextActions: {
|
|
183
|
+
waitForIdle: () => session.agent.waitForIdle(),
|
|
184
|
+
newSession: async (opts) => {
|
|
185
|
+
const success = await session.newSession({ parentSession: opts?.parentSession });
|
|
186
|
+
if (success && opts?.setup) {
|
|
187
|
+
await opts.setup(session.sessionManager);
|
|
188
|
+
}
|
|
189
|
+
return { cancelled: !success };
|
|
190
|
+
},
|
|
191
|
+
fork: async (entryId) => {
|
|
192
|
+
const r = await session.fork(entryId);
|
|
193
|
+
return { cancelled: r.cancelled };
|
|
194
|
+
},
|
|
195
|
+
navigateTree: async (targetId, opts) => {
|
|
196
|
+
const r = await session.navigateTree(targetId, {
|
|
197
|
+
summarize: opts?.summarize,
|
|
198
|
+
customInstructions: opts?.customInstructions,
|
|
199
|
+
replaceInstructions: opts?.replaceInstructions,
|
|
200
|
+
label: opts?.label,
|
|
201
|
+
});
|
|
202
|
+
return { cancelled: r.cancelled };
|
|
203
|
+
},
|
|
204
|
+
switchSession: async (sessionPath) => {
|
|
205
|
+
const success = await session.switchSession(sessionPath);
|
|
206
|
+
return { cancelled: !success };
|
|
207
|
+
},
|
|
208
|
+
reload: async () => {
|
|
209
|
+
await session.reload();
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
onError: (err) => {
|
|
213
|
+
console.error(`[daemon] Extension error (${err.extensionPath}): ${err.error}`);
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Subscribe to enable session persistence
|
|
218
|
+
session.subscribe(() => {});
|
|
219
|
+
|
|
220
|
+
sessionCache.set(chatKey, session);
|
|
221
|
+
return session;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Session helpers ───────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* List all session names for a given chat identifier.
|
|
228
|
+
* Scans ~/.clankie/sessions/ for directories matching the chatIdentifier prefix.
|
|
229
|
+
*/
|
|
230
|
+
function listSessionNames(chatIdentifier: string): string[] {
|
|
231
|
+
const sessionsDir = join(getAppDir(), "sessions");
|
|
232
|
+
if (!existsSync(sessionsDir)) {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const { readdirSync, statSync } = require("node:fs");
|
|
238
|
+
const entries = readdirSync(sessionsDir);
|
|
239
|
+
const sessionNames = new Set<string>();
|
|
240
|
+
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
// Session directories are named: {channel}_{chatId}_{personaName}_{sessionName}
|
|
243
|
+
// We want to extract unique sessionNames for this chatIdentifier
|
|
244
|
+
if (entry.startsWith(`${chatIdentifier}_`)) {
|
|
245
|
+
const entryPath = join(sessionsDir, entry);
|
|
246
|
+
if (statSync(entryPath).isDirectory()) {
|
|
247
|
+
// Extract session name from: chatIdentifier_personaName_sessionName
|
|
248
|
+
const parts = entry.substring(chatIdentifier.length + 1).split("_");
|
|
249
|
+
// parts[0] is personaName, rest is sessionName
|
|
250
|
+
if (parts.length >= 2) {
|
|
251
|
+
const sessionName = parts.slice(1).join("_");
|
|
252
|
+
sessionNames.add(sessionName);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return Array.from(sessionNames).sort();
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error(`[daemon] Error listing session names: ${err instanceof Error ? err.message : String(err)}`);
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─── Attachment helpers ────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
const IMAGE_MIME_PREFIXES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
|
268
|
+
|
|
269
|
+
/** Convert image attachments to pi's ImageContent format for vision models. */
|
|
270
|
+
function toImageContents(attachments?: Attachment[]): ImageContent[] {
|
|
271
|
+
if (!attachments) return [];
|
|
272
|
+
return attachments
|
|
273
|
+
.filter((a) => IMAGE_MIME_PREFIXES.some((prefix) => a.mimeType.startsWith(prefix)))
|
|
274
|
+
.map((a) => ({ type: "image" as const, data: a.data, mimeType: a.mimeType }));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Save non-image attachments to disk and return their paths. */
|
|
278
|
+
async function saveNonImageAttachments(
|
|
279
|
+
attachments: Attachment[] | undefined,
|
|
280
|
+
chatKey: string,
|
|
281
|
+
): Promise<{ fileName: string; path: string }[]> {
|
|
282
|
+
if (!attachments) return [];
|
|
283
|
+
|
|
284
|
+
const nonImages = attachments.filter((a) => !IMAGE_MIME_PREFIXES.some((prefix) => a.mimeType.startsWith(prefix)));
|
|
285
|
+
if (nonImages.length === 0) return [];
|
|
286
|
+
|
|
287
|
+
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
288
|
+
const { join } = await import("node:path");
|
|
289
|
+
|
|
290
|
+
const dir = join(getAppDir(), "attachments", chatKey);
|
|
291
|
+
mkdirSync(dir, { recursive: true });
|
|
292
|
+
|
|
293
|
+
const results: { fileName: string; path: string }[] = [];
|
|
294
|
+
for (const att of nonImages) {
|
|
295
|
+
const name = att.fileName || `file_${Date.now()}`;
|
|
296
|
+
const filePath = join(dir, name);
|
|
297
|
+
writeFileSync(filePath, Buffer.from(att.data, "base64"));
|
|
298
|
+
results.push({ fileName: name, path: filePath });
|
|
299
|
+
console.log(`[daemon] Saved attachment: ${filePath} (${att.mimeType})`);
|
|
300
|
+
}
|
|
301
|
+
return results;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Message handling ──────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/** Lock to serialize message processing per chat */
|
|
307
|
+
const chatLocks = new Map<string, Promise<void>>();
|
|
308
|
+
|
|
309
|
+
async function handleMessage(message: InboundMessage, channel: Channel): Promise<void> {
|
|
310
|
+
const config = loadConfig();
|
|
311
|
+
|
|
312
|
+
// Determine session name:
|
|
313
|
+
// 1. For forum topics (threadId present) → use threadId
|
|
314
|
+
// 2. For regular chats → use active session name or "default"
|
|
315
|
+
const chatIdentifier = `${message.channel}_${message.chatId}`;
|
|
316
|
+
|
|
317
|
+
let sessionName: string;
|
|
318
|
+
if (message.threadId) {
|
|
319
|
+
// Forum topic — use threadId as session name
|
|
320
|
+
sessionName = message.threadId;
|
|
321
|
+
} else {
|
|
322
|
+
// Regular chat — use active session name
|
|
323
|
+
sessionName = activeSessionNames.get(chatIdentifier) ?? "default";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Resolve persona for this channel
|
|
327
|
+
const personaName = resolveChannelPersona(message.channel, message.chatId, config);
|
|
328
|
+
|
|
329
|
+
// Include persona in chatKey so each persona gets its own session
|
|
330
|
+
const chatKey = `${chatIdentifier}_${personaName}_${sessionName}`;
|
|
331
|
+
|
|
332
|
+
// Serialize messages per chat — wait for previous message to finish
|
|
333
|
+
const previous = chatLocks.get(chatKey) ?? Promise.resolve();
|
|
334
|
+
const current = previous.then(() =>
|
|
335
|
+
processMessage(message, channel, chatKey, chatIdentifier, sessionName, personaName),
|
|
336
|
+
);
|
|
337
|
+
chatLocks.set(
|
|
338
|
+
chatKey,
|
|
339
|
+
current.catch(() => {}),
|
|
340
|
+
); // swallow errors in the chain
|
|
341
|
+
await current;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function processMessage(
|
|
345
|
+
message: InboundMessage,
|
|
346
|
+
channel: Channel,
|
|
347
|
+
chatKey: string,
|
|
348
|
+
chatIdentifier: string,
|
|
349
|
+
sessionName: string,
|
|
350
|
+
personaName: string,
|
|
351
|
+
): Promise<void> {
|
|
352
|
+
const config = loadConfig();
|
|
353
|
+
|
|
354
|
+
const attachCount = message.attachments?.length ?? 0;
|
|
355
|
+
const preview = message.text.slice(0, 100) || (attachCount > 0 ? `[${attachCount} attachment(s)]` : "[empty]");
|
|
356
|
+
const sessionInfo = sessionName !== "default" ? ` [session:${sessionName}]` : "";
|
|
357
|
+
const personaInfo = personaName !== "default" ? ` [persona:${personaName}]` : "";
|
|
358
|
+
console.log(
|
|
359
|
+
`[daemon] ${message.channel}/${message.chatId}${sessionInfo}${personaInfo} (${message.senderName}): ${preview}`,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
// Prepare send options for thread-aware responses
|
|
363
|
+
// Always reply in a thread: use existing thread or create new one with message.id as parent
|
|
364
|
+
const sendOptions = { threadId: message.threadId || message.id };
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const trimmed = message.text.trim();
|
|
368
|
+
|
|
369
|
+
// Handle /switch <name> command — switch to a different session
|
|
370
|
+
if (trimmed.startsWith("/switch ")) {
|
|
371
|
+
const newSessionName = trimmed.substring(8).trim();
|
|
372
|
+
if (!newSessionName || newSessionName.includes(" ")) {
|
|
373
|
+
await channel.send(message.chatId, "⚠️ Usage: /switch <session-name>\n\nExample: /switch coding", sendOptions);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
activeSessionNames.set(chatIdentifier, newSessionName);
|
|
378
|
+
console.log(`[daemon] Switched ${chatIdentifier} to session "${newSessionName}"`);
|
|
379
|
+
await channel.send(
|
|
380
|
+
message.chatId,
|
|
381
|
+
`💬 Switched to session "${newSessionName}"\n\nUse /sessions to see all sessions.`,
|
|
382
|
+
sendOptions,
|
|
383
|
+
);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Handle /sessions command — list all sessions for this chat
|
|
388
|
+
if (trimmed === "/sessions") {
|
|
389
|
+
const chatSessions = listSessionNames(chatIdentifier);
|
|
390
|
+
|
|
391
|
+
if (chatSessions.length === 0) {
|
|
392
|
+
await channel.send(
|
|
393
|
+
message.chatId,
|
|
394
|
+
"No sessions found yet. Send a message to create the first one!",
|
|
395
|
+
sendOptions,
|
|
396
|
+
);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const currentSession = activeSessionNames.get(chatIdentifier) ?? "default";
|
|
401
|
+
const sessionList = chatSessions
|
|
402
|
+
.map((name) => (name === currentSession ? `• ${name} ✓ (active)` : `• ${name}`))
|
|
403
|
+
.join("\n");
|
|
404
|
+
|
|
405
|
+
await channel.send(
|
|
406
|
+
message.chatId,
|
|
407
|
+
`📋 Available sessions:\n\n${sessionList}\n\nSwitch with: /switch <name>`,
|
|
408
|
+
sendOptions,
|
|
409
|
+
);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Handle /new command — reset current session
|
|
414
|
+
if (trimmed === "/new") {
|
|
415
|
+
const session = await getOrCreateSession(chatKey, config, personaName);
|
|
416
|
+
await session.newSession();
|
|
417
|
+
console.log(`[daemon] Session reset for ${chatKey}`);
|
|
418
|
+
await channel.send(
|
|
419
|
+
message.chatId,
|
|
420
|
+
`✨ Started a fresh session in "${sessionName}". Previous context cleared.`,
|
|
421
|
+
sendOptions,
|
|
422
|
+
);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Handle /persona command — show/switch/reset persona
|
|
427
|
+
if (trimmed === "/persona" || trimmed.startsWith("/persona ")) {
|
|
428
|
+
const args = trimmed.substring(8).trim();
|
|
429
|
+
|
|
430
|
+
// /persona (no args) — show current persona
|
|
431
|
+
if (!args) {
|
|
432
|
+
const channelKey = `${message.channel}_${message.chatId}`;
|
|
433
|
+
const runtimeOverride = channelPersonaOverrides.get(channelKey);
|
|
434
|
+
const configChannelPersona =
|
|
435
|
+
message.channel === "slack" ? config.channels?.slack?.channelPersonas?.[message.chatId] : undefined;
|
|
436
|
+
const configDefault = config.channels?.slack?.persona ?? config.agent?.persona ?? "default";
|
|
437
|
+
|
|
438
|
+
const lines = [`**Current persona:** ${personaName}`];
|
|
439
|
+
if (runtimeOverride) {
|
|
440
|
+
lines.push(` (Runtime override — use \`/persona reset\` to clear)`);
|
|
441
|
+
} else if (configChannelPersona) {
|
|
442
|
+
lines.push(` (From config: \`channels.slack.channelPersonas["${message.chatId}"]\`)`);
|
|
443
|
+
} else {
|
|
444
|
+
lines.push(` (Config default: ${configDefault})`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
lines.push("");
|
|
448
|
+
lines.push("**Commands:**");
|
|
449
|
+
lines.push(" `/persona <name>` — switch to a different persona");
|
|
450
|
+
lines.push(" `/persona reset` — reset to the configured default");
|
|
451
|
+
lines.push("");
|
|
452
|
+
lines.push("Use `clankie persona` to list available personas.");
|
|
453
|
+
|
|
454
|
+
await channel.send(message.chatId, lines.join("\n"), sendOptions);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// /persona reset — remove runtime override
|
|
459
|
+
if (args === "reset") {
|
|
460
|
+
const channelKey = `${message.channel}_${message.chatId}`;
|
|
461
|
+
const hadOverride = channelPersonaOverrides.has(channelKey);
|
|
462
|
+
|
|
463
|
+
if (hadOverride) {
|
|
464
|
+
channelPersonaOverrides.delete(channelKey);
|
|
465
|
+
// Persist to disk
|
|
466
|
+
const overrides: Record<string, string> = {};
|
|
467
|
+
for (const [k, v] of channelPersonaOverrides.entries()) {
|
|
468
|
+
overrides[k] = v;
|
|
469
|
+
}
|
|
470
|
+
saveChannelPersonaOverrides(overrides);
|
|
471
|
+
|
|
472
|
+
const newPersona = resolveChannelPersona(message.channel, message.chatId, config);
|
|
473
|
+
await channel.send(
|
|
474
|
+
message.chatId,
|
|
475
|
+
`✅ Reset persona to **${newPersona}** (from config).\n\nNext message will use this persona.`,
|
|
476
|
+
sendOptions,
|
|
477
|
+
);
|
|
478
|
+
} else {
|
|
479
|
+
await channel.send(
|
|
480
|
+
message.chatId,
|
|
481
|
+
"ℹ️ No runtime override is set. Already using the configured default.",
|
|
482
|
+
sendOptions,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// /persona <name> — switch to a different persona
|
|
489
|
+
const newPersonaName = args;
|
|
490
|
+
|
|
491
|
+
// Validate persona exists
|
|
492
|
+
try {
|
|
493
|
+
getPersonaDir(newPersonaName);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
await channel.send(
|
|
496
|
+
message.chatId,
|
|
497
|
+
`⚠️ Invalid persona name: ${err instanceof Error ? err.message : String(err)}\n\nUse \`clankie persona\` to list available personas.`,
|
|
498
|
+
sendOptions,
|
|
499
|
+
);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Update runtime override
|
|
504
|
+
const channelKey = `${message.channel}_${message.chatId}`;
|
|
505
|
+
channelPersonaOverrides.set(channelKey, newPersonaName);
|
|
506
|
+
|
|
507
|
+
// Persist to disk
|
|
508
|
+
const overrides: Record<string, string> = {};
|
|
509
|
+
for (const [k, v] of channelPersonaOverrides.entries()) {
|
|
510
|
+
overrides[k] = v;
|
|
511
|
+
}
|
|
512
|
+
saveChannelPersonaOverrides(overrides);
|
|
513
|
+
|
|
514
|
+
console.log(`[daemon] Switched ${channelKey} to persona "${newPersonaName}"`);
|
|
515
|
+
await channel.send(
|
|
516
|
+
message.chatId,
|
|
517
|
+
`✅ Switched to persona **${newPersonaName}**.\n\nNext message will use this persona.\nUse \`/persona reset\` to revert to the default.`,
|
|
518
|
+
sendOptions,
|
|
519
|
+
);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const session = await getOrCreateSession(chatKey, config, personaName);
|
|
524
|
+
|
|
525
|
+
// Build image attachments for the agent (vision-capable models)
|
|
526
|
+
const images = toImageContents(message.attachments);
|
|
527
|
+
|
|
528
|
+
// For non-image attachments, save to temp files and note paths in the prompt
|
|
529
|
+
const filePaths = await saveNonImageAttachments(message.attachments, chatKey);
|
|
530
|
+
|
|
531
|
+
let promptText = message.text;
|
|
532
|
+
if (filePaths.length > 0) {
|
|
533
|
+
const fileList = filePaths.map((f) => ` - ${f.fileName}: ${f.path}`).join("\n");
|
|
534
|
+
const prefix = promptText ? `${promptText}\n\n` : "";
|
|
535
|
+
promptText = `${prefix}[Attached files saved to disk]\n${fileList}`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (!promptText && images.length === 0) {
|
|
539
|
+
// Nothing to send — likely an unsupported attachment type that failed download
|
|
540
|
+
await channel.send(message.chatId, "⚠️ Received an empty message with no processable content.", sendOptions);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Send message to agent and wait for completion
|
|
545
|
+
await session.prompt(promptText || "Describe this image.", {
|
|
546
|
+
source: "rpc",
|
|
547
|
+
images: images.length > 0 ? images : undefined,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Extract the assistant's response
|
|
551
|
+
const state = session.state;
|
|
552
|
+
const lastMessage = state.messages[state.messages.length - 1];
|
|
553
|
+
|
|
554
|
+
if (lastMessage?.role === "assistant") {
|
|
555
|
+
const textParts: string[] = [];
|
|
556
|
+
for (const content of lastMessage.content) {
|
|
557
|
+
if (content.type === "text" && content.text.trim()) {
|
|
558
|
+
textParts.push(content.text);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const responseText = textParts.join("\n").trim();
|
|
563
|
+
if (responseText) {
|
|
564
|
+
await channel.send(message.chatId, responseText, sendOptions);
|
|
565
|
+
} else {
|
|
566
|
+
await channel.send(message.chatId, "(No text response)", sendOptions);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
} catch (err) {
|
|
570
|
+
console.error(`[daemon] Error processing message:`, err);
|
|
571
|
+
try {
|
|
572
|
+
await channel.send(message.chatId, `⚠️ Error: ${err instanceof Error ? err.message : String(err)}`, sendOptions);
|
|
573
|
+
} catch {
|
|
574
|
+
// Failed to send error — ignore
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ─── Daemon lifecycle ──────────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
export async function startDaemon(): Promise<void> {
|
|
582
|
+
const config = loadConfig();
|
|
583
|
+
|
|
584
|
+
// Load channel persona overrides from disk
|
|
585
|
+
const overrides = loadChannelPersonaOverrides();
|
|
586
|
+
channelPersonaOverrides.clear();
|
|
587
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
588
|
+
channelPersonaOverrides.set(key, value);
|
|
589
|
+
}
|
|
590
|
+
if (channelPersonaOverrides.size > 0) {
|
|
591
|
+
console.log(`[daemon] Loaded ${channelPersonaOverrides.size} channel persona override(s)`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const channels: Channel[] = [];
|
|
595
|
+
|
|
596
|
+
// Slack
|
|
597
|
+
const slack = config.channels?.slack;
|
|
598
|
+
if (slack?.appToken && slack.botToken && slack.enabled !== false) {
|
|
599
|
+
channels.push(
|
|
600
|
+
new SlackChannel({
|
|
601
|
+
appToken: slack.appToken,
|
|
602
|
+
botToken: slack.botToken,
|
|
603
|
+
allowedUsers: slack.allowFrom ?? [],
|
|
604
|
+
}),
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (channels.length === 0) {
|
|
609
|
+
console.error(
|
|
610
|
+
"No channels configured. Set up Slack:\n\n" +
|
|
611
|
+
" clankie config set channels.slack.appToken <xapp-...>\n" +
|
|
612
|
+
" clankie config set channels.slack.botToken <xoxb-...>\n" +
|
|
613
|
+
' clankie config set channels.slack.allowFrom ["U12345678"]\n' +
|
|
614
|
+
"\nOr edit ~/.clankie/clankie.json directly.\n",
|
|
615
|
+
);
|
|
616
|
+
process.exit(1);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Write PID file
|
|
620
|
+
writePidFile();
|
|
621
|
+
|
|
622
|
+
// ─── Heartbeat service ──────────────────────────────────────────────
|
|
623
|
+
|
|
624
|
+
const heartbeat = new HeartbeatService(config);
|
|
625
|
+
|
|
626
|
+
// Track last active channel for heartbeat responses
|
|
627
|
+
let lastChannel: Channel | null = null;
|
|
628
|
+
let lastChatId: string | null = null;
|
|
629
|
+
let lastThreadId: string | null = null;
|
|
630
|
+
|
|
631
|
+
heartbeat.setHandler(async (prompt: string) => {
|
|
632
|
+
// Use last active channel to deliver heartbeat results
|
|
633
|
+
if (!lastChannel || !lastChatId) {
|
|
634
|
+
console.log("[heartbeat] No active channel — skipping delivery");
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const chatKey = `heartbeat_${lastChatId}${lastThreadId ? `_${lastThreadId}` : "_default"}`;
|
|
639
|
+
const personaName = config.agent?.persona ?? "default";
|
|
640
|
+
const session = await getOrCreateSession(chatKey, config, personaName);
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
await session.prompt(prompt, { source: "rpc" });
|
|
644
|
+
|
|
645
|
+
const state = session.state;
|
|
646
|
+
const lastMessage = state.messages[state.messages.length - 1];
|
|
647
|
+
|
|
648
|
+
if (lastMessage?.role === "assistant") {
|
|
649
|
+
const textParts: string[] = [];
|
|
650
|
+
for (const content of lastMessage.content) {
|
|
651
|
+
if (content.type === "text" && content.text.trim()) {
|
|
652
|
+
textParts.push(content.text);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const responseText = textParts.join("\n").trim();
|
|
657
|
+
// Don't send "all clear" messages — only actionable results
|
|
658
|
+
if (responseText && !responseText.match(/^all\s+clear\.?$/i)) {
|
|
659
|
+
const sendOptions = lastThreadId ? { threadId: lastThreadId } : undefined;
|
|
660
|
+
await lastChannel.send(lastChatId, `🔔 ${responseText}`, sendOptions);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
} catch (err) {
|
|
664
|
+
console.error(`[heartbeat] Error processing:`, err instanceof Error ? err.message : String(err));
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// ─── Graceful shutdown ────────────────────────────────────────────────
|
|
669
|
+
|
|
670
|
+
const shutdown = async (signal: string) => {
|
|
671
|
+
console.log(`\n[daemon] Received ${signal}, shutting down...`);
|
|
672
|
+
heartbeat.stop();
|
|
673
|
+
for (const ch of channels) {
|
|
674
|
+
await ch.stop().catch(() => {});
|
|
675
|
+
}
|
|
676
|
+
cleanupPidFile();
|
|
677
|
+
process.exit(0);
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
681
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
682
|
+
|
|
683
|
+
// ─── Start channels ──────────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
console.log(`[daemon] Starting clankie daemon (pid ${process.pid})...`);
|
|
686
|
+
console.log(`[daemon] Workspace: ${getWorkspace(config)}`);
|
|
687
|
+
console.log(`[daemon] Channels: ${channels.length > 0 ? channels.map((c) => c.name).join(", ") : "(none)"}`);
|
|
688
|
+
|
|
689
|
+
for (const ch of channels) {
|
|
690
|
+
await ch.start((msg) => {
|
|
691
|
+
// Track last active channel for heartbeat delivery
|
|
692
|
+
lastChannel = ch;
|
|
693
|
+
lastChatId = msg.chatId;
|
|
694
|
+
lastThreadId = msg.threadId ?? null;
|
|
695
|
+
return handleMessage(msg, ch);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Start heartbeat after channels are ready
|
|
700
|
+
heartbeat.start();
|
|
701
|
+
|
|
702
|
+
console.log("[daemon] Ready. Waiting for messages...");
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export function stopDaemon(): boolean {
|
|
706
|
+
const status = isRunning();
|
|
707
|
+
if (!status.running || !status.pid) {
|
|
708
|
+
console.log("Daemon is not running.");
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
try {
|
|
713
|
+
process.kill(status.pid, "SIGTERM");
|
|
714
|
+
console.log(`Stopped daemon (pid ${status.pid}).`);
|
|
715
|
+
cleanupPidFile();
|
|
716
|
+
return true;
|
|
717
|
+
} catch (err) {
|
|
718
|
+
console.error(`Failed to stop daemon: ${err instanceof Error ? err.message : String(err)}`);
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
}
|