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.
Files changed (44) hide show
  1. package/README.md +6 -1
  2. package/index.js +277 -55
  3. package/package.json +3 -2
  4. package/scripts/agent-layer.js +4 -2
  5. package/scripts/bin/dispatch_to +18 -6
  6. package/scripts/bin/push-clean.sh +72 -0
  7. package/scripts/daemon-admin-commands.js +266 -64
  8. package/scripts/daemon-agent-commands.js +188 -66
  9. package/scripts/daemon-bridges.js +475 -50
  10. package/scripts/daemon-checkpoints.js +84 -30
  11. package/scripts/daemon-claude-engine.js +651 -103
  12. package/scripts/daemon-command-router.js +134 -27
  13. package/scripts/daemon-command-session-route.js +118 -0
  14. package/scripts/daemon-default.yaml +2 -0
  15. package/scripts/daemon-dispatch-cards.js +185 -0
  16. package/scripts/daemon-engine-runtime.js +96 -20
  17. package/scripts/daemon-exec-commands.js +106 -50
  18. package/scripts/daemon-file-browser.js +63 -7
  19. package/scripts/daemon-notify.js +18 -4
  20. package/scripts/daemon-ops-commands.js +28 -6
  21. package/scripts/daemon-remote-dispatch.js +34 -2
  22. package/scripts/daemon-session-commands.js +102 -45
  23. package/scripts/daemon-session-store.js +497 -66
  24. package/scripts/daemon-siri-bridge.js +234 -0
  25. package/scripts/daemon-siri-imessage.js +209 -0
  26. package/scripts/daemon-task-scheduler.js +10 -2
  27. package/scripts/{team-dispatch.js → daemon-team-dispatch.js} +150 -11
  28. package/scripts/daemon.js +484 -181
  29. package/scripts/docs/hook-config.md +7 -4
  30. package/scripts/docs/maintenance-manual.md +10 -3
  31. package/scripts/docs/pointer-map.md +2 -2
  32. package/scripts/feishu-adapter.js +7 -15
  33. package/scripts/hooks/doc-router.js +29 -0
  34. package/scripts/hooks/intent-doc-router.js +54 -0
  35. package/scripts/hooks/intent-engine.js +9 -40
  36. package/scripts/intent-registry.js +59 -0
  37. package/scripts/memory-extract.js +59 -0
  38. package/scripts/mentor-engine.js +6 -0
  39. package/scripts/schema.js +1 -0
  40. package/scripts/self-reflect.js +110 -12
  41. package/scripts/session-analytics.js +160 -0
  42. package/scripts/signal-capture.js +1 -1
  43. package/scripts/hooks/intent-agent-manage.js +0 -50
  44. 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
- // Don't exit Feishu and Telegram have built-in auto-reconnect
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 shared context (inbox / now.md / _latest.md)
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/shared.md global progress whiteboard
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. Shared progress whiteboard
257
+ // 1. Target private now file
137
258
  try {
138
- const nowFile = path.join(base, 'memory', 'now', 'shared.md');
139
- if (fs.existsSync(nowFile)) {
140
- const content = fs.readFileSync(nowFile, 'utf8').trim();
141
- if (content) ctx += `[共享进度 now.md]\n${content}\n\n`;
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. Target's last output
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
- // 3. Inbox unread messages (archive after reading)
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 = { resolveProjectKey, findTeamMember, buildTeamRosterHint, buildEnrichedPrompt };
308
+ module.exports = {
309
+ resolveProjectKey,
310
+ findTeamMember,
311
+ buildTeamRosterHint,
312
+ buildEnrichedPrompt,
313
+ resolveDispatchActor,
314
+ updateDispatchContextFiles,
315
+ };