metame-cli 1.5.0 → 1.5.1

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/scripts/daemon.js CHANGED
@@ -35,13 +35,14 @@ process.on('uncaughtException', (err) => {
35
35
  const fs = require('fs');
36
36
  const path = require('path');
37
37
  const os = require('os');
38
- const { execSync, execFileSync, spawn } = require('child_process');
38
+ const { execSync, execFileSync, execFile, spawn } = require('child_process');
39
39
 
40
40
  const HOME = os.homedir();
41
41
  const METAME_DIR = path.join(HOME, '.metame');
42
42
  const CONFIG_FILE = path.join(METAME_DIR, 'daemon.yaml');
43
43
  const STATE_FILE = path.join(METAME_DIR, 'daemon_state.json');
44
44
  const PID_FILE = path.join(METAME_DIR, 'daemon.pid');
45
+ const LOCK_FILE = path.join(METAME_DIR, 'daemon.lock');
45
46
  const LOG_FILE = path.join(METAME_DIR, 'daemon.log');
46
47
  const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
47
48
  const DISPATCH_DIR = path.join(METAME_DIR, 'dispatch');
@@ -59,7 +60,7 @@ const CLAUDE_BIN = (() => {
59
60
  ];
60
61
  try {
61
62
  const cmd = process.platform === 'win32' ? 'where claude' : 'which claude 2>/dev/null';
62
- return execSync(cmd, { encoding: 'utf8' }).trim().split('\n')[0];
63
+ return execSync(cmd, { encoding: 'utf8', ...(process.platform === 'win32' ? { windowsHide: true } : {}) }).trim().split('\n')[0];
63
64
  } catch {}
64
65
  for (const p of candidates) { if (fs.existsSync(p)) return p; }
65
66
  return 'claude'; // fallback: hope it's in PATH
@@ -144,7 +145,7 @@ const { createFileBrowser } = require('./daemon-file-browser');
144
145
  const { createPidManager, setupRuntimeWatchers } = require('./daemon-runtime-lifecycle');
145
146
  const { createNotifier } = require('./daemon-notify');
146
147
  const { createClaudeEngine } = require('./daemon-claude-engine');
147
- const { createEngineRuntimeFactory, detectDefaultEngine, ENGINE_DISTILL_MAP } = require('./daemon-engine-runtime');
148
+ const { createEngineRuntimeFactory, detectDefaultEngine, ENGINE_MODEL_CONFIG, ENGINE_DISTILL_MAP, ENGINE_DEFAULT_MODEL } = require('./daemon-engine-runtime');
148
149
  const { createCommandRouter } = require('./daemon-command-router');
149
150
  const { createTaskScheduler } = require('./daemon-task-scheduler');
150
151
  const { createAgentTools } = require('./daemon-agent-tools');
@@ -164,6 +165,11 @@ function getDaemonProviderEnv() {
164
165
  try { return providerMod.buildDaemonEnv(); } catch { return {}; }
165
166
  }
166
167
 
168
+ function getDistillModel() {
169
+ if (!providerMod || typeof providerMod.getDistillModel !== 'function') return 'haiku';
170
+ try { return providerMod.getDistillModel(); } catch { return 'haiku'; }
171
+ }
172
+
167
173
  function getActiveProviderEnv() {
168
174
  if (!providerMod) return {};
169
175
  try { return providerMod.buildActiveEnv(); } catch { return {}; }
@@ -206,9 +212,10 @@ const {
206
212
  cpExtractTimestamp,
207
213
  cpDisplayLabel,
208
214
  gitCheckpoint,
215
+ gitCheckpointAsync,
209
216
  listCheckpoints,
210
217
  cleanupCheckpoints,
211
- } = createCheckpointUtils({ execSync, path, log });
218
+ } = createCheckpointUtils({ execSync, execFile, path, log });
212
219
 
213
220
  // ---------------------------------------------------------
214
221
  // CONFIG & STATE
@@ -894,6 +901,8 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
894
901
  * Spawn session-summarize.js for sessions that have been idle 2-24 hours.
895
902
  * Called on sleep mode entry. Skips sessions that already have a fresh summary.
896
903
  */
904
+ const MAX_CONCURRENT_SUMMARIES = 3;
905
+
897
906
  function spawnSessionSummaries() {
898
907
  const scriptPath = path.join(__dirname, 'session-summarize.js');
899
908
  if (!fs.existsSync(scriptPath)) return;
@@ -901,18 +910,43 @@ function spawnSessionSummaries() {
901
910
  const now = Date.now();
902
911
  const TWO_HOURS = 2 * 60 * 60 * 1000;
903
912
  const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
913
+ // Collect eligible sessions, sort by most recently active first
914
+ const eligible = [];
904
915
  for (const [cid, sess] of Object.entries(state.sessions || {})) {
905
- if (!sess.id || !sess.started) continue;
916
+ // Support both old flat format and new per-engine format
917
+ let sessionId, started;
918
+ if (sess.engines) {
919
+ const active = Object.values(sess.engines).find(s => s.id && s.started);
920
+ if (!active) continue;
921
+ sessionId = active.id;
922
+ started = true;
923
+ } else {
924
+ sessionId = sess.id;
925
+ started = sess.started;
926
+ }
927
+ if (!sessionId || !started) continue;
906
928
  const lastActive = sess.last_active || 0;
907
929
  const idleMs = now - lastActive;
908
930
  if (idleMs < TWO_HOURS || idleMs > SEVEN_DAYS) continue;
909
- // Skip if summary is already newer than last activity
910
931
  if ((sess.last_summary_at || 0) > lastActive) continue;
932
+ eligible.push({ cid, sess: { ...sess, id: sessionId, started }, lastActive });
933
+ }
934
+ eligible.sort((a, b) => b.lastActive - a.lastActive);
935
+
936
+ let spawned = 0;
937
+ for (const { cid, sess } of eligible) {
938
+ if (spawned >= MAX_CONCURRENT_SUMMARIES) {
939
+ log('INFO', `[DAEMON] Session summary concurrency limit (${MAX_CONCURRENT_SUMMARIES}) reached, deferring remaining`);
940
+ break;
941
+ }
942
+ const idleMs = now - (sess.last_active || 0);
911
943
  try {
912
944
  const child = spawn(process.execPath, [scriptPath, cid, sess.id], {
913
945
  detached: true, stdio: 'ignore',
946
+ ...(process.platform === 'win32' ? { windowsHide: true } : {}),
914
947
  });
915
948
  child.unref();
949
+ spawned++;
916
950
  log('INFO', `[DAEMON] Session summary spawned for ${cid} (idle ${Math.round(idleMs / 3600000)}h)`);
917
951
  } catch (e) {
918
952
  log('WARN', `[DAEMON] Failed to spawn session summary: ${e.message}`);
@@ -1253,11 +1287,13 @@ const {
1253
1287
  buildSessionCardElements,
1254
1288
  listProjectDirs,
1255
1289
  getSession,
1290
+ getSessionForEngine,
1256
1291
  createSession,
1257
1292
  getSessionName,
1258
1293
  writeSessionName,
1259
1294
  markSessionStarted,
1260
1295
  watchSessionFiles,
1296
+ isEngineSessionValid,
1261
1297
  } = createSessionStore({
1262
1298
  fs,
1263
1299
  path,
@@ -1361,6 +1397,16 @@ if (providerMod && typeof providerMod.setEngine === 'function') {
1361
1397
  }
1362
1398
  log('INFO', `Default engine: ${_defaultEngine} (detected: ${detectedEngine})`);
1363
1399
 
1400
+ // One-time migration: daemon.model (legacy) → daemon.models.<engine>
1401
+ try {
1402
+ const _migCfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
1403
+ if (_migCfg.daemon && _migCfg.daemon.model && !_migCfg.daemon.models) {
1404
+ _migCfg.daemon.models = { [_defaultEngine]: _migCfg.daemon.model };
1405
+ writeConfigSafe(_migCfg);
1406
+ log('INFO', `Migrated daemon.model="${_migCfg.daemon.model}" → daemon.models.${_defaultEngine}`);
1407
+ }
1408
+ } catch { /* ignore */ }
1409
+
1364
1410
  function getDefaultEngine() {
1365
1411
  return _defaultEngine;
1366
1412
  }
@@ -1371,16 +1417,24 @@ function setDefaultEngine(engine) {
1371
1417
  st.default_engine = engine;
1372
1418
  saveState(st);
1373
1419
  if (providerMod) {
1374
- // Couple distill model
1420
+ // Sync distill model to this engine's default
1375
1421
  if (typeof providerMod.setDistillModel === 'function') {
1376
- const paired = ENGINE_DISTILL_MAP[engine] || ENGINE_DISTILL_MAP.claude;
1377
- try { providerMod.setDistillModel(paired); } catch { /* ignore */ }
1422
+ const distill = (ENGINE_MODEL_CONFIG[engine] || ENGINE_MODEL_CONFIG.claude).distill;
1423
+ try { providerMod.setDistillModel(distill); } catch { /* ignore */ }
1378
1424
  }
1379
- // Couple distill binary
1380
1425
  if (typeof providerMod.setEngine === 'function') {
1381
1426
  try { providerMod.setEngine(engine); } catch { /* ignore */ }
1382
1427
  }
1383
1428
  }
1429
+ // Migrate old daemon.model → daemon.models[engine] on first switch
1430
+ try {
1431
+ const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
1432
+ if (!cfg.daemon) cfg.daemon = {};
1433
+ if (cfg.daemon.model && !cfg.daemon.models) {
1434
+ cfg.daemon.models = { [engine]: cfg.daemon.model };
1435
+ writeConfigSafe(cfg);
1436
+ }
1437
+ } catch { /* ignore */ }
1384
1438
  }
1385
1439
 
1386
1440
  const getEngineRuntime = createEngineRuntimeFactory({
@@ -1413,6 +1467,7 @@ const {
1413
1467
  recordTokens,
1414
1468
  buildProfilePreamble,
1415
1469
  getDaemonProviderEnv,
1470
+ getDistillModel,
1416
1471
  log,
1417
1472
  physiologicalHeartbeat,
1418
1473
  isUserIdle,
@@ -1460,6 +1515,7 @@ const { handleAdminCommand } = createAdminCommandHandler({
1460
1515
  saveState,
1461
1516
  getDefaultEngine,
1462
1517
  setDefaultEngine,
1518
+ getDistillModel,
1463
1519
  });
1464
1520
 
1465
1521
  const { handleSessionCommand } = createSessionCommandHandler({
@@ -1519,12 +1575,15 @@ const { spawnClaudeAsync, askClaude } = createClaudeEngine({
1519
1575
  sendFileButtons,
1520
1576
  findSessionFile,
1521
1577
  listRecentSessions,
1578
+ isEngineSessionValid,
1522
1579
  getSession,
1580
+ getSessionForEngine,
1523
1581
  createSession,
1524
1582
  getSessionName,
1525
1583
  writeSessionName,
1526
1584
  markSessionStarted,
1527
1585
  gitCheckpoint,
1586
+ gitCheckpointAsync,
1528
1587
  recordTokens,
1529
1588
  skillEvolution,
1530
1589
  touchInteraction,
@@ -1617,6 +1676,7 @@ const { handleExecCommand } = createExecCommandHandler({
1617
1676
  createSession,
1618
1677
  findSessionFile,
1619
1678
  loadConfig,
1679
+ getDistillModel,
1620
1680
  });
1621
1681
 
1622
1682
  const { handleOpsCommand } = createOpsCommandHandler({
@@ -1705,6 +1765,72 @@ function sleep(ms) {
1705
1765
  return new Promise(resolve => setTimeout(resolve, ms));
1706
1766
  }
1707
1767
 
1768
+ let daemonLockFd = null;
1769
+ function isPidAlive(pid) {
1770
+ if (!pid || Number.isNaN(pid)) return false;
1771
+ try {
1772
+ process.kill(pid, 0);
1773
+ return true;
1774
+ } catch {
1775
+ return false;
1776
+ }
1777
+ }
1778
+
1779
+ function acquireDaemonLock() {
1780
+ const restartFromPid = parseInt(process.env.METAME_RESTART_FROM_PID || '', 10);
1781
+ const maxAttempts = restartFromPid ? 6 : 2;
1782
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1783
+ try {
1784
+ daemonLockFd = fs.openSync(LOCK_FILE, 'wx');
1785
+ fs.writeFileSync(daemonLockFd, JSON.stringify({
1786
+ pid: process.pid,
1787
+ started_at: new Date().toISOString(),
1788
+ }), 'utf8');
1789
+ return true;
1790
+ } catch (e) {
1791
+ if (e.code !== 'EEXIST') {
1792
+ log('ERROR', `Failed to acquire daemon lock: ${e.message}`);
1793
+ return false;
1794
+ }
1795
+ try {
1796
+ const raw = fs.readFileSync(LOCK_FILE, 'utf8');
1797
+ const meta = JSON.parse(raw || '{}');
1798
+ const ownerPid = parseInt(meta.pid, 10);
1799
+ if (isPidAlive(ownerPid)) {
1800
+ // Restart handoff: allow child to wait for parent to exit and take over.
1801
+ if (restartFromPid && ownerPid === restartFromPid) {
1802
+ for (let i = 0; i < 30; i++) {
1803
+ sleepSync(500);
1804
+ if (!isPidAlive(ownerPid)) break;
1805
+ }
1806
+ if (isPidAlive(ownerPid)) {
1807
+ log('WARN', `Restart handoff timed out, previous daemon still alive (PID: ${ownerPid})`);
1808
+ if (attempt < maxAttempts - 1) continue;
1809
+ return false;
1810
+ }
1811
+ try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ }
1812
+ continue;
1813
+ }
1814
+ log('WARN', `Another daemon instance owns lock (PID: ${meta.pid})`);
1815
+ return false;
1816
+ }
1817
+ } catch {
1818
+ // Ignore malformed lock metadata and treat as stale.
1819
+ }
1820
+ try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ }
1821
+ }
1822
+ }
1823
+ return false;
1824
+ }
1825
+
1826
+ function releaseDaemonLock() {
1827
+ try {
1828
+ if (daemonLockFd !== null) fs.closeSync(daemonLockFd);
1829
+ } catch { /* ignore */ }
1830
+ daemonLockFd = null;
1831
+ try { if (fs.existsSync(LOCK_FILE)) fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ }
1832
+ }
1833
+
1708
1834
  // ---------------------------------------------------------
1709
1835
  // MAIN
1710
1836
  // ---------------------------------------------------------
@@ -1724,7 +1850,9 @@ async function main() {
1724
1850
  // Config validation: warn on unknown/suspect fields
1725
1851
  const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget', 'projects'];
1726
1852
  const KNOWN_DAEMON = [
1727
- 'model',
1853
+ 'model', // legacy (still valid as fallback)
1854
+ 'models', // per-engine model map: { claude, codex }
1855
+ 'distill_models', // per-engine distill model map
1728
1856
  'log_max_size',
1729
1857
  'heartbeat_check_interval',
1730
1858
  'session_allowed_tools',
@@ -1736,7 +1864,8 @@ async function main() {
1736
1864
  'enable_nl_mac_control',
1737
1865
  'enable_nl_mac_fallback',
1738
1866
  ];
1739
- const VALID_MODELS = ['sonnet', 'opus', 'haiku'];
1867
+ // All known models across all engines (for legacy daemon.model validation only)
1868
+ const BUILTIN_CLAUDE_MODELS = ENGINE_MODEL_CONFIG.claude.options;
1740
1869
  for (const key of Object.keys(config)) {
1741
1870
  if (!KNOWN_SECTIONS.includes(key)) log('WARN', `Config: unknown section "${key}" (typo?)`);
1742
1871
  }
@@ -1744,17 +1873,22 @@ async function main() {
1744
1873
  for (const key of Object.keys(config.daemon)) {
1745
1874
  if (!KNOWN_DAEMON.includes(key)) log('WARN', `Config: unknown daemon.${key} (typo?)`);
1746
1875
  }
1747
- if (config.daemon.model && !VALID_MODELS.includes(config.daemon.model)) {
1748
- // Custom model names are valid when using non-anthropic providers
1876
+ // Validate legacy daemon.model (only warn if anthropic provider + unknown Claude model)
1877
+ if (config.daemon.model && !BUILTIN_CLAUDE_MODELS.includes(config.daemon.model)) {
1749
1878
  const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
1750
- if (activeProv === 'anthropic') {
1751
- log('WARN', `Config: daemon.model="${config.daemon.model}" is not a known model`);
1879
+ if (activeProv === 'anthropic' && _defaultEngine === 'claude') {
1880
+ log('WARN', `Config: daemon.model="${config.daemon.model}" is not a known Claude model`);
1752
1881
  } else {
1753
- log('INFO', `Config: custom model "${config.daemon.model}" for provider "${activeProv}"`);
1882
+ log('INFO', `Config: model "${config.daemon.model}" for engine "${_defaultEngine}" / provider "${activeProv}"`);
1754
1883
  }
1755
1884
  }
1756
1885
  }
1757
1886
 
1887
+ if (!acquireDaemonLock()) {
1888
+ process.exit(0);
1889
+ }
1890
+ process.on('exit', releaseDaemonLock);
1891
+
1758
1892
  // Takeover: kill any existing daemon
1759
1893
  killExistingDaemon();
1760
1894
  writePid();
@@ -1819,6 +1953,32 @@ async function main() {
1819
1953
  // Start heartbeat scheduler
1820
1954
  let heartbeatTimer = startHeartbeat(config, notifyFn, notifyPersonalFn);
1821
1955
 
1956
+ let shuttingDown = false;
1957
+ function spawnReplacementDaemon(reason) {
1958
+ try {
1959
+ const replacementScript = path.join(METAME_DIR, 'daemon.js');
1960
+ const bg = spawn(process.execPath, [replacementScript], {
1961
+ detached: process.platform !== 'win32',
1962
+ stdio: 'ignore',
1963
+ windowsHide: true,
1964
+ cwd: METAME_DIR,
1965
+ env: {
1966
+ ...process.env,
1967
+ HOME,
1968
+ USERPROFILE: HOME,
1969
+ METAME_ROOT: process.env.METAME_ROOT || path.dirname(__filename),
1970
+ METAME_RESTART_FROM_PID: String(process.pid),
1971
+ },
1972
+ });
1973
+ bg.unref();
1974
+ log('INFO', `[RESTART] Spawned replacement daemon (PID: ${bg.pid}) reason=${reason}`);
1975
+ return true;
1976
+ } catch (e) {
1977
+ log('ERROR', `[RESTART] Failed to spawn replacement daemon: ${e.message}`);
1978
+ return false;
1979
+ }
1980
+ }
1981
+
1822
1982
  const runtimeWatchers = setupRuntimeWatchers({
1823
1983
  fs,
1824
1984
  path,
@@ -1839,8 +1999,8 @@ async function main() {
1839
1999
  getHeartbeatTimer: () => heartbeatTimer,
1840
2000
  setHeartbeatTimer: (next) => { heartbeatTimer = next; },
1841
2001
  onRestartRequested: () => {
1842
- // Reuse full shutdown logic (kill child processes, clean PID, stop bridges)
1843
- shutdown().catch(() => process.exit(0));
2002
+ // Reuse full shutdown logic, then self-spawn replacement.
2003
+ shutdown({ restartReason: 'daemon-script-changed' }).catch(() => process.exit(1));
1844
2004
  },
1845
2005
  });
1846
2006
  // Expose reloadConfig to handleCommand via closure
@@ -1872,7 +2032,17 @@ async function main() {
1872
2032
  }
1873
2033
 
1874
2034
  // Graceful shutdown
1875
- const shutdown = async () => {
2035
+ const shutdown = async (opts = {}) => {
2036
+ if (shuttingDown) return;
2037
+ shuttingDown = true; // set immediately to prevent double-spawn race condition
2038
+ if (opts.restartReason) {
2039
+ const spawned = spawnReplacementDaemon(opts.restartReason);
2040
+ if (!spawned) {
2041
+ log('ERROR', `[RESTART] Abort shutdown: failed to spawn replacement (${opts.restartReason})`);
2042
+ shuttingDown = false;
2043
+ return;
2044
+ }
2045
+ }
1876
2046
  log('INFO', 'Daemon shutting down...');
1877
2047
  await notifyActiveUsers('关闭').catch(() => {});
1878
2048
  runtimeWatchers.stop();
@@ -1891,6 +2061,7 @@ async function main() {
1891
2061
  activeProcesses.clear();
1892
2062
  try { if (fs.existsSync(ACTIVE_PIDS_FILE)) fs.unlinkSync(ACTIVE_PIDS_FILE); } catch { }
1893
2063
  cleanPid();
2064
+ releaseDaemonLock();
1894
2065
  const s = loadState();
1895
2066
  s.pid = null;
1896
2067
  saveState(s);
@@ -1914,7 +2085,7 @@ async function main() {
1914
2085
  st.watchdog_restart = new Date().toISOString();
1915
2086
  st.watchdog_stall_seconds = Math.round(elapsed / 1000);
1916
2087
  saveState(st);
1917
- process.exit(1); // caffeinate or launchd will restart us
2088
+ shutdown({ restartReason: 'watchdog-stall' }).catch(() => process.exit(1));
1918
2089
  }
1919
2090
  } catch (e) {
1920
2091
  log('WARN', `[WATCHDOG] Check failed: ${e.message}`);
@@ -1409,5 +1409,9 @@ if (require.main === module) {
1409
1409
  } else {
1410
1410
  console.log(`💤 ${result.summary}`);
1411
1411
  }
1412
+ // Report estimated token usage for daemon budget tracking
1413
+ // Each callHaiku invocation ~2k-5k tokens; estimate from signal count + result size
1414
+ const estTokens = Math.ceil(((result.signalCount || 1) * 500) + ((result.summary || '').length / 4));
1415
+ console.log(`__TOKENS__:${estTokens}`);
1412
1416
  })();
1413
1417
  }
@@ -72,13 +72,30 @@ feishu:
72
72
  - 仅在“当前默认引擎对应 CLI 不可用”时判为故障
73
73
  - 自定义 provider 下允许任意合法模型名(不再强制 sonnet/opus/haiku)
74
74
 
75
- ## 5. 运行时文件与状态
75
+ ## 5. Agent Soul 身份层
76
+
77
+ - 集中存储:`~/.metame/agents/<agent_id>/`(soul.md、memory-snapshot.md、agent.yaml)
78
+ - 项目视图:`<cwd>/SOUL.md`、`<cwd>/MEMORY.md` 是指向集中存储的链接
79
+ - Claude:`@SOUL.md` 写入 CLAUDE.md 头部,CLI 每次 session 自动加载(引用式,改源文件立即生效)
80
+ - Codex:每次新 session 合并 CLAUDE.md + SOUL.md 写入 `<cwd>/AGENTS.md`(快照式,需新 session 生效)
81
+ - 老项目迁移:`/agent soul repair` 幂等补建 soul 层
82
+ - 注意:Windows 上 copy 模式的 SOUL.md 不会自动同步源文件变更,需 `/agent soul repair` 刷新
83
+
84
+ ## 6. 运行时文件与状态
76
85
 
77
86
  - 配置:`~/.metame/daemon.yaml`
78
87
  - daemon 状态:`~/.metame/daemon_state.json`
79
88
  - 活跃子进程:`~/.metame/active_agent_pids.json`
89
+ - 热重载备份:`~/.metame/.last-good/`(daemon 稳定运行 60s 后自动备份)
90
+ - 崩溃计数:`~/.metame/.crash-count`(连续 2 次快速崩溃触发自动恢复)
91
+
92
+ ## 7. 热重载安全机制(三层防护)
93
+
94
+ 1. **部署前预检**(`index.js`):`node -c` 语法检查所有 `.js`,不通过则拒绝部署到 `~/.metame/`
95
+ 2. **重启前预检**(`daemon-runtime-lifecycle.js`):daemon.js 变更触发重启前再次语法校验,不通过则阻止重启并通知 admin
96
+ 3. **崩溃循环自愈**:连续 2 次在 30s 内崩溃 → 自动从 `.last-good/` 恢复 → 通知 admin
80
97
 
81
- ## 6. 常见故障排查
98
+ ## 8. 常见故障排查
82
99
 
83
100
  ### Codex 认证失败
84
101
 
@@ -109,7 +126,26 @@ feishu:
109
126
  2. 若仍失败,手动 `/new` 新开会话
110
127
  3. 检查 `~/.metame/active_agent_pids.json` 是否残留异常进程
111
128
 
112
- ## 7. 变更后维护动作
129
+ ## 9. 双平台/双引擎维护矩阵
130
+
131
+ ### 统一维护(改一处即可)
132
+ - agent-layer.js / daemon-agent-tools.js / daemon-agent-commands.js / daemon-user-acl.js
133
+ - ENGINE_MODEL_CONFIG(daemon-engine-runtime.js 集中管理)
134
+ - daemon-runtime-lifecycle.js 的语法检查和备份机制
135
+
136
+ ### 需分别维护(有平台/引擎特殊分支)
137
+
138
+ | 模块 | 差异点 | 注意事项 |
139
+ |------|--------|----------|
140
+ | platform.js `killProcessTree` | POSIX: `kill(-pid)` / Windows: `taskkill /T /F` | 所有进程杀死调用点应统一使用此函数 |
141
+ | daemon-engine-runtime.js `resolveBinary` | macOS: `which` + homebrew / Windows: `where` + `.cmd` | 新增引擎需两端测试 |
142
+ | daemon-engine-runtime.js `buildArgs` | Claude: `--resume`/`--continue` / Codex: `exec resume`,Codex resume 不能传权限 flag | 改参数结构时两端验证 |
143
+ | daemon-claude-engine.js Soul 注入 | Claude: `@SOUL.md` import(引用式)/ Codex: AGENTS.md 合并写入(快照式) | 改 soul 加载方式需两端测试 |
144
+ | agent-layer.js `createLinkOrMirror` | macOS: symlink / Windows: hardlink → copy 降级 | copy 模式不会自动同步源文件变更 |
145
+ | daemon.js `spawnReplacementDaemon` | POSIX: `detached: true` / Windows: `detached: false` | 改 spawn 参数时注意平台分支 |
146
+ | NL Mac 控制(command-router) | macOS only,`process.platform === 'darwin'` 守卫 | Windows 天然跳过 |
147
+
148
+ ## 10. 变更后维护动作
113
149
 
114
150
  1. `npm test`
115
151
  2. `npm run sync:plugin`
@@ -15,6 +15,8 @@
15
15
  - 会话存储:`scripts/daemon-session-store.js`
16
16
  - 默认配置:`scripts/daemon-default.yaml`
17
17
  - Provider/蒸馏模型配置:`scripts/providers.js`(`/provider`、`/distill-model`)
18
+ - 跨平台基础设施:`scripts/platform.js`(`killProcessTree`、`socketPath`、`sleepSync`、`icon`)
19
+ - 热重载安全机制:`scripts/daemon-runtime-lifecycle.js`(语法预检、last-good 备份、crash-loop 自愈)
18
20
  - 维护手册:`scripts/docs/maintenance-manual.md`
19
21
 
20
22
  ## 多引擎(Claude/Codex)定位
@@ -28,10 +30,24 @@
28
30
  - 关键点:`askClaude()` 按 `project.engine`/session 选择 runtime;`patchSessionSerialized()` 串行回写 session
29
31
  - Codex 规则:`exec`/`resume`、10 分钟窗口内一次自动重试、`thread_id` 迁移回写
30
32
 
33
+ - Agent Soul 身份层(新):
34
+ - `scripts/agent-layer.js`
35
+ - 关键点:`ensureAgentLayer()` 创建 `~/.metame/agents/<id>/`(soul.md、memory-snapshot.md、agent.yaml);
36
+ `createLinkOrMirror()` Windows 兼容(symlink → hardlink → copy 降级);
37
+ `ensureClaudeMdSoulImport()` 在 CLAUDE.md 头部注入 `@SOUL.md`(Claude CLI 自动加载);
38
+ Codex 引擎在每次新 session 时将 CLAUDE.md + SOUL.md 合并写入 AGENTS.md(见 daemon-claude-engine.js:957);
39
+ `repairAgentLayer()` 懒迁移:老项目补建 soul 层,幂等安全
40
+
41
+ - Agent 命令处理(新):
42
+ - `scripts/daemon-agent-commands.js`
43
+ - 关键点:`createAgentCommandHandler()` 处理 `/agent`、`/activate`、`/resume`;
44
+ `/agent soul [repair|edit]`;`pendingActivations` 无 TTL(消费即删);防止创建群自激活
45
+
31
46
  - 路由与 Agent 创建:
32
47
  - `scripts/daemon-command-router.js`
33
48
  - `scripts/daemon-agent-tools.js`
34
- - 关键点:自然语言提取 `codex` 关键词;默认 `claude` 不写 `engine` 字段,仅 `codex` 持久化 `engine: codex`
49
+ - 关键点:自然语言提取 `codex` 关键词;默认 `claude` 不写 `engine` 字段,仅 `codex` 持久化 `engine: codex`;
50
+ `bindAgentToChat()` 自动调用 `ensureAgentMetadata()` 建立 soul 层
35
51
 
36
52
  - 会话命令与兼容边界:
37
53
  - `scripts/daemon-exec-commands.js`
@@ -74,6 +90,12 @@
74
90
  - 夜间反思文档:`~/.metame/memory/decisions/`、`~/.metame/memory/lessons/`
75
91
  - 知识胶囊:`~/.metame/memory/capsules/`
76
92
  - 复盘文档:`~/.metame/memory/postmortems/`
93
+ - **Agent Soul 层**:`~/.metame/agents/<agent_id>/`
94
+ - `agent.yaml` — id / name / engine / aliases
95
+ - `soul.md` — 身份定义(主文件,项目目录的 SOUL.md 是其链接)
96
+ - `memory-snapshot.md` — 近期记忆快照(注入 session prompt)
97
+ - 项目视图:`<cwd>/SOUL.md`(symlink/hardlink/copy)、`<cwd>/MEMORY.md`(同)
98
+ - `<cwd>/AGENTS.md` — Codex 专用,每次新 session 由 daemon 合并 CLAUDE.md + SOUL.md 写入
77
99
 
78
100
  ## 诊断顺序(推荐)
79
101
 
@@ -10,15 +10,40 @@ const fs = require('fs');
10
10
  const path = require('path');
11
11
 
12
12
  let Lark;
13
- try {
14
- Lark = require('@larksuiteoapi/node-sdk');
15
- } catch {
13
+ function _tryRequireLark() {
14
+ // 1. local node_modules (dev environment)
15
+ try { return require('@larksuiteoapi/node-sdk'); } catch {}
16
+ // 2. METAME_ROOT/node_modules (packaged metame-cli)
16
17
  const metameRoot = process.env.METAME_ROOT;
17
18
  if (metameRoot) {
18
- Lark = require(require('path').join(metameRoot, 'node_modules', '@larksuiteoapi/node-sdk'));
19
+ try { return require(path.join(metameRoot, 'node_modules', '@larksuiteoapi/node-sdk')); } catch {}
19
20
  }
20
- if (!Lark) {
21
- console.error('Cannot find @larksuiteoapi/node-sdk. Run: npm install @larksuiteoapi/node-sdk');
21
+ // 3. ~/.metame/node_modules (auto-installed for new users)
22
+ const home = process.env.HOME || process.env.USERPROFILE;
23
+ if (home) {
24
+ try { return require(path.join(home, '.metame', 'node_modules', '@larksuiteoapi', 'node-sdk')); } catch {}
25
+ }
26
+ return null;
27
+ }
28
+ Lark = _tryRequireLark();
29
+ if (!Lark) {
30
+ // Auto-install into ~/.metame so new users never see this error
31
+ const home = process.env.HOME || process.env.USERPROFILE;
32
+ const prefix = home ? path.join(home, '.metame') : null;
33
+ if (prefix) {
34
+ console.log('[feishu] @larksuiteoapi/node-sdk not found, auto-installing into ~/.metame ...');
35
+ const { execSync } = require('child_process');
36
+ try {
37
+ execSync(`npm install @larksuiteoapi/node-sdk --prefix "${prefix}" --silent`, { stdio: 'inherit' });
38
+ Lark = require(path.join(prefix, 'node_modules', '@larksuiteoapi', 'node-sdk'));
39
+ console.log('[feishu] SDK installed successfully.');
40
+ } catch (e) {
41
+ console.error('[feishu] Auto-install failed:', e.message);
42
+ console.error('Manual fix: npm install @larksuiteoapi/node-sdk --prefix ~/.metame');
43
+ process.exit(1);
44
+ }
45
+ } else {
46
+ console.error('[feishu] Cannot find @larksuiteoapi/node-sdk and HOME is not set.');
22
47
  process.exit(1);
23
48
  }
24
49
  }
@@ -26,12 +51,11 @@ try {
26
51
  // Timeout wrapper: prevents SDK calls from hanging indefinitely when
27
52
  // Feishu's token refresh HTTP request has no response (e.g. network down)
28
53
  function withTimeout(promise, ms = 10000) {
29
- return Promise.race([
30
- promise,
31
- new Promise((_, reject) =>
32
- setTimeout(() => reject(new Error(`Feishu API timeout after ${ms}ms`)), ms)
33
- ),
34
- ]);
54
+ let timer;
55
+ const timeout = new Promise((_, reject) => {
56
+ timer = setTimeout(() => reject(new Error(`Feishu API timeout after ${ms}ms`)), ms);
57
+ });
58
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
35
59
  }
36
60
 
37
61
  // Max chars per lark_md element (Feishu limit ~4000)
@@ -7,7 +7,7 @@
7
7
  * into memory.db. Runs independently of raw_signals.jsonl so that
8
8
  * pure technical sessions (no preference signals) are still captured.
9
9
  *
10
- * Designed to run as a standalone heartbeat task every 30 minutes.
10
+ * Designed to run as a standalone heartbeat task (default interval: 4h).
11
11
  */
12
12
 
13
13
  'use strict';
@@ -356,6 +356,10 @@ async function run() {
356
356
  if (require.main === module) {
357
357
  run().then(({ sessionsProcessed, factsSaved, factsSkipped }) => {
358
358
  console.log(`✅ memory-extract: ${sessionsProcessed} session(s), ${factsSaved} facts saved, ${factsSkipped} skipped`);
359
+ // Report estimated token usage for daemon budget tracking
360
+ // Each session processed ≈ 1 callHaiku invocation ≈ 3k tokens
361
+ const estTokens = sessionsProcessed * 3000;
362
+ if (estTokens > 0) console.log(`__TOKENS__:${estTokens}`);
359
363
  }).catch(e => {
360
364
  console.error(`[memory-extract] Fatal: ${e.message}`);
361
365
  process.exit(1);
@@ -465,6 +465,9 @@ facts(json): ${JSON.stringify(groupFacts, null, 2).slice(0, 5000)}
465
465
  if (require.main === module) {
466
466
  run().then(() => {
467
467
  console.log('✅ nightly-reflect complete');
468
+ // Report estimated token usage for daemon budget tracking
469
+ // ~5k tokens per reflection + capsule generation
470
+ console.log('__TOKENS__:5000');
468
471
  }).catch(e => {
469
472
  console.error(`[NIGHTLY-REFLECT] Fatal: ${e.message}`);
470
473
  process.exit(1);
@@ -365,14 +365,14 @@ function buildMentorPrompt(sessionState = {}, profile = {}, config = {}, nowMs =
365
365
  lines.push(`- mode=${mode}, zone=${zone}`);
366
366
 
367
367
  if (mode === 'gentle') {
368
- lines.push('- Before solution, optionally ask one short guiding question.');
368
+ lines.push('- Give solution but include brief rationale so user can learn the "why".');
369
369
  } else if (mode === 'active') {
370
- lines.push('- Ask user for their design idea first, then provide improvements.');
371
- lines.push('- End with one-line "关键收获".');
370
+ lines.push('- Lead with the key concept/principle before the implementation.');
371
+ lines.push('- Add one-line "关键收获" at the end of your reply.');
372
372
  } else {
373
373
  lines.push('- Prefer scaffold/pseudocode first; avoid dumping full solution immediately.');
374
- lines.push('- Use Socratic prompts to force active reasoning.');
375
374
  lines.push('- Apply knowledge firewall: do not fill user logic gaps with unstated assumptions.');
375
+ lines.push('- Guide via explanation structure, not by asking the user questions.');
376
376
  }
377
377
 
378
378
  if (zone === 'comfort') lines.push('- Increase challenge slightly (new method or stronger abstraction).');