claudity 1.0.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/.env.example +2 -0
- package/README.md +32 -0
- package/install.sh +343 -0
- package/package.json +28 -0
- package/public/css/style.css +1672 -0
- package/public/favicon.svg +9 -0
- package/public/font/berkeley.woff2 +0 -0
- package/public/index.html +262 -0
- package/public/js/app.js +1240 -0
- package/setup.sh +134 -0
- package/src/db.js +162 -0
- package/src/index.js +61 -0
- package/src/routes/api.js +194 -0
- package/src/routes/connections.js +41 -0
- package/src/routes/relay.js +37 -0
- package/src/services/auth.js +88 -0
- package/src/services/chat.js +365 -0
- package/src/services/claude.js +353 -0
- package/src/services/connections.js +97 -0
- package/src/services/discord.js +126 -0
- package/src/services/heartbeat.js +106 -0
- package/src/services/imessage.js +200 -0
- package/src/services/memory.js +183 -0
- package/src/services/scheduler.js +32 -0
- package/src/services/signal.js +237 -0
- package/src/services/slack.js +113 -0
- package/src/services/telegram.js +94 -0
- package/src/services/tools.js +467 -0
- package/src/services/whatsapp.js +111 -0
- package/src/services/workspace.js +162 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
const Database = require('better-sqlite3');
|
|
2
|
+
const { execFile } = require('child_process');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
const chatDbPath = path.join(process.env.HOME, 'Library', 'Messages', 'chat.db');
|
|
7
|
+
const POLL_INTERVAL = 3000;
|
|
8
|
+
const MAX_RESPONSE_LENGTH = 1500;
|
|
9
|
+
|
|
10
|
+
let db = null;
|
|
11
|
+
let lastRowId = 0;
|
|
12
|
+
let timer = null;
|
|
13
|
+
let chatModule = null;
|
|
14
|
+
let selfChatId = null;
|
|
15
|
+
const recentSent = new Set();
|
|
16
|
+
|
|
17
|
+
function log(msg) {
|
|
18
|
+
console.log(`[imessage] ${msg}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function open() {
|
|
22
|
+
if (!fs.existsSync(chatDbPath)) {
|
|
23
|
+
log('chat.db not found');
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
db = new Database(chatDbPath, { fileMustExist: true });
|
|
29
|
+
db.pragma('journal_mode = WAL');
|
|
30
|
+
db.pragma('busy_timeout = 2000');
|
|
31
|
+
db.function('after_delete_message_plugin', { varargs: true }, () => {});
|
|
32
|
+
db.function('before_delete_attachment_path', { varargs: true }, () => {});
|
|
33
|
+
db.function('delete_attachment_path', { varargs: true }, () => {});
|
|
34
|
+
db.function('delete_chat_background_before_deleting_chat', { varargs: true }, () => {});
|
|
35
|
+
db.function('guid_for_chat', { varargs: true }, () => '');
|
|
36
|
+
db.function('is_mic_enabled', { varargs: true }, () => 0);
|
|
37
|
+
db.function('verify_chat', { varargs: true }, () => {});
|
|
38
|
+
return true;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
log('cannot open chat.db: ' + err.message);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function initSelfChat(phone) {
|
|
46
|
+
if (!phone) {
|
|
47
|
+
log('no phone number configured');
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const row = db.prepare("select ROWID, guid from chat where chat_identifier = ?").get(phone);
|
|
52
|
+
if (!row) {
|
|
53
|
+
log(`no self-chat found for ${phone}`);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
selfChatId = row.ROWID;
|
|
58
|
+
log(`self-chat: ${row.guid} (id ${selfChatId})`);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function initLastRowId() {
|
|
63
|
+
const row = db.prepare("select max(ROWID) as maxId from message").get();
|
|
64
|
+
lastRowId = row && row.maxId ? row.maxId : 0;
|
|
65
|
+
log(`starting from rowid ${lastRowId}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseMessage(text) {
|
|
69
|
+
if (!text) return null;
|
|
70
|
+
const match = text.match(/^(\w+):\s*(.+)$/s);
|
|
71
|
+
if (!match) return null;
|
|
72
|
+
return { agent: match[1].toLowerCase(), command: match[2].trim() };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readNewMessages() {
|
|
76
|
+
try {
|
|
77
|
+
return db.prepare(
|
|
78
|
+
"select m.ROWID, m.text, m.is_from_me, m.date, c.guid as chat_guid from message m join chat_message_join cmj on cmj.message_id = m.ROWID join chat c on c.ROWID = cmj.chat_id where m.ROWID > ? and m.is_from_me = 0 and m.text is not null and cmj.chat_id = ? order by m.ROWID asc limit 20"
|
|
79
|
+
).all(lastRowId, selfChatId);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
log('read error: ' + err.message);
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sendMessage(chatGuid, text) {
|
|
87
|
+
let truncated = text;
|
|
88
|
+
if (text.length > MAX_RESPONSE_LENGTH) {
|
|
89
|
+
truncated = text.slice(0, MAX_RESPONSE_LENGTH) + '...';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const escaped = truncated.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
93
|
+
|
|
94
|
+
const script = `tell application "Messages"
|
|
95
|
+
send "${escaped}" to chat id "${chatGuid}"
|
|
96
|
+
end tell`;
|
|
97
|
+
|
|
98
|
+
recentSent.add(truncated);
|
|
99
|
+
setTimeout(() => recentSent.delete(truncated), 15000);
|
|
100
|
+
|
|
101
|
+
const beforeMax = db.prepare("select max(ROWID) as maxId from message").get();
|
|
102
|
+
const beforeId = beforeMax ? beforeMax.maxId : 0;
|
|
103
|
+
|
|
104
|
+
execFile('osascript', ['-e', script], (err) => {
|
|
105
|
+
if (err) {
|
|
106
|
+
log('send error: ' + err.message);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
setTimeout(() => deleteResponseEcho(beforeId, truncated), 2000);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function deleteResponseEcho(afterRowId, text) {
|
|
114
|
+
try {
|
|
115
|
+
const echo = db.prepare(
|
|
116
|
+
"select m.ROWID from message m where m.is_from_me = 0 and m.ROWID > ? and m.text = ?"
|
|
117
|
+
).get(afterRowId, text);
|
|
118
|
+
|
|
119
|
+
if (echo) {
|
|
120
|
+
db.prepare("delete from chat_message_join where message_id = ?").run(echo.ROWID);
|
|
121
|
+
log('deleted response echo');
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
log('response echo delete error: ' + err.message);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function poll() {
|
|
129
|
+
const messages = readNewMessages();
|
|
130
|
+
|
|
131
|
+
for (const msg of messages) {
|
|
132
|
+
if (msg.ROWID > lastRowId) {
|
|
133
|
+
lastRowId = msg.ROWID;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!msg.text) continue;
|
|
137
|
+
if (recentSent.has(msg.text)) continue;
|
|
138
|
+
|
|
139
|
+
const { stmts } = require('../db');
|
|
140
|
+
let parsed = parseMessage(msg.text);
|
|
141
|
+
let agent;
|
|
142
|
+
if (parsed) {
|
|
143
|
+
agent = stmts.getAgentByName.get(parsed.agent);
|
|
144
|
+
if (!agent) continue;
|
|
145
|
+
} else {
|
|
146
|
+
agent = stmts.getDefaultAgent.get();
|
|
147
|
+
if (!agent) continue;
|
|
148
|
+
parsed = { agent: agent.name, command: msg.text.trim() };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
log(`${parsed.agent}: ${parsed.command}`);
|
|
152
|
+
handleCommand(agent, parsed.command, msg.chat_guid);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function handleCommand(agent, command, chatGuid) {
|
|
157
|
+
try {
|
|
158
|
+
const result = await chatModule.enqueueMessage(agent.id, command, {
|
|
159
|
+
onAck: (text) => sendMessage(chatGuid, text)
|
|
160
|
+
});
|
|
161
|
+
if (result && result.content) {
|
|
162
|
+
sendMessage(chatGuid, result.content);
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
sendMessage(chatGuid, `error: ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function start(config, callbacks) {
|
|
170
|
+
chatModule = require('./chat');
|
|
171
|
+
const { onStatus } = callbacks || {};
|
|
172
|
+
|
|
173
|
+
if (!open()) {
|
|
174
|
+
if (onStatus) onStatus('error', 'chat.db not found');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!initSelfChat(config && config.phone)) {
|
|
178
|
+
if (onStatus) onStatus('error', 'self-chat not found for ' + (config && config.phone));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
initLastRowId();
|
|
183
|
+
timer = setInterval(poll, POLL_INTERVAL);
|
|
184
|
+
log('relay started');
|
|
185
|
+
if (onStatus) onStatus('connected', 'polling self-chat');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function stop() {
|
|
189
|
+
if (timer) {
|
|
190
|
+
clearInterval(timer);
|
|
191
|
+
timer = null;
|
|
192
|
+
}
|
|
193
|
+
if (db) {
|
|
194
|
+
db.close();
|
|
195
|
+
db = null;
|
|
196
|
+
}
|
|
197
|
+
log('relay stopped');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { start, stop };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const { v4: uuid } = require('uuid');
|
|
3
|
+
const { db, stmts } = require('../db');
|
|
4
|
+
const auth = require('./auth');
|
|
5
|
+
const workspace = require('./workspace');
|
|
6
|
+
|
|
7
|
+
const API_URL = 'https://api.anthropic.com/v1/messages';
|
|
8
|
+
const HAIKU_MODEL = 'claude-haiku-4-5-20251001';
|
|
9
|
+
const EXTRACTION_COOLDOWN = 30000;
|
|
10
|
+
|
|
11
|
+
const lastExtractionTime = new Map();
|
|
12
|
+
|
|
13
|
+
async function callLightweight(prompt, systemPrompt) {
|
|
14
|
+
const status = auth.getAuthStatus();
|
|
15
|
+
if (!status.authenticated) return null;
|
|
16
|
+
|
|
17
|
+
if (status.mode === 'api_key') {
|
|
18
|
+
const headers = auth.getHeaders();
|
|
19
|
+
const body = {
|
|
20
|
+
model: HAIKU_MODEL,
|
|
21
|
+
max_tokens: 1024,
|
|
22
|
+
system: systemPrompt,
|
|
23
|
+
messages: [{ role: 'user', content: prompt }]
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const res = await fetch(API_URL, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers,
|
|
29
|
+
body: JSON.stringify(body)
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!res.ok) return null;
|
|
33
|
+
const data = await res.json();
|
|
34
|
+
const textBlock = data.content?.find(b => b.type === 'text');
|
|
35
|
+
return textBlock?.text || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return cliCall(['--model', 'haiku'], prompt, systemPrompt);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cliCall(extraArgs, prompt, systemPrompt) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const args = ['-p', '--output-format', 'json', '--no-session-persistence', '--dangerously-skip-permissions', '--setting-sources', '', ...extraArgs];
|
|
44
|
+
if (systemPrompt) args.push('--system-prompt', systemPrompt);
|
|
45
|
+
let done = false;
|
|
46
|
+
const proc = spawn('claude', args, {
|
|
47
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
48
|
+
timeout: 60000,
|
|
49
|
+
cwd: '/tmp'
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const fallback = setTimeout(() => {
|
|
53
|
+
if (!done) {
|
|
54
|
+
done = true;
|
|
55
|
+
proc.kill();
|
|
56
|
+
resolve(null);
|
|
57
|
+
}
|
|
58
|
+
}, 65000);
|
|
59
|
+
|
|
60
|
+
let stdout = '';
|
|
61
|
+
proc.stdout.on('data', d => stdout += d);
|
|
62
|
+
proc.on('close', () => {
|
|
63
|
+
if (done) return;
|
|
64
|
+
done = true;
|
|
65
|
+
clearTimeout(fallback);
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(stdout.trim());
|
|
68
|
+
resolve(typeof parsed.result === 'string' ? parsed.result : null);
|
|
69
|
+
} catch {
|
|
70
|
+
resolve(stdout.trim() || null);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
proc.on('error', () => {
|
|
74
|
+
if (done) return;
|
|
75
|
+
done = true;
|
|
76
|
+
clearTimeout(fallback);
|
|
77
|
+
resolve(null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
proc.stdin.write(prompt);
|
|
81
|
+
proc.stdin.end();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function extractMemories(agentId, userContent, assistantContent) {
|
|
86
|
+
if (typeof userContent === 'string' && userContent.startsWith('[scheduled reminder]')) return;
|
|
87
|
+
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
const last = lastExtractionTime.get(agentId) || 0;
|
|
90
|
+
if (now - last < EXTRACTION_COOLDOWN) return;
|
|
91
|
+
lastExtractionTime.set(agentId, now);
|
|
92
|
+
|
|
93
|
+
const existing = stmts.listMemories.all(agentId);
|
|
94
|
+
const existingBlock = existing.length
|
|
95
|
+
? 'existing memories:\n' + existing.map(m => `- ${m.summary}`).join('\n')
|
|
96
|
+
: 'no existing memories.';
|
|
97
|
+
|
|
98
|
+
const systemPrompt = `extract only concrete facts THE USER explicitly stated. ignore everything the assistant said. max 3 memories per exchange. only extract: user's name, stated preferences, explicit instructions, key decisions. do NOT extract: greetings, small talk, questions, the assistant's personality, setup details, or anything implied. output a bullet list starting with "- ". if nothing worth remembering, respond with exactly "none".`;
|
|
99
|
+
|
|
100
|
+
const prompt = `${existingBlock}\n\nlatest exchange:\nuser: ${userContent}\nassistant: ${assistantContent}`;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const result = await callLightweight(prompt, systemPrompt);
|
|
104
|
+
if (!result || result.trim().toLowerCase() === 'none') return;
|
|
105
|
+
|
|
106
|
+
const lines = result.split('\n')
|
|
107
|
+
.map(l => l.replace(/^-\s*/, '').trim())
|
|
108
|
+
.filter(l => l.length > 0 && l.length < 500)
|
|
109
|
+
.filter(l => !/^(done|here|your|i've|i have|consolidated|saved|memory is|sure|no new)/i.test(l));
|
|
110
|
+
|
|
111
|
+
const agent = stmts.getAgent.get(agentId);
|
|
112
|
+
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
stmts.createMemory.run(uuid(), agentId, line);
|
|
115
|
+
if (agent) {
|
|
116
|
+
try { workspace.appendToDaily(agent.name, line); } catch (err) {
|
|
117
|
+
console.error(`[memory] daily log error: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (lines.length > 0) {
|
|
123
|
+
console.log(`[memory] extracted ${lines.length} memories for agent ${agentId}`);
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.error(`[memory] extraction error: ${err.message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function startConsolidation() {
|
|
131
|
+
console.log('[memory] consolidation service started (every 15 min)');
|
|
132
|
+
|
|
133
|
+
setInterval(async () => {
|
|
134
|
+
try {
|
|
135
|
+
const agents = stmts.listAgents.all();
|
|
136
|
+
|
|
137
|
+
for (const agent of agents) {
|
|
138
|
+
const memories = stmts.listMemories.all(agent.id);
|
|
139
|
+
if (memories.length < 5) continue;
|
|
140
|
+
|
|
141
|
+
const memoryList = memories.map(m => `- ${m.summary}`).join('\n');
|
|
142
|
+
const systemPrompt = `you are a memory consolidation tool. your ONLY job is to deduplicate and clean up a list of factual memories. output ONLY a bullet list — one fact per line, each starting with "- ". do NOT add commentary, explanations, confirmations, or any text that is not a memory. do NOT say "done" or "here is your list" or anything like that. just output the cleaned list.`;
|
|
143
|
+
|
|
144
|
+
const result = await callLightweight(memoryList, systemPrompt);
|
|
145
|
+
if (!result || result.trim().toLowerCase() === 'none') continue;
|
|
146
|
+
|
|
147
|
+
const lines = result.split('\n')
|
|
148
|
+
.map(l => l.replace(/^-\s*/, '').trim())
|
|
149
|
+
.filter(l => l.length > 0 && l.length < 500)
|
|
150
|
+
.filter(l => /^[A-Z]/.test(l) || /^[a-z]/.test(l))
|
|
151
|
+
.filter(l => !/^(done|here|your|i've|i have|consolidated|saved|memory is|sure|i appreciate|i'm claude|i'm an|i need to|based on|please provide|what memories|i'm ready)/i.test(l))
|
|
152
|
+
.filter(l => !/(clarify my|actual role|anthropic|memory consolidation tool|deduplicate)/i.test(l));
|
|
153
|
+
|
|
154
|
+
if (lines.length === 0) continue;
|
|
155
|
+
if (lines.length < memories.length * 0.3) {
|
|
156
|
+
console.log(`[memory] skipping consolidation for ${agent.name} — output too small (${lines.length} vs ${memories.length})`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const consolidate = db.transaction(() => {
|
|
161
|
+
stmts.deleteMemories.run(agent.id);
|
|
162
|
+
for (const line of lines) {
|
|
163
|
+
stmts.createMemory.run(uuid(), agent.id, line);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
consolidate();
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const memoryMd = lines.map(l => `- ${l}`).join('\n');
|
|
170
|
+
workspace.writeFile(agent.name, 'MEMORY.md', memoryMd + '\n');
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error(`[memory] failed to write MEMORY.md for ${agent.name}: ${err.message}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
console.log(`[memory] consolidated ${memories.length} → ${lines.length} memories for ${agent.name}`);
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error(`[memory] consolidation error: ${err.message}`);
|
|
179
|
+
}
|
|
180
|
+
}, 15 * 60 * 1000);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { extractMemories, startConsolidation, callLightweight };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const { stmts } = require('../db');
|
|
2
|
+
|
|
3
|
+
let chatModule = null;
|
|
4
|
+
let timer = null;
|
|
5
|
+
|
|
6
|
+
function start() {
|
|
7
|
+
chatModule = require('./chat');
|
|
8
|
+
timer = setInterval(tick, 30000);
|
|
9
|
+
tick();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function stop() {
|
|
13
|
+
if (timer) clearInterval(timer);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function tick() {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const due = stmts.dueSchedules.all(now);
|
|
19
|
+
|
|
20
|
+
for (const schedule of due) {
|
|
21
|
+
stmts.updateScheduleRun.run(now, now + schedule.interval_ms, schedule.id);
|
|
22
|
+
|
|
23
|
+
chatModule.enqueueMessage(
|
|
24
|
+
schedule.agent_id,
|
|
25
|
+
`[scheduled reminder] ${schedule.description}`
|
|
26
|
+
).catch(err => {
|
|
27
|
+
console.log(`[scheduler] schedule ${schedule.id} failed: ${err.message}`);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { start, stop };
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
const { spawn, execSync } = require('child_process');
|
|
2
|
+
const QRCode = require('qrcode');
|
|
3
|
+
|
|
4
|
+
const MAX_RESPONSE_LENGTH = 4000;
|
|
5
|
+
|
|
6
|
+
let daemon = null;
|
|
7
|
+
let rpcId = 1;
|
|
8
|
+
|
|
9
|
+
function log(msg) {
|
|
10
|
+
console.log(`[signal] ${msg}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseMessage(text) {
|
|
14
|
+
if (!text) return null;
|
|
15
|
+
const match = text.match(/^(\w+):\s*(.+)$/s);
|
|
16
|
+
if (!match) return null;
|
|
17
|
+
return { agent: match[1].toLowerCase(), command: match[2].trim() };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function signalCliAvailable() {
|
|
21
|
+
try {
|
|
22
|
+
execSync('which signal-cli', { stdio: 'ignore' });
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function send(recipient, message) {
|
|
30
|
+
if (!daemon || !daemon.stdin.writable) return;
|
|
31
|
+
const req = JSON.stringify({ jsonrpc: '2.0', id: rpcId++, method: 'send', params: { recipient: [recipient], message } });
|
|
32
|
+
daemon.stdin.write(req + '\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function startDaemon(phone, onStatus, chatModule, stmts) {
|
|
36
|
+
daemon = spawn('signal-cli', ['-a', phone, 'jsonRpc', '--receive-mode=on-start', '--send-read-receipts'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
37
|
+
|
|
38
|
+
log(`daemon started for ${phone} (pid ${daemon.pid})`);
|
|
39
|
+
if (onStatus) onStatus('connected', phone);
|
|
40
|
+
|
|
41
|
+
let buffer = '';
|
|
42
|
+
|
|
43
|
+
daemon.stdout.on('data', (chunk) => {
|
|
44
|
+
buffer += chunk.toString();
|
|
45
|
+
let newline;
|
|
46
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
47
|
+
const line = buffer.slice(0, newline).trim();
|
|
48
|
+
buffer = buffer.slice(newline + 1);
|
|
49
|
+
if (!line) continue;
|
|
50
|
+
|
|
51
|
+
let msg;
|
|
52
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
53
|
+
|
|
54
|
+
if (msg.method === 'receive') {
|
|
55
|
+
const envelope = msg.params && msg.params.envelope;
|
|
56
|
+
if (!envelope) continue;
|
|
57
|
+
|
|
58
|
+
let text, sender;
|
|
59
|
+
|
|
60
|
+
if (envelope.dataMessage && envelope.dataMessage.message) {
|
|
61
|
+
text = envelope.dataMessage.message;
|
|
62
|
+
sender = envelope.sourceNumber || envelope.source;
|
|
63
|
+
} else if (envelope.syncMessage && envelope.syncMessage.sentMessage && envelope.syncMessage.sentMessage.message) {
|
|
64
|
+
text = envelope.syncMessage.sentMessage.message;
|
|
65
|
+
sender = envelope.syncMessage.sentMessage.destinationNumber || envelope.syncMessage.sentMessage.destination || envelope.sourceNumber || envelope.source;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!text || !sender) continue;
|
|
69
|
+
if (!sender) continue;
|
|
70
|
+
|
|
71
|
+
let parsed = parseMessage(text);
|
|
72
|
+
let agent;
|
|
73
|
+
if (parsed) {
|
|
74
|
+
agent = stmts.getAgentByName.get(parsed.agent);
|
|
75
|
+
if (!agent) {
|
|
76
|
+
send(sender, `no agent named "${parsed.agent}"`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
agent = stmts.getDefaultAgent.get();
|
|
81
|
+
if (!agent) continue;
|
|
82
|
+
parsed = { agent: agent.name, command: text.trim() };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
log(`${parsed.agent}: ${parsed.command}`);
|
|
86
|
+
|
|
87
|
+
chatModule.enqueueMessage(agent.id, parsed.command, {
|
|
88
|
+
onAck: (ackText) => send(sender, ackText)
|
|
89
|
+
}).then((result) => {
|
|
90
|
+
if (result && result.content) {
|
|
91
|
+
let reply = result.content;
|
|
92
|
+
if (reply.length > MAX_RESPONSE_LENGTH) {
|
|
93
|
+
reply = reply.slice(0, MAX_RESPONSE_LENGTH) + '...';
|
|
94
|
+
}
|
|
95
|
+
send(sender, reply);
|
|
96
|
+
}
|
|
97
|
+
}).catch((err) => {
|
|
98
|
+
send(sender, `error: ${err.message}`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
daemon.stderr.on('data', (chunk) => {
|
|
105
|
+
const text = chunk.toString().trim();
|
|
106
|
+
if (text) log(`stderr: ${text}`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
daemon.on('close', (code) => {
|
|
110
|
+
log(`daemon exited with code ${code}`);
|
|
111
|
+
daemon = null;
|
|
112
|
+
if (onStatus) onStatus('error', `signal-cli exited (code ${code})`);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function extractPhone(text) {
|
|
117
|
+
const match = text.match(/(\+\d{7,15})/);
|
|
118
|
+
return match ? match[1] : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function detectAccount() {
|
|
122
|
+
try {
|
|
123
|
+
const output = execSync('signal-cli --list-accounts 2>&1', { encoding: 'utf8', timeout: 5000 });
|
|
124
|
+
return extractPhone(output);
|
|
125
|
+
} catch { return null; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function finishLink(phone, onStatus, chatModule, stmts, updateConfig) {
|
|
129
|
+
log(`linked as ${phone}`);
|
|
130
|
+
updateConfig({ phone });
|
|
131
|
+
startDaemon(phone, onStatus, chatModule, stmts);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function startLinking(onStatus, chatModule, stmts, updateConfig) {
|
|
135
|
+
const link = spawn('signal-cli', ['link', '-n', 'claudity'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
136
|
+
let linked = false;
|
|
137
|
+
let linkBuffer = '';
|
|
138
|
+
let errBuffer = '';
|
|
139
|
+
|
|
140
|
+
function tryLink(text) {
|
|
141
|
+
if (linked) return;
|
|
142
|
+
const phone = extractPhone(text);
|
|
143
|
+
if (phone) {
|
|
144
|
+
linked = true;
|
|
145
|
+
finishLink(phone, onStatus, chatModule, stmts, updateConfig);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
link.stdout.on('data', (chunk) => {
|
|
150
|
+
linkBuffer += chunk.toString();
|
|
151
|
+
let newline;
|
|
152
|
+
while ((newline = linkBuffer.indexOf('\n')) !== -1) {
|
|
153
|
+
const line = linkBuffer.slice(0, newline).trim();
|
|
154
|
+
linkBuffer = linkBuffer.slice(newline + 1);
|
|
155
|
+
if (!line) continue;
|
|
156
|
+
|
|
157
|
+
if (line.startsWith('tsdevice:') || line.startsWith('sgnl:')) {
|
|
158
|
+
log('link uri received');
|
|
159
|
+
QRCode.toDataURL(line, { width: 256, margin: 2 }).then((dataUrl) => {
|
|
160
|
+
if (onStatus) onStatus('qr', dataUrl);
|
|
161
|
+
}).catch((err) => {
|
|
162
|
+
log('qr generation failed: ' + err.message);
|
|
163
|
+
});
|
|
164
|
+
} else {
|
|
165
|
+
tryLink(line);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
link.stderr.on('data', (chunk) => {
|
|
171
|
+
const text = chunk.toString();
|
|
172
|
+
errBuffer += text;
|
|
173
|
+
const trimmed = text.trim();
|
|
174
|
+
if (trimmed) log(`link stderr: ${trimmed}`);
|
|
175
|
+
tryLink(trimmed);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
link.on('close', (code) => {
|
|
179
|
+
if (linked) return;
|
|
180
|
+
|
|
181
|
+
tryLink(errBuffer);
|
|
182
|
+
|
|
183
|
+
if (!linked) {
|
|
184
|
+
const phone = detectAccount();
|
|
185
|
+
if (phone) {
|
|
186
|
+
linked = true;
|
|
187
|
+
finishLink(phone, onStatus, chatModule, stmts, updateConfig);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!linked) {
|
|
193
|
+
log(`link process exited with code ${code}`);
|
|
194
|
+
if (onStatus) onStatus('error', 'linking failed — scan the qr code within 60 seconds');
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return link;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let linkProcess = null;
|
|
202
|
+
|
|
203
|
+
function start(config, callbacks) {
|
|
204
|
+
const { onStatus } = callbacks || {};
|
|
205
|
+
|
|
206
|
+
if (!signalCliAvailable()) {
|
|
207
|
+
if (onStatus) onStatus('error', 'signal-cli not found — install with: brew install signal-cli');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const chatModule = require('./chat');
|
|
212
|
+
const { stmts } = require('../db');
|
|
213
|
+
|
|
214
|
+
if (config.phone) {
|
|
215
|
+
startDaemon(config.phone, onStatus, chatModule, stmts);
|
|
216
|
+
} else {
|
|
217
|
+
const updateConfig = (newFields) => {
|
|
218
|
+
const merged = { ...config, ...newFields };
|
|
219
|
+
stmts.updateConnectionConfig.run(JSON.stringify(merged), 'signal');
|
|
220
|
+
};
|
|
221
|
+
linkProcess = startLinking(onStatus, chatModule, stmts, updateConfig);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function stop() {
|
|
226
|
+
if (linkProcess) {
|
|
227
|
+
linkProcess.kill();
|
|
228
|
+
linkProcess = null;
|
|
229
|
+
}
|
|
230
|
+
if (daemon) {
|
|
231
|
+
daemon.kill();
|
|
232
|
+
daemon = null;
|
|
233
|
+
}
|
|
234
|
+
log('disconnected');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = { start, stop };
|