imessage-mcp-server 1.0.0 → 2.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/README.md +159 -79
- package/README.zh.md +310 -0
- package/bin/imessage-mcp-server +4 -0
- package/package.json +18 -8
- package/scripts/install-launchagent.sh +98 -0
- package/src/bridge/daemon.js +243 -0
- package/src/bridge/index.js +31 -0
- package/src/bridge/llm-loop.js +232 -0
- package/src/bridge/mcp-client.js +100 -0
- package/src/cli.js +125 -0
- package/src/config.js +236 -0
- package/src/providers/anthropic.js +80 -0
- package/src/providers/base.js +37 -0
- package/src/providers/index.js +19 -0
- package/src/providers/openai.js +104 -0
- package/src/safety.js +58 -0
- package/src/server/index.js +45 -0
- package/src/server/tools.js +163 -0
- package/src/shared/constants.js +30 -0
- package/src/shared/imessage.js +277 -0
- package/src/shared/utils.js +54 -0
- package/index.js +0 -480
package/package.json
CHANGED
|
@@ -1,34 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imessage-mcp-server",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "MCP server
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "MCP server and AI bridge for iMessage on macOS",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "index.js",
|
|
7
6
|
"bin": {
|
|
8
|
-
"imessage-mcp-server": "./
|
|
7
|
+
"imessage-mcp-server": "./bin/imessage-mcp-server"
|
|
9
8
|
},
|
|
10
9
|
"publishConfig": {
|
|
11
10
|
"access": "public"
|
|
12
11
|
},
|
|
13
12
|
"files": [
|
|
14
|
-
"
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/",
|
|
15
|
+
"scripts/",
|
|
15
16
|
"README.md",
|
|
17
|
+
"README.zh.md",
|
|
16
18
|
"LICENSE"
|
|
17
19
|
],
|
|
18
20
|
"preferUnplugged": true,
|
|
19
21
|
"scripts": {
|
|
20
|
-
"start": "node
|
|
22
|
+
"start": "node bin/imessage-mcp-server --server",
|
|
23
|
+
"bridge": "node bin/imessage-mcp-server --bridge",
|
|
21
24
|
"test": "echo \"No tests yet\" && exit 0"
|
|
22
25
|
},
|
|
23
26
|
"keywords": [
|
|
24
27
|
"imessage",
|
|
28
|
+
"imessage-mcp",
|
|
29
|
+
"imessage-mcp-server",
|
|
25
30
|
"mcp",
|
|
26
31
|
"mcp-server",
|
|
27
32
|
"model-context-protocol",
|
|
28
33
|
"macos",
|
|
29
34
|
"messages",
|
|
30
35
|
"icloud",
|
|
31
|
-
"apple"
|
|
36
|
+
"apple",
|
|
37
|
+
"ai",
|
|
38
|
+
"bridge",
|
|
39
|
+
"agent"
|
|
32
40
|
],
|
|
33
41
|
"author": "tinyxia",
|
|
34
42
|
"license": "MIT",
|
|
@@ -44,7 +52,9 @@
|
|
|
44
52
|
"url": "https://github.com/tinyxia/imessage-mcp/issues"
|
|
45
53
|
},
|
|
46
54
|
"dependencies": {
|
|
55
|
+
"@anthropic-ai/sdk": "^0.30.0",
|
|
47
56
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
|
-
"better-sqlite3": "^12.11.1"
|
|
57
|
+
"better-sqlite3": "^12.11.1",
|
|
58
|
+
"openai": "^4.0.0"
|
|
49
59
|
}
|
|
50
60
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Install iMessage Bridge as macOS LaunchAgent (auto-start on login)
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# install-launchagent.sh [load|unload|status] [config-path]
|
|
6
|
+
|
|
7
|
+
set -e
|
|
8
|
+
|
|
9
|
+
PLIST="com.inddaily.imessage-mcp-server.bridge.plist"
|
|
10
|
+
LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents"
|
|
11
|
+
DEST="$LAUNCH_AGENTS_DIR/$PLIST"
|
|
12
|
+
LOG_DIR="$HOME/Library/Logs"
|
|
13
|
+
CONFIG_PATH="${2:-}"
|
|
14
|
+
|
|
15
|
+
# Determine how to run the bridge.
|
|
16
|
+
# Prefer npx if available, otherwise fall back to node with this package path.
|
|
17
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
18
|
+
PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
19
|
+
|
|
20
|
+
if command -v npx >/dev/null 2>&1; then
|
|
21
|
+
RUN_COMMAND="npx"
|
|
22
|
+
RUN_ARGS="-y imessage-mcp-server"
|
|
23
|
+
else
|
|
24
|
+
RUN_COMMAND="node"
|
|
25
|
+
RUN_ARGS="$PACKAGE_DIR/bin/imessage-mcp-server"
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
case "${1:-load}" in
|
|
29
|
+
load)
|
|
30
|
+
echo "📦 Installing iMessage Bridge LaunchAgent..."
|
|
31
|
+
mkdir -p "$LAUNCH_AGENTS_DIR"
|
|
32
|
+
mkdir -p "$LOG_DIR"
|
|
33
|
+
|
|
34
|
+
cat > "$DEST" <<EOF
|
|
35
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
36
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
37
|
+
<plist version="1.0">
|
|
38
|
+
<dict>
|
|
39
|
+
<key>Label</key>
|
|
40
|
+
<string>com.inddaily.imessage-mcp-server.bridge</string>
|
|
41
|
+
<key>ProgramArguments</key>
|
|
42
|
+
<array>
|
|
43
|
+
<string>$RUN_COMMAND</string>
|
|
44
|
+
EOF
|
|
45
|
+
|
|
46
|
+
for arg in $RUN_ARGS; do
|
|
47
|
+
echo " <string>$arg</string>" >> "$DEST"
|
|
48
|
+
done
|
|
49
|
+
|
|
50
|
+
echo " <string>--bridge</string>" >> "$DEST"
|
|
51
|
+
if [ -n "$CONFIG_PATH" ]; then
|
|
52
|
+
echo " <string>--config</string>" >> "$DEST"
|
|
53
|
+
echo " <string>$CONFIG_PATH</string>" >> "$DEST"
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
cat >> "$DEST" <<EOF
|
|
57
|
+
</array>
|
|
58
|
+
<key>RunAtLoad</key>
|
|
59
|
+
<true/>
|
|
60
|
+
<key>KeepAlive</key>
|
|
61
|
+
<true/>
|
|
62
|
+
<key>StandardOutPath</key>
|
|
63
|
+
<string>$LOG_DIR/imessage-mcp-server-bridge.log</string>
|
|
64
|
+
<key>StandardErrorPath</key>
|
|
65
|
+
<string>$LOG_DIR/imessage-mcp-server-bridge.log</string>
|
|
66
|
+
<key>EnvironmentVariables</key>
|
|
67
|
+
<dict>
|
|
68
|
+
<key>PATH</key>
|
|
69
|
+
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin</string>
|
|
70
|
+
</dict>
|
|
71
|
+
</dict>
|
|
72
|
+
</plist>
|
|
73
|
+
EOF
|
|
74
|
+
|
|
75
|
+
launchctl load "$DEST" || launchctl bootstrap gui/"$(id -u)" "$DEST"
|
|
76
|
+
echo "✅ Installed and started!"
|
|
77
|
+
echo " Log: $LOG_DIR/imessage-mcp-server-bridge.log"
|
|
78
|
+
echo " Stop: launchctl unload $DEST"
|
|
79
|
+
;;
|
|
80
|
+
unload)
|
|
81
|
+
echo "🗑️ Uninstalling iMessage Bridge..."
|
|
82
|
+
launchctl unload "$DEST" 2>/dev/null || true
|
|
83
|
+
rm -f "$DEST"
|
|
84
|
+
echo "✅ Uninstalled"
|
|
85
|
+
;;
|
|
86
|
+
status)
|
|
87
|
+
if launchctl list | grep -q "com.inddaily.imessage-mcp-server.bridge"; then
|
|
88
|
+
echo "🟢 Running"
|
|
89
|
+
launchctl list | grep com.inddaily.imessage-mcp-server.bridge
|
|
90
|
+
else
|
|
91
|
+
echo "🔴 Not running"
|
|
92
|
+
fi
|
|
93
|
+
;;
|
|
94
|
+
*)
|
|
95
|
+
echo "Usage: $0 [load|unload|status] [config-path]"
|
|
96
|
+
exit 1
|
|
97
|
+
;;
|
|
98
|
+
esac
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import {
|
|
5
|
+
openDb,
|
|
6
|
+
getMasterHandleId,
|
|
7
|
+
getNewMessagesForBridge,
|
|
8
|
+
sendMessage,
|
|
9
|
+
} from "../shared/imessage.js";
|
|
10
|
+
import { LOG, sleep } from "../shared/utils.js";
|
|
11
|
+
import { LlmLoop } from "./llm-loop.js";
|
|
12
|
+
|
|
13
|
+
const STATE_DIR = path.join(homedir(), ".imessage-mcp-server");
|
|
14
|
+
const STATE_PATH = path.join(STATE_DIR, "bridge-state.json");
|
|
15
|
+
|
|
16
|
+
export class BridgeDaemon {
|
|
17
|
+
constructor(config, mcpManager) {
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.mcpManager = mcpManager;
|
|
20
|
+
this.llmLoop = new LlmLoop(config, mcpManager);
|
|
21
|
+
this.state = this.loadState();
|
|
22
|
+
this.shutdownRequested = false;
|
|
23
|
+
this.processing = false;
|
|
24
|
+
|
|
25
|
+
process.on("SIGINT", () => this.requestShutdown("SIGINT"));
|
|
26
|
+
process.on("SIGTERM", () => this.requestShutdown("SIGTERM"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
requestShutdown(signal) {
|
|
30
|
+
LOG.info(`Received ${signal}, shutting down...`);
|
|
31
|
+
this.shutdownRequested = true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
loadState() {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(STATE_PATH, "utf-8"));
|
|
37
|
+
} catch {
|
|
38
|
+
return {
|
|
39
|
+
lastProcessedId: {},
|
|
40
|
+
conversations: {},
|
|
41
|
+
initialized: false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
saveState() {
|
|
47
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
48
|
+
const cleaned = { ...this.state };
|
|
49
|
+
for (const [chatId, msgs] of Object.entries(cleaned.conversations || {})) {
|
|
50
|
+
const max = 50;
|
|
51
|
+
if (msgs.length > max) {
|
|
52
|
+
cleaned.conversations[chatId] = msgs.slice(msgs.length - max);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
fs.writeFileSync(STATE_PATH, JSON.stringify(cleaned, null, 2));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async run() {
|
|
59
|
+
LOG.info("Starting iMessage Bridge Daemon", {
|
|
60
|
+
masterHandle: this.config.masterHandle,
|
|
61
|
+
provider: this.config.provider,
|
|
62
|
+
model: this.config.model,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await this.mcpManager.connectAll();
|
|
66
|
+
|
|
67
|
+
if (!this.state.initialized) {
|
|
68
|
+
await this.initializeState();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
LOG.info("Entering polling loop...");
|
|
72
|
+
|
|
73
|
+
while (!this.shutdownRequested) {
|
|
74
|
+
try {
|
|
75
|
+
await this.pollCycle();
|
|
76
|
+
} catch (err) {
|
|
77
|
+
LOG.error("Poll cycle error", { error: err.message });
|
|
78
|
+
}
|
|
79
|
+
if (!this.shutdownRequested) {
|
|
80
|
+
await sleep(this.config.pollIntervalMs);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await this.mcpManager.close();
|
|
85
|
+
LOG.info("Daemon shut down gracefully");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async initializeState() {
|
|
89
|
+
const db = openDb();
|
|
90
|
+
try {
|
|
91
|
+
const handleId = getMasterHandleId(db, this.config.masterHandle);
|
|
92
|
+
if (!handleId) {
|
|
93
|
+
LOG.error("Master handle not found in chat.db", {
|
|
94
|
+
handle: this.config.masterHandle,
|
|
95
|
+
});
|
|
96
|
+
const handles = db.prepare("SELECT id FROM handle").all();
|
|
97
|
+
LOG.info("Available handles:");
|
|
98
|
+
for (const h of handles) LOG.info(` - ${h.id}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (Object.keys(this.state.lastProcessedId).length === 0) {
|
|
103
|
+
const chats = db
|
|
104
|
+
.prepare(
|
|
105
|
+
`
|
|
106
|
+
SELECT DISTINCT cmj.chat_id
|
|
107
|
+
FROM message m
|
|
108
|
+
JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
|
|
109
|
+
WHERE m.handle_id = ?
|
|
110
|
+
`
|
|
111
|
+
)
|
|
112
|
+
.all(handleId);
|
|
113
|
+
for (const c of chats) {
|
|
114
|
+
const lastMsg = db
|
|
115
|
+
.prepare(
|
|
116
|
+
`
|
|
117
|
+
SELECT MAX(m.ROWID) as last_id
|
|
118
|
+
FROM message m
|
|
119
|
+
JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
|
|
120
|
+
WHERE m.handle_id = ? AND cmj.chat_id = ?
|
|
121
|
+
`
|
|
122
|
+
)
|
|
123
|
+
.get(handleId, c.chat_id);
|
|
124
|
+
if (lastMsg?.last_id) {
|
|
125
|
+
this.state.lastProcessedId[String(c.chat_id)] = lastMsg.last_id;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.state.initialized = true;
|
|
131
|
+
this.saveState();
|
|
132
|
+
LOG.info("Initialized: skipping existing messages", {
|
|
133
|
+
chatsTracked: Object.keys(this.state.lastProcessedId).length,
|
|
134
|
+
});
|
|
135
|
+
} finally {
|
|
136
|
+
db.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async pollCycle() {
|
|
141
|
+
const db = openDb();
|
|
142
|
+
try {
|
|
143
|
+
const handleId = getMasterHandleId(db, this.config.masterHandle);
|
|
144
|
+
if (!handleId) {
|
|
145
|
+
LOG.warn("Master handle not found, skipping poll");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const chatIds = Object.keys(this.state.lastProcessedId);
|
|
150
|
+
let newMessages = [];
|
|
151
|
+
|
|
152
|
+
if (chatIds.length === 0) {
|
|
153
|
+
// First run - discover chats
|
|
154
|
+
const chats = db
|
|
155
|
+
.prepare(
|
|
156
|
+
`
|
|
157
|
+
SELECT DISTINCT cmj.chat_id
|
|
158
|
+
FROM message m
|
|
159
|
+
JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
|
|
160
|
+
WHERE m.handle_id = ?
|
|
161
|
+
`
|
|
162
|
+
)
|
|
163
|
+
.all(handleId);
|
|
164
|
+
for (const c of chats) {
|
|
165
|
+
const lastMsg = db
|
|
166
|
+
.prepare(
|
|
167
|
+
`
|
|
168
|
+
SELECT MAX(m.ROWID) as last_id
|
|
169
|
+
FROM message m
|
|
170
|
+
JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
|
|
171
|
+
WHERE m.handle_id = ? AND cmj.chat_id = ?
|
|
172
|
+
`
|
|
173
|
+
)
|
|
174
|
+
.get(handleId, c.chat_id);
|
|
175
|
+
if (lastMsg?.last_id) {
|
|
176
|
+
this.state.lastProcessedId[String(c.chat_id)] = lastMsg.last_id;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
this.saveState();
|
|
180
|
+
} else {
|
|
181
|
+
for (const chatId of chatIds) {
|
|
182
|
+
const lastId = this.state.lastProcessedId[chatId] || 0;
|
|
183
|
+
const msgs = getNewMessagesForBridge(db, handleId, lastId);
|
|
184
|
+
newMessages.push(...msgs);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (newMessages.length > 0 && !this.processing) {
|
|
189
|
+
this.processing = true;
|
|
190
|
+
LOG.info(`Found ${newMessages.length} new message(s)`);
|
|
191
|
+
|
|
192
|
+
for (const msg of newMessages) {
|
|
193
|
+
if (this.shutdownRequested) break;
|
|
194
|
+
try {
|
|
195
|
+
await this.processMessage(msg);
|
|
196
|
+
this.saveState();
|
|
197
|
+
} catch (err) {
|
|
198
|
+
LOG.error("Error processing message", {
|
|
199
|
+
msgId: msg.ROWID,
|
|
200
|
+
error: err.message,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
this.processing = false;
|
|
206
|
+
} else if (newMessages.length > 0 && this.processing) {
|
|
207
|
+
LOG.debug("Still processing previous message, skipping poll cycle");
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
db.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async processMessage(message) {
|
|
215
|
+
const { ROWID: msgId, text: userText, chat_id: chatId } = message;
|
|
216
|
+
LOG.info(`Processing message #${msgId}`, {
|
|
217
|
+
chatId,
|
|
218
|
+
text: userText.substring(0, 100),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (this.config.sendProcessingIndicator) {
|
|
222
|
+
await this.reply("⏳ 收到,正在处理...");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const replyFn = (text) => this.reply(text);
|
|
226
|
+
await this.llmLoop.process(userText, this.state, chatId, replyFn);
|
|
227
|
+
|
|
228
|
+
this.state.lastProcessedId[String(chatId)] = msgId;
|
|
229
|
+
LOG.info(`Message #${msgId} processed successfully`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async reply(text) {
|
|
233
|
+
try {
|
|
234
|
+
sendMessage(this.config.masterHandle, text);
|
|
235
|
+
LOG.info("Reply sent", {
|
|
236
|
+
length: text.length,
|
|
237
|
+
preview: text.substring(0, 60),
|
|
238
|
+
});
|
|
239
|
+
} catch (err) {
|
|
240
|
+
LOG.error("Failed to send reply", { error: err.message });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { validateBridgeConfig, buildBridgeConfig } from "../config.js";
|
|
2
|
+
import { McpClientManager } from "./mcp-client.js";
|
|
3
|
+
import { BridgeDaemon } from "./daemon.js";
|
|
4
|
+
import { LOG } from "../shared/utils.js";
|
|
5
|
+
|
|
6
|
+
export async function startBridge(args) {
|
|
7
|
+
const config = buildBridgeConfig(args);
|
|
8
|
+
validateBridgeConfig(config);
|
|
9
|
+
|
|
10
|
+
const mcpManager = new McpClientManager(config.mcpServers);
|
|
11
|
+
const daemon = new BridgeDaemon(config, mcpManager);
|
|
12
|
+
|
|
13
|
+
if (args.testConfig) {
|
|
14
|
+
LOG.info("Testing bridge configuration...");
|
|
15
|
+
await mcpManager.connectAll();
|
|
16
|
+
const tools = mcpManager.getAllTools();
|
|
17
|
+
LOG.info("Configuration valid", {
|
|
18
|
+
provider: config.provider,
|
|
19
|
+
model: config.model,
|
|
20
|
+
mcpServers: Object.keys(config.mcpServers).length,
|
|
21
|
+
toolsAvailable: tools.length,
|
|
22
|
+
});
|
|
23
|
+
for (const t of tools) {
|
|
24
|
+
LOG.info(` - ${t.name} (${t.serverId})`);
|
|
25
|
+
}
|
|
26
|
+
await mcpManager.close();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await daemon.run();
|
|
31
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { sendMessage } from "../shared/imessage.js";
|
|
2
|
+
import { LOG, sleep, splitLongReply } from "../shared/utils.js";
|
|
3
|
+
import { isToolAllowed, isCommandBlocked } from "../safety.js";
|
|
4
|
+
import { createProvider } from "../providers/index.js";
|
|
5
|
+
|
|
6
|
+
export class LlmLoop {
|
|
7
|
+
constructor(config, mcpManager) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
this.mcpManager = mcpManager;
|
|
10
|
+
this.provider = createProvider(config);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async process(userText, state, chatId, replyFn) {
|
|
14
|
+
const chatHistory = state.conversations[chatId] || [];
|
|
15
|
+
const apiMessages = [
|
|
16
|
+
...chatHistory.slice(-(this.config.maxHistoryPerConversation || 20) * 2),
|
|
17
|
+
{ role: "user", content: userText },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const allMcpTools = this.mcpManager.getAllTools();
|
|
21
|
+
const localTools = this.getLocalTools();
|
|
22
|
+
const availableTools = [...allMcpTools, ...localTools]
|
|
23
|
+
.filter((t) => isToolAllowed(t.name, this.config.safety))
|
|
24
|
+
.map((t) => ({
|
|
25
|
+
name: t.name,
|
|
26
|
+
description: t.description,
|
|
27
|
+
inputSchema: t.inputSchema,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
let currentMessages = [...apiMessages];
|
|
31
|
+
let maxIterations = this.config.maxToolIterations || 10;
|
|
32
|
+
let finalText = "";
|
|
33
|
+
let usedLongReply = false;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
while (maxIterations-- > 0) {
|
|
37
|
+
LOG.debug("Calling LLM", {
|
|
38
|
+
messagesCount: currentMessages.length,
|
|
39
|
+
toolsCount: availableTools.length,
|
|
40
|
+
iterationsLeft: maxIterations,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const result = await this.provider.chat(currentMessages, availableTools);
|
|
44
|
+
|
|
45
|
+
const assistantContent = [];
|
|
46
|
+
for (const block of this.normalizeResultBlocks(result)) {
|
|
47
|
+
if (block.type === "text") {
|
|
48
|
+
finalText = block.text;
|
|
49
|
+
assistantContent.push(block);
|
|
50
|
+
} else if (block.type === "thinking") {
|
|
51
|
+
assistantContent.push(block);
|
|
52
|
+
} else if (block.type === "tool_use") {
|
|
53
|
+
assistantContent.push(block);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (assistantContent.length > 0) {
|
|
58
|
+
currentMessages.push({ role: "assistant", content: assistantContent });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (result.toolCalls.length === 0) break;
|
|
62
|
+
|
|
63
|
+
for (const toolCall of result.toolCalls) {
|
|
64
|
+
const toolResult = await this.executeTool(toolCall, replyFn);
|
|
65
|
+
|
|
66
|
+
currentMessages.push({
|
|
67
|
+
role: "user",
|
|
68
|
+
content: [
|
|
69
|
+
{
|
|
70
|
+
type: "tool_result",
|
|
71
|
+
tool_use_id: toolCall.id,
|
|
72
|
+
content:
|
|
73
|
+
typeof toolResult === "string"
|
|
74
|
+
? toolResult
|
|
75
|
+
: JSON.stringify(toolResult),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (toolCall.name === "send_long_reply") {
|
|
81
|
+
usedLongReply = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
LOG.error("LLM error", { error: err.message, status: err.status });
|
|
87
|
+
const errorMsg =
|
|
88
|
+
err.status === 401
|
|
89
|
+
? "❌ API 认证失败,请检查 API Key 配置"
|
|
90
|
+
: err.status === 429
|
|
91
|
+
? "❌ API 速率限制,请稍后再试"
|
|
92
|
+
: `❌ 处理出错: ${err.message}`;
|
|
93
|
+
await replyFn(errorMsg);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Send final response
|
|
98
|
+
if (finalText && !usedLongReply) {
|
|
99
|
+
await this.sendReply(finalText, replyFn);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Update conversation history
|
|
103
|
+
state.conversations[chatId] = [
|
|
104
|
+
...(state.conversations[chatId] || []).slice(
|
|
105
|
+
-(this.config.maxHistoryPerConversation || 20) * 2
|
|
106
|
+
),
|
|
107
|
+
{ role: "user", content: userText },
|
|
108
|
+
];
|
|
109
|
+
if (finalText) {
|
|
110
|
+
state.conversations[chatId].push({
|
|
111
|
+
role: "assistant",
|
|
112
|
+
content: finalText,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
normalizeResultBlocks(result) {
|
|
118
|
+
const blocks = [];
|
|
119
|
+
if (result.text) {
|
|
120
|
+
blocks.push({ type: "text", text: result.text });
|
|
121
|
+
}
|
|
122
|
+
for (const t of result.thinking || []) {
|
|
123
|
+
blocks.push({
|
|
124
|
+
type: "thinking",
|
|
125
|
+
thinking: t.thinking || "",
|
|
126
|
+
signature: t.signature,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
for (const tc of result.toolCalls || []) {
|
|
130
|
+
blocks.push({
|
|
131
|
+
type: "tool_use",
|
|
132
|
+
id: tc.id,
|
|
133
|
+
name: tc.name,
|
|
134
|
+
input: tc.input || {},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return blocks;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
getLocalTools() {
|
|
141
|
+
return [
|
|
142
|
+
{
|
|
143
|
+
name: "send_imessage",
|
|
144
|
+
description:
|
|
145
|
+
"Send an iMessage to a specified recipient. Use only when explicitly asked.",
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: {
|
|
149
|
+
recipient: {
|
|
150
|
+
type: "string",
|
|
151
|
+
description: "Email or phone number of the recipient",
|
|
152
|
+
},
|
|
153
|
+
text: { type: "string", description: "Message text to send" },
|
|
154
|
+
},
|
|
155
|
+
required: ["recipient", "text"],
|
|
156
|
+
},
|
|
157
|
+
local: true,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "send_long_reply",
|
|
161
|
+
description:
|
|
162
|
+
"Send a long reply that exceeds a single iMessage by splitting it into multiple messages. Call this instead of producing text when the response is long.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: "object",
|
|
165
|
+
properties: {
|
|
166
|
+
parts: {
|
|
167
|
+
type: "array",
|
|
168
|
+
items: { type: "string" },
|
|
169
|
+
description: "Array of message parts, each ≤ 1000 characters",
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
required: ["parts"],
|
|
173
|
+
},
|
|
174
|
+
local: true,
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async executeTool(toolCall, replyFn) {
|
|
180
|
+
LOG.info(`Tool call`, { name: toolCall.name, input: toolCall.input });
|
|
181
|
+
|
|
182
|
+
if (!isToolAllowed(toolCall.name, this.config.safety)) {
|
|
183
|
+
return `工具 ${toolCall.name} 被安全策略禁止`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const command = toolCall.input?.command || toolCall.input?.cmd || toolCall.input?.shell;
|
|
187
|
+
if (typeof command === "string" && isCommandBlocked(command, this.config.safety.blockedCommands)) {
|
|
188
|
+
return `命令被安全策略禁止: ${command}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
switch (toolCall.name) {
|
|
193
|
+
case "send_imessage": {
|
|
194
|
+
const { recipient, text } = toolCall.input || {};
|
|
195
|
+
if (!recipient || !text) return "缺少 recipient 或 text";
|
|
196
|
+
sendMessage(recipient, text);
|
|
197
|
+
return `已发送消息给 ${recipient}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
case "send_long_reply": {
|
|
201
|
+
const parts = toolCall.input?.parts || [];
|
|
202
|
+
for (const part of parts) {
|
|
203
|
+
await replyFn(part);
|
|
204
|
+
if (parts.length > 1) await sleep(1000);
|
|
205
|
+
}
|
|
206
|
+
return `已发送 ${parts.length} 条消息`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
default:
|
|
210
|
+
return await this.mcpManager.callTool(toolCall.name, toolCall.input);
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
LOG.error(`Tool execution failed`, {
|
|
214
|
+
name: toolCall.name,
|
|
215
|
+
error: err.message,
|
|
216
|
+
});
|
|
217
|
+
return `工具执行出错: ${err.message}`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async sendReply(text, replyFn) {
|
|
222
|
+
if (text.length > 300) {
|
|
223
|
+
const parts = splitLongReply(text, 300);
|
|
224
|
+
for (const part of parts) {
|
|
225
|
+
await replyFn(part);
|
|
226
|
+
if (parts.length > 1) await sleep(500);
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
await replyFn(text);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|