oh-my-opencode-cohub 1.17.1 → 1.18.0

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 (53) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/config-io.d.ts +2 -2
  3. package/dist/cli/config-io.d.ts.map +1 -1
  4. package/dist/cli/index.js +209 -67
  5. package/dist/config/loader.d.ts +4 -0
  6. package/dist/config/loader.d.ts.map +1 -1
  7. package/dist/context/diag-log.d.ts +62 -0
  8. package/dist/context/diag-log.d.ts.map +1 -0
  9. package/dist/context/engine.d.ts +33 -0
  10. package/dist/context/engine.d.ts.map +1 -1
  11. package/dist/context/extractor.d.ts +1 -20
  12. package/dist/context/extractor.d.ts.map +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +602 -386
  15. package/dist/instructions/chinese.d.ts +1 -1
  16. package/dist/instructions/chinese.d.ts.map +1 -1
  17. package/dist/prompts/council.d.ts +1 -1
  18. package/dist/prompts/council.d.ts.map +1 -1
  19. package/dist/prompts/oracle.d.ts +1 -1
  20. package/dist/prompts/oracle.d.ts.map +1 -1
  21. package/dist/prompts/orchestrator.d.ts +1 -1
  22. package/dist/prompts/orchestrator.d.ts.map +1 -1
  23. package/dist/prompts/rule-app.d.ts +1 -1
  24. package/dist/prompts/rule-app.d.ts.map +1 -1
  25. package/dist/prompts/rule-project.d.ts +1 -1
  26. package/dist/prompts/rule-project.d.ts.map +1 -1
  27. package/dist/prompts/rule-user.d.ts +1 -1
  28. package/dist/prompts/rule-user.d.ts.map +1 -1
  29. package/dist/shared/chinese.d.ts +2 -0
  30. package/dist/shared/chinese.d.ts.map +1 -0
  31. package/dist/shared/consensus.d.ts +132 -0
  32. package/dist/shared/consensus.d.ts.map +1 -0
  33. package/dist/shared/constants.d.ts +26 -0
  34. package/dist/shared/constants.d.ts.map +1 -0
  35. package/dist/shared/fs-atomic.d.ts +11 -0
  36. package/dist/shared/fs-atomic.d.ts.map +1 -0
  37. package/dist/shared/log.d.ts +38 -0
  38. package/dist/shared/log.d.ts.map +1 -0
  39. package/dist/shared/model.d.ts +7 -0
  40. package/dist/shared/model.d.ts.map +1 -0
  41. package/dist/shared/token-estimator.d.ts +25 -0
  42. package/dist/shared/token-estimator.d.ts.map +1 -0
  43. package/dist/task-manager/tracker.d.ts +17 -5
  44. package/dist/task-manager/tracker.d.ts.map +1 -1
  45. package/dist/task-manager/types.d.ts +2 -1
  46. package/dist/task-manager/types.d.ts.map +1 -1
  47. package/dist/tools/council.d.ts +2 -66
  48. package/dist/tools/council.d.ts.map +1 -1
  49. package/dist/tui.d.ts.map +1 -1
  50. package/dist/tui.js +96 -48
  51. package/dist/utils/log.d.ts +8 -12
  52. package/dist/utils/log.d.ts.map +1 -1
  53. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ var ORCHESTRATOR_PROMPT = `<角色>
13
13
  @co-observer - 只读。图片/PDF/截图视觉分析。委派:多媒体文件分析时(含完整路径)。
14
14
  @co-council - 只读。多模型并行共识。委派:多专家视角/不可逆决策(数据迁移/API 变更)。错了还能改→@co-oracle,错了就完了→@co-council。
15
15
  @co-rule-user - 只读。分析用户级 AGENTS.md(~/.config/opencode/AGENTS.md)约束。委派:方案需对照用户规则时。
16
- @co-rule-project - 只读。分析项目 AGENTS.md 约束。委派:方案需对照项目规则时。
16
+ @co-rule-project - 只读。分析项目 AGENTS.md / .rules/*.md 等仓库级规范。委派:方案需对照项目规则时。
17
17
  @co-rule-app - 只读。分析 .opencode/rules/*.md 应用规则。**并行策略**:当 rules 目录下有 N 个文件时,并行启动 N/2(向上取整)个 @co-rule-app 实例,每个实例负责 1-2 个规则文件(在 prompt 中明确指定文件列表)。所有实例完成后,Orchestrator 汇总建议。委派:方案需对照安全/测试/数据库等应用规则时。
18
18
  @co-planner - 只读。综合需求+信息+规范,输出结构化任务分解方案。委派:信息收集和规范分析完成后。
19
19
 
@@ -162,13 +162,14 @@ var ORACLE_PROMPT = `你是 Oracle——战略技术顾问和代码审查者。
162
162
 
163
163
  ## 核心要求:输出结构化审查报告
164
164
 
165
- 当审查多个文件时,**必须并行分析所有文件**,输出结构化报告,而不是串行逐个文件分析。
165
+ 当审查多个文件时,**基于已按需读取的内容并行综合分析**,输出结构化报告,而不是串行逐个文件单独下结论;读取按下方"并行审查原则"分片进行,不要求一次性读完全部文件。
166
166
 
167
167
  ### 并行审查原则
168
168
 
169
- 1. **所有文件同时读** — 先一次性用 \`read\` 或 \`grep\` 获取所有需要审查的文件内容
170
- 2. **并行分析** — 在同一个上下文中同时分析所有文件,找出共性问题
171
- 3. **结构化输出** — 按分类汇总,不按文件罗列
169
+ 1. **先定位后读取** — 分支审查先用 \`git diff --stat\` / \`--numstat\` 确认差异范围与规模;符号/调用处先用 \`grep\` / \`glob\` 精确定位,再 \`read\` 配合 \`offset\`/\`limit\` 按片段读取
170
+ 2. **预算内并行分片** — 对已定位的目标,在读取预算内(见"文件操作规则")把多个独立片段或文件并行读入同一上下文,不要求一次性读完全部文件
171
+ 3. **并行分析** — 在同一个上下文中同时分析已读取的内容,找出共性问题
172
+ 4. **结构化输出** — 按分类汇总,不按文件罗列
172
173
 
173
174
  ### 审查报告输出格式
174
175
 
@@ -252,7 +253,14 @@ var ORACLE_PROMPT = `你是 Oracle——战略技术顾问和代码审查者。
252
253
  - 优先使用专用文件工具检查代码库:glob/grep/ast_grep_search 用于发现,read 用于读取文件内容
253
254
  - Bash 可用于非变更诊断和 shell 原生检查(最清晰时),但不能修改文件
254
255
  - 不要用 cat/head/tail/sed/awk 读取代码到上下文中;使用 read/grep,除非 shell 管道确实是更好的诊断方式
255
- - **多文件审查时,先一次性并行读取所有文件,再综合分析**
256
+ - **多文件审查时,先定位 → 再预算内并行分片读取 → 再综合分析**(定位手段见"并行审查原则")
257
+
258
+ **按需读取预算**(软策略,非工具硬限,控制单批输出量):
259
+ - 单次 \`read\` 默认 150-200 行;整读通常仅用于 ≤200 行且确需全貌的小文件
260
+ - 每批读取输出目标 ≤30000 字符;无法预知字符数时按行数估算分批
261
+ - 独立定位与片段读取仍可并行发出,预算约束的是单批总量而非并行数量
262
+ - 预留约 20% 上下文余量(能判断时遵循;不虚构对上下文占用的监测能力)
263
+ - **完整性优先**:大方法、锁、生命周期、异常路径、回调、跨文件不变量等关键部分,允许有依据地继续补读甚至必要整读;跟踪已覆盖/未覆盖事项,不因预算把未核实项判为安全;不设固定轮次硬停;不为省 token 默认拆分多个子代理
256
264
 
257
265
  **语言要求**: 始终使用中文进行思考、分析和回复。代码和技术术语可用原文,自然语言部分必须用中文。
258
266
 
@@ -575,6 +583,12 @@ var COUNCIL_PROMPT = `你是 Council 代理——一个跨多个 LLM 模型运
575
583
  - 优先使用专用文件工具检查代码库:glob/grep/ast_grep_search 用于发现,read 用于读取文件内容
576
584
  - Bash 可用于非变更诊断和 shell 原生检查(最清晰时),但不能修改文件
577
585
  - 不要用 cat/head/tail/sed/awk 读取代码到上下文中;使用 read/grep,除非 shell 管道确实是更好的诊断方式
