metame-cli 1.5.10 → 1.5.11

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
@@ -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 ─────────────────────────────────────────────────────────
@@ -2315,6 +2391,7 @@ const { handleOpsCommand } = createOpsCommandHandler({
2315
2391
  path,
2316
2392
  spawn,
2317
2393
  execSync,
2394
+ execFileSync,
2318
2395
  log,
2319
2396
  loadConfig,
2320
2397
  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}`,
@@ -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() {
@@ -74,6 +74,7 @@ const SESSION_TAGS_FILE = path.join(os.homedir(), '.metame', 'session_tags.json'
74
74
 
75
75
  /**
76
76
  * Persist session name and derived tags to ~/.metame/session_tags.json.
77
+ * Consumed by /sessions and /resume commands for friendly display titles.
77
78
  * Merges into existing file — never overwrites existing entries.
78
79
  */
79
80
  function saveSessionTag(sessionId, sessionName, facts) {
@@ -337,6 +338,19 @@ async function run() {
337
338
 
338
339
  sessionAnalytics.markFactsExtracted(skeleton.session_id);
339
340
 
341
+ // Persist session summary to memory.db sessions table (makes sessions searchable)
342
+ try {
343
+ const keywords = facts.flatMap(f => Array.isArray(f.tags) ? f.tags : [])
344
+ .filter((v, i, a) => a.indexOf(v) === i).slice(0, 10).join(',');
345
+ memory.saveSession({
346
+ sessionId: skeleton.session_id,
347
+ project: skeleton.project || 'unknown',
348
+ scope: skeleton.project_id || null,
349
+ summary: `[${session_name}] ${facts.map(f => f.value).join(' | ').slice(0, 2000)}`,
350
+ keywords,
351
+ });
352
+ } catch { /* non-fatal — facts already saved, session is bonus */ }
353
+
340
354
  // P2-A: persist session name + tags to session_tags.json
341
355
  saveSessionTag(skeleton.session_id, session_name, facts);
342
356
 
@@ -391,12 +405,26 @@ async function run() {
391
405
  const superMsg = superseded > 0 ? `, ${superseded} superseded` : '';
392
406
  const labelMsg = labelsSaved > 0 ? `, ${labelsSaved} labels` : '';
393
407
  console.log(`[memory-extract] Codex ${cs.session_id.slice(0, 8)} (${session_name}): ${saved} facts saved${superMsg}${labelMsg}`);
408
+
409
+ // Persist Codex session summary to memory.db sessions table
410
+ try {
411
+ const keywords = facts.flatMap(f => Array.isArray(f.tags) ? f.tags : [])
412
+ .filter((v, i, a) => a.indexOf(v) === i).slice(0, 10).join(',');
413
+ memory.saveSession({
414
+ sessionId: cs.session_id,
415
+ project: skeleton.project || 'unknown',
416
+ scope: skeleton.project_id || null,
417
+ summary: `[${session_name}] ${facts.map(f => f.value).join(' | ').slice(0, 2000)}`,
418
+ keywords,
419
+ });
420
+ } catch { /* non-fatal */ }
421
+
422
+ saveSessionTag(cs.session_id, session_name, facts);
394
423
  } else {
395
424
  console.log(`[memory-extract] Codex ${cs.session_id.slice(0, 8)} (${session_name}): no facts extracted`);
396
425
  }
397
426
 
398
427
  sessionAnalytics.markCodexFactsExtracted(cs.session_id);
399
- saveSessionTag(cs.session_id, session_name, facts);
400
428
  processed++;
401
429
  } catch (e) {
402
430
  console.log(`[memory-extract] Codex session error: ${e.message}`);
@@ -503,6 +503,109 @@ entity_prefix: ${group.prefix}
503
503
  try { if (memory && typeof memory.close === 'function') memory.close(); } catch { /* non-fatal */ }
504
504
  }
505
505
 
506
+ // ── Conflict Resolution ──────────────────────────────────────────────
507
+ // Query CONFLICT facts grouped by entity+relation, ask Haiku to pick winner.
508
+ // Loser is marked superseded_by winner; winner restored to OK.
509
+ let conflictsResolved = 0;
510
+ try {
511
+ const conflictGroups = db.prepare(`
512
+ SELECT entity, relation, COUNT(*) as cnt
513
+ FROM facts
514
+ WHERE conflict_status = 'CONFLICT' AND superseded_by IS NULL
515
+ GROUP BY entity, relation
516
+ HAVING cnt >= 2
517
+ ORDER BY cnt DESC
518
+ LIMIT 10
519
+ `).all();
520
+
521
+ if (conflictGroups.length > 0) {
522
+ console.log(`[NIGHTLY-REFLECT] Found ${conflictGroups.length} conflict group(s) to resolve.`);
523
+
524
+ // Collect all conflicting facts for these groups (batch to reduce queries)
525
+ const allConflicts = [];
526
+ for (const g of conflictGroups) {
527
+ const rows = db.prepare(`
528
+ SELECT id, entity, relation, value, confidence, created_at
529
+ FROM facts
530
+ WHERE entity = ? AND relation = ? AND conflict_status = 'CONFLICT' AND superseded_by IS NULL
531
+ ORDER BY created_at DESC
532
+ `).all(g.entity, g.relation);
533
+ if (rows.length >= 2) allConflicts.push({ entity: g.entity, relation: g.relation, facts: rows });
534
+ }
535
+
536
+ if (allConflicts.length > 0) {
537
+ // Limit to 5 groups to avoid truncating serialized JSON
538
+ const conflictInput = allConflicts.slice(0, 5).map(g => ({
539
+ entity: g.entity,
540
+ relation: g.relation,
541
+ candidates: g.facts.slice(0, 5).map(f => ({ id: f.id, value: f.value.slice(0, 150), confidence: f.confidence, created_at: f.created_at })),
542
+ }));
543
+
544
+ const resolvePrompt = `你是知识库冲突调解员。以下是同一 entity+relation 下的冲突事实,请选出每组最准确的一条保留。
545
+
546
+ 冲突组(JSON):
547
+ ${JSON.stringify(conflictInput, null, 2)}
548
+
549
+ 输出 JSON 数组,每个元素对应一组冲突的裁决:
550
+ [
551
+ {
552
+ "entity": "...",
553
+ "relation": "...",
554
+ "winner_id": "保留的fact id",
555
+ "reason": "一句话理由"
556
+ }
557
+ ]
558
+
559
+ 规则:
560
+ - 优先选最新(created_at)且 confidence=high 的
561
+ - 如果旧条目更准确具体,选旧的
562
+ - 只输出 JSON 数组`;
563
+
564
+ try {
565
+ const resolveRaw = await Promise.race([
566
+ callHaiku(resolvePrompt, distillEnv, 60000),
567
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 65000)),
568
+ ]);
569
+ const verdicts = parseJsonFromLlm(resolveRaw);
570
+ if (Array.isArray(verdicts)) {
571
+ for (const v of verdicts) {
572
+ if (!v || !v.winner_id || !v.entity || !v.relation) continue;
573
+ // Validate winner exists in our conflict set
574
+ const group = allConflicts.find(g => g.entity === v.entity && g.relation === v.relation);
575
+ if (!group) continue;
576
+ const winnerExists = group.facts.some(f => f.id === v.winner_id);
577
+ if (!winnerExists) continue;
578
+
579
+ const loserIds = group.facts.filter(f => f.id !== v.winner_id).map(f => f.id);
580
+ if (loserIds.length === 0) continue;
581
+
582
+ // Mark losers as superseded
583
+ const placeholders = loserIds.map(() => '?').join(',');
584
+ db.prepare(
585
+ `UPDATE facts SET superseded_by = ?, conflict_status = 'OK', updated_at = datetime('now')
586
+ WHERE id IN (${placeholders})`
587
+ ).run(v.winner_id, ...loserIds);
588
+
589
+ // Restore winner
590
+ db.prepare(
591
+ `UPDATE facts SET conflict_status = 'OK', updated_at = datetime('now') WHERE id = ?`
592
+ ).run(v.winner_id);
593
+
594
+ conflictsResolved += loserIds.length;
595
+ }
596
+ if (conflictsResolved > 0) {
597
+ console.log(`[NIGHTLY-REFLECT] Resolved ${conflictsResolved} conflicting fact(s).`);
598
+ }
599
+ }
600
+ } catch (e) {
601
+ console.log(`[NIGHTLY-REFLECT] Conflict resolution failed (non-fatal): ${e.message}`);
602
+ }
603
+ }
604
+ }
605
+ } catch (e) {
606
+ console.log(`[NIGHTLY-REFLECT] Conflict query failed (non-fatal): ${e.message}`);
607
+ }
608
+
506
609
  // Write audit log
507
610
  writeReflectLog({
508
611
  status: 'success',
@@ -510,6 +613,7 @@ entity_prefix: ${group.prefix}
510
613
  decisions_written: decisions.length,
511
614
  lessons_written: lessons.length,
512
615
  synthesized_insights_saved: synthesizedSaved,
616
+ conflicts_resolved: conflictsResolved,
513
617
  capsules_written: capsulesWritten,
514
618
  capsule_facts_saved: capsuleFactsSaved,
515
619
  decision_file: decisions.length > 0 ? decisionFile : null,
@@ -97,7 +97,7 @@ function withBufferLock(fn) {
97
97
  acquired = true;
98
98
  break;
99
99
  } catch (e) {
100
- if (e.code !== 'EEXIST') throw e;
100
+ if (e.code !== 'EEXIST') return false; // non-EEXIST errors (EMFILE, EACCES) → skip lock gracefully
101
101
  try {
102
102
  const age = Date.now() - fs.statSync(LOCK_FILE).mtimeMs;
103
103
  if (age > LOCK_STALE_MS) {
@@ -159,12 +159,12 @@ process.stdin.on('end', () => {
159
159
  // Skip empty or very short messages
160
160
  // Chinese chars carry more info per char, so use weighted length
161
161
  const weightedLen = [...prompt].reduce((sum, ch) => sum + (ch.charCodeAt(0) > 0x2e80 ? 3 : 1), 0);
162
- if (weightedLen < 15) {
162
+ if (!isMeta && weightedLen < 15) {
163
163
  process.exit(0);
164
164
  }
165
165
 
166
166
  // Hard cap to prevent giant prompt pastes from poisoning distill budget.
167
- if (prompt.length > ABSOLUTE_MAX_CAPTURE_CHARS) {
167
+ if (!isMeta && prompt.length > ABSOLUTE_MAX_CAPTURE_CHARS) {
168
168
  process.exit(0);
169
169
  }
170
170
  if (prompt.length > MAX_CAPTURE_CHARS) {
@@ -75,6 +75,7 @@ const DEFAULT_POLICY = {
75
75
  min_evidence_for_gap: 3,
76
76
  max_updates_per_analysis: 3,
77
77
  max_gaps_per_analysis: 2,
78
+ max_signals_per_analysis: 30, // cap signals sent to Haiku per run
78
79
 
79
80
  // Workflow discovery
80
81
  workflow_discovery_interval: 2, // every N cold-path cycles
@@ -200,6 +201,7 @@ function sanitizePolicy(input) {
200
201
  min_evidence_for_gap: clampInt(merged.min_evidence_for_gap, DEFAULT_POLICY.min_evidence_for_gap, 1, 20),
201
202
  max_updates_per_analysis: clampInt(merged.max_updates_per_analysis, DEFAULT_POLICY.max_updates_per_analysis, 1, 20),
202
203
  max_gaps_per_analysis: clampInt(merged.max_gaps_per_analysis, DEFAULT_POLICY.max_gaps_per_analysis, 1, 20),
204
+ max_signals_per_analysis: clampInt(merged.max_signals_per_analysis, DEFAULT_POLICY.max_signals_per_analysis, 5, 100),
203
205
  workflow_discovery_interval: clampInt(merged.workflow_discovery_interval, DEFAULT_POLICY.workflow_discovery_interval, 1, 100),
204
206
  min_signals_for_workflow: clampInt(merged.min_signals_for_workflow, DEFAULT_POLICY.min_signals_for_workflow, 1, 100),
205
207
  workflow_proposal_threshold: clampInt(merged.workflow_proposal_threshold, DEFAULT_POLICY.workflow_proposal_threshold, 2, 50),
@@ -610,8 +612,15 @@ async function distillSkills() {
610
612
  if (!content) return null;
611
613
 
612
614
  const lines = content.split('\n');
613
- const signals = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
614
- if (signals.length < policy.min_signals_for_distill) return null;
615
+ const allSignals = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
616
+ if (allSignals.length < policy.min_signals_for_distill) return null;
617
+
618
+ // Cap signals sent to Haiku to avoid prompt bloat / timeout.
619
+ // Keep most recent signals (higher relevance); overflow is still cleared.
620
+ const maxSignals = policy.max_signals_per_analysis || 30;
621
+ const signals = allSignals.length > maxSignals
622
+ ? allSignals.slice(-maxSignals)
623
+ : allSignals;
615
624
 
616
625
  // Get installed skills list
617
626
  const installedSkills = listInstalledSkills();