talkiebot 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.
@@ -0,0 +1,246 @@
1
+ import { spawn } from "child_process";
2
+ import { writeFileSync, mkdirSync, unlinkSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ function detectPlanFromTool(toolName, input) {
6
+ if (toolName !== "Write" && toolName !== "Edit") return null;
7
+ const filePath = input.file_path || "";
8
+ const content = input.content || input.new_string || "";
9
+ if (!content || content.length < 100) return null;
10
+ const isPlanFile = /plan/i.test(filePath);
11
+ const headingCount = (content.match(/^#{1,3}\s+.+/gm) || []).length;
12
+ const listItemCount = (content.match(/^(?:\d+\.|[-*])\s+/gm) || []).length;
13
+ const hasPlanHeading = /^#{1,3}\s+.*(?:plan|implementation|approach|strategy|roadmap|phases?|proposal)/im.test(content);
14
+ const hasStructure = headingCount >= 2 && listItemCount >= 4;
15
+ if (!isPlanFile && !hasPlanHeading && !hasStructure) return null;
16
+ let title = "Untitled Plan";
17
+ const titleMatch = content.match(/^#{1,3}\s+(.*(?:plan|implementation|approach|strategy|roadmap|phases?|proposal).*)/im);
18
+ if (titleMatch) {
19
+ title = titleMatch[1].replace(/\*\*/g, "").replace(/`/g, "").trim();
20
+ } else {
21
+ const firstHeading = content.match(/^#{1,3}\s+(.+)/m);
22
+ if (firstHeading) {
23
+ title = firstHeading[1].replace(/\*\*/g, "").replace(/`/g, "").trim();
24
+ }
25
+ }
26
+ if (title.length > 100) title = title.slice(0, 97) + "...";
27
+ return { title, content };
28
+ }
29
+ function spawnClaude(options) {
30
+ const { prompt, history, images, rawMode, callbacks } = options;
31
+ const tempImagePaths = [];
32
+ if (images && images.length > 0) {
33
+ const tempDir = join(tmpdir(), "talkie-images");
34
+ mkdirSync(tempDir, { recursive: true });
35
+ for (const img of images) {
36
+ const base64Data = img.dataUrl.split(",")[1];
37
+ if (!base64Data) continue;
38
+ const ext = img.fileName.split(".").pop() || "png";
39
+ const tempPath = join(tempDir, `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
40
+ writeFileSync(tempPath, Buffer.from(base64Data, "base64"));
41
+ tempImagePaths.push(tempPath);
42
+ }
43
+ }
44
+ let fullPrompt;
45
+ if (rawMode) {
46
+ let imageBlock = "";
47
+ if (tempImagePaths.length > 0) {
48
+ imageBlock = "Read these image files and then follow the instructions below:\n" + tempImagePaths.map((p) => p).join("\n") + "\n\n";
49
+ }
50
+ fullPrompt = `${imageBlock}${prompt}`;
51
+ } else {
52
+ const recentMessages = (history || []).slice(-10);
53
+ let contextBlock = "";
54
+ if (recentMessages.length > 0) {
55
+ contextBlock = "[Recent conversation]\n" + recentMessages.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`).join("\n") + "\n[/Recent conversation]\n\n";
56
+ }
57
+ let imageBlock = "";
58
+ if (tempImagePaths.length > 0) {
59
+ imageBlock = "[Attached Images - Use the Read tool to view these image files]\n" + tempImagePaths.map((p) => p).join("\n") + "\n[/Attached Images]\n\n";
60
+ }
61
+ const isPlanRequest = /\b(?:plan|design|architect|propose|strategy|roadmap|outline)\b/i.test(prompt);
62
+ const planInstruction = isPlanRequest ? "\n[PLAN MODE - The user is asking you to make a plan. Write the full detailed plan (with markdown headings, numbered steps, etc.) to a file using the Write tool at /tmp/talkie-plan.md. Then give a brief voice summary of what you planned.]" : "";
63
+ fullPrompt = `${contextBlock}${imageBlock}[VOICE MODE - Keep responses to 1-2 sentences, no markdown, speak naturally]${planInstruction}
64
+
65
+ User: ${prompt}`;
66
+ }
67
+ const args = [
68
+ "-p",
69
+ fullPrompt,
70
+ "--output-format",
71
+ "stream-json",
72
+ "--verbose",
73
+ "--permission-mode",
74
+ "bypassPermissions",
75
+ "--no-session-persistence"
76
+ ];
77
+ const claudePath = process.env.CLAUDE_PATH || "claude";
78
+ console.log("Spawning claude:", claudePath, "prompt length:", fullPrompt.length, rawMode ? "(raw mode)" : "(voice mode)");
79
+ const env = { ...process.env, FORCE_COLOR: "0" };
80
+ delete env.CLAUDECODE;
81
+ const claude = spawn(claudePath, args, {
82
+ cwd: process.cwd(),
83
+ env,
84
+ stdio: ["ignore", "pipe", "pipe"],
85
+ detached: false
86
+ });
87
+ let buffer = "";
88
+ const toolInputs = {};
89
+ const toolNames = {};
90
+ let currentToolId = null;
91
+ claude.stdout.on("data", (data) => {
92
+ buffer += data.toString();
93
+ const lines = buffer.split("\n");
94
+ buffer = lines.pop() || "";
95
+ for (const line of lines) {
96
+ if (!line.trim()) continue;
97
+ try {
98
+ const event = JSON.parse(line);
99
+ if (event.type === "assistant") {
100
+ const textContent = event.message?.content?.find((c) => c.type === "text");
101
+ if (textContent?.text) {
102
+ let text = textContent.text.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, "");
103
+ if (text.trim()) {
104
+ callbacks.onText(text);
105
+ }
106
+ }
107
+ const toolUseBlocks = event.message?.content?.filter((c) => c.type === "tool_use") || [];
108
+ for (const toolBlock of toolUseBlocks) {
109
+ if (toolBlock.id && toolBlock.input) {
110
+ toolInputs[toolBlock.id] = JSON.stringify(toolBlock.input);
111
+ }
112
+ let inputDetail = "";
113
+ if (toolBlock.input) {
114
+ if (toolBlock.input.file_path) inputDetail = toolBlock.input.file_path;
115
+ else if (toolBlock.input.command) inputDetail = toolBlock.input.command;
116
+ else if (toolBlock.input.pattern) inputDetail = toolBlock.input.pattern;
117
+ }
118
+ callbacks.onActivity({
119
+ type: "tool_start",
120
+ tool: toolBlock.name,
121
+ id: toolBlock.id,
122
+ input: inputDetail
123
+ });
124
+ if (callbacks.onPlan && toolBlock.input) {
125
+ const plan = detectPlanFromTool(toolBlock.name, toolBlock.input);
126
+ if (plan) callbacks.onPlan(plan);
127
+ }
128
+ }
129
+ } else if (event.type === "content_block_start") {
130
+ if (event.content_block?.type === "tool_use") {
131
+ currentToolId = event.content_block.id;
132
+ toolInputs[currentToolId] = "";
133
+ toolNames[currentToolId] = event.content_block.name;
134
+ callbacks.onActivity({
135
+ type: "tool_start",
136
+ tool: event.content_block.name,
137
+ id: event.content_block.id
138
+ });
139
+ }
140
+ } else if (event.type === "content_block_delta") {
141
+ if (event.delta?.type === "text_delta" && event.delta?.text) {
142
+ let text = event.delta.text.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, "");
143
+ if (text) {
144
+ callbacks.onText(text);
145
+ }
146
+ } else if (event.delta?.type === "input_json_delta" && currentToolId) {
147
+ toolInputs[currentToolId] = (toolInputs[currentToolId] || "") + event.delta.partial_json;
148
+ }
149
+ } else if (event.type === "content_block_stop" && currentToolId) {
150
+ try {
151
+ const inputJson = toolInputs[currentToolId];
152
+ if (inputJson) {
153
+ const input = JSON.parse(inputJson);
154
+ let inputDetail = "";
155
+ if (input.file_path) inputDetail = input.file_path;
156
+ else if (input.command) inputDetail = input.command;
157
+ else if (input.pattern) inputDetail = input.pattern;
158
+ if (inputDetail) {
159
+ callbacks.onActivity({
160
+ type: "tool_input",
161
+ id: currentToolId,
162
+ input: inputDetail
163
+ });
164
+ }
165
+ if (callbacks.onPlan) {
166
+ const toolName = toolNames[currentToolId] || "";
167
+ const plan = detectPlanFromTool(toolName, input);
168
+ if (plan) callbacks.onPlan(plan);
169
+ }
170
+ }
171
+ } catch {
172
+ }
173
+ currentToolId = null;
174
+ } else if (event.type === "result") {
175
+ const subtype = event.subtype || "complete";
176
+ callbacks.onActivity({
177
+ type: "all_complete",
178
+ status: subtype === "error" ? "error" : "complete"
179
+ });
180
+ } else if (event.type === "user") {
181
+ const toolResults = event.message?.content?.filter((c) => c.type === "tool_result") || [];
182
+ for (const result of toolResults) {
183
+ const toolId = result.tool_use_id;
184
+ const toolName = toolNames[toolId] || "tool";
185
+ const isError = result.is_error === true;
186
+ let output = "";
187
+ if (typeof result.content === "string") {
188
+ output = result.content.slice(0, 200);
189
+ } else if (Array.isArray(result.content)) {
190
+ const textContent = result.content.find((c) => c.type === "text");
191
+ output = textContent?.text?.slice(0, 200) || "";
192
+ }
193
+ callbacks.onActivity({
194
+ type: "tool_end",
195
+ tool: toolName,
196
+ id: toolId,
197
+ status: isError ? "error" : "complete",
198
+ output
199
+ });
200
+ }
201
+ }
202
+ } catch (e) {
203
+ console.log("Parse error for line:", line.slice(0, 100));
204
+ }
205
+ }
206
+ });
207
+ claude.stderr.on("data", (data) => {
208
+ const text = data.toString();
209
+ console.error("Claude stderr:", text);
210
+ callbacks.onError(text);
211
+ });
212
+ const cleanupTempFiles = () => {
213
+ for (const p of tempImagePaths) {
214
+ try {
215
+ unlinkSync(p);
216
+ } catch {
217
+ }
218
+ }
219
+ };
220
+ const promise = new Promise((resolve) => {
221
+ claude.on("close", (code) => {
222
+ cleanupTempFiles();
223
+ callbacks.onComplete(code || 0);
224
+ resolve(code || 0);
225
+ });
226
+ claude.on("error", (err) => {
227
+ cleanupTempFiles();
228
+ callbacks.onError(err.message);
229
+ callbacks.onComplete(1);
230
+ resolve(1);
231
+ });
232
+ });
233
+ return {
234
+ pid: claude.pid || 0,
235
+ kill: () => {
236
+ try {
237
+ claude.kill("SIGTERM");
238
+ } catch {
239
+ }
240
+ },
241
+ promise
242
+ };
243
+ }
244
+ export {
245
+ spawnClaude
246
+ };
@@ -0,0 +1,40 @@
1
+ class NotificationDispatcher {
2
+ channels = [];
3
+ register(channel) {
4
+ if (channel.isAvailable()) {
5
+ this.channels.push(channel);
6
+ console.log(`Notification channel registered: ${channel.name}`);
7
+ } else {
8
+ console.log(`Notification channel not available: ${channel.name}`);
9
+ }
10
+ }
11
+ unregister(name) {
12
+ this.channels = this.channels.filter((c) => c.name !== name);
13
+ }
14
+ async dispatch(notification) {
15
+ const results = await Promise.allSettled(
16
+ this.channels.map((channel) => channel.send(notification))
17
+ );
18
+ for (let i = 0; i < results.length; i++) {
19
+ const result = results[i];
20
+ const channel = this.channels[i];
21
+ if (result.status === "rejected") {
22
+ console.error(`Notification failed on ${channel.name}:`, result.reason);
23
+ }
24
+ }
25
+ }
26
+ getChannels() {
27
+ return this.channels.map((c) => c.name);
28
+ }
29
+ }
30
+ let dispatcher = null;
31
+ function getNotificationDispatcher() {
32
+ if (!dispatcher) {
33
+ dispatcher = new NotificationDispatcher();
34
+ }
35
+ return dispatcher;
36
+ }
37
+ export {
38
+ NotificationDispatcher,
39
+ getNotificationDispatcher
40
+ };
@@ -0,0 +1,24 @@
1
+ import { spawn } from "child_process";
2
+ function escapeAppleScript(str) {
3
+ return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
4
+ }
5
+ class MacOSNotificationChannel {
6
+ name = "macos";
7
+ async send(notification) {
8
+ const title = escapeAppleScript(notification.title);
9
+ const body = escapeAppleScript(notification.body.slice(0, 200));
10
+ const sound = notification.type === "job_failed" ? "Basso" : "Glass";
11
+ const script = `display notification "${body}" with title "${title}" sound name "${sound}"`;
12
+ return new Promise((resolve) => {
13
+ const proc = spawn("osascript", ["-e", script]);
14
+ proc.on("close", (code) => resolve(code === 0));
15
+ proc.on("error", () => resolve(false));
16
+ });
17
+ }
18
+ isAvailable() {
19
+ return process.platform === "darwin";
20
+ }
21
+ }
22
+ export {
23
+ MacOSNotificationChannel
24
+ };
File without changes
package/server/ssl.js ADDED
@@ -0,0 +1,58 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import selfsigned from "selfsigned";
5
+ const TALKIE_DIR = join(homedir(), ".talkie");
6
+ const OLD_DIR = join(homedir(), ".talkboy");
7
+ const CERT_PATH = join(TALKIE_DIR, "cert.pem");
8
+ const KEY_PATH = join(TALKIE_DIR, "key.pem");
9
+ const TAILSCALE_CERT_PATH = join(TALKIE_DIR, "tailscale.crt");
10
+ const TAILSCALE_KEY_PATH = join(TALKIE_DIR, "tailscale.key");
11
+ function getSSLCerts() {
12
+ if (existsSync(OLD_DIR) && !existsSync(TALKIE_DIR)) {
13
+ renameSync(OLD_DIR, TALKIE_DIR);
14
+ }
15
+ if (!existsSync(TALKIE_DIR)) {
16
+ mkdirSync(TALKIE_DIR, { recursive: true });
17
+ }
18
+ if (existsSync(TAILSCALE_CERT_PATH) && existsSync(TAILSCALE_KEY_PATH)) {
19
+ console.log("Using Tailscale HTTPS certificates");
20
+ return {
21
+ cert: readFileSync(TAILSCALE_CERT_PATH, "utf-8"),
22
+ key: readFileSync(TAILSCALE_KEY_PATH, "utf-8"),
23
+ isTailscale: true
24
+ };
25
+ }
26
+ if (existsSync(CERT_PATH) && existsSync(KEY_PATH)) {
27
+ return {
28
+ cert: readFileSync(CERT_PATH, "utf-8"),
29
+ key: readFileSync(KEY_PATH, "utf-8")
30
+ };
31
+ }
32
+ console.log("Generating self-signed SSL certificates...");
33
+ const attrs = [{ name: "commonName", value: "localhost" }];
34
+ const pems = selfsigned.generate(attrs, {
35
+ algorithm: "sha256",
36
+ days: 365,
37
+ keySize: 2048,
38
+ extensions: [
39
+ {
40
+ name: "subjectAltName",
41
+ altNames: [
42
+ { type: 2, value: "localhost" },
43
+ { type: 7, ip: "127.0.0.1" }
44
+ ]
45
+ }
46
+ ]
47
+ });
48
+ writeFileSync(CERT_PATH, pems.cert);
49
+ writeFileSync(KEY_PATH, pems.private);
50
+ console.log(`SSL certificates saved to ${TALKIE_DIR}`);
51
+ return {
52
+ cert: pems.cert,
53
+ key: pems.private
54
+ };
55
+ }
56
+ export {
57
+ getSSLCerts
58
+ };
@@ -0,0 +1,30 @@
1
+ let state = {
2
+ avatarState: "idle",
3
+ transcript: "",
4
+ lastUserMessage: "",
5
+ lastAssistantMessage: "",
6
+ messages: [],
7
+ claudeSessionId: null,
8
+ pendingMessage: null,
9
+ responseCallbacks: []
10
+ };
11
+ function updateState(update) {
12
+ state = { ...state, ...update };
13
+ }
14
+ function resetState() {
15
+ state = {
16
+ avatarState: "idle",
17
+ transcript: "",
18
+ lastUserMessage: "",
19
+ lastAssistantMessage: "",
20
+ messages: [],
21
+ claudeSessionId: null,
22
+ pendingMessage: null,
23
+ responseCallbacks: []
24
+ };
25
+ }
26
+ export {
27
+ resetState,
28
+ state,
29
+ updateState
30
+ };
@@ -0,0 +1,160 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import * as conversations from "../db/repositories/conversations.js";
3
+ import * as telegramState from "../db/repositories/telegram.js";
4
+ const WEB_UI_URL = process.env.TALKIE_URL || "https://localhost:5173";
5
+ function setupCommands(bot) {
6
+ bot.command("start", async (ctx) => {
7
+ const userId = ctx.from?.id;
8
+ if (!userId) return;
9
+ await ctx.reply(
10
+ `Welcome to Talkie!
11
+
12
+ I'm your mobile interface to Claude Code conversations.
13
+
14
+ Commands:
15
+ /conversations - List recent conversations
16
+ /new <name> - Create new conversation
17
+ /current - Show current conversation
18
+ /status - Check what Claude is doing
19
+ /help - Show this message
20
+
21
+ Web UI: ${WEB_UI_URL}
22
+
23
+ Just send me a text message to chat with Claude!`
24
+ );
25
+ });
26
+ bot.command("help", async (ctx) => {
27
+ await ctx.reply(
28
+ `Talkie Commands:
29
+
30
+ /conversations - List recent conversations
31
+ /new <name> - Create new conversation
32
+ /current - Show current conversation
33
+ /status - Check what Claude is doing
34
+ /help - Show this message
35
+
36
+ Send any text message to continue your current conversation.`
37
+ );
38
+ });
39
+ bot.command("conversations", async (ctx) => {
40
+ const convos = conversations.listConversations(5, 0);
41
+ if (convos.length === 0) {
42
+ await ctx.reply("No conversations yet. Send a message or use /new to create one.");
43
+ return;
44
+ }
45
+ const keyboard = new InlineKeyboard();
46
+ for (const conv of convos) {
47
+ const title = conv.title.length > 30 ? conv.title.slice(0, 30) + "..." : conv.title;
48
+ keyboard.text(title, `select_conv:${conv.id}`).row();
49
+ }
50
+ keyboard.text("+ Create new", "create_conv");
51
+ await ctx.reply("Select a conversation:", { reply_markup: keyboard });
52
+ });
53
+ bot.command("new", async (ctx) => {
54
+ const userId = ctx.from?.id;
55
+ if (!userId) return;
56
+ const name = ctx.match?.trim() || "New conversation";
57
+ const id = crypto.randomUUID();
58
+ const conv = conversations.createConversation({ id, title: name });
59
+ telegramState.setTelegramConversation(userId, conv.id);
60
+ await ctx.reply(`Created new conversation: "${conv.title}"
61
+
62
+ Send me a message to start chatting.`);
63
+ });
64
+ bot.command("current", async (ctx) => {
65
+ const userId = ctx.from?.id;
66
+ if (!userId) return;
67
+ const state = telegramState.getTelegramState(userId);
68
+ if (!state?.current_conversation_id) {
69
+ await ctx.reply("No conversation selected. Use /conversations to pick one or /new to create one.");
70
+ return;
71
+ }
72
+ const conv = conversations.getConversation(state.current_conversation_id);
73
+ if (!conv) {
74
+ telegramState.setTelegramConversation(userId, null);
75
+ await ctx.reply("Current conversation no longer exists. Use /conversations to pick a new one.");
76
+ return;
77
+ }
78
+ const keyboard = new InlineKeyboard().text("Switch conversation", "switch_conv").text("Open in web", "open_web");
79
+ await ctx.reply(
80
+ `Current conversation: "${conv.title}"
81
+
82
+ Created: ${new Date(conv.created_at).toLocaleDateString()}
83
+ Last updated: ${new Date(conv.updated_at).toLocaleString()}`,
84
+ { reply_markup: keyboard }
85
+ );
86
+ });
87
+ bot.command("status", async (ctx) => {
88
+ try {
89
+ const { Agent, fetch: undiciFetch } = await import("undici");
90
+ const response = await undiciFetch(`${WEB_UI_URL}/api/status`, {
91
+ dispatcher: new Agent({ connect: { rejectUnauthorized: false } })
92
+ });
93
+ const data = await response.json();
94
+ const stateEmoji = {
95
+ idle: "\u{1F634}",
96
+ listening: "\u{1F442}",
97
+ thinking: "\u{1F914}",
98
+ speaking: "\u{1F5E3}",
99
+ happy: "\u{1F60A}",
100
+ confused: "\u{1F615}"
101
+ };
102
+ await ctx.reply(
103
+ `Talkie Status:
104
+
105
+ Server: ${data.running ? "\u2705 Running" : "\u274C Stopped"}
106
+ Database: ${data.dbStatus === "connected" ? "\u2705 Connected" : "\u26A0\uFE0F Unavailable"}
107
+ Claude: ${stateEmoji[data.avatarState] || "\u2753"} ${data.avatarState}`
108
+ );
109
+ } catch {
110
+ await ctx.reply("Could not reach Talkie server. Is it running?");
111
+ }
112
+ });
113
+ bot.callbackQuery(/^select_conv:(.+)$/, async (ctx) => {
114
+ const userId = ctx.from?.id;
115
+ if (!userId) return;
116
+ const convId = ctx.match[1];
117
+ const conv = conversations.getConversation(convId);
118
+ if (!conv) {
119
+ await ctx.answerCallbackQuery({ text: "Conversation not found" });
120
+ return;
121
+ }
122
+ telegramState.setTelegramConversation(userId, convId);
123
+ await ctx.answerCallbackQuery({ text: `Switched to: ${conv.title}` });
124
+ await ctx.editMessageText(`Selected: "${conv.title}"
125
+
126
+ Send me a message to continue chatting.`);
127
+ });
128
+ bot.callbackQuery("create_conv", async (ctx) => {
129
+ const userId = ctx.from?.id;
130
+ if (!userId) return;
131
+ const id = crypto.randomUUID();
132
+ const conv = conversations.createConversation({ id, title: "New conversation" });
133
+ telegramState.setTelegramConversation(userId, conv.id);
134
+ await ctx.answerCallbackQuery({ text: "Created new conversation" });
135
+ await ctx.editMessageText(`Created new conversation.
136
+
137
+ Send me a message to start chatting.`);
138
+ });
139
+ bot.callbackQuery("switch_conv", async (ctx) => {
140
+ const convos = conversations.listConversations(5, 0);
141
+ if (convos.length === 0) {
142
+ await ctx.answerCallbackQuery({ text: "No conversations available" });
143
+ return;
144
+ }
145
+ const keyboard = new InlineKeyboard();
146
+ for (const conv of convos) {
147
+ const title = conv.title.length > 30 ? conv.title.slice(0, 30) + "..." : conv.title;
148
+ keyboard.text(title, `select_conv:${conv.id}`).row();
149
+ }
150
+ keyboard.text("+ Create new", "create_conv");
151
+ await ctx.answerCallbackQuery();
152
+ await ctx.editMessageText("Select a conversation:", { reply_markup: keyboard });
153
+ });
154
+ bot.callbackQuery("open_web", async (ctx) => {
155
+ await ctx.answerCallbackQuery({ text: `Open ${WEB_UI_URL} in your browser` });
156
+ });
157
+ }
158
+ export {
159
+ setupCommands
160
+ };