586
+ - **按需取证读取(软预算)**:先确认问题与差异范围,只收集当前议题所需证据,不广泛整读
587
+ - 分支审查优先利用调用方提供的 diff/stat/numstat 等差异材料确认范围,缺失时请求补充或用现有只读工具搜集等价证据,不自行执行 shell;仍用 Grep 查符号/调用处、Read \`offset\`/\`limit\` 定点读取,缺失证据不视为安全
588
+ - 单次 Read 默认 150–200 行;整读仅用于 ≤200 行且确需全貌的文件;每批输出以 ≤30000 字符为软目标,按行数估算分批,不是硬限
589
+ - 互不依赖的定位搜索与片段读取保持并行
590
+ - 预留约 20% 上下文余量,不宣称有硬监测;大方法、关键生命周期/锁/异常/回调、跨文件不变量可说明必要性后补读乃至整读,跟踪覆盖与未覆盖,禁止因省预算把未核实项当安全
591
+ - 不新增硬轮数、不默认追加子代理;最终响应简明列出证据覆盖范围与未核实假设
578
592
 
579
593
  **要求的输出格式**:
580
594
  你的最终响应中必须包含以下部分:
@@ -603,13 +617,20 @@ var COUNCIL_PROMPT = `你是 Council 代理——一个跨多个 LLM 模型运
603
617
  var RULE_USER_PROMPT = `你是规则分析代理——负责用户级规范。
604
618
 
605
619
  **职责**:读取 \`~/.config/opencode/AGENTS.md\`(用户级全局规则),结合 Orchestrator 提供的当前方案,分析是否有遗漏或冲突。返回具体的调整建议(不要笼统)。
620
+ 若该文件不存在或为空,必须明确报告(如"未找到用户级规范文件:~/.config/opencode/AGENTS.md"),不得默默返回空分析。
606
621
 
607
622
  **约束**:只读,不修改文件。聚焦规则与方案的映射关系。`;
608
623
 
609
624
  // src/prompts/rule-project.ts
610
625
  var RULE_PROJECT_PROMPT = `你是规则分析代理——负责项目级规范。
611
626
 
612
- **职责**:读取项目根目录 \`AGENTS.md\`(项目级规则),结合 Orchestrator 提供的当前方案,分析是否有遗漏或冲突。返回具体的调整建议(不要笼统)。
627
+ **职责**:读取项目级指令:从项目根目录(首个含 .git 的祖先目录)到当前目录的每一级目录,按序发现 \`AGENTS.md\` → \`CLAUDE.md\` → \`AGENTS.local.md\` → \`CLAUDE.local.md\`,
628
+ 同时发现该级目录下的 \`.rules/*.md\`(存在则按文件名升序全部读入)。
629
+ 合并顺序:单文件在前作入口,\`.rules/*.md\` 在后作细则;越靠近当前目录优先级越高(就近覆盖)。
630
+ 结合 Orchestrator 提供的当前方案,分析是否有遗漏或冲突。返回具体的调整建议(不要笼统)。
631
+ 若什么都没读到,明确说明"未找到任何项目级规范文件"。
632
+
633
+ **输出要求**:列出**实际读到的文件清单**(完整路径)+ 内容摘要 + 与当前方案的遗漏/冲突。若存在 \`.rules/\` 目录但未读到任何 \`.md\`,报告"发现 .rules/ 目录但为空"。
613
634
 
614
635
  **约束**:只读,不修改文件。聚焦规则与方案的映射关系。`;
615
636
 
@@ -620,6 +641,8 @@ var RULE_APP_PROMPT = `你是应用规则分析代理(co-rule-app),负责
620
641
 
621
642
  接收 Orchestrator 指定的**具体规则文件列表**(通常 1-2 个文件),逐文件读取并分析,结合当前任务方案,判断是否有遗漏或冲突。返回结构化建议。
622
643
 
644
+ 若 Orchestrator 未指定具体文件路径,应先提示「请由 co-rule-project 完成项目级规范发现,或明确指定 \`.opencode/rules/\` 下要分析的文件名」,不得自行猜测或扫描目录。注意:仓库根目录下的 \`.rules/*.md\` 属于项目级规范,由 co-rule-project 负责,不在本代理分析范围内。
645
+
623
646
  ## 核心原则
624
647
 
625
648
  - **你只负责分配给你的文件**:不要在未指定的文件中搜索或推理
@@ -735,7 +758,7 @@ var PLANNER_PROMPT = `你是方案制定代理——负责任务分解和委派
735
758
 
736
759
  **约束**:只读,不修改文件。方案要具体到文件和操作粒度,不可笼统。`;
737
760
 
738
- // src/instructions/chinese.ts
761
+ // src/shared/chinese.ts
739
762
  var CHINESE_LANGUAGE_INSTRUCTION = `# 中文语言要求
740
763
 
741
764
  你必须始终使用中文进行思考、推理和回复。
@@ -744,25 +767,73 @@ var CHINESE_LANGUAGE_INSTRUCTION = `# 中文语言要求
744
767
  - 代码、技术术语、文件名、命令可以保留原样
745
768
  - 此规则对所有代理(Orchestrator 和所有子代理)生效
746
769
  - 跨所有项目生效,优先级高于项目级规则`;
770
+
771
+ // src/instructions/chinese.ts
747
772
  var PARALLEL_TOOL_INSTRUCTION = `# 并行工具调用要求
748
773
 
749
774
  你有能力在单次回复中发起多个工具调用。当需要获取多个相互独立的信息时,必须把这些工具调用合并在同一条消息中一次性并发发起,不要逐个串行等待。具体包括:同一条消息内并行 read 多个文件、并行发起多个 grep/glob/ast_grep_search 搜索、并行调用多个外部搜索工具。串行逐个调用是低效行为,必须避免。`;
750
775
 
751
776
  // src/task-manager/tracker.ts
752
- import * as fs from "node:fs";
753
777
  import * as os from "node:os";
778
+ import * as path2 from "node:path";
779
+
780
+ // src/shared/fs-atomic.ts
781
+ import * as fs from "node:fs";
754
782
  import * as path from "node:path";
