metame-cli 1.5.4 → 1.5.5
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 +6 -1
- package/index.js +277 -55
- package/package.json +2 -2
- package/scripts/agent-layer.js +4 -2
- package/scripts/bin/dispatch_to +17 -5
- package/scripts/daemon-admin-commands.js +264 -62
- package/scripts/daemon-agent-commands.js +188 -66
- package/scripts/daemon-bridges.js +447 -48
- package/scripts/daemon-claude-engine.js +650 -103
- package/scripts/daemon-command-router.js +134 -27
- package/scripts/daemon-command-session-route.js +118 -0
- package/scripts/daemon-default.yaml +2 -0
- package/scripts/daemon-engine-runtime.js +96 -20
- package/scripts/daemon-exec-commands.js +106 -50
- package/scripts/daemon-file-browser.js +63 -7
- package/scripts/daemon-notify.js +18 -4
- package/scripts/daemon-ops-commands.js +16 -2
- package/scripts/daemon-remote-dispatch.js +34 -2
- package/scripts/daemon-session-commands.js +102 -45
- package/scripts/daemon-session-store.js +497 -66
- package/scripts/daemon-siri-bridge.js +234 -0
- package/scripts/daemon-siri-imessage.js +209 -0
- package/scripts/daemon-task-scheduler.js +10 -2
- package/scripts/daemon.js +610 -181
- package/scripts/docs/hook-config.md +7 -4
- package/scripts/docs/maintenance-manual.md +8 -1
- package/scripts/feishu-adapter.js +7 -15
- package/scripts/hooks/doc-router.js +29 -0
- package/scripts/hooks/intent-doc-router.js +54 -0
- package/scripts/hooks/intent-engine.js +9 -40
- package/scripts/intent-registry.js +59 -0
- package/scripts/memory-extract.js +59 -0
- package/scripts/mentor-engine.js +6 -0
- package/scripts/schema.js +1 -0
- package/scripts/self-reflect.js +110 -12
- package/scripts/session-analytics.js +160 -0
- package/scripts/signal-capture.js +1 -1
- package/scripts/team-dispatch.js +150 -11
- package/scripts/hooks/intent-agent-manage.js +0 -50
- package/scripts/hooks/intent-hook-config.js +0 -28
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* daemon-siri-bridge.js — HTTP bridge for Siri Shortcuts integration
|
|
4
|
+
*
|
|
5
|
+
* Exposes GET/POST /ask endpoint and returns plain text.
|
|
6
|
+
* Processes through the same Claude pipeline as Telegram/Feishu/iMessage.
|
|
7
|
+
* Designed for iOS Shortcuts: Dictate → HTTP GET/POST → Speak Text.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const http = require('http');
|
|
11
|
+
const querystring = require('querystring');
|
|
12
|
+
|
|
13
|
+
function createSiriBridge(deps) {
|
|
14
|
+
const { log, loadConfig, handleCommand } = deps;
|
|
15
|
+
|
|
16
|
+
function writeText(res, statusCode, text, extraHeaders = {}) {
|
|
17
|
+
const body = String(text || '');
|
|
18
|
+
res.writeHead(statusCode, {
|
|
19
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
20
|
+
'Content-Length': Buffer.byteLength(body),
|
|
21
|
+
'Cache-Control': 'no-store, no-transform',
|
|
22
|
+
'X-Content-Type-Options': 'nosniff',
|
|
23
|
+
...extraHeaders,
|
|
24
|
+
});
|
|
25
|
+
res.end(body);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeJson(res, statusCode, payload, extraHeaders = {}) {
|
|
29
|
+
const body = JSON.stringify(payload);
|
|
30
|
+
res.writeHead(statusCode, {
|
|
31
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
32
|
+
'Content-Length': Buffer.byteLength(body),
|
|
33
|
+
'Cache-Control': 'no-store, no-transform',
|
|
34
|
+
'X-Content-Type-Options': 'nosniff',
|
|
35
|
+
...extraHeaders,
|
|
36
|
+
});
|
|
37
|
+
res.end(body);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizePlainText(text) {
|
|
41
|
+
return String(text || '').replace(/\r\n/g, '\n').trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getAuthToken(req, urlObj) {
|
|
45
|
+
const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
|
|
46
|
+
if (auth) return auth;
|
|
47
|
+
return String(urlObj.searchParams.get('token') || req.headers['x-api-key'] || '').trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readRequestBody(req) {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
53
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractAskText(req, urlObj, rawBody) {
|
|
57
|
+
const queryText = normalizePlainText(urlObj.searchParams.get('q') || urlObj.searchParams.get('text') || '');
|
|
58
|
+
if (queryText) return queryText;
|
|
59
|
+
|
|
60
|
+
const body = String(rawBody || '');
|
|
61
|
+
if (!body.trim()) return '';
|
|
62
|
+
|
|
63
|
+
const contentType = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
64
|
+
if (contentType === 'application/json') {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(body);
|
|
67
|
+
return normalizePlainText(
|
|
68
|
+
parsed && typeof parsed === 'object'
|
|
69
|
+
? (parsed.text || parsed.q || parsed.prompt || '')
|
|
70
|
+
: ''
|
|
71
|
+
);
|
|
72
|
+
} catch {
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (contentType === 'application/x-www-form-urlencoded') {
|
|
78
|
+
const parsed = querystring.parse(body);
|
|
79
|
+
return normalizePlainText(parsed.text || parsed.q || parsed.prompt || '');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return normalizePlainText(body);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Collector bot — captures Claude's response instead of sending to a chat.
|
|
87
|
+
* handleCommand calls bot.sendMessage / sendMarkdown / editMessage as it
|
|
88
|
+
* streams; we collect everything and return the final text.
|
|
89
|
+
*/
|
|
90
|
+
function createCollectorBot() {
|
|
91
|
+
const messages = new Map();
|
|
92
|
+
let nextId = 1;
|
|
93
|
+
|
|
94
|
+
const bot = {
|
|
95
|
+
suppressAck: true,
|
|
96
|
+
sendMessage: async (_chatId, text) => {
|
|
97
|
+
const id = nextId++;
|
|
98
|
+
messages.set(id, String(text || ''));
|
|
99
|
+
return { message_id: id };
|
|
100
|
+
},
|
|
101
|
+
sendMarkdown: async (_chatId, text) => {
|
|
102
|
+
const id = nextId++;
|
|
103
|
+
messages.set(id, String(text || '').replace(/[*_`~#>]/g, '').trim());
|
|
104
|
+
return { message_id: id };
|
|
105
|
+
},
|
|
106
|
+
editMessage: async (_chatId, msgId, text) => {
|
|
107
|
+
const plain = String(text || '').replace(/[*_`~#>]/g, '').trim();
|
|
108
|
+
messages.set(msgId, plain);
|
|
109
|
+
return true;
|
|
110
|
+
},
|
|
111
|
+
deleteMessage: async () => false,
|
|
112
|
+
sendTyping: async () => {},
|
|
113
|
+
getResult: () => {
|
|
114
|
+
let last = '';
|
|
115
|
+
for (const [, text] of messages) {
|
|
116
|
+
if (text && text.trim()) last = text.trim();
|
|
117
|
+
}
|
|
118
|
+
return last;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
return bot;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function startSiriBridge(config, executeTaskByName) {
|
|
125
|
+
const cfg = config.siri_bridge || {};
|
|
126
|
+
if (!cfg.enabled) return null;
|
|
127
|
+
|
|
128
|
+
const port = cfg.port || 8200;
|
|
129
|
+
const token = cfg.token || '';
|
|
130
|
+
const chatId = cfg.chat_id || '_siri_';
|
|
131
|
+
const timeoutMs = cfg.timeout_ms || 120000;
|
|
132
|
+
const maxReplyLen = cfg.max_reply_length || 0; // 0 = unlimited
|
|
133
|
+
|
|
134
|
+
if (!token) {
|
|
135
|
+
log('WARN', '[SIRI] siri_bridge.token not configured — bridge disabled');
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const server = http.createServer(async (req, res) => {
|
|
140
|
+
log('DEBUG', `[SIRI] ${req.method} ${req.url} from ${req.socket.remoteAddress}`);
|
|
141
|
+
const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
142
|
+
|
|
143
|
+
// CORS preflight
|
|
144
|
+
if (req.method === 'OPTIONS') {
|
|
145
|
+
res.writeHead(204, {
|
|
146
|
+
'Access-Control-Allow-Origin': '*',
|
|
147
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
148
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',
|
|
149
|
+
'Access-Control-Max-Age': '86400',
|
|
150
|
+
});
|
|
151
|
+
res.end();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Health check
|
|
156
|
+
if (req.method === 'GET' && urlObj.pathname === '/health') {
|
|
157
|
+
writeJson(res, 200, { status: 'ok' });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Quick echo test — GET /echo?t=xxx returns plain text immediately
|
|
162
|
+
if (req.method === 'GET' && urlObj.pathname === '/echo') {
|
|
163
|
+
const t = normalizePlainText(urlObj.searchParams.get('t') || 'hello from jarvis');
|
|
164
|
+
writeText(res, 200, t);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// /ask supports GET for iOS Shortcuts and POST for compatibility.
|
|
169
|
+
if (!['GET', 'POST'].includes(req.method) || urlObj.pathname !== '/ask') {
|
|
170
|
+
writeText(res, 404, 'Not Found');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Auth
|
|
175
|
+
const auth = getAuthToken(req, urlObj);
|
|
176
|
+
if (auth !== token) {
|
|
177
|
+
writeText(res, 401, 'Unauthorized');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const rawBody = req.method === 'POST' ? await readRequestBody(req) : '';
|
|
182
|
+
const text = extractAskText(req, urlObj, rawBody);
|
|
183
|
+
if (!text) {
|
|
184
|
+
writeText(res, 400, 'Missing q/text');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
log('INFO', `[SIRI] Received: "${text.slice(0, 80)}"`);
|
|
189
|
+
|
|
190
|
+
// Timeout guard
|
|
191
|
+
let timedOut = false;
|
|
192
|
+
const timer = setTimeout(() => {
|
|
193
|
+
timedOut = true;
|
|
194
|
+
writeText(res, 504, 'timeout');
|
|
195
|
+
}, timeoutMs);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const bot = createCollectorBot();
|
|
199
|
+
const liveCfg = loadConfig();
|
|
200
|
+
await handleCommand(bot, chatId, text, liveCfg, executeTaskByName, null, true);
|
|
201
|
+
|
|
202
|
+
if (timedOut) return;
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
|
|
205
|
+
let reply = bot.getResult() || '(no response)';
|
|
206
|
+
if (maxReplyLen && reply.length > maxReplyLen) {
|
|
207
|
+
reply = reply.slice(0, maxReplyLen) + '...';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
log('INFO', `[SIRI] Reply: "${reply.slice(0, 80)}"`);
|
|
211
|
+
writeText(res, 200, reply);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if (timedOut) return;
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
log('ERROR', `[SIRI] Error: ${err.message}`);
|
|
216
|
+
writeText(res, 500, err.message);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
server.listen(port, '0.0.0.0', () => {
|
|
221
|
+
log('INFO', `[SIRI] HTTP bridge listening on 0.0.0.0:${port}`);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
server.on('error', (err) => {
|
|
225
|
+
log('ERROR', `[SIRI] Server error: ${err.message}`);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return { stop: () => server.close() };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { startSiriBridge };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = { createSiriBridge };
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* daemon-siri-imessage.js — iMessage I/O layer
|
|
4
|
+
*
|
|
5
|
+
* 底层函数:轮询 chat.db + AppleScript 发送。
|
|
6
|
+
* 上层 Bridge 逻辑在 daemon-bridges.js 的 startImessageBridge()。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execFile, execFileSync } = require('child_process');
|
|
13
|
+
|
|
14
|
+
const CHAT_DB = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
|
|
15
|
+
|
|
16
|
+
// macOS Ventura+: text may be stored in attributedBody blob, not text field.
|
|
17
|
+
// Returns TSV rows:
|
|
18
|
+
// rowid\ttext\tsender\tchat_guid\tchat_identifier\tchat_name
|
|
19
|
+
//
|
|
20
|
+
// Key filters:
|
|
21
|
+
// 1. is_from_me=0 in SQL WHERE clause is the primary echo guard.
|
|
22
|
+
// 2. local_guids filter REMOVED: in self-chat (note-to-self), all incoming messages
|
|
23
|
+
// share the Mac's account_guid, so the old filter blocked everything.
|
|
24
|
+
// Echo prevention is now handled by content fingerprint in createImessageBot.
|
|
25
|
+
// 3. Drop reactions/system/service messages so tapbacks never become prompts.
|
|
26
|
+
const PYTHON_QUERY = `
|
|
27
|
+
import sqlite3,os,re,sys
|
|
28
|
+
db=os.path.expanduser('~/Library/Messages/chat.db')
|
|
29
|
+
con=sqlite3.connect(db)
|
|
30
|
+
rows=con.execute("""
|
|
31
|
+
SELECT
|
|
32
|
+
m.rowid,
|
|
33
|
+
m.text,
|
|
34
|
+
m.attributedBody,
|
|
35
|
+
h.id,
|
|
36
|
+
c.guid,
|
|
37
|
+
c.chat_identifier,
|
|
38
|
+
coalesce(c.display_name, c.room_name, c.chat_identifier, c.guid, ''),
|
|
39
|
+
coalesce(m.account_guid, ''),
|
|
40
|
+
coalesce(m.associated_message_guid, ''),
|
|
41
|
+
coalesce(m.associated_message_type, 0),
|
|
42
|
+
coalesce(m.associated_message_emoji, ''),
|
|
43
|
+
coalesce(m.is_system_message, 0),
|
|
44
|
+
coalesce(m.is_service_message, 0),
|
|
45
|
+
coalesce(m.item_type, 0)
|
|
46
|
+
FROM message m
|
|
47
|
+
LEFT JOIN handle h ON m.handle_id=h.rowid
|
|
48
|
+
LEFT JOIN chat_message_join cmj ON cmj.message_id=m.rowid
|
|
49
|
+
LEFT JOIN chat c ON c.rowid=cmj.chat_id
|
|
50
|
+
WHERE m.rowid > ? AND m.is_from_me = 0
|
|
51
|
+
ORDER BY m.rowid ASC LIMIT 20
|
|
52
|
+
""",(int(sys.argv[1]),)).fetchall()
|
|
53
|
+
seen=set()
|
|
54
|
+
for rowid,text,body,sender,chat_guid,chat_identifier,chat_name,account_guid,assoc_guid,assoc_type,assoc_emoji,is_system,is_service,item_type in rows:
|
|
55
|
+
if assoc_guid or assoc_type or assoc_emoji or is_system or is_service or item_type:
|
|
56
|
+
continue
|
|
57
|
+
if not text and body:
|
|
58
|
+
decoded=body.decode('utf-8','ignore')
|
|
59
|
+
m=re.search(r'[\\x20-\\x7e\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef]{2,}',decoded)
|
|
60
|
+
if m: text=m.group(0).strip()
|
|
61
|
+
if text and text.strip():
|
|
62
|
+
key=(rowid, chat_guid or chat_identifier or sender or '')
|
|
63
|
+
if key in seen:
|
|
64
|
+
continue
|
|
65
|
+
seen.add(key)
|
|
66
|
+
print(
|
|
67
|
+
str(rowid)+'\\t'
|
|
68
|
+
+str(text).replace('\\n',' ')+'\\t'
|
|
69
|
+
+(sender or '')+'\\t'
|
|
70
|
+
+(chat_guid or '')+'\\t'
|
|
71
|
+
+(chat_identifier or '')+'\\t'
|
|
72
|
+
+str(chat_name or '').replace('\\n',' ')
|
|
73
|
+
)
|
|
74
|
+
`.trim();
|
|
75
|
+
|
|
76
|
+
function isAvailable() {
|
|
77
|
+
return fs.existsSync(CHAT_DB);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getMaxRowId() {
|
|
81
|
+
try {
|
|
82
|
+
return parseInt(
|
|
83
|
+
execFileSync('sqlite3', [CHAT_DB, 'SELECT MAX(rowid) FROM message;'],
|
|
84
|
+
{ encoding: 'utf8', timeout: 5000 }).trim(), 10
|
|
85
|
+
) || 0;
|
|
86
|
+
} catch { return 0; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function queryNewMessages(lastRowId) {
|
|
90
|
+
try {
|
|
91
|
+
return execFileSync('python3', ['-c', PYTHON_QUERY, String(lastRowId)],
|
|
92
|
+
{ encoding: 'utf8', timeout: 8000 }).trim();
|
|
93
|
+
} catch { return ''; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function escapeAppleScriptString(input) {
|
|
97
|
+
return String(input || '')
|
|
98
|
+
.replace(/\\/g, '\\\\')
|
|
99
|
+
.replace(/"/g, '\\"')
|
|
100
|
+
.replace(/\n/g, '\\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Send iMessage via AppleScript; targetChatId should be chat.id / chat.guid.
|
|
104
|
+
function sendImessage(targetChatId, text) {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
const safe = escapeAppleScriptString(text);
|
|
107
|
+
const safeChatId = escapeAppleScriptString(targetChatId);
|
|
108
|
+
const script = [
|
|
109
|
+
'tell application "Messages"',
|
|
110
|
+
` send "${safe}" to chat id "${safeChatId}"`,
|
|
111
|
+
'end tell',
|
|
112
|
+
].join('\n');
|
|
113
|
+
execFile('osascript', ['-e', script], { timeout: 15000 }, (err) => resolve(!err));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Create a bot object compatible with handleCommand / daemon-claude-engine.
|
|
118
|
+
//
|
|
119
|
+
// Design goals for iMessage:
|
|
120
|
+
// 1. No intermediate status spam — buffer all sends, flush only the final text.
|
|
121
|
+
// 2. Prevent echo loop — track recently-sent content fingerprints.
|
|
122
|
+
// 3. editMessage returns true (pretend success) so engine doesn't double-send.
|
|
123
|
+
function createImessageBot(targetChatId, log) {
|
|
124
|
+
let flushTimer = null;
|
|
125
|
+
let pendingText = '';
|
|
126
|
+
// onAfterSend: bridge injects this to advance lastRowId after we send
|
|
127
|
+
let _onAfterSend = null;
|
|
128
|
+
|
|
129
|
+
// Echo fingerprint ring — stores { text, ts } of recently sent messages.
|
|
130
|
+
// Used by bridge to detect and skip echoed incoming messages.
|
|
131
|
+
const sentFingerprints = [];
|
|
132
|
+
const FINGERPRINT_TTL = 30000; // 30s window
|
|
133
|
+
|
|
134
|
+
const addFingerprint = (text) => {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
sentFingerprints.push({ text: text.trim().toLowerCase().replace(/\s+/g, ' '), ts: now });
|
|
137
|
+
// Prune expired entries
|
|
138
|
+
while (sentFingerprints.length && sentFingerprints[0].ts < now - FINGERPRINT_TTL) {
|
|
139
|
+
sentFingerprints.shift();
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const isEcho = (text) => {
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
const needle = text.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
146
|
+
for (let i = sentFingerprints.length - 1; i >= 0; i--) {
|
|
147
|
+
const fp = sentFingerprints[i];
|
|
148
|
+
if (fp.ts < now - FINGERPRINT_TTL) break;
|
|
149
|
+
// Prefix match: echo may have trailing whitespace or truncation differences
|
|
150
|
+
if (fp.text === needle || needle.startsWith(fp.text.slice(0, 20)) || fp.text.startsWith(needle.slice(0, 20))) return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const scheduleFlush = (text) => {
|
|
156
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
157
|
+
pendingText = text;
|
|
158
|
+
flushTimer = setTimeout(async () => {
|
|
159
|
+
flushTimer = null;
|
|
160
|
+
const final = pendingText;
|
|
161
|
+
pendingText = '';
|
|
162
|
+
if (!final) return;
|
|
163
|
+
const tagged = `🤖 ${final}`;
|
|
164
|
+
addFingerprint(tagged);
|
|
165
|
+
const ok = await sendImessage(targetChatId, tagged);
|
|
166
|
+
if (!ok && log) log('WARN', `[IMESSAGE] send failed to ${targetChatId}`);
|
|
167
|
+
// After sending, advance lastRowId so the echo is skipped
|
|
168
|
+
if (_onAfterSend) _onAfterSend();
|
|
169
|
+
}, 3000); // wait 3s for streaming to settle before sending
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Record fingerprint IMMEDIATELY on send/edit calls — not in flush timer.
|
|
173
|
+
// This ensures the echo is recognized even if poll runs before flush fires.
|
|
174
|
+
const sendAndFingerprint = (text) => {
|
|
175
|
+
if (text) {
|
|
176
|
+
addFingerprint(text);
|
|
177
|
+
scheduleFlush(text);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const bot = {
|
|
182
|
+
suppressAck: true,
|
|
183
|
+
isEcho,
|
|
184
|
+
sendMessage: (_chatId, text) => {
|
|
185
|
+
const plain = String(text || '').replace(/[*_`~]/g, '').trim();
|
|
186
|
+
sendAndFingerprint(plain);
|
|
187
|
+
return Promise.resolve({ message_id: Date.now() });
|
|
188
|
+
},
|
|
189
|
+
sendMarkdown: (_chatId, text) => {
|
|
190
|
+
const plain = String(text || '').replace(/[*_`~#>]/g, '').trim();
|
|
191
|
+
sendAndFingerprint(plain);
|
|
192
|
+
return Promise.resolve({ message_id: Date.now() });
|
|
193
|
+
},
|
|
194
|
+
// editMessage: update buffered text and extend flush timer — return true so
|
|
195
|
+
// engine doesn't fall through to a redundant sendMessage call.
|
|
196
|
+
editMessage: (_chatId, _msgId, text) => {
|
|
197
|
+
const plain = String(text || '').replace(/[*_`~]/g, '').trim();
|
|
198
|
+
sendAndFingerprint(plain);
|
|
199
|
+
return Promise.resolve(true);
|
|
200
|
+
},
|
|
201
|
+
deleteMessage: async () => false,
|
|
202
|
+
sendTyping: async () => {},
|
|
203
|
+
// Bridge injects this after bot is created
|
|
204
|
+
setOnAfterSend: (fn) => { _onAfterSend = fn; },
|
|
205
|
+
};
|
|
206
|
+
return bot;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = { isAvailable, getMaxRowId, queryNewMessages, createImessageBot };
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const { classifyTaskUsage } = require('./usage-classifier');
|
|
5
|
-
const { IS_WIN } = require('./platform');
|
|
6
5
|
|
|
7
6
|
const WEEKDAY_INDEX = Object.freeze({
|
|
8
7
|
sun: 0,
|
|
@@ -196,6 +195,7 @@ function createTaskScheduler(deps) {
|
|
|
196
195
|
isInSleepMode,
|
|
197
196
|
setSleepMode,
|
|
198
197
|
spawnSessionSummaries,
|
|
198
|
+
getWakeRecoveryHook,
|
|
199
199
|
skillEvolution,
|
|
200
200
|
} = deps;
|
|
201
201
|
|
|
@@ -746,7 +746,15 @@ function createTaskScheduler(deps) {
|
|
|
746
746
|
st.wake_restart = new Date().toISOString();
|
|
747
747
|
st.wake_sleep_seconds = Math.round(tickElapsed / 1000);
|
|
748
748
|
saveState(st);
|
|
749
|
-
|
|
749
|
+
const onWakeDetected = typeof getWakeRecoveryHook === 'function' ? getWakeRecoveryHook() : null;
|
|
750
|
+
if (typeof onWakeDetected === 'function') {
|
|
751
|
+
Promise.resolve(onWakeDetected({
|
|
752
|
+
sleepSeconds: Math.round(tickElapsed / 1000),
|
|
753
|
+
resumedAt: new Date(tickNow).toISOString(),
|
|
754
|
+
})).catch((e) => {
|
|
755
|
+
log('WARN', `[WAKE-DETECT] bridge recovery failed: ${e.message}`);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
750
758
|
}
|
|
751
759
|
|
|
752
760
|
// ① Physiological heartbeat (zero token, pure awareness)
|