metame-cli 1.5.10 → 1.5.12

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 (67) hide show
  1. package/README.md +49 -6
  2. package/index.js +266 -72
  3. package/package.json +7 -3
  4. package/scripts/daemon-admin-commands.js +34 -0
  5. package/scripts/daemon-agent-commands.js +6 -2
  6. package/scripts/daemon-bridges.js +41 -10
  7. package/scripts/daemon-claude-engine.js +128 -29
  8. package/scripts/daemon-command-router.js +16 -0
  9. package/scripts/daemon-command-session-route.js +3 -1
  10. package/scripts/daemon-default.yaml +3 -1
  11. package/scripts/daemon-engine-runtime.js +1 -5
  12. package/scripts/daemon-message-pipeline.js +113 -44
  13. package/scripts/daemon-ops-commands.js +25 -11
  14. package/scripts/daemon-reactive-lifecycle.js +757 -76
  15. package/scripts/daemon-session-commands.js +3 -2
  16. package/scripts/daemon-session-store.js +82 -27
  17. package/scripts/daemon-team-dispatch.js +21 -5
  18. package/scripts/daemon-utils.js +3 -1
  19. package/scripts/daemon.js +80 -2
  20. package/scripts/distill.js +1 -1
  21. package/scripts/docs/file-transfer.md +1 -0
  22. package/scripts/docs/maintenance-manual.md +55 -2
  23. package/scripts/docs/pointer-map.md +34 -0
  24. package/scripts/feishu-adapter.js +25 -0
  25. package/scripts/hooks/intent-file-transfer.js +2 -1
  26. package/scripts/hooks/intent-perpetual.js +109 -0
  27. package/scripts/hooks/intent-research.js +112 -0
  28. package/scripts/intent-registry.js +4 -0
  29. package/scripts/memory-extract.js +29 -1
  30. package/scripts/memory-nightly-reflect.js +104 -0
  31. package/scripts/ops-mission-queue.js +258 -0
  32. package/scripts/ops-verifier.js +197 -0
  33. package/scripts/signal-capture.js +3 -3
  34. package/scripts/skill-evolution.js +11 -2
  35. package/skills/agent-browser/SKILL.md +153 -0
  36. package/skills/agent-reach/SKILL.md +66 -0
  37. package/skills/agent-reach/evolution.json +13 -0
  38. package/skills/deep-research/SKILL.md +77 -0
  39. package/skills/find-skills/SKILL.md +133 -0
  40. package/skills/heartbeat-task-manager/SKILL.md +63 -0
  41. package/skills/macos-local-orchestrator/SKILL.md +192 -0
  42. package/skills/macos-local-orchestrator/agents/openai.yaml +4 -0
  43. package/skills/macos-local-orchestrator/references/tooling-landscape.md +70 -0
  44. package/skills/macos-mail-calendar/SKILL.md +394 -0
  45. package/skills/mcp-installer/SKILL.md +138 -0
  46. package/skills/skill-creator/LICENSE.txt +202 -0
  47. package/skills/skill-creator/README.md +72 -0
  48. package/skills/skill-creator/SKILL.md +96 -0
  49. package/skills/skill-creator/evolution.json +6 -0
  50. package/skills/skill-creator/references/creation-guide.md +116 -0
  51. package/skills/skill-creator/references/evolution-guide.md +74 -0
  52. package/skills/skill-creator/references/output-patterns.md +82 -0
  53. package/skills/skill-creator/references/workflows.md +28 -0
  54. package/skills/skill-creator/scripts/align_all.py +32 -0
  55. package/skills/skill-creator/scripts/auto_evolve_hook.js +247 -0
  56. package/skills/skill-creator/scripts/init_skill.py +303 -0
  57. package/skills/skill-creator/scripts/merge_evolution.py +70 -0
  58. package/skills/skill-creator/scripts/package_skill.py +110 -0
  59. package/skills/skill-creator/scripts/quick_validate.py +103 -0
  60. package/skills/skill-creator/scripts/setup.py +141 -0
  61. package/skills/skill-creator/scripts/smart_stitch.py +82 -0
  62. package/skills/skill-manager/SKILL.md +112 -0
  63. package/skills/skill-manager/scripts/delete_skill.py +31 -0
  64. package/skills/skill-manager/scripts/list_skills.py +61 -0
  65. package/skills/skill-manager/scripts/scan_and_check.py +125 -0
  66. package/skills/skill-manager/scripts/sync_index.py +144 -0
  67. package/skills/skill-manager/scripts/update_helper.py +39 -0
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { normalizeEngineName: _normalizeEngine } = require('./daemon-utils');
4
+
3
5
  function createSessionCommandHandler(deps) {
4
6
  const {
5
7
  fs,
@@ -29,8 +31,7 @@ function createSessionCommandHandler(deps) {
29
31
  } = deps;
30
32
 
31
33
  function normalizeEngineName(name) {
32
- const n = String(name || '').trim().toLowerCase();
33
- return n === 'codex' ? 'codex' : getDefaultEngine();
34
+ return _normalizeEngine(name, getDefaultEngine);
34
35
  }
35
36
 
36
37
  function inferStoredEngine(rawSession) {
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
+ const { normalizeEngineName } = require('./daemon-utils');
4
5
 
5
6
  function normalizeCodexSandboxMode(value, fallback = null) {
6
7
  const text = String(value || '').trim().toLowerCase();
@@ -45,10 +46,6 @@ function normalizeCodexPermissionMeta(meta = {}) {
45
46
  };
46
47
  }
47
48
 
48
- function normalizeEngineName(name) {
49
- return String(name || 'claude').trim().toLowerCase() === 'codex' ? 'codex' : 'claude';
50
- }
51
-
52
49
  function stripCodexInjectedHints(text) {
53
50
  return String(text || '')
54
51
  .replace(/\r\n/g, '\n')
@@ -217,17 +214,36 @@ function createSessionStore(deps) {
217
214
  }
218
215
 
219
216
  // [M3] 共享辅助:从 reversed JSONL 行数组中提取最后一条外部用户消息(统一规则)
217
+ function _extractMessageText(d) {
218
+ const content = d.message && d.message.content;
219
+ let raw = typeof content === 'string' ? content
220
+ : Array.isArray(content) ? (content.find(c => c.type === 'text') || {}).text || '' : '';
221
+ raw = raw.replace(/\[System hints[\s\S]*/i, '')
222
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
223
+ return raw;
224
+ }
225
+
220
226
  function extractLastUserFromLines(lines) {
221
227
  for (const line of lines) {
222
228
  if (!line) continue;
223
229
  try {
224
230
  const d = JSON.parse(line);
225
231
  if (d.type === 'user' && d.message && d.userType !== 'internal') {
226
- const content = d.message.content;
227
- let raw = typeof content === 'string' ? content
228
- : Array.isArray(content) ? (content.find(c => c.type === 'text') || {}).text || '' : '';
229
- raw = raw.replace(/\[System hints[\s\S]*/i, '')
230
- .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
232
+ const raw = _extractMessageText(d);
233
+ if (raw.length > 2) return raw.slice(0, 80);
234
+ }
235
+ } catch { /* skip */ }
236
+ }
237
+ return '';
238
+ }
239
+
240
+ function extractLastAssistantFromLines(lines) {
241
+ for (const line of lines) {
242
+ if (!line) continue;
243
+ try {
244
+ const d = JSON.parse(line);
245
+ if (d.type === 'assistant' && d.message) {
246
+ const raw = _extractMessageText(d);
231
247
  if (raw.length > 2) return raw.slice(0, 80);
232
248
  }
233
249
  } catch { /* skip */ }
@@ -256,11 +272,30 @@ function createSessionStore(deps) {
256
272
  }
257
273
  }
258
274
  }
259
- // Fallback: decode projectPath from directory name (e.g. -Users-yaron-AGI-AChat → /Users/yaron/AGI/AChat)
275
+ // Fallback: decode projectPath from directory name (macOS: -Users-foo → /Users/foo)
260
276
  if (!projPathCache.has(proj) && proj.startsWith('-')) {
261
277
  const decoded = proj.replace(/-/g, '/');
262
278
  if (fs.existsSync(decoded)) projPathCache.set(proj, decoded);
263
279
  }
280
+ // Fallback 2: read cwd from first JSONL entry (works on all platforms,
281
+ // handles directory names that can't be reliably decoded, e.g. Windows paths
282
+ // with drive letters or filenames containing hyphens)
283
+ if (!projPathCache.has(proj)) {
284
+ try {
285
+ const _jsonls = fs.readdirSync(projDir).filter(f => f.endsWith('.jsonl'));
286
+ if (_jsonls.length > 0) {
287
+ const _fd = fs.openSync(path.join(projDir, _jsonls[0]), 'r');
288
+ try {
289
+ const _buf = Buffer.alloc(4096);
290
+ const _bytes = fs.readSync(_fd, _buf, 0, 4096, 0);
291
+ for (const _line of _buf.toString('utf8', 0, _bytes).split('\n')) {
292
+ if (!_line) continue;
293
+ try { const _d = JSON.parse(_line); if (_d.cwd) { projPathCache.set(proj, path.resolve(_d.cwd)); break; } } catch {}
294
+ }
295
+ } finally { fs.closeSync(_fd); }
296
+ }
297
+ } catch {}
298
+ }
264
299
  } catch { /* skip */ }
265
300
 
266
301
  try {
@@ -287,6 +322,8 @@ function createSessionStore(deps) {
287
322
  }
288
323
 
289
324
  const all = Array.from(sessionMap.values()).map((entry) => ({ ...entry, engine: 'claude' }));
325
+ // Sort by recency BEFORE enrichment so we enrich the most recent sessions first
326
+ all.sort((a, b) => (b.fileMtime || 0) - (a.fileMtime || 0));
290
327
  const ENRICH_LIMIT = 20;
291
328
  for (let i = 0; i < Math.min(all.length, ENRICH_LIMIT); i++) {
292
329
  const s = all[i];
@@ -338,6 +375,9 @@ function createSessionStore(deps) {
338
375
  if (!s.lastUser) {
339
376
  s.lastUser = extractLastUserFromLines(tailLines);
340
377
  }
378
+ if (!s.lastAssistant) {
379
+ s.lastAssistant = extractLastAssistantFromLines(tailLines);
380
+ }
341
381
  } finally {
342
382
  fs.closeSync(fd);
343
383
  }
@@ -613,6 +653,13 @@ function createSessionStore(deps) {
613
653
  return s.sessionId ? s.sessionId.slice(0, 8) : '';
614
654
  }
615
655
 
656
+ // ── Display helpers (shared by sessionRichLabel & buildSessionCardElements) ──
657
+ const _escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
658
+ function _cleanSnippet(raw, maxLen) {
659
+ if (!raw || raw.length <= 2) return '';
660
+ return _escapeMd(raw.replace(/\n/g, ' ').slice(0, maxLen)) + (raw.length > maxLen ? '…' : '');
661
+ }
662
+
616
663
  function sessionRichLabel(s, index, sessionTags) {
617
664
  sessionTags = sessionTags || loadSessionTags();
618
665
  const title = sessionDisplayTitle(s, 50, sessionTags);
@@ -622,17 +669,15 @@ function createSessionStore(deps) {
622
669
  const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 3);
623
670
  const engineLabel = (s.engine || 'claude') === 'codex' ? 'codex' : 'claude';
624
671
 
625
- // [M2] 转义 markdown 特殊字符,防止用户历史消息破坏渲染
626
- const escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
627
- // fallback to firstPrompt when lastUser not found in tail
628
- const snippetRaw = s.lastUser || (s.firstPrompt || '').replace(/<[^>]+>/g, '').replace(/\[System hints[\s\S]*/i, '').trim().slice(0, 80);
629
- let line = `${index}. ${title}${title.length >= 50 ? '..' : ''}`; // [M4] title 已有 sessionId 兜底,不会为空
672
+ let line = `${index}. ${title}${title.length >= 50 ? '..' : ''}`;
630
673
  if (tags.length) line += ` ${tags.map(t => `#${t}`).join(' ')}`;
631
674
  line += `\n 📁${proj} · ${ago} · ${engineLabel}`;
632
- if (snippetRaw && snippetRaw.length > 2) {
633
- const snippet = escapeMd(snippetRaw.replace(/\n/g, ' ').slice(0, 60));
634
- line += `\n 💬 ${snippet}${snippetRaw.length > 60 ? '…' : ''}`;
635
- }
675
+ const firstSnippet = _cleanSnippet(s.firstPrompt, 50);
676
+ const lastUserSnippet = _cleanSnippet(s.lastUser, 50);
677
+ const lastAiSnippet = _cleanSnippet(s.lastAssistant, 50);
678
+ if (firstSnippet) line += `\n 📝 ${firstSnippet}`;
679
+ if (lastUserSnippet && lastUserSnippet !== firstSnippet) line += `\n 💬 ${lastUserSnippet}`;
680
+ if (lastAiSnippet) line += `\n 🤖 ${lastAiSnippet}`;
636
681
  line += `\n /resume ${shortId}`;
637
682
  return line;
638
683
  }
@@ -648,15 +693,16 @@ function createSessionStore(deps) {
648
693
  const shortId = s.sessionId.slice(0, 6);
649
694
  const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 4);
650
695
  const engineLabel = (s.engine || 'claude') === 'codex' ? 'codex' : 'claude';
651
- // [M2] 转义 markdown 特殊字符;[M4] title 已有 sessionId 兜底
652
- const escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
653
- const snippetRaw = s.lastUser || (s.firstPrompt || '').replace(/<[^>]+>/g, '').replace(/\[System hints[\s\S]*/i, '').trim().slice(0, 80);
696
+
654
697
  let desc = `**${i + 1}. ${title}**\n📁${proj} · ${ago} · ${engineLabel}`;
655
698
  if (tags.length) desc += `\n${tags.map(t => `\`${t}\``).join(' ')}`;
656
- if (snippetRaw && snippetRaw.length > 2) {
657
- const snippet = escapeMd(snippetRaw.replace(/\n/g, ' ').slice(0, 60));
658
- desc += `\n💬 ${snippet}${snippetRaw.length > 60 ? '…' : ''}`;
659
- }
699
+ // Show first prompt, last user message, and last assistant reply
700
+ const firstSnippet = _cleanSnippet(s.firstPrompt, 50);
701
+ const lastUserSnippet = _cleanSnippet(s.lastUser, 50);
702
+ const lastAiSnippet = _cleanSnippet(s.lastAssistant, 50);
703
+ if (firstSnippet) desc += `\n📝 ${firstSnippet}`;
704
+ if (lastUserSnippet && lastUserSnippet !== firstSnippet) desc += `\n💬 ${lastUserSnippet}`;
705
+ if (lastAiSnippet) desc += `\n🤖 ${lastAiSnippet}`;
660
706
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: desc } });
661
707
  elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: `▶️ Switch #${shortId}` }, type: 'primary', value: { cmd: `/resume ${s.sessionId}` } }] });
662
708
  });
@@ -924,6 +970,9 @@ function createSessionStore(deps) {
924
970
  }
925
971
  slot.started = true;
926
972
  s.last_active = Date.now();
973
+ // Clear stale findSessionFile cache: the JSONL/SQLite file now exists
974
+ // but may have been cached as null during createSession (before CLI created it).
975
+ if (slot.id) clearSessionFileCache(slot.id);
927
976
  } else {
928
977
  s.started = true; // old flat format
929
978
  s.last_active = Date.now();
@@ -970,7 +1019,13 @@ function createSessionStore(deps) {
970
1019
  // Best approach: read cwd directly from session file content (not from dir name)
971
1020
  function _isClaudeSessionValid(sessionId, normCwd) {
972
1021
  try {
973
- const sessionFile = findSessionFile(sessionId);
1022
+ let sessionFile = findSessionFile(sessionId);
1023
+ if (!sessionFile) {
1024
+ // Cache may hold a stale null from createSession (before CLI wrote the JSONL).
1025
+ // Clear and retry once to avoid false invalidation.
1026
+ clearSessionFileCache(sessionId);
1027
+ sessionFile = findSessionFile(sessionId);
1028
+ }
974
1029
  if (!sessionFile) {
975
1030
  log('WARN', `[SessionValid] ${sessionId.slice(0, 8)}: JSONL file not found`);
976
1031
  return false;
@@ -274,15 +274,31 @@ function buildEnrichedPrompt(target, rawPrompt, metameDir, opts = {}) {
274
274
  } catch { /* non-critical */ }
275
275
  }
276
276
 
277
- // 3. Target's last output
277
+ // 3+5. Structured memory (L1+L2) OR legacy _latest.md fallback
278
+ // Single stat — structured memory supersedes raw last output
279
+ let hasStructuredMemory = false;
278
280
  try {
279
- const latestFile = path.join(base, 'memory', 'agents', `${target}_latest.md`);
280
- if (fs.existsSync(latestFile)) {
281
- const content = fs.readFileSync(latestFile, 'utf8').trim();
282
- if (content) ctx += `[${target} 上次产出]\n${content}\n\n`;
281
+ const memFile = path.join(base, 'memory', 'now', `${target}_memory.md`);
282
+ if (fs.existsSync(memFile)) {
283
+ const content = fs.readFileSync(memFile, 'utf8').trim();
284
+ if (content) {
285
+ ctx += `[Memory Context]\n${content}\n\n`;
286
+ hasStructuredMemory = true;
287
+ }
283
288
  }
284
289
  } catch { /* non-critical */ }
285
290
 
291
+ if (!hasStructuredMemory) {
292
+ // Fallback: raw last output (for non-reactive projects without memory system)
293
+ try {
294
+ const latestFile = path.join(base, 'memory', 'agents', `${target}_latest.md`);
295
+ if (fs.existsSync(latestFile)) {
296
+ const content = fs.readFileSync(latestFile, 'utf8').trim();
297
+ if (content) ctx += `[${target} 上次产出]\n${content}\n\n`;
298
+ }
299
+ } catch { /* non-critical */ }
300
+ }
301
+
286
302
  // 4. Inbox unread messages (archive after reading)
287
303
  try {
288
304
  const inboxDir = path.join(base, 'memory', 'inbox', target);
@@ -9,7 +9,9 @@
9
9
 
10
10
  function normalizeEngineName(name, defaultEngine = 'claude') {
11
11
  const n = String(name || '').trim().toLowerCase();
12
- return n === 'codex' ? 'codex' : (typeof defaultEngine === 'function' ? defaultEngine() : defaultEngine);
12
+ if (n === 'codex') return 'codex';
13
+ if (n === 'claude') return 'claude';
14
+ return typeof defaultEngine === 'function' ? defaultEngine() : defaultEngine;
13
15
  }
14
16
 
15
17
  function normalizeCodexSandboxMode(value, fallback = null) {
package/scripts/daemon.js CHANGED
@@ -48,6 +48,7 @@ const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
48
48
  const DISPATCH_DIR = path.join(METAME_DIR, 'dispatch');
49
49
  const DISPATCH_LOG = path.join(DISPATCH_DIR, 'dispatch-log.jsonl');
50
50
  const { sleepSync, socketPath, needsSocketCleanup } = require('./platform');
51
+ const { handleReactiveOutput } = require('./daemon-reactive-lifecycle');
51
52
  const SOCK_PATH = socketPath(METAME_DIR);
52
53
 
53
54
  // Resolve claude binary path (daemon may not inherit user's full PATH)
@@ -286,12 +287,28 @@ function restoreConfig() {
286
287
  if (!fs.existsSync(bak)) return false;
287
288
  try {
288
289
  const bakCfg = yaml.load(fs.readFileSync(bak, 'utf8')) || {};
289
- // Preserve security-critical fields from current config (chat IDs, agent map)
290
- // so a /fix never loses manually-added channels
290
+ // Preserve ALL user-critical fields from current config so /fix never
291
+ // loses secrets, chat IDs, or agent mappings
291
292
  let curCfg = {};
292
293
  try { curCfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {}; } catch { }
294
+ // Secret fields that must NEVER be reverted by a restore
295
+ const SECRET_FIELDS = ['app_id', 'app_secret', 'bot_token', 'operator_ids'];
293
296
  for (const adapter of ['feishu', 'telegram']) {
294
297
  if (curCfg[adapter] && bakCfg[adapter]) {
298
+ // Preserve secrets: current config always wins
299
+ for (const field of SECRET_FIELDS) {
300
+ if (curCfg[adapter][field] != null) {
301
+ bakCfg[adapter][field] = curCfg[adapter][field];
302
+ }
303
+ }
304
+ // Preserve remote_dispatch secrets
305
+ if (curCfg[adapter].remote_dispatch && bakCfg[adapter].remote_dispatch) {
306
+ if (curCfg[adapter].remote_dispatch.secret) {
307
+ bakCfg[adapter].remote_dispatch.secret = curCfg[adapter].remote_dispatch.secret;
308
+ }
309
+ } else if (curCfg[adapter].remote_dispatch) {
310
+ bakCfg[adapter].remote_dispatch = curCfg[adapter].remote_dispatch;
311
+ }
295
312
  const curIds = curCfg[adapter].allowed_chat_ids || [];
296
313
  const bakIds = bakCfg[adapter].allowed_chat_ids || [];
297
314
  // Union of both lists
@@ -301,8 +318,15 @@ function restoreConfig() {
301
318
  bakCfg[adapter].chat_agent_map = Object.assign(
302
319
  {}, bakCfg[adapter].chat_agent_map || {}, curCfg[adapter].chat_agent_map || {}
303
320
  );
321
+ } else if (curCfg[adapter] && !bakCfg[adapter]) {
322
+ // Backup doesn't have this adapter at all — keep current entirely
323
+ bakCfg[adapter] = curCfg[adapter];
304
324
  }
305
325
  }
326
+ // Preserve projects (current takes precedence for each project key)
327
+ if (curCfg.projects) {
328
+ bakCfg.projects = Object.assign({}, bakCfg.projects || {}, curCfg.projects);
329
+ }
306
330
  writeConfigSafe(bakCfg);
307
331
  config = loadConfig(); // eslint-disable-line no-undef -- config is declared in main() closure
308
332
  return true;
@@ -1083,6 +1107,37 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
1083
1107
  chain: [], // reset chain for callbacks
1084
1108
  }, config);
1085
1109
  }
1110
+
1111
+ // ── Reactive lifecycle hook ──
1112
+ try {
1113
+ handleReactiveOutput(targetProject, outStr, loadConfig(), {
1114
+ log,
1115
+ loadState,
1116
+ saveState,
1117
+ checkBudget,
1118
+ handleDispatchItem: (item, cfg) => {
1119
+ dispatchTask(item.target, {
1120
+ from: item.from || '_reactive',
1121
+ type: 'reactive',
1122
+ priority: 'normal',
1123
+ new_session: !!item.new_session,
1124
+ payload: { title: 'reactive dispatch', prompt: item.prompt },
1125
+ }, cfg, null, null);
1126
+ },
1127
+ notifyUser: (msg) => {
1128
+ try {
1129
+ const cfg = loadConfig();
1130
+ if (cfg.feishu && cfg.feishu.enabled && cfg.feishu.admin_chat_id) {
1131
+ const { sendFeishuText } = require('./daemon-notify');
1132
+ sendFeishuText(cfg.feishu.admin_chat_id, msg, cfg);
1133
+ }
1134
+ } catch (e) { log('WARN', `Reactive notify failed: ${e.message}`); }
1135
+ },
1136
+ metameDir: path.join(os.homedir(), '.metame'),
1137
+ });
1138
+ } catch (e) {
1139
+ log('ERROR', `Reactive lifecycle error for ${targetProject}: ${e.message}`);
1140
+ }
1086
1141
  };
1087
1142
  // If streamOptions provided, use real bot so output appears in target's Feishu channel.
1088
1143
  // Otherwise fall back to nullBot which captures output for replyFn.
@@ -1697,6 +1752,27 @@ function physiologicalHeartbeat(config) {
1697
1752
  } catch (e) {
1698
1753
  log('WARN', `Dispatch log rotation failed: ${e.message}`);
1699
1754
  }
1755
+
1756
+ // 4. Reconcile perpetual projects — detect stale reactive loops
1757
+ try {
1758
+ const { reconcilePerpetualProjects } = require('./daemon-reactive-lifecycle');
1759
+ reconcilePerpetualProjects(config, {
1760
+ log,
1761
+ loadState,
1762
+ saveState,
1763
+ notifyUser: (msg) => {
1764
+ try {
1765
+ const cfg = loadConfig();
1766
+ if (cfg.feishu && cfg.feishu.enabled && cfg.feishu.admin_chat_id) {
1767
+ const { sendFeishuText } = require('./daemon-notify');
1768
+ sendFeishuText(cfg.feishu.admin_chat_id, msg, cfg);
1769
+ }
1770
+ } catch (e) { log('WARN', `Reconcile notify failed: ${e.message}`); }
1771
+ },
1772
+ });
1773
+ } catch (e) {
1774
+ log('WARN', `Reconcile check failed: ${e.message}`);
1775
+ }
1700
1776
  }
1701
1777
 
1702
1778
  // ── Timing constants ─────────────────────────────────────────────────────────
@@ -2276,6 +2352,7 @@ const { handleAgentCommand } = createAgentCommandHandler({
2276
2352
  writeConfigSafe,
2277
2353
  backupConfig,
2278
2354
  execSync,
2355
+ log,
2279
2356
  });
2280
2357
 
2281
2358
  // Caffeinate process for /nosleep toggle (macOS only)
@@ -2315,6 +2392,7 @@ const { handleOpsCommand } = createOpsCommandHandler({
2315
2392
  path,
2316
2393
  spawn,
2317
2394
  execSync,
2395
+ execFileSync,
2318
2396
  log,
2319
2397
  loadConfig,
2320
2398
  loadState,
@@ -221,7 +221,7 @@ ${promptInput}
221
221
 
222
222
  fs.mkdirSync(POSTMORTEM_DIR, { recursive: true });
223
223
  const day = new Date().toISOString().slice(0, 10);
224
- const topicSlug = sanitizeSlug(skeleton.intent || title, `session-${String(skeleton.session_id || '').slice(0, 8)}`);
224
+ const topicSlug = sanitizeSlug(title || skeleton.intent, `session-${String(skeleton.session_id || '').slice(0, 8)}`);
225
225
  const filePath = path.join(POSTMORTEM_DIR, `${day}-${topicSlug}.md`);
226
226
  const markdown = [
227
227
  `# ${title}`,
@@ -30,3 +30,4 @@
30
30
  - **永远不要读取再复述文件内容**,直接用 `[[FILE:...]]` 标记发送
31
31
  - 路径必须是绝对路径
32
32
  - daemon 会自动解析标记并通过 bot 发送给用户
33
+ - **⛔ 禁止发到 open_id**:即使系统上下文中存在用户的飞书 `ou_...` ID,也绝不用它发文件。`[[FILE:...]]` 会自动发到**当前对话群**,这才是正确行为。直接发 open_id 会导致文件出现在 bot 和用户的 1-on-1 私聊,而不在当前群里。
@@ -354,7 +354,60 @@ Claude 看到 hook 注入:
354
354
  - `/dispatch to windows:hunter <任务>`:手动跨设备派发
355
355
  - `/dispatch to 猎手 <任务>`:按昵称解析,自动检测 `member.peer` 走远端
356
356
 
357
- ## 12. 私人配置保护
357
+ ## 12. 永续任务系统(Perpetual Task Engine)
358
+
359
+ ### 概念
360
+
361
+ 永续任务系统允许任何项目作为 reactive 永续循环运行。Agent 产出信号 → daemon 解析 → 门控检查 → 调度下一步。平台完全领域无关,科研、代码审计、文档维护等任何长期任务均可接入。
362
+
363
+ ### 核心组件
364
+
365
+ | 组件 | 文件 | 职责 |
366
+ |------|------|------|
367
+ | Reactive Lifecycle | `daemon-reactive-lifecycle.js` | 信号解析、budget/depth gate、事件溯源、verifier 调用、state 生成 |
368
+ | Event Log | `~/.metame/events/<key>.jsonl` | 唯一 Source of Truth,daemon 独占写入 |
369
+ | Manifest | `<cwd>/perpetual.yaml` | 可选项目清单(completion_signal、脚本路径、约束) |
370
+ | Reconciliation | `reconcilePerpetualProjects()` | heartbeat 中零 token 停滞检测 |
371
+ | Status 命令 | `/status perpetual` | 查看所有永续项目的 phase/depth/mission/status |
372
+
373
+ ### 接入一个新永续项目
374
+
375
+ 1. 在 `daemon.yaml` 中注册项目,添加 `reactive: true`
376
+ 2. 在项目目录创建 `CLAUDE.md`(定义 agent 行为)
377
+ 3. 可选:创建 `scripts/verifier.js`(阶段门控)
378
+ 4. 可选:创建 `perpetual.yaml`(覆盖默认约定)
379
+ 5. 可选:创建 `scripts/archiver.js` + `scripts/mission-queue.js`(归档与任务队列)
380
+
381
+ 不创建 perpetual.yaml 时,平台使用默认约定:
382
+ - Verifier: `scripts/verifier.js`
383
+ - Archiver: `scripts/archiver.js`
384
+ - Mission Queue: `scripts/mission-queue.js`
385
+ - 完成信号: `MISSION_COMPLETE`
386
+
387
+ ### 事件溯源协议
388
+
389
+ 所有状态变更记录在 `~/.metame/events/<projectKey>.jsonl`,一行一个 JSON 事件。`now/<key>.md` 和 `workspace/progress.tsv` 都是 event log 的投影(Projection),可随时从 event log 重建。
390
+
391
+ Event 类型:`MISSION_START` / `DISPATCH` / `MEMBER_COMPLETE` / `PHASE_GATE` / `DEPTH_LIMIT` / `BUDGET_LIMIT` / `MISSION_COMPLETE` / `ARCHIVE` / `STALE` / `INFRA_PAUSE`
392
+
393
+ ### 设计契约
394
+
395
+ 1. **Tolerant Reader**:`replayEventLog` 逐行解析,损坏行 WARN + skip,绝不 crash
396
+ 2. **Error Semantic Isolation**:verifier L2b 区分 404(幻觉,打回 agent)和 50x(基建故障,挂起项目通知人类)
397
+ 3. **State 由 daemon 生成**:agent 只读 `now/<key>.md`,不负责维护
398
+
399
+ ### 故障排查
400
+
401
+ | 症状 | 检查 |
402
+ |------|------|
403
+ | 永续项目不启动 | `daemon.yaml` 中是否有 `reactive: true`? |
404
+ | 完成信号不触发 | 检查 `perpetual.yaml` 中的 `completion_signal` 是否与 CLAUDE.md 一致 |
405
+ | Verifier 不运行 | 检查 `scripts/verifier.js` 路径(或 manifest 中的自定义路径)是否存在 |
406
+ | 项目挂起 (infra_failure) | 外部 API 不可用,检查网络。非 agent 错误。 |
407
+ | Event log 损坏 | 重启后 replay 会跳过损坏行。`progress.tsv` 和 `now/<key>.md` 可从 event log 重建 |
408
+ | `/status perpetual` 无输出 | 确认项目配置了 `reactive: true` |
409
+
410
+ ## 13. 私人配置保护(原 §12)
358
411
 
359
412
  - `daemon.yaml` 是用户私人配置,包含 API keys、chat IDs、个人项目配置
360
413
  - **绝不上传**到代码仓库,已加入 `.gitignore`
@@ -363,7 +416,7 @@ Claude 看到 hook 注入:
363
416
  - 同样不应上传的文件:`MEMORY.md`、`SOUL.md`、`.env*`
364
417
  - Agent 在执行任务时,**绝不能** `cp scripts/daemon.yaml ~/.metame/daemon.yaml`,这会覆盖用户私人配置
365
418
 
366
- ## 13. 变更后维护动作
419
+ ## 14. 变更后维护动作
367
420
 
368
421
  1. `npm test`
369
422
  2. `npm run sync:plugin`
@@ -130,6 +130,39 @@
130
130
  - `scripts/memory.js`:`saveFactLabels()` 原子写入 API
131
131
  - `scripts/memory-nightly-reflect.js`:`synthesized_insight` 回写、知识胶囊聚合与 `knowledge_capsule` 回写
132
132
 
133
+ ## 永续任务系统(Perpetual Task Engine)
134
+
135
+ - Reactive 生命周期引擎:
136
+ - `scripts/daemon-reactive-lifecycle.js`
137
+ - 关键点:`handleReactiveOutput()` 通用任务链引擎(领域无关);
138
+ `parseReactiveSignals()` 信号解析(NEXT_DISPATCH + 可配完成信号);
139
+ `reconcilePerpetualProjects()` 停滞检测(零 token,heartbeat 驱动);
140
+ `replayEventLog()` 事件溯源状态重放;
141
+ `generateStateFile()` 从 event log 生成 `now/<key>.md`(投影);
142
+ `appendEvent()` 追加事件到 `~/.metame/events/<key>.jsonl`(唯一 SoT);
143
+ `loadProjectManifest()` 读取项目 `perpetual.yaml`(convention over config);
144
+ `resolveProjectScripts()` 按约定/manifest 发现 verifier/archiver/mission-queue 脚本
145
+
146
+ - daemon.js 接入点:
147
+ - `scripts/daemon.js` `outputHandler` 内 `handleReactiveOutput` 调用(try/catch 隔离)
148
+ - `scripts/daemon.js` `physiologicalHeartbeat` 内 `reconcilePerpetualProjects` 调用
149
+
150
+ - 可观测命令:
151
+ - `scripts/daemon-admin-commands.js`
152
+ - 关键点:`/status perpetual`(或 `/status reactive`)显示所有永续项目状态
153
+
154
+ - 设计文档:
155
+ - `docs/perpetual-task-system-design.md`(v4,含 4 份附录)
156
+ - `docs/perpetual-task-system-plan.md`(实施计划,Phase A-C)
157
+
158
+ - 项目清单协议:
159
+ - `<cwd>/perpetual.yaml` — 可选,声明 completion_signal / verifier / archiver / mission_queue 路径
160
+ - 不存在时使用默认约定:`scripts/verifier.js`、`scripts/archiver.js`、`scripts/mission-queue.js`、信号 `MISSION_COMPLETE`
161
+
162
+ - 事件日志:
163
+ - `~/.metame/events/<projectKey>.jsonl` — append-only,daemon 独占写入
164
+ - Event 类型:MISSION_START / DISPATCH / MEMBER_COMPLETE / PHASE_GATE / DEPTH_LIMIT / BUDGET_LIMIT / MISSION_COMPLETE / ARCHIVE / STALE / INFRA_PAUSE
165
+
133
166
  ## 运行时数据位置
134
167
 
135
168
  - 画像:`~/.claude_profile.yaml`
@@ -141,6 +174,7 @@
141
174
  - 复盘文档:`~/.metame/memory/postmortems/`
142
175
  - Dispatch 队列:`~/.metame/dispatch/pending.jsonl`(本地 socket 降级)
143
176
  - 远端 Dispatch 队列:`~/.metame/dispatch/remote-pending.jsonl`(跨设备中继)
177
+ - **永续任务事件日志**:`~/.metame/events/<projectKey>.jsonl`(唯一 SoT,append-only)
144
178
  - 共享进度白板:`~/.metame/memory/now/shared.md`
145
179
  - Agent 最新产出:`~/.metame/memory/agents/{key}_latest.md`
146
180
  - Agent 收件箱:`~/.metame/memory/inbox/{key}/`(未读),`read/`(已归档)
@@ -91,6 +91,27 @@ function createBot(config) {
91
91
  appSecret: app_secret,
92
92
  });
93
93
 
94
+ /**
95
+ * Validate credentials by attempting a lightweight API call.
96
+ * Returns { ok: true } or { ok: false, error: string }.
97
+ */
98
+ async function validateCredentials() {
99
+ try {
100
+ await withTimeout(client.im.chat.list({ params: { page_size: 1 } }), 15000);
101
+ return { ok: true };
102
+ } catch (err) {
103
+ const msg = err && err.message || String(err);
104
+ const isAuthError = /invalid|unauthorized|forbidden|token|credential|app_id|app_secret|permission|99991663|99991664|99991665/i.test(msg);
105
+ return {
106
+ ok: false,
107
+ error: isAuthError
108
+ ? `Feishu credential validation failed (app_id/app_secret may be incorrect): ${msg}`
109
+ : `Feishu API probe failed (network or config issue): ${msg}`,
110
+ isAuthError,
111
+ };
112
+ }
113
+ }
114
+
94
115
  // Private: send an interactive card JSON; returns { message_id } or null.
95
116
  // All card functions funnel through here to avoid repeating the SDK call.
96
117
  async function _sendInteractive(chatId, card) {
@@ -106,6 +127,7 @@ function createBot(config) {
106
127
  let _editBrokenAt = 0; // timestamp when broken; auto-resets after 10min
107
128
 
108
129
  return {
130
+ validateCredentials,
109
131
  /**
110
132
  * Send a plain text message
111
133
  */
@@ -526,6 +548,9 @@ function createBot(config) {
526
548
  reconnect() {
527
549
  _log('INFO', 'Feishu manual reconnect triggered');
528
550
  reconnectDelay = 5000;
551
+ clearTimeout(reconnectTimer);
552
+ try { currentWs?.stop?.(); } catch { /* ignore */ }
553
+ currentWs = null;
529
554
  connect();
530
555
  },
531
556
  isAlive() {
@@ -35,9 +35,10 @@ module.exports = function detectFileTransfer(prompt) {
35
35
 
36
36
  if (isSend) {
37
37
  hints.push(
38
- '- **发送文件到手机**:在回复末尾加 `[[FILE:/absolute/path]]`,daemon 自动发送',
38
+ '- **发送文件到手机**:在回复末尾加 `[[FILE:/absolute/path]]`,daemon 自动发到**当前对话群**',
39
39
  '- 多个文件用多个 `[[FILE:...]]` 标记',
40
40
  '- **不要读取文件内容再复述**,直接用标记发送(省 token)',
41
+ '- **⛔ 严禁发到 open_id**:即使上下文中有 `ou_...` 用户ID,也绝对不用它发文件——那会发到 bot 私聊而非当前群',
41
42
  );
42
43
  }
43
44