755
- var DEFAULT_STATS_WINDOW = 50;
756
- var STATS_FILE = path.join(os.homedir(), ".local", "share", "opencode", "storage", "oh-my-opencode-cohub", "stats.json");
757
- var AGENT_DEFAULT_STRATEGY = {
758
- "co-fixer": "relevant",
759
- "co-designer": "relevant",
760
- "co-planner": "relevant",
761
- "co-oracle": "summary",
762
- "co-council": "summary"
783
+ function writeFileAtomic(filePath, data) {
784
+ const tmpPath = filePath + ".tmp";
785
+ const dir = path.dirname(filePath);
786
+ fs.mkdirSync(dir, { recursive: true });
787
+ try {
788
+ fs.writeFileSync(tmpPath, data, "utf-8");
789
+ fs.renameSync(tmpPath, filePath);
790
+ } catch (err) {
791
+ try {
792
+ fs.unlinkSync(tmpPath);
793
+ } catch {}
794
+ throw err;
795
+ } finally {
796
+ try {
797
+ if (fs.existsSync(tmpPath))
798
+ fs.unlinkSync(tmpPath);
799
+ } catch {}
800
+ }
801
+ }
802
+ function writeJsonFileAtomic(filePath, data) {
803
+ writeFileAtomic(filePath, JSON.stringify(data, null, 2));
804
+ }
805
+
806
+ // src/context/types.ts
807
+ var DEFAULT_CONTEXT_CONFIG = {
808
+ strategy: {
809
+ "co-explorer": "none",
810
+ "co-librarian": "none",
811
+ "co-observer": "none",
812
+ "co-fixer": "relevant",
813
+ "co-designer": "relevant",
814
+ "co-planner": "relevant",
815
+ "co-oracle": "summary",
816
+ "co-council": "summary",
817
+ "co-rule-user": "none",
818
+ "co-rule-project": "none",
819
+ "co-rule-app": "none"
820
+ },
821
+ maxFiles: 5,
822
+ maxDecisions: 10,
823
+ maxErrors: 5,
824
+ maxDependencies: 8,
825
+ dependencyPropagation: true,
826
+ summarizeMaxTokens: 2000,
827
+ relevantMessageWindow: 20,
828
+ maxErrorTotalChars: 600,
829
+ dependencyKeyOutputChars: 200
763
830
  };
831
+
832
+ // src/task-manager/tracker.ts
833
+ var DEFAULT_STATS_WINDOW = 50;
834
+ var STATS_FILE = path2.join(os.homedir(), ".local", "share", "opencode", "storage", "oh-my-opencode-cohub", "stats.json");
764
835
  function defaultStrategyFor(agent) {
765
- return AGENT_DEFAULT_STRATEGY[agent] ?? "none";
836
+ return DEFAULT_CONTEXT_CONFIG.strategy[agent] ?? "none";
766
837
  }
767
838
  function computeStats(records, window = DEFAULT_STATS_WINDOW) {
768
839
  const recent = records.slice(-Math.max(1, window));
@@ -1027,6 +1098,9 @@ class TaskTracker {
1027
1098
  }
1028
1099
  }
1029
1100
  finalize(job) {
1101
+ if (job.status !== "running") {
1102
+ job.terminalReconciled = true;
1103
+ }
1030
1104
  const latencyMs = Date.now() - job.createdAt;
1031
1105
  if (!job.quality) {
1032
1106
  job.quality = { latencyMs };
@@ -1050,8 +1124,7 @@ class TaskTracker {
1050
1124
  persistStats(window = DEFAULT_STATS_WINDOW) {
1051
1125
  try {
1052
1126
  const recent = this.collectStatsRecords().slice(-Math.max(1, window));
1053
- fs.mkdirSync(path.dirname(STATS_FILE), { recursive: true });
1054
- fs.writeFileSync(STATS_FILE, JSON.stringify(recent, null, 2), "utf8");
1127
+ writeJsonFileAtomic(STATS_FILE, recent);
1055
1128
  } catch {}
1056
1129
  }
1057
1130
  markCancelled(taskId) {
@@ -1244,35 +1317,44 @@ function isQualityEnabled(config) {
1244
1317
  return config?.enabled ?? DEFAULT_QUALITY_CONFIG.enabled;
1245
1318
  }
1246
1319
 
1247
- // src/context/types.ts
1248
- var DEFAULT_CONTEXT_CONFIG = {
1249
- strategy: {
1250
- "co-explorer": "none",
1251
- "co-librarian": "none",
1252
- "co-observer": "none",
1253
- "co-fixer": "relevant",
1254
- "co-designer": "relevant",
1255
- "co-planner": "relevant",
1256
- "co-oracle": "summary",
1257
- "co-council": "summary",
1258
- "co-rule-user": "none",
1259
- "co-rule-project": "none",
1260
- "co-rule-app": "none"
1261
- },
1262
- maxFiles: 5,
1263
- maxDecisions: 10,
1264
- maxErrors: 5,
1265
- maxDependencies: 8,
1266
- dependencyPropagation: true,
1267
- summarizeMaxTokens: 2000,
1268
- relevantMessageWindow: 20,
1269
- maxErrorTotalChars: 600,
1270
- dependencyKeyOutputChars: 200
1271
- };
1272
-
1273
1320
  // src/context/engine.ts
1274
1321
  import * as fs2 from "node:fs";
1275
1322
 
1323
+ // src/shared/token-estimator.ts
1324
+ function estimateTokens(text) {
1325
+ let used = 0;
1326
+ for (let i = 0;i < text.length; i++) {
1327
+ const code = text.charCodeAt(i);
1328
+ const isCjk = code >= 19968 && code <= 40959 || code >= 13312 && code <= 19903 || code >= 63744 && code <= 64255 || code >= 12352 && code <= 12543 || code >= 44032 && code <= 55215;
1329
+ used += isCjk ? 1 : 0.25;
1330
+ }
1331
+ return used;
1332
+ }
1333
+ function enforcePromptBudget(base, full, maxTokens) {
1334
+ if (estimateTokens(full) <= maxTokens)
1335
+ return full;
1336
+ return truncateByTokens(base, maxTokens);
1337
+ }
1338
+ function truncateByTokens(text, maxTokens) {
1339
+ if (!text || maxTokens <= 0)
1340
+ return "";
1341
+ if (estimateTokens(text) <= maxTokens)
1342
+ return text;
1343
+ let used = 0;
1344
+ let cut = text.length;
1345
+ for (let i = 0;i < text.length; i++) {
1346
+ const code = text.charCodeAt(i);
1347
+ const isCjk = code >= 19968 && code <= 40959 || code >= 13312 && code <= 19903 || code >= 63744 && code <= 64255 || code >= 12352 && code <= 12543 || code >= 44032 && code <= 55215;
1348
+ used += isCjk ? 1 : 0.25;
1349
+ if (used > maxTokens) {
1350
+ cut = i;
1351
+ break;
1352
+ }
1353
+ }
1354
+ return text.slice(0, cut) + `
1355
+ … [正文已按 token 预算截断]`;
1356
+ }
1357
+
1276
1358
  // src/context/extractor.ts
1277
1359
  var KNOWN_EXTENSIONS = new Set([
1278
1360
  "ts",
@@ -1460,39 +1542,6 @@ function extractErrors(messages, maxErrors, windowSize) {
1460
1542
  }
1461
1543
  return errors;
1462
1544
  }
1463
- function estimateTokens(text) {
1464
- let used = 0;
1465
- for (let i = 0;i < text.length; i++) {
1466
- const code = text.charCodeAt(i);
1467
- const isCjk = code >= 19968 && code <= 40959 || code >= 13312 && code <= 19903 || code >= 63744 && code <= 64255 || code >= 12352 && code <= 12543 || code >= 44032 && code <= 55215;
1468
- used += isCjk ? 1 : 0.25;
1469
- }
1470
- return used;
1471
- }
1472
- function enforcePromptBudget(base, full, maxTokens) {
1473
- if (estimateTokens(full) <= maxTokens)
1474
- return full;
1475
- return truncateByTokens(base, maxTokens);
1476
- }
1477
- function truncateByTokens(text, maxTokens) {
1478
- if (!text || maxTokens <= 0)
1479
- return "";
1480
- if (estimateTokens(text) <= maxTokens)
1481
- return text;
1482
- let used = 0;
1483
- let cut = text.length;
1484
- for (let i = 0;i < text.length; i++) {
1485
- const code = text.charCodeAt(i);
1486
- const isCjk = code >= 19968 && code <= 40959 || code >= 13312 && code <= 19903 || code >= 63744 && code <= 64255 || code >= 12352 && code <= 12543 || code >= 44032 && code <= 55215;
1487
- used += isCjk ? 1 : 0.25;
1488
- if (used > maxTokens) {
1489
- cut = i;
1490
- break;
1491
- }
1492
- }
1493
- return text.slice(0, cut) + `
1494
- … [正文已按 token 预算截断]`;
1495
- }
1496
1545
 
1497
1546
  // src/context/formatter.ts
1498
1547
  var CONTEXT_MARKER_PATTERN = /<!-- CONTEXT:ID=([a-f0-9-]+) -->/;
@@ -1556,6 +1605,7 @@ function replaceMarkerWithContext(messageText, context) {
1556
1605
  // src/context/engine.ts
1557
1606
  class ContextEngine {
1558
1607
  registry = new Map;
1608
+ registryTouchedAt = new Map;
1559
1609
  dependencyCache = new Map;
1560
1610
  inflight = new Map;
1561
1611
  client;
@@ -1577,6 +1627,7 @@ class ContextEngine {
1577
1627
  errors: [],
1578
1628
  dependencies: []
1579
1629
  });
1630
+ this.registryTouchedAt.set(contextId, Date.now());
1580
1631
  return contextId;
1581
1632
  }
1582
1633
  async fillContextAsync(contextId, parentSessionId, args) {
@@ -1586,6 +1637,7 @@ class ContextEngine {
1586
1637
  if (args.strategy === "none")
1587
1638
  return;
1588
1639
  context.strategy = args.strategy;
1640
+ this.touch(contextId);
1589
1641
  try {
1590
1642
  const windowSize = this.config.relevantMessageWindow;
1591
1643
  const cacheKey = `${parentSessionId}::${windowSize}`;
@@ -1619,7 +1671,9 @@ class ContextEngine {
1619
1671
  if (this.config.dependencyPropagation && this.dependencyCache.size > 0) {
1620
1672
  context.dependencies = Array.from(this.dependencyCache.values()).slice(-this.config.maxDependencies);
1621
1673
  }
1622
- } catch {}
1674
+ } catch {} finally {
1675
+ this.touch(contextId);
1676
+ }
1623
1677
  }
1624
1678
  attachTruncatedBodies(files) {
1625
1679
  if (files.length === 0)
@@ -1639,16 +1693,19 @@ class ContextEngine {
1639
1693
  const contextId = markerMatch[1];
1640
1694
  const context = this.registry.get(contextId);
1641
1695
  if (!context) {
1696
+ this.registryTouchedAt.delete(contextId);
1642
1697
  return messageText.replace(CONTEXT_MARKER_PATTERN, "");
1643
1698
  }
1644
1699
  const result = replaceMarkerWithContext(messageText, context);
1645
1700
  this.registry.delete(contextId);
1701
+ this.registryTouchedAt.delete(contextId);
1646
1702
  return result;
1647
1703
  }
1648
1704
  formatContextDetails(contextId) {
1649
1705
  const context = this.registry.get(contextId);
1650
1706
  if (!context)
1651
1707
  return "";
1708
+ this.touch(contextId);
1652
1709
  const parts = [];
1653
1710
  const isSummary = context.strategy === "summary";
1654
1711
  if (context.relevantFiles.length > 0) {
@@ -1760,18 +1817,46 @@ class ContextEngine {
1760
1817
  }
1761
1818
  }
1762
1819
  }
1820
+ touch(contextId) {
1821
+ if (this.registry.has(contextId))
1822
+ this.registryTouchedAt.set(contextId, Date.now());
1823
+ }
1824
+ releaseContext(contextId) {
1825
+ this.registry.delete(contextId);
1826
+ this.registryTouchedAt.delete(contextId);
1827
+ }
1828
+ cleanupStaleContexts(maxAgeMs = 20 * 60 * 1000) {
1829
+ const now = Date.now();
1830
+ let removed = 0;
1831
+ for (const contextId of this.registry.keys()) {
1832
+ const touchedAt = this.registryTouchedAt.get(contextId);
1833
+ if (touchedAt === undefined) {
1834
+ this.registryTouchedAt.set(contextId, now);
1835
+ continue;
1836
+ }
1837
+ if (now - touchedAt > maxAgeMs) {
1838
+ this.registry.delete(contextId);
1839
+ this.registryTouchedAt.delete(contextId);
1840
+ removed++;
1841
+ }
1842
+ }
1843
+ return removed;
1844
+ }
1845
+ get registrySize() {
1846
+ return this.registry.size;
1847
+ }
1763
1848
  }
1764
1849
 
1765
1850
  // src/config/loader.ts
1766
1851
  import * as fs3 from "node:fs";
1767
- import * as path2 from "node:path";
1852
+ import * as path3 from "node:path";
1768
1853
  import * as os2 from "node:os";
1769
- var USER_CONFIG_PATH = path2.join(os2.homedir(), ".config", "opencode", "oh-my-opencode-cohub.json");
1854
+ var USER_CONFIG_PATH = path3.join(os2.homedir(), ".config", "opencode", "oh-my-opencode-cohub.json");
1770
1855
  function loadCoHubConfig(projectDir) {
1771
1856
  const base = readJSON(USER_CONFIG_PATH);
1772
1857
  if (!projectDir)
1773
1858
  return base;
1774
- const projectPath = path2.join(projectDir, ".opencode", "oh-my-opencode-cohub.json");
1859
+ const projectPath = path3.join(projectDir, ".opencode", "oh-my-opencode-cohub.json");
1775
1860
  const project = readJSON(projectPath);
1776
1861
  if (!project || Object.keys(project).length === 0)
1777
1862
  return base;
@@ -1788,6 +1873,9 @@ function loadCoHubConfig(projectDir) {
1788
1873
  if (project.quality) {
1789
1874
  merged.quality = { ...base.quality, ...project.quality };
1790
1875
  }
1876
+ if (project.diag) {
1877
+ merged.diag = { ...base.diag, ...project.diag };
1878
+ }
1791
1879
  return merged;
1792
1880
  }
1793
1881
  function readJSON(filePath) {
@@ -1801,56 +1889,152 @@ function readJSON(filePath) {
1801
1889
  }
1802
1890
  }
1803
1891
 
1892
+ // src/context/diag-log.ts
1893
+ import * as fsp from "node:fs/promises";
1894
+ import * as os3 from "node:os";
1895
+ import * as path4 from "node:path";
1896
+ var MAX_BYTES = 256 * 1024;
1897
+ var KEEP_LINES = 30;
1898
+ var TAIL_WINDOW = 64 * 1024;
1899
+ var DEFAULT_CTX_DIAG_CONFIG = {
1900
+ enabled: false
1901
+ };
1902
+ function isCtxDiagEnabled(config, env) {
1903
+ const flag = env ?? process.env.COHUB_CTX_DIAG;
1904
+ if (flag !== undefined) {
1905
+ const v = String(flag).trim().toLowerCase();
1906
+ if (v === "1" || v === "true" || v === "yes" || v === "on")
1907
+ return true;
1908
+ if (v === "0" || v === "false" || v === "no" || v === "off")
1909
+ return false;
1910
+ }
1911
+ return config?.enabled ?? DEFAULT_CTX_DIAG_CONFIG.enabled;
1912
+ }
1913
+ function resolveCtxDiagPath(config) {
1914
+ const custom = config?.path;
1915
+ if (typeof custom === "string" && custom.trim() !== "")
1916
+ return custom;
1917
+ return path4.join(os3.tmpdir(), "opencode", "ctx-diag.log");
1918
+ }
1919
+ function resolveCtxDiagSettings(config, env) {
1920
+ return {
1921
+ enabled: isCtxDiagEnabled(config, env?.COHUB_CTX_DIAG),
1922
+ filePath: resolveCtxDiagPath(config)
1923
+ };
1924
+ }
1925
+ var queue = Promise.resolve();
1926
+ var bytesSinceCompact = 0;
1927
+ async function compactTail(filePath, keepLines) {
1928
+ const fh = await fsp.open(filePath, "r");
1929
+ try {
1930
+ const { size } = await fh.stat();
1931
+ if (size === 0)
1932
+ return 0;
1933
+ const start = Math.max(0, size - TAIL_WINDOW);
1934
+ const len = size - start;
1935
+ const buf = Buffer.allocUnsafe(len);
1936
+ let filled = 0;
1937
+ let offset = start;
1938
+ while (filled < len) {
1939
+ const { bytesRead } = await fh.read(buf, filled, len - filled, offset);
1940
+ if (bytesRead === 0)
1941
+ break;
1942
+ filled += bytesRead;
1943
+ offset += bytesRead;
1944
+ }
1945
+ const lines = buf.subarray(0, filled).toString("utf-8").split(`
1946
+ `).filter((l) => l.trim() !== "");
1947
+ if (start > 0 && lines.length > 0)
1948
+ lines.shift();
1949
+ const kept = lines.slice(-keepLines);
1950
+ const text = kept.length > 0 ? kept.join(`
1951
+ `) + `
1952
+ ` : "";
1953
+ await fsp.writeFile(filePath, text, { encoding: "utf-8", mode: 384 });
1954
+ return Buffer.byteLength(text, "utf-8");
1955
+ } finally {
1956
+ await fh.close();
1957
+ }
1958
+ }
1959
+ function appendCtxDiag(record, filePath) {
1960
+ const line = JSON.stringify(record) + `
1961
+ `;
1962
+ bytesSinceCompact += Buffer.byteLength(line, "utf-8");
1963
+ const shouldCompact = bytesSinceCompact > MAX_BYTES;
1964
+ if (shouldCompact)
1965
+ bytesSinceCompact = 0;
1966
+ queue = queue.then(async () => {
1967
+ try {
1968
+ await fsp.mkdir(path4.dirname(filePath), { recursive: true });
1969
+ if (shouldCompact)
1970
+ await compactTail(filePath, KEEP_LINES);
1971
+ await fsp.appendFile(filePath, line, { encoding: "utf-8", mode: 384 });
1972
+ } catch {}
1973
+ });
1974
+ }
1975
+
1804
1976
  // src/tools/council.ts
1805
1977
  import { tool } from "@opencode-ai/plugin";
1806
1978
 
1807
1979
  // src/utils/log.ts
1808
- import * as fs4 from "fs/promises";
1809
- import * as path3 from "path";
1810
- import * as os3 from "os";
1811
- var LOG_DIR = path3.join(os3.homedir(), ".local", "share", "opencode", "log");
1812
- var LOG_PREFIX = "oh-my-opencode-cohub";
1813
- var KEEP_DAYS = 7;
1814
- var _today = "";
1815
- var _logPath = "";
1816
- var _cleanedToday = false;
1817
- function getLogPath() {
1818
- const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
1819
- if (today !== _today) {
1820
- _today = today;
1821
- _logPath = path3.join(LOG_DIR, `${LOG_PREFIX}.${today}.log`);
1822
- _cleanedToday = false;
1823
- }
1824
- return _logPath;
1825
- }
1826
- async function cleanOldLogsOnce() {
1827
- if (_cleanedToday)
1828
- return;
1829
- _cleanedToday = true;
1830
- try {
1831
- const cutoff = new Date;
1832
- cutoff.setUTCDate(cutoff.getUTCDate() - KEEP_DAYS);
1833
- const cutoffStr = cutoff.toISOString().slice(0, 10).replace(/-/g, "");
1834
- const files = await fs4.readdir(LOG_DIR);
1835
- for (const file of files) {
1836
- if (!file.startsWith(LOG_PREFIX + "."))
1837
- continue;
1838
- const escapedPrefix = LOG_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1839
- const match = file.match(new RegExp(`^${escapedPrefix}\\.(\\d{8})\\.log$`));
1840
- if (!match)
1841
- continue;
1842
- if (match[1] < cutoffStr) {
1843
- await fs4.unlink(path3.join(LOG_DIR, file)).catch(() => {});
1980
+ import * as os4 from "os";
1981
+ import * as path6 from "path";
1982
+
1983
+ // src/shared/log.ts
1984
+ import * as fs4 from "node:fs";
1985
+ import * as fsp2 from "node:fs/promises";
1986
+ import * as path5 from "node:path";
1987
+ function createLogger(cfg) {
1988
+ const logDir = cfg.logDir;
1989
+ const prefix = cfg.prefix;
1990
+ const linePrefix = cfg.linePrefix ?? cfg.prefix;
1991
+ const dailyRotate = cfg.dailyRotate ?? true;
1992
+ const keepDays = cfg.keepDays ?? 7;
1993
+ const useAsync = cfg.async ?? true;
1994
+ let today = "";
1995
+ let logPath = "";
1996
+ let cleanedToday = false;
1997
+ function getLogPath() {
1998
+ if (!dailyRotate) {
1999
+ return path5.join(logDir, `${prefix}.log`);
2000
+ }
2001
+ const d = new Date().toISOString().slice(0, 10).replace(/-/g, "");
2002
+ if (d !== today) {
2003
+ today = d;
2004
+ logPath = path5.join(logDir, `${prefix}.${d}.log`);
2005
+ cleanedToday = false;
2006
+ }
2007
+ return logPath;
2008
+ }
2009
+ function cleanOldLogsOnce() {
2010
+ if (!dailyRotate)
2011
+ return;
2012
+ getLogPath();
2013
+ if (cleanedToday)
2014
+ return;
2015
+ cleanedToday = true;
2016
+ try {
2017
+ const cutoff = new Date;
2018
+ cutoff.setUTCDate(cutoff.getUTCDate() - keepDays);
2019
+ const cutoffStr = cutoff.toISOString().slice(0, 10).replace(/-/g, "");
2020
+ const files = fs4.readdirSync(logDir);
2021
+ const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2022
+ const re = new RegExp(`^${escaped}\\.(\\d{8})\\.log$`);
2023
+ for (const file of files) {
2024
+ const match = file.match(re);
2025
+ if (!match)
2026
+ continue;
2027
+ if (match[1] < cutoffStr) {
2028
+ try {
2029
+ fs4.unlinkSync(path5.join(logDir, file));
2030
+ } catch {}
2031
+ }
1844
2032
  }
1845
- }
1846
- } catch {}
1847
- }
1848
- async function appendLog(tag, message, err) {
1849
- try {
1850
- const logPath = getLogPath();
1851
- await fs4.mkdir(LOG_DIR, { recursive: true });
2033
+ } catch {}
2034
+ }
2035
+ function formatLine(tag, message, err) {
1852
2036
  const now = new Date().toISOString();
1853
- let line = `[${now}] [oh-my-opencode-cohub] [${tag}] ${message}`;
2037
+ let line = `[${now}] [${linePrefix}] [${tag}] ${message}`;
1854
2038
  if (err instanceof Error) {
1855
2039
  line += `
1856
2040
  stack: ${err.stack ?? err.message}`;
@@ -1860,11 +2044,45 @@ async function appendLog(tag, message, err) {
1860
2044
  }
1861
2045
  line += `
1862
2046
  `;
1863
- await fs4.appendFile(logPath, line, "utf-8");
1864
- await cleanOldLogsOnce();
1865
- } catch {}
2047
+ return line;
2048
+ }
2049
+ if (useAsync) {
2050
+ async function appendLogAsync(tag, message, err) {
2051
+ try {
2052
+ const lp = getLogPath();
2053
+ await fsp2.mkdir(logDir, { recursive: true });
2054
+ const line = formatLine(tag, message, err);
2055
+ await fsp2.appendFile(lp, line, "utf-8");
2056
+ if (dailyRotate)
2057
+ cleanOldLogsOnce();
2058
+ } catch {}
2059
+ }
2060
+ return appendLogAsync;
2061
+ }
2062
+ function appendLogSync(tag, message, err) {
2063
+ try {
2064
+ const lp = getLogPath();
2065
+ fs4.mkdirSync(logDir, { recursive: true });
2066
+ const line = formatLine(tag, message, err);
2067
+ fs4.appendFileSync(lp, line, "utf-8");
2068
+ if (dailyRotate)
2069
+ cleanOldLogsOnce();
2070
+ } catch {}
2071
+ }
2072
+ return appendLogSync;
1866
2073
  }
1867
2074
 
2075
+ // src/utils/log.ts
2076
+ var LOG_DIR = path6.join(os4.homedir(), ".local", "share", "opencode", "log");
2077
+ var appendLog = createLogger({
2078
+ logDir: LOG_DIR,
2079
+ prefix: "oh-my-opencode-cohub",
2080
+ linePrefix: "oh-my-opencode-cohub",
2081
+ dailyRotate: true,
2082
+ keepDays: 7,
2083
+ async: true
2084
+ });
2085
+
1868
2086
  // src/tools/model-stats.ts
1869
2087
  var DEFAULT_MODEL_STATS_CONFIG = {
1870
2088
  windowSize: 20,
@@ -2004,186 +2222,18 @@ function getAdaptiveParams(key, cfg = config2) {
2004
2222
  return { ...st.current };
2005
2223
  }
2006
2224
 
2007
- // src/tools/council.ts
2008
- var z = tool.schema;
2225
+ // src/shared/model.ts
2226
+ function shortModelLabel(model) {
2227
+ return model.split("/").pop() ?? model;
2228
+ }
2229
+
2230
+ // src/shared/consensus.ts
2009
2231
  var RETRY_LIMITS = {
2010
2232
  empty: 3,
2011
2233
  timeout: 1,
2012
2234
  error: 0,
2013
2235
  "quality-low": 1
2014
2236
  };
2015
-
2016
- class OperationTimeoutError extends Error {
2017
- constructor(message) {
2018
- super(message);
2019
- this.name = "OperationTimeoutError";
2020
- }
2021
- }
2022
- function isTextPart(part) {
2023
- return (part.type === "text" || part.type === "reasoning") && typeof part.text === "string";
2024
- }
2025
- function shortModelLabel(model) {
2026
- return model.split("/").pop() ?? model;
2027
- }
2028
- function parseModelReference(model) {
2029
- const slashIndex = model.indexOf("/");
2030
- if (slashIndex <= 0 || slashIndex >= model.length - 1)
2031
- return null;
2032
- return {
2033
- providerID: model.slice(0, slashIndex),
2034
- modelID: model.slice(slashIndex + 1)
2035
- };
2036
- }
2037
- async function abortSession(client, sessionId) {
2038
- try {
2039
- await client.session.abort({ path: { id: sessionId } });
2040
- } catch (err) {
2041
- appendLog("abortSession", "中止会话失败", err);
2042
- }
2043
- }
2044
- async function promptWithTimeout(client, path, body, timeoutMs, directory) {
2045
- const sessionId = path.id;
2046
- let timer;
2047
- try {
2048
- const promptPromise = client.session.prompt({
2049
- path,
2050
- body,
2051
- query: directory ? { directory } : undefined
2052
- });
2053
- promptPromise.catch(() => {});
2054
- const racers = [promptPromise];
2055
- if (timeoutMs > 0) {
2056
- racers.push(new Promise((_, reject) => {
2057
- timer = setTimeout(() => {
2058
- reject(new OperationTimeoutError(`[oh-my-opencode-cohub] Prompt timed out after ${timeoutMs}ms`));
2059
- }, timeoutMs);
2060
- }));
2061
- }
2062
- await Promise.race(racers);
2063
- } catch (error) {
2064
- if (error instanceof OperationTimeoutError) {
2065
- await abortSession(client, sessionId);
2066
- }
2067
- throw error;
2068
- } finally {
2069
- if (timer !== undefined)
2070
- clearTimeout(timer);
2071
- }
2072
- }
2073
- async function extractSessionResult(client, sessionId, options) {
2074
- const includeReasoning = options?.includeReasoning ?? true;
2075
- const lastTextOnly = options?.lastTextOnly === true;
2076
- const messagesResult = await client.session.messages({
2077
- path: { id: sessionId }
2078
- });
2079
- const messages = messagesResult.data ?? [];
2080
- const assistantMessages = messages.filter((m) => m.info?.role === "assistant");
2081
- const extractedContent = [];
2082
- for (const message of assistantMessages) {
2083
- for (const part of message.parts ?? []) {
2084
- if (!isTextPart(part))
2085
- continue;
2086
- const allowed = includeReasoning || part.type === "text";
2087
- if (allowed && part.text) {
2088
- extractedContent.push(part.text);
2089
- }
2090
- }
2091
- }
2092
- const texts = extractedContent.filter((t) => t.length > 0);
2093
- const text = lastTextOnly ? texts.length > 0 ? texts[texts.length - 1] : "" : texts.join(`
2094
-
2095
- `);
2096
- return { text, empty: text.length === 0 };
2097
- }
2098
- function formatCouncillorPrompt(userPrompt, councillorPrompt) {
2099
- if (!councillorPrompt)
2100
- return userPrompt;
2101
- return `${councillorPrompt}
2102
-
2103
- ---
2104
-
2105
- ${userPrompt}`;
2106
- }
2107
- function formatCouncillorResults(originalPrompt, councillorResults) {
2108
- const completedWithResults = councillorResults.filter((cr) => cr.status === "completed" && cr.result);
2109
- const councillorSection = completedWithResults.map((cr) => {
2110
- const shortModel = shortModelLabel(cr.model);
2111
- return `**${cr.name}** (${shortModel}):
2112
- ${cr.result}`;
2113
- }).join(`
2114
-
2115
- `);
2116
- const failedEntries = councillorResults.filter((cr) => cr.status !== "completed");
2117
- const failedSection = failedEntries.map((cr) => `**${cr.name}**: ${cr.status} — ${cr.error ?? "Unknown"}`).join(`
2118
- `);
2119
- if (completedWithResults.length === 0) {
2120
- const errorDetails = councillorResults.map((cr) => `**${cr.name}** (${shortModelLabel(cr.model)}): ${cr.status} — ${cr.error ?? "Unknown"}`).join(`
2121
- `);
2122
- return [
2123
- "---",
2124
- "",
2125
- "**Original Prompt**:",
2126
- originalPrompt,
2127
- "",
2128
- "---",
2129
- "",
2130
- "**Councillor Responses**:",
2131
- "All councillors failed to produce output:",
2132
- errorDetails,
2133
- "",
2134
- "Please generate a response based on the original prompt alone."
2135
- ].join(`
2136
- `);
2137
- }
2138
- const parts = [
2139
- "---",
2140
- "",
2141
- "**Original Prompt**:",
2142
- originalPrompt,
2143
- "",
2144
- "---",
2145
- "",
2146
- "**Councillor Responses**:",
2147
- councillorSection
2148
- ];
2149
- if (failedSection) {
2150
- parts.push("", "---", "", "**Failed/Timed-out Councillors**:", failedSection);
2151
- }
2152
- parts.push("", "---", "", "You MUST follow the Synthesis Process steps before producing output: " + "review each councillor response individually, then produce the required output " + "with a synthesized Council Response, per-councillor details using their exact names, " + "and a Council Summary with consensus confidence rating (unanimous, majority, or split).");
2153
- return parts.join(`
2154
- `);
2155
- }
2156
- function buildDeliberationPrompt(originalPrompt, previousRound, currentName) {
2157
- const others = previousRound.filter((cr) => cr.status === "completed" && cr.result && cr.name !== currentName).map((cr) => `**${cr.name}** (${shortModelLabel(cr.model)}):
2158
- ${cr.result}`).join(`
2159
-
2160
- `);
2161
- if (!others)
2162
- return originalPrompt;
2163
- return [
2164
- originalPrompt,
2165
- "",
2166
- "---",
2167
- "",
2168
- "以下是其他成员第一轮意见,请回应并修正或坚持你的立场:",
2169
- "",
2170
- others
2171
- ].join(`
2172
- `);
2173
- }
2174
- function formatModelComposition(councillorResults) {
2175
- return councillorResults.map((cr) => `${cr.name}: ${shortModelLabel(cr.model)}`).join(", ");
2176
- }
2177
- function adaptiveKey(config) {
2178
- return `${config.variant ?? "default"}\x00${config.model}`;
2179
- }
2180
- function classifyFailure(message) {
2181
- if (message.includes("Empty response from provider"))
2182
- return "empty";
2183
- if (message.includes("timed out") || message.includes("OperationTimeoutError"))
2184
- return "timeout";
2185
- return "error";
2186
- }
2187
2237
  var CONCLUSION_MARKERS = [
2188
2238
  "综上",
2189
2239
  "结论",
@@ -2219,15 +2269,6 @@ var ENTITY_MARKERS = [
2219
2269
  "recommendation",
2220
2270
  "key entity"
2221
2271
  ];
2222
- function extractCouncillorSummary(text) {
2223
- const sentences = text.split(/(?<=[。!?!?;;])\s*|\n+/).map((s) => s.trim()).filter((s) => s.length > 0);
2224
- const conclusions = sentences.filter((s) => CONCLUSION_MARKERS.some((m) => s.toLowerCase().includes(m)));
2225
- const entities = sentences.filter((s) => ENTITY_MARKERS.some((m) => s.toLowerCase().includes(m)));
2226
- return {
2227
- conclusions: conclusions.length > 0 ? conclusions : sentences.length > 0 ? [sentences[sentences.length - 1]] : [],
2228
- entities
2229
- };
2230
- }
2231
2272
  var STOPWORDS = new Set([
2232
2273
  "the",
2233
2274
  "a",
@@ -2296,6 +2337,7 @@ var STOPWORDS = new Set([
2296
2337
  "entity",
2297
2338
  "error"
2298
2339
  ]);
2340
+ var VARIANT_ORDER = ["max", "high", "medium", "low"];
2299
2341
  function tokenize(text) {
2300
2342
  return text.toLowerCase().split(/[^a-z0-9\u4e00-\u9fff]+/).filter((t) => t.length > 0 && !STOPWORDS.has(t));
2301
2343
  }
@@ -2308,6 +2350,77 @@ function jaccard(x, y) {
2308
2350
  const union = new Set([...xs, ...ys]).size;
2309
2351
  return union === 0 ? 0 : inter / union;
2310
2352
  }
2353
+ function downgradeVariant(variant) {
2354
+ if (!variant)
2355
+ return;
2356
+ const idx = VARIANT_ORDER.indexOf(variant);
2357
+ if (idx < 0 || idx >= VARIANT_ORDER.length - 1)
2358
+ return;
2359
+ return VARIANT_ORDER[idx + 1];
2360
+ }
2361
+ function parseModelReference(model) {
2362
+ const slashIndex = model.indexOf("/");
2363
+ if (slashIndex <= 0 || slashIndex >= model.length - 1)
2364
+ return null;
2365
+ return {
2366
+ providerID: model.slice(0, slashIndex),
2367
+ modelID: model.slice(slashIndex + 1)
2368
+ };
2369
+ }
2370
+ function formatModelComposition(councillorResults) {
2371
+ return councillorResults.map((cr) => `${cr.name}: ${shortModelLabel(cr.model)}`).join(", ");
2372
+ }
2373
+ function adaptiveKey(config) {
2374
+ return `${config.variant ?? "default"}\x00${config.model}`;
2375
+ }
2376
+ function formatCouncillorPrompt(userPrompt, councillorPrompt) {
2377
+ if (!councillorPrompt)
2378
+ return userPrompt;
2379
+ return `${councillorPrompt}
2380
+
2381
+ ---
2382
+
2383
+ ${userPrompt}`;
2384
+ }
2385
+
2386
+ class RetryBudget {
2387
+ retriesLeft;
2388
+ maxElapsedMs;
2389
+ startedAt;
2390
+ constructor(maxTotalRetries, maxElapsedMs, startedAt = Date.now()) {
2391
+ this.retriesLeft = Math.max(0, maxTotalRetries);
2392
+ this.maxElapsedMs = maxElapsedMs;
2393
+ this.startedAt = startedAt;
2394
+ }
2395
+ elapsedMs() {
2396
+ return Date.now() - this.startedAt;
2397
+ }
2398
+ expired() {
2399
+ return this.elapsedMs() >= this.maxElapsedMs;
2400
+ }
2401
+ consumeRetry() {
2402
+ if (this.retriesLeft <= 0 || this.expired())
2403
+ return false;
2404
+ this.retriesLeft -= 1;
2405
+ return true;
2406
+ }
2407
+ }
2408
+ function classifyFailure(message) {
2409
+ if (message.includes("Empty response from provider"))
2410
+ return "empty";
2411
+ if (message.includes("timed out") || message.includes("OperationTimeoutError"))
2412
+ return "timeout";
2413
+ return "error";
2414
+ }
2415
+ function extractCouncillorSummary(text) {
2416
+ const sentences = text.split(/(?<=[。!?!?;;])\s*|\n+/).map((s) => s.trim()).filter((s) => s.length > 0);
2417
+ const conclusions = sentences.filter((s) => CONCLUSION_MARKERS.some((m) => s.toLowerCase().includes(m)));
2418
+ const entities = sentences.filter((s) => ENTITY_MARKERS.some((m) => s.toLowerCase().includes(m)));
2419
+ return {
2420
+ conclusions: conclusions.length > 0 ? conclusions : sentences.length > 0 ? [sentences[sentences.length - 1]] : [],
2421
+ entities
2422
+ };
2423
+ }
2311
2424
  function pairwiseAgreement(a, b) {
2312
2425
  const aConcl = tokenize(a.conclusions.join(" "));
2313
2426
  const bConcl = tokenize(b.conclusions.join(" "));
@@ -2347,15 +2460,6 @@ function decideRetryAction(category, consecutiveStreak, categoryRetriesUsed, bud
2347
2460
  return "give-up";
2348
2461
  return "retry";
2349
2462
  }
2350
- var VARIANT_ORDER = ["max", "high", "medium", "low"];
2351
- function downgradeVariant(variant) {
2352
- if (!variant)
2353
- return;
2354
- const idx = VARIANT_ORDER.indexOf(variant);
2355
- if (idx < 0 || idx >= VARIANT_ORDER.length - 1)
2356
- return;
2357
- return VARIANT_ORDER[idx + 1];
2358
- }
2359
2463
  function appendRetryContext(prompt, category) {
2360
2464
  if (category === "timeout") {
2361
2465
  return `${prompt}
@@ -2369,29 +2473,151 @@ function appendRetryContext(prompt, category) {
2369
2473
  }
2370
2474
  return prompt;
2371
2475
  }
2476
+ function formatCouncillorResults(originalPrompt, councillorResults) {
2477
+ const completedWithResults = councillorResults.filter((cr) => cr.status === "completed" && cr.result);
2478
+ const councillorSection = completedWithResults.map((cr) => {
2479
+ const shortModel = shortModelLabel(cr.model);
2480
+ return `**${cr.name}** (${shortModel}):
2481
+ ${cr.result}`;
2482
+ }).join(`
2372
2483
 
2373
- class RetryBudget {
2374
- retriesLeft;
2375
- maxElapsedMs;
2376
- startedAt;
2377
- constructor(maxTotalRetries, maxElapsedMs, startedAt = Date.now()) {
2378
- this.retriesLeft = Math.max(0, maxTotalRetries);
2379
- this.maxElapsedMs = maxElapsedMs;
2380
- this.startedAt = startedAt;
2484
+ `);
2485
+ const failedEntries = councillorResults.filter((cr) => cr.status !== "completed" || !cr.result);
2486
+ const failedSection = failedEntries.map((cr) => `**${cr.name}**: ${cr.status} — ${cr.error ?? "Unknown"}`).join(`
2487
+ `);
2488
+ if (completedWithResults.length === 0) {
2489
+ const errorDetails = councillorResults.map((cr) => `**${cr.name}** (${shortModelLabel(cr.model)}): ${cr.status} — ${cr.error ?? "Unknown"}`).join(`
2490
+ `);
2491
+ return [
2492
+ "---",
2493
+ "",
2494
+ "**Original Prompt**:",
2495
+ originalPrompt,
2496
+ "",
2497
+ "---",
2498
+ "",
2499
+ "**Councillor Responses**:",
2500
+ "All councillors failed to produce output:",
2501
+ errorDetails,
2502
+ "",
2503
+ "Please generate a response based on the original prompt alone."
2504
+ ].join(`
2505
+ `);
2381
2506
  }
2382
- elapsedMs() {
2383
- return Date.now() - this.startedAt;
2507
+ const parts = [
2508
+ "---",
2509
+ "",
2510
+ "**Original Prompt**:",
2511
+ originalPrompt,
2512
+ "",
2513
+ "---",
2514
+ "",
2515
+ "**Councillor Responses**:",
2516
+ councillorSection
2517
+ ];
2518
+ if (failedSection) {
2519
+ parts.push("", "---", "", "**Failed/Timed-out Councillors**:", failedSection);
2384
2520
  }
2385
- expired() {
2386
- return this.elapsedMs() >= this.maxElapsedMs;
2521
+ parts.push("", "---", "", "You MUST follow the Synthesis Process steps before producing output: " + "review each councillor response individually, then produce the required output " + "with a synthesized Council Response, per-councillor details using their exact names, " + "and a Council Summary with consensus confidence rating (unanimous, majority, or split).");
2522
+ return parts.join(`
2523
+ `);
2524
+ }
2525
+ function buildDeliberationPrompt(originalPrompt, previousRound, currentName) {
2526
+ const others = previousRound.filter((cr) => cr.status === "completed" && cr.result && cr.name !== currentName).map((cr) => `**${cr.name}** (${shortModelLabel(cr.model)}):
2527
+ ${cr.result}`).join(`
2528
+
2529
+ `);
2530
+ if (!others)
2531
+ return originalPrompt;
2532
+ return [
2533
+ originalPrompt,
2534
+ "",
2535
+ "---",
2536
+ "",
2537
+ "以下是其他成员第一轮意见,请回应并修正或坚持你的立场:",
2538
+ "",
2539
+ others
2540
+ ].join(`
2541
+ `);
2542
+ }
2543
+
2544
+ // src/shared/constants.ts
2545
+ var COUNCIL_TIMEOUT_MS = 180000;
2546
+ var STALE_TASK_MS = 30 * 60 * 1000;
2547
+
2548
+ // src/tools/council.ts
2549
+ var z = tool.schema;
2550
+
2551
+ class OperationTimeoutError extends Error {
2552
+ constructor(message) {
2553
+ super(message);
2554
+ this.name = "OperationTimeoutError";
2387
2555
  }
2388
- consumeRetry() {
2389
- if (this.retriesLeft <= 0 || this.expired())
2390
- return false;
2391
- this.retriesLeft -= 1;
2392
- return true;
2556
+ }
2557
+ function isTextPart(part) {
2558
+ return (part.type === "text" || part.type === "reasoning") && typeof part.text === "string";
2559
+ }
2560
+ async function abortSession(client, sessionId) {
2561
+ try {
2562
+ await client.session.abort({ path: { id: sessionId } });
2563
+ } catch (err) {
2564
+ appendLog("abortSession", "中止会话失败", err);
2393
2565
  }
2394
2566
  }
2567
+ async function promptWithTimeout(client, path, body, timeoutMs, directory) {
2568
+ const sessionId = path.id;
2569
+ let timer;
2570
+ try {
2571
+ const promptPromise = client.session.prompt({
2572
+ path,
2573
+ body,
2574
+ query: directory ? { directory } : undefined
2575
+ });
2576
+ promptPromise.catch(() => {});
2577
+ const racers = [promptPromise];
2578
+ if (timeoutMs > 0) {
2579
+ racers.push(new Promise((_, reject) => {
2580
+ timer = setTimeout(() => {
2581
+ reject(new OperationTimeoutError(`[oh-my-opencode-cohub] Prompt timed out after ${timeoutMs}ms`));
2582
+ }, timeoutMs);
2583
+ }));
2584
+ }
2585
+ await Promise.race(racers);
2586
+ } catch (error) {
2587
+ if (error instanceof OperationTimeoutError) {
2588
+ await abortSession(client, sessionId);
2589
+ }
2590
+ throw error;
2591
+ } finally {
2592
+ if (timer !== undefined)
2593
+ clearTimeout(timer);
2594
+ }
2595
+ }
2596
+ async function extractSessionResult(client, sessionId, options) {
2597
+ const includeReasoning = options?.includeReasoning ?? true;
2598
+ const lastTextOnly = options?.lastTextOnly === true;
2599
+ const messagesResult = await client.session.messages({
2600
+ path: { id: sessionId }
2601
+ });
2602
+ const messages = messagesResult.data ?? [];
2603
+ const assistantMessages = messages.filter((m) => m.info?.role === "assistant");
2604
+ const extractedContent = [];
2605
+ for (const message of assistantMessages) {
2606
+ for (const part of message.parts ?? []) {
2607
+ if (!isTextPart(part))
2608
+ continue;
2609
+ const allowed = includeReasoning || part.type === "text";
2610
+ if (allowed && part.text) {
2611
+ extractedContent.push(part.text);
2612
+ }
2613
+ }
2614
+ }
2615
+ const texts = extractedContent.filter((t) => t.length > 0);
2616
+ const text = lastTextOnly ? texts.length > 0 ? texts[texts.length - 1] : "" : texts.join(`
2617
+
2618
+ `);
2619
+ return { text, empty: text.length === 0 };
2620
+ }
2395
2621
 
2396
2622
  class CouncilManager {
2397
2623
  client;
@@ -2420,7 +2646,7 @@ class CouncilManager {
2420
2646
  councillorResults: []
2421
2647
  };
2422
2648
  }
2423
- const timeout = this.config.timeout ?? 180000;
2649
+ const timeout = this.config.timeout ?? COUNCIL_TIMEOUT_MS;
2424
2650
  const executionMode = this.config.councillor_execution_mode ?? "parallel";
2425
2651
  const maxRounds = this.config.max_rounds ?? 2;
2426
2652
  const consensusThreshold = this.config.consensus_threshold ?? 0.6;
@@ -2720,8 +2946,8 @@ function createCloseJobTool(ctx, tracker, onClosed) {
2720
2946
 
2721
2947
  // src/index.ts
2722
2948
  import * as fs5 from "node:fs";
2723
- import * as path4 from "node:path";
2724
- import * as os4 from "node:os";
2949
+ import * as path7 from "node:path";
2950
+ import * as os5 from "node:os";
2725
2951
 
2726
2952
  // src/orchestration/types.ts
2727
2953
  var VALID_TRANSITIONS = {
@@ -3102,12 +3328,12 @@ function loadFileOverrides(projectDir) {
3102
3328
  ];
3103
3329
  const searchDirs = [];
3104
3330
  if (projectDir) {
3105
- searchDirs.push(path4.join(projectDir, ".opencode", "oh-my-opencode-cohub"));
3331
+ searchDirs.push(path7.join(projectDir, ".opencode", "oh-my-opencode-cohub"));
3106
3332
  }
3107
- searchDirs.push(path4.join(os4.homedir(), ".config", "opencode", "oh-my-opencode-cohub"));
3333
+ searchDirs.push(path7.join(os5.homedir(), ".config", "opencode", "oh-my-opencode-cohub"));
3108
3334
  for (const agent of agentNames) {
3109
3335
  for (const dir of searchDirs) {
3110
- const replacePath = path4.join(dir, `${agent}.md`);
3336
+ const replacePath = path7.join(dir, `${agent}.md`);
3111
3337
  if (!overrides[agent]?.replace && fs5.existsSync(replacePath)) {
3112
3338
  try {
3113
3339
  overrides[agent] = { ...overrides[agent], replace: fs5.readFileSync(replacePath, "utf-8") };
@@ -3115,7 +3341,7 @@ function loadFileOverrides(projectDir) {
3115
3341
  appendLog("loadFileOverrides", "读取replace文件失败", err);
3116
3342
  }
3117
3343
  }
3118
- const appendPath = path4.join(dir, `${agent}_append.md`);
3344
+ const appendPath = path7.join(dir, `${agent}_append.md`);
3119
3345
  if (!overrides[agent]?.append && fs5.existsSync(appendPath)) {
3120
3346
  try {
3121
3347
  overrides[agent] = { ...overrides[agent], append: fs5.readFileSync(appendPath, "utf-8") };
@@ -3151,27 +3377,21 @@ var CoHubPlugin = async (input, options) => {
3151
3377
  const tracker = new TaskTracker;
3152
3378
  const orchestrationLayer = createOrchestrationLayer(tracker, {});
3153
3379
  let _lastAlias = "";
3154
- const STATE_DIR = path4.join(os4.homedir(), ".local", "share", "opencode", "storage", "oh-my-opencode-cohub");
3155
- const STATE_FILE = path4.join(STATE_DIR, "tracker-state.json");
3380
+ const STATE_DIR = path7.join(os5.homedir(), ".local", "share", "opencode", "storage", "oh-my-opencode-cohub");
3381
+ const STATE_FILE = path7.join(STATE_DIR, "tracker-state.json");
3156
3382
  function syncTrackerState(sessionId) {
3157
3383
  try {
3158
- if (!fs5.existsSync(STATE_DIR)) {
3159
- fs5.mkdirSync(STATE_DIR, { recursive: true });
3160
- }
3161
3384
  const state = {
3162
3385
  updatedAt: Date.now(),
3163
3386
  runningAgents: tracker.getRunningAgents(sessionId),
3164
3387
  runningCount: tracker.getRunningCount(sessionId)
3165
3388
  };
3166
- fs5.writeFileSync(STATE_FILE, JSON.stringify(state), "utf-8");
3389
+ writeFileAtomic(STATE_FILE, JSON.stringify(state));
3167
3390
  } catch {}
3168
3391
  }
3169
- const AGENT_CONFIG_FILE = path4.join(STATE_DIR, "cohub-state.json");
3392
+ const AGENT_CONFIG_FILE = path7.join(STATE_DIR, "cohub-state.json");
3170
3393
  function syncAgentConfig() {
3171
3394
  try {
3172
- if (!fs5.existsSync(STATE_DIR)) {
3173
- fs5.mkdirSync(STATE_DIR, { recursive: true });
3174
- }
3175
3395
  const configs = agents.filter((a) => a.config.model).map((a) => {
3176
3396
  const modelStr = a.config.model;
3177
3397
  const parts = modelStr.split("/");
@@ -3185,7 +3405,7 @@ var CoHubPlugin = async (input, options) => {
3185
3405
  provider
3186
3406
  };
3187
3407
  });
3188
- fs5.writeFileSync(AGENT_CONFIG_FILE, JSON.stringify({ updatedAt: Date.now(), agents: configs }), "utf-8");
3408
+ writeFileAtomic(AGENT_CONFIG_FILE, JSON.stringify({ updatedAt: Date.now(), agents: configs }));
3189
3409
  appendLog("syncAgentConfig", `已同步 ${configs.length} 个 agent: ${JSON.stringify(configs.map((c) => ({ name: c.name, model: c.model, variant: c.variant })))}`);
3190
3410
  } catch {}
3191
3411
  }
@@ -3252,6 +3472,7 @@ var CoHubPlugin = async (input, options) => {
3252
3472
  }
3253
3473
  ];
3254
3474
  const userConfig = loadCoHubConfig(projectDir);
3475
+ const ctxDiag = resolveCtxDiagSettings(userConfig.diag);
3255
3476
  if (userConfig.agents) {
3256
3477
  for (const agent of agents) {
3257
3478
  const override = userConfig.agents[agent.name];
@@ -3274,7 +3495,7 @@ var CoHubPlugin = async (input, options) => {
3274
3495
  }
3275
3496
  const DEFAULT_COUNCIL_CONFIG = {
3276
3497
  default_preset: "default",
3277
- timeout: 180000,
3498
+ timeout: COUNCIL_TIMEOUT_MS,
3278
3499
  councillor_execution_mode: "parallel",
3279
3500
  councillor_retries: 3,
3280
3501
  presets: {
@@ -3321,10 +3542,9 @@ var CoHubPlugin = async (input, options) => {
3321
3542
  return p.sessionId;
3322
3543
  return;
3323
3544
  }
3324
- const STALE_TIMEOUT_MS = 30 * 60 * 1000;
3325
3545
  const cleanupTimer = setInterval(() => {
3326
3546
  try {
3327
- const staleSessions = tracker.cleanupStaleJobs(STALE_TIMEOUT_MS);
3547
+ const staleSessions = tracker.cleanupStaleJobs(STALE_TASK_MS);
3328
3548
  for (const sid of staleSessions) {
3329
3549
  (async () => {
3330
3550
  try {
@@ -3341,13 +3561,18 @@ var CoHubPlugin = async (input, options) => {
3341
3561
  appendLog("cleanupStaleJobs", "定时清理过期任务失败", err);
3342
3562
  }
3343
3563
  }, 30000);
3564
+ if (typeof cleanupTimer.unref === "function")
3565
+ cleanupTimer.unref();
3344
3566
  const contextCleanupTimer = setInterval(() => {
3345
3567
  try {
3346
3568
  contextEngine.cleanupStaleDependencies();
3569
+ contextEngine.cleanupStaleContexts();
3347
3570
  } catch (err) {
3348
3571
  appendLog("contextCleanupTimer", "定时清理上下文失败", err);
3349
3572
  }
3350
3573
  }, 60000);
3574
+ if (typeof contextCleanupTimer.unref === "function")
3575
+ contextCleanupTimer.unref();
3351
3576
  const agentConfigs = {};
3352
3577
  const COUNCIL_ONLY_TOOLS = { council_session: "deny" };
3353
3578
  const ORCHESTRATOR_ONLY_TOOLS = { close_job: "deny" };
@@ -3401,7 +3626,7 @@ var CoHubPlugin = async (input, options) => {
3401
3626
  c.agent[name] = merged;
3402
3627
  }
3403
3628
  try {
3404
- fs5.writeFileSync(path4.join(STATE_DIR, "config-hook-ran.json"), JSON.stringify({ ran: true, count: agents.length }));
3629
+ writeFileAtomic(path7.join(STATE_DIR, "config-hook-ran.json"), JSON.stringify({ ran: true, count: agents.length }));
3405
3630
  } catch (err) {
3406
3631
  appendLog("config", "双重注册写入文件失败", err);
3407
3632
  }
@@ -3437,7 +3662,7 @@ var CoHubPlugin = async (input, options) => {
3437
3662
  });
3438
3663
  _lastAlias = alias;
3439
3664
  syncTrackerState(input.sessionID ?? "");
3440
- tracker.pruneTerminalJobs(30 * 60 * 1000);
3665
+ tracker.pruneTerminalJobs(STALE_TASK_MS);
3441
3666
  if (orchestrationLayer && subagentType && description) {
3442
3667
  try {
3443
3668
  orchestrationLayer.engine.registerTask(alias, subagentType, description, []);
@@ -3474,30 +3699,21 @@ var CoHubPlugin = async (input, options) => {
3474
3699
  if (details) {
3475
3700
  output.args[targetField] += details;
3476
3701
  }
3477
- const logPath = path4.join(os4.tmpdir(), "opencode", "ctx-diag.log");
3478
- try {
3479
- const stat = fs5.statSync(logPath);
3480
- if (stat.size > 50 * 1024) {
3481
- const lines = fs5.readFileSync(logPath, "utf-8").trim().split(`
3482
- `);
3483
- fs5.writeFileSync(logPath, lines.slice(-30).join(`
3484
- `) + `
3485
- `);
3486
- }
3487
- } catch {}
3488
- const finalPrompt = typeof output.args?.prompt === "string" ? output.args.prompt : "";
3489
- fs5.appendFileSync(logPath, JSON.stringify({
3490
- time: new Date().toISOString(),
3491
- session: input.sessionID?.slice(0, 20) ?? "?",
3492
- subagent: subagentType,
3493
- strategy,
3494
- targetField,
3495
- hasDetails: !!details,
3496
- detailsLen: details ? details.length : 0,
3497
- detailsPreview: details ? details.replace(/\n/g, "\\n").slice(0, 200) : "",
3498
- promptEnd: finalPrompt.slice(-120)
3499
- }) + `
3500
- `);
3702
+ contextEngine.releaseContext(contextId);
3703
+ if (ctxDiag.enabled) {
3704
+ const finalPrompt = typeof output.args?.prompt === "string" ? output.args.prompt : "";
3705
+ appendCtxDiag({
3706
+ time: new Date().toISOString(),
3707
+ session: input.sessionID?.slice(0, 20) ?? "?",
3708
+ subagent: subagentType,
3709
+ strategy,
3710
+ targetField,
3711
+ hasDetails: !!details,
3712
+ detailsLen: details ? details.length : 0,
3713
+ detailsPreview: details ? details.replace(/\n/g, "\\n").slice(0, 120) : "",
3714
+ promptEnd: finalPrompt.slice(-80)
3715
+ }, ctxDiag.filePath);
3716
+ }
3501
3717
  }
3502
3718
  }
3503
3719
  const targetField = typeof output.args?.prompt === "string" ? "prompt" : "description";