metame-cli 1.5.4 → 1.5.6
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 +3 -2
- package/scripts/agent-layer.js +4 -2
- package/scripts/bin/dispatch_to +18 -6
- package/scripts/bin/push-clean.sh +72 -0
- package/scripts/daemon-admin-commands.js +266 -64
- package/scripts/daemon-agent-commands.js +188 -66
- package/scripts/daemon-bridges.js +475 -50
- package/scripts/daemon-checkpoints.js +84 -30
- package/scripts/daemon-claude-engine.js +651 -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-dispatch-cards.js +185 -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 +28 -6
- 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/{team-dispatch.js → daemon-team-dispatch.js} +150 -11
- package/scripts/daemon.js +484 -181
- package/scripts/docs/hook-config.md +7 -4
- package/scripts/docs/maintenance-manual.md +10 -3
- package/scripts/docs/pointer-map.md +2 -2
- 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/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)
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* Single source of truth for:
|
|
7
7
|
* - Project/team member resolution by name or nickname
|
|
8
8
|
* - Team roster hint generation (injected into member sessions)
|
|
9
|
-
* - Prompt enrichment with
|
|
9
|
+
* - Prompt enrichment with scoped context (private now / shared now / inbox / _latest.md)
|
|
10
|
+
* - Dispatch context file writes for target-only and team-shared tasks
|
|
10
11
|
*
|
|
11
12
|
* Used by: dispatch_to binary, daemon-admin-commands, daemon-bridges, daemon.js
|
|
12
13
|
*/
|
|
@@ -112,13 +113,132 @@ function buildTeamRosterHint(parentKey, memberKey, projects) {
|
|
|
112
113
|
].join('\n');
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
function resolveDispatchActor(sourceKey, projects) {
|
|
117
|
+
const rawKey = String(sourceKey || '').trim();
|
|
118
|
+
const userSources = new Set(['', 'unknown', 'claude_session', '_claude_session', 'user']);
|
|
119
|
+
if (userSources.has(rawKey)) return { key: 'user', name: '用户', icon: '👤', isUser: true };
|
|
120
|
+
const proj = projects && projects[rawKey];
|
|
121
|
+
if (proj) return { key: rawKey, name: proj.name || rawKey, icon: proj.icon || '🤖', isUser: false };
|
|
122
|
+
return { key: rawKey || 'unknown', name: rawKey || 'unknown', icon: '🤖', isUser: false };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function buildPrivateNowContent({ actor, target, title, prompt, timeStr, dispatchId, taskId, scopeId, chain }) {
|
|
126
|
+
const lines = [
|
|
127
|
+
'# 当前任务',
|
|
128
|
+
`**最后更新**: ${timeStr} **更新者**: ${actor.icon} ${actor.name}`,
|
|
129
|
+
'',
|
|
130
|
+
'## 当前派发',
|
|
131
|
+
`- **目标**: ${target.icon} ${target.name} (${target.key})`,
|
|
132
|
+
`- **任务**: ${title || prompt.slice(0, 120) || '(empty)'}`,
|
|
133
|
+
dispatchId ? `- **编号**: ${dispatchId}` : '',
|
|
134
|
+
taskId ? `- **TeamTask**: ${taskId}` : '',
|
|
135
|
+
scopeId && scopeId !== taskId ? `- **Scope**: ${scopeId}` : '',
|
|
136
|
+
'',
|
|
137
|
+
'## 任务链',
|
|
138
|
+
chain && chain.length > 0 ? chain.join(' → ') : `${actor.key} → ${target.key}`,
|
|
139
|
+
].filter(Boolean);
|
|
140
|
+
return `${lines.join('\n')}\n`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildSharedNowContent({ actor, target, title, prompt, timeStr, dispatchId, taskId, scopeId, chain }) {
|
|
144
|
+
const lines = [
|
|
145
|
+
'# 共享当前状态',
|
|
146
|
+
`**最后更新**: ${timeStr} **更新者**: ${actor.icon} ${actor.name}`,
|
|
147
|
+
'',
|
|
148
|
+
'## 当前任务',
|
|
149
|
+
`- **派发给**: ${target.icon} ${target.name} (${target.key})`,
|
|
150
|
+
`- **任务**: ${title || prompt.slice(0, 120) || '(empty)'}`,
|
|
151
|
+
dispatchId ? `- **编号**: ${dispatchId}` : '',
|
|
152
|
+
taskId ? `- **TeamTask**: ${taskId}` : '',
|
|
153
|
+
scopeId && scopeId !== taskId ? `- **Scope**: ${scopeId}` : '',
|
|
154
|
+
`- **时间**: ${timeStr}`,
|
|
155
|
+
'',
|
|
156
|
+
'## 任务链',
|
|
157
|
+
chain && chain.length > 0 ? chain.join(' → ') : `${actor.key} → ${target.key}`,
|
|
158
|
+
].filter(Boolean);
|
|
159
|
+
return `${lines.join('\n')}\n`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function updateDispatchContextFiles({ fs: fsMod = fs, path: pathMod = path, baseDir = METAME_DIR, fullMsg, targetProject, config, envelope, logger = null }) {
|
|
163
|
+
if (!fullMsg || !targetProject) return { targetNowPath: null, sharedNowPath: null, tasksFilePath: null };
|
|
164
|
+
|
|
165
|
+
const logWarn = (msg) => {
|
|
166
|
+
if (typeof logger === 'function') logger(msg);
|
|
167
|
+
};
|
|
168
|
+
const nowDir = pathMod.join(baseDir, 'memory', 'now');
|
|
169
|
+
const sharedDir = pathMod.join(baseDir, 'memory', 'shared');
|
|
170
|
+
const targetNowPath = pathMod.join(nowDir, `${targetProject}.md`);
|
|
171
|
+
const sharedNowPath = pathMod.join(nowDir, 'shared.md');
|
|
172
|
+
const tasksFilePath = pathMod.join(sharedDir, 'tasks.md');
|
|
173
|
+
fsMod.mkdirSync(nowDir, { recursive: true });
|
|
174
|
+
|
|
175
|
+
const projects = (config && config.projects) || {};
|
|
176
|
+
const actor = resolveDispatchActor((fullMsg && fullMsg.source_sender_key) || (fullMsg && fullMsg.from), projects);
|
|
177
|
+
const targetProj = projects[targetProject] || {};
|
|
178
|
+
const target = { key: targetProject, name: targetProj.name || targetProject, icon: targetProj.icon || '🤖' };
|
|
179
|
+
const prompt = String(fullMsg && fullMsg.payload && fullMsg.payload.prompt || '').trim();
|
|
180
|
+
const title = String(fullMsg && fullMsg.payload && fullMsg.payload.title || '').trim();
|
|
181
|
+
const now = new Date();
|
|
182
|
+
const timeStr = now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
|
183
|
+
const dateStr = now.toISOString().slice(0, 10);
|
|
184
|
+
const taskId = String(envelope && envelope.task_id || '').trim();
|
|
185
|
+
const scopeId = String(envelope && envelope.scope_id || '').trim();
|
|
186
|
+
const isSharedTeamTask = !!(envelope && envelope.task_kind === 'team');
|
|
187
|
+
|
|
188
|
+
fsMod.writeFileSync(targetNowPath, buildPrivateNowContent({
|
|
189
|
+
actor, target, title, prompt, timeStr,
|
|
190
|
+
dispatchId: fullMsg.id, taskId, scopeId, chain: fullMsg.chain,
|
|
191
|
+
}), 'utf8');
|
|
192
|
+
|
|
193
|
+
if (!isSharedTeamTask) return { targetNowPath, sharedNowPath: null, tasksFilePath: null };
|
|
194
|
+
|
|
195
|
+
fsMod.writeFileSync(sharedNowPath, buildSharedNowContent({
|
|
196
|
+
actor, target, title, prompt, timeStr,
|
|
197
|
+
dispatchId: fullMsg.id, taskId, scopeId, chain: fullMsg.chain,
|
|
198
|
+
}), 'utf8');
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
if (!fsMod.existsSync(sharedDir)) fsMod.mkdirSync(sharedDir, { recursive: true });
|
|
202
|
+
const taskLine = `- [${dateStr}] ${actor.icon} ${actor.name} → ${target.icon} ${target.name}: ${title || prompt.slice(0, 40)}`;
|
|
203
|
+
let tasksContent = fsMod.existsSync(tasksFilePath)
|
|
204
|
+
? fsMod.readFileSync(tasksFilePath, 'utf8')
|
|
205
|
+
: '# 任务看板\n\n## 🔄 进行中\n\n## ✅ 已完成\n\n## 📅 待开始\n';
|
|
206
|
+
if (!tasksContent.includes(taskLine)) {
|
|
207
|
+
const lines = tasksContent.split('\n');
|
|
208
|
+
const nextLines = [];
|
|
209
|
+
let inserted = false;
|
|
210
|
+
let inProgress = false;
|
|
211
|
+
for (const line of lines) {
|
|
212
|
+
nextLines.push(line);
|
|
213
|
+
if (line.includes('## 🔄 进行中')) {
|
|
214
|
+
inProgress = true;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (inProgress && line.startsWith('## ')) {
|
|
218
|
+
nextLines.splice(nextLines.length - 1, 0, taskLine);
|
|
219
|
+
inserted = true;
|
|
220
|
+
inProgress = false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!inserted) nextLines.push(taskLine);
|
|
224
|
+
tasksContent = nextLines.join('\n');
|
|
225
|
+
fsMod.writeFileSync(tasksFilePath, tasksContent, 'utf8');
|
|
226
|
+
}
|
|
227
|
+
} catch (e) {
|
|
228
|
+
logWarn(`Failed to update shared task board: ${e.message}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { targetNowPath, sharedNowPath, tasksFilePath };
|
|
232
|
+
}
|
|
233
|
+
|
|
115
234
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
235
|
// Prompt enrichment (shared context injection)
|
|
117
236
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
118
237
|
|
|
119
238
|
/**
|
|
120
239
|
* Enrich a dispatch prompt with shared context read at send time:
|
|
121
|
-
* 1. now
|
|
240
|
+
* 1. now/<target>.md — target private progress handoff
|
|
241
|
+
* 2. now/shared.md — global team progress whiteboard (only when includeShared=true)
|
|
122
242
|
* 2. agents/<target>_latest.md — target's last output
|
|
123
243
|
* 3. inbox/<target>/ — unread messages (archived to read/ after reading)
|
|
124
244
|
*
|
|
@@ -129,20 +249,32 @@ function buildTeamRosterHint(parentKey, memberKey, projects) {
|
|
|
129
249
|
* @param {string} [metameDir] - override METAME_DIR (for testing)
|
|
130
250
|
* @returns {string}
|
|
131
251
|
*/
|
|
132
|
-
function buildEnrichedPrompt(target, rawPrompt, metameDir) {
|
|
252
|
+
function buildEnrichedPrompt(target, rawPrompt, metameDir, opts = {}) {
|
|
133
253
|
const base = metameDir || METAME_DIR;
|
|
254
|
+
const includeShared = !!(opts && opts.includeShared);
|
|
134
255
|
let ctx = '';
|
|
135
256
|
|
|
136
|
-
// 1.
|
|
257
|
+
// 1. Target private now file
|
|
137
258
|
try {
|
|
138
|
-
const
|
|
139
|
-
if (fs.existsSync(
|
|
140
|
-
const content = fs.readFileSync(
|
|
141
|
-
if (content) ctx += `[
|
|
259
|
+
const targetNowFile = path.join(base, 'memory', 'now', `${target}.md`);
|
|
260
|
+
if (fs.existsSync(targetNowFile)) {
|
|
261
|
+
const content = fs.readFileSync(targetNowFile, 'utf8').trim();
|
|
262
|
+
if (content) ctx += `[当前进度 now/${target}.md]\n${content}\n\n`;
|
|
142
263
|
}
|
|
143
264
|
} catch { /* non-critical */ }
|
|
144
265
|
|
|
145
|
-
// 2.
|
|
266
|
+
// 2. Shared progress whiteboard for real team tasks only
|
|
267
|
+
if (includeShared) {
|
|
268
|
+
try {
|
|
269
|
+
const nowFile = path.join(base, 'memory', 'now', 'shared.md');
|
|
270
|
+
if (fs.existsSync(nowFile)) {
|
|
271
|
+
const content = fs.readFileSync(nowFile, 'utf8').trim();
|
|
272
|
+
if (content) ctx += `[共享进度 now/shared.md]\n${content}\n\n`;
|
|
273
|
+
}
|
|
274
|
+
} catch { /* non-critical */ }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 3. Target's last output
|
|
146
278
|
try {
|
|
147
279
|
const latestFile = path.join(base, 'memory', 'agents', `${target}_latest.md`);
|
|
148
280
|
if (fs.existsSync(latestFile)) {
|
|
@@ -151,7 +283,7 @@ function buildEnrichedPrompt(target, rawPrompt, metameDir) {
|
|
|
151
283
|
}
|
|
152
284
|
} catch { /* non-critical */ }
|
|
153
285
|
|
|
154
|
-
//
|
|
286
|
+
// 4. Inbox unread messages (archive after reading)
|
|
155
287
|
try {
|
|
156
288
|
const inboxDir = path.join(base, 'memory', 'inbox', target);
|
|
157
289
|
const readDir = path.join(inboxDir, 'read');
|
|
@@ -173,4 +305,11 @@ function buildEnrichedPrompt(target, rawPrompt, metameDir) {
|
|
|
173
305
|
return ctx ? `${ctx}---\n${rawPrompt}` : rawPrompt;
|
|
174
306
|
}
|
|
175
307
|
|
|
176
|
-
module.exports = {
|
|
308
|
+
module.exports = {
|
|
309
|
+
resolveProjectKey,
|
|
310
|
+
findTeamMember,
|
|
311
|
+
buildTeamRosterHint,
|
|
312
|
+
buildEnrichedPrompt,
|
|
313
|
+
resolveDispatchActor,
|
|
314
|
+
updateDispatchContextFiles,
|
|
315
|
+
};
|