oh-my-opencode-cohub 1.12.13 → 1.13.0-beta.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 (39) hide show
  1. package/README.md +1 -0
  2. package/dist/cli/index.js +115 -15
  3. package/dist/config/loader.d.ts +5 -16
  4. package/dist/config/loader.d.ts.map +1 -1
  5. package/dist/context/engine.d.ts +15 -1
  6. package/dist/context/engine.d.ts.map +1 -1
  7. package/dist/context/extractor.d.ts +8 -0
  8. package/dist/context/extractor.d.ts.map +1 -1
  9. package/dist/context/truncate.test.d.ts +2 -0
  10. package/dist/context/truncate.test.d.ts.map +1 -0
  11. package/dist/context/types.d.ts +2 -0
  12. package/dist/context/types.d.ts.map +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +924 -109
  15. package/dist/task-manager/quality.d.ts +51 -0
  16. package/dist/task-manager/quality.d.ts.map +1 -0
  17. package/dist/task-manager/quality.test.d.ts +2 -0
  18. package/dist/task-manager/quality.test.d.ts.map +1 -0
  19. package/dist/task-manager/stats.test.d.ts +2 -0
  20. package/dist/task-manager/stats.test.d.ts.map +1 -0
  21. package/dist/task-manager/tracker.d.ts +57 -6
  22. package/dist/task-manager/tracker.d.ts.map +1 -1
  23. package/dist/task-manager/types.d.ts +11 -0
  24. package/dist/task-manager/types.d.ts.map +1 -1
  25. package/dist/tools/adaptive-params.d.ts +63 -0
  26. package/dist/tools/adaptive-params.d.ts.map +1 -0
  27. package/dist/tools/adaptive-params.test.d.ts +2 -0
  28. package/dist/tools/adaptive-params.test.d.ts.map +1 -0
  29. package/dist/tools/council.d.ts +78 -1
  30. package/dist/tools/council.d.ts.map +1 -1
  31. package/dist/tools/council.test.d.ts +2 -0
  32. package/dist/tools/council.test.d.ts.map +1 -0
  33. package/dist/tools/model-stats.d.ts +56 -0
  34. package/dist/tools/model-stats.d.ts.map +1 -0
  35. package/dist/tools/model-stats.test.d.ts +2 -0
  36. package/dist/tools/model-stats.test.d.ts.map +1 -0
  37. package/dist/tui.d.ts.map +1 -1
  38. package/dist/tui.js +26 -5
  39. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -560,6 +560,62 @@ var CHINESE_LANGUAGE_INSTRUCTION = `# 中文语言要求
560
560
  - 跨所有项目生效,优先级高于项目级规则`;
561
561
 
562
562
  // src/task-manager/tracker.ts
563
+ import * as fs from "node:fs";
564
+ import * as os from "node:os";
565
+ import * as path from "node:path";
566
+ var DEFAULT_STATS_WINDOW = 50;
567
+ var STATS_FILE = path.join(os.homedir(), ".local", "share", "opencode", "storage", "oh-my-opencode-cohub", "stats.json");
568
+ var AGENT_DEFAULT_STRATEGY = {
569
+ "co-fixer": "relevant",
570
+ "co-designer": "relevant",
571
+ "co-planner": "relevant",
572
+ "co-oracle": "summary",
573
+ "co-council": "summary"
574
+ };
575
+ function defaultStrategyFor(agent) {
576
+ return AGENT_DEFAULT_STRATEGY[agent] ?? "none";
577
+ }
578
+ function computeStats(records, window = DEFAULT_STATS_WINDOW) {
579
+ const recent = records.slice(-Math.max(1, window));
580
+ const buckets = new Map;
581
+ for (const r of recent) {
582
+ const key = r.strategy + "\x00" + r.agent;
583
+ let bucket = buckets.get(key);
584
+ if (!bucket) {
585
+ bucket = {
586
+ strategy: r.strategy,
587
+ agent: r.agent,
588
+ count: 0,
589
+ successCount: 0,
590
+ successRate: 0,
591
+ avgLatencyMs: 0,
592
+ latencySamples: 0,
593
+ avgTokens: 0,
594
+ tokenSamples: 0
595
+ };
596
+ buckets.set(key, bucket);
597
+ }
598
+ bucket.count += 1;
599
+ if (r.status === "completed")
600
+ bucket.successCount += 1;
601
+ if (typeof r.latencyMs === "number") {
602
+ bucket.avgLatencyMs = (bucket.avgLatencyMs * bucket.latencySamples + r.latencyMs) / (bucket.latencySamples + 1);
603
+ bucket.latencySamples += 1;
604
+ }
605
+ if (r.tokens && typeof r.tokens.input === "number" && typeof r.tokens.output === "number") {
606
+ const total = r.tokens.input + r.tokens.output;
607
+ bucket.avgTokens = (bucket.avgTokens * bucket.tokenSamples + total) / (bucket.tokenSamples + 1);
608
+ bucket.tokenSamples += 1;
609
+ }
610
+ }
611
+ const result = Array.from(buckets.values());
612
+ for (const b of result) {
613
+ b.successRate = b.count > 0 ? b.successCount / b.count * 100 : 0;
614
+ }
615
+ result.sort((a, b) => a.strategy.localeCompare(b.strategy) || a.agent.localeCompare(b.agent));
616
+ return result;
617
+ }
618
+
563
619
  class TaskTracker {
564
620
  jobs = new Map;
565
621
  counters = new Map;
@@ -592,6 +648,7 @@ class TaskTracker {
592
648
  sessionId: args.task_id ?? "",
593
649
  parentSessionId,
594
650
  agent,
651
+ contextStrategy: defaultStrategyFor(agent),
595
652
  label,
596
653
  status: "running",
597
654
  background: args.background ?? false,
@@ -618,12 +675,16 @@ class TaskTracker {
618
675
  latest.terminalReconciled = true;
619
676
  if (sessionId)
620
677
  latest.sessionId = sessionId;
678
+ this.finalize(latest);
679
+ this.persistStats();
621
680
  }
622
681
  }
623
682
  updateByChildSessionId(sessionId, status) {
624
683
  for (const job of this.jobs.values()) {
625
684
  if (job.sessionId === sessionId && job.background && job.status === "running") {
626
685
  job.status = status;
686
+ this.finalize(job);
687
+ this.persistStats();
627
688
  return;
628
689
  }
629
690
  }
@@ -702,8 +763,10 @@ class TaskTracker {
702
763
  for (const job of this.jobs.values()) {
703
764
  if (job.background && job.status === "running" && now - job.createdAt > timeoutMs) {
704
765
  job.status = "errored";
766
+ this.finalize(job);
705
767
  }
706
768
  }
769
+ this.persistStats();
707
770
  }
708
771
  isReusable(alias) {
709
772
  const job = this.jobs.get(alias);
@@ -730,11 +793,49 @@ class TaskTracker {
730
793
  getJobBySessionId(sessionId) {
731
794
  for (const job of this.jobs.values()) {
732
795
  if (job.sessionId === sessionId) {
733
- return { alias: job.alias, agent: job.agent };
796
+ return job;
734
797
  }
735
798
  }
736
799
  return;
737
800
  }
801
+ updateQuality(sessionId, quality) {
802
+ for (const job of this.jobs.values()) {
803
+ if (job.sessionId === sessionId) {
804
+ job.quality = quality;
805
+ this.finalize(job);
806
+ this.persistStats();
807
+ return;
808
+ }
809
+ }
810
+ }
811
+ finalize(job) {
812
+ const latencyMs = Date.now() - job.createdAt;
813
+ if (!job.quality) {
814
+ job.quality = { latencyMs };
815
+ } else if (job.quality.latencyMs === undefined) {
816
+ job.quality.latencyMs = latencyMs;
817
+ }
818
+ }
819
+ collectStatsRecords() {
820
+ const jobs = Array.from(this.jobs.values()).filter((j) => j.status !== "running").sort((a, b) => a.createdAt - b.createdAt);
821
+ return jobs.map((job) => ({
822
+ agent: job.agent,
823
+ strategy: job.contextStrategy ?? defaultStrategyFor(job.agent),
824
+ status: job.status,
825
+ ...job.quality?.latencyMs !== undefined ? { latencyMs: job.quality.latencyMs } : {},
826
+ ...job.quality?.tokens ? { tokens: job.quality.tokens } : {}
827
+ }));
828
+ }
829
+ getStats(window = DEFAULT_STATS_WINDOW) {
830
+ return computeStats(this.collectStatsRecords(), window);
831
+ }
832
+ persistStats(window = DEFAULT_STATS_WINDOW) {
833
+ try {
834
+ const recent = this.collectStatsRecords().slice(-Math.max(1, window));
835
+ fs.mkdirSync(path.dirname(STATS_FILE), { recursive: true });
836
+ fs.writeFileSync(STATS_FILE, JSON.stringify(recent, null, 2), "utf8");
837
+ } catch {}
838
+ }
738
839
  markCancelled(taskId) {
739
840
  let job = this.jobs.get(taskId);
740
841
  if (!job) {
@@ -748,10 +849,162 @@ class TaskTracker {
748
849
  if (job) {
749
850
  job.status = "cancelled";
750
851
  job.terminalReconciled = true;
852
+ this.finalize(job);
853
+ this.persistStats();
751
854
  }
752
855
  }
753
856
  }
754
857
 
858
+ // src/task-manager/quality.ts
859
+ var DEFAULT_QUALITY_CONFIG = {
860
+ enabled: true
861
+ };
862
+ var ERROR_KEYWORDS = [
863
+ "error",
864
+ "failed",
865
+ "failure",
866
+ "exception",
867
+ "traceback",
868
+ "unable to",
869
+ "失败",
870
+ "错误",
871
+ "异常",
872
+ "无法完成",
873
+ "未能"
874
+ ];
875
+ var BOUNDARY_KEYWORDS = new Set(["error", "failed", "failure", "exception"]);
876
+ var NEGATION_PHRASES = [
877
+ "no error",
878
+ "no errors",
879
+ "no failure",
880
+ "no failures",
881
+ "no exception",
882
+ "no exceptions",
883
+ "without error",
884
+ "without errors",
885
+ "without any error",
886
+ "without any errors",
887
+ "without failure",
888
+ "without failures",
889
+ "without exception",
890
+ "without exceptions",
891
+ "without any failure",
892
+ "without any failures",
893
+ "without any exception",
894
+ "without any exceptions",
895
+ "with no error",
896
+ "with no errors",
897
+ "with no failure",
898
+ "with no failures",
899
+ "not an error",
900
+ "not a failure",
901
+ "is not an error",
902
+ "never failed",
903
+ "never fails",
904
+ "did not fail",
905
+ "does not fail",
906
+ "not failed",
907
+ "无错误",
908
+ "没有错误",
909
+ "未失败",
910
+ "没有失败",
911
+ "未发现错误",
912
+ "未出现错误",
913
+ "未发生错误",
914
+ "不存在错误",
915
+ "无异常",
916
+ "没有异常",
917
+ "未发现异常",
918
+ "未出现异常",
919
+ "没有发现错误",
920
+ "没有发现任何错误",
921
+ "未发现任何错误",
922
+ "无任何错误",
923
+ "未出现任何错误",
924
+ "未发生任何错误",
925
+ "不存在任何错误",
926
+ "没有发现异常",
927
+ "未发现任何异常",
928
+ "无任何异常",
929
+ "未遇到错误",
930
+ "未产生错误",
931
+ "未遇到异常",
932
+ "未产生异常"
933
+ ];
934
+ var NEGATION_WINDOW = 16;
935
+ function isNegated(lower, hitStart, hitLength) {
936
+ const windowStart = Math.max(0, hitStart - NEGATION_WINDOW);
937
+ const window = lower.slice(windowStart, hitStart + hitLength);
938
+ return NEGATION_PHRASES.some((phrase) => window.includes(phrase));
939
+ }
940
+ function hasErrorKeywords(text) {
941
+ const lower = text.toLowerCase();
942
+ for (const kw of BOUNDARY_KEYWORDS) {
943
+ const re = new RegExp(`\\b${kw}\\b`, "g");
944
+ let m;
945
+ while ((m = re.exec(lower)) !== null) {
946
+ const before = m.index > 0 ? lower[m.index - 1] : "";
947
+ const after = lower[m.index + kw.length] ?? "";
948
+ if (before === "." || after === "(")
949
+ continue;
950
+ if (isNegated(lower, m.index, kw.length))
951
+ continue;
952
+ return true;
953
+ }
954
+ }
955
+ for (const kw of ERROR_KEYWORDS) {
956
+ if (BOUNDARY_KEYWORDS.has(kw))
957
+ continue;
958
+ let idx = lower.indexOf(kw);
959
+ while (idx !== -1) {
960
+ if (!isNegated(lower, idx, kw.length))
961
+ return true;
962
+ idx = lower.indexOf(kw, idx + kw.length);
963
+ }
964
+ }
965
+ return false;
966
+ }
967
+ function assessQuality(input) {
968
+ const outputOk = input.output.trim().length > 0;
969
+ const exitOk = input.exitCode === undefined || input.exitCode === 0;
970
+ const keywordOk = !hasErrorKeywords(input.output);
971
+ const decisionsOk = input.decisions >= 1;
972
+ const timedOut = input.timedOut === true;
973
+ let score = 0;
974
+ if (outputOk)
975
+ score += 0.25;
976
+ if (exitOk && !timedOut)
977
+ score += 0.25;
978
+ if (keywordOk && !timedOut)
979
+ score += 0.25;
980
+ if (decisionsOk && !timedOut)
981
+ score += 0.25;
982
+ if (!outputOk)
983
+ score = 0;
984
+ const passed = !timedOut && outputOk && exitOk && keywordOk && decisionsOk;
985
+ let failureCategory;
986
+ if (!passed) {
987
+ if (timedOut)
988
+ failureCategory = "timeout";
989
+ else if (!outputOk)
990
+ failureCategory = "empty";
991
+ else if (!exitOk || !keywordOk)
992
+ failureCategory = "error";
993
+ else
994
+ failureCategory = "quality-low";
995
+ }
996
+ return {
997
+ score,
998
+ passed,
999
+ ...failureCategory ? { failureCategory } : {},
1000
+ ...input.latencyMs !== undefined ? { latencyMs: input.latencyMs } : {},
1001
+ ...input.tokens ? { tokens: input.tokens } : {}
1002
+ };
1003
+ }
1004
+ function isQualityEnabled(config) {
1005
+ return config?.enabled ?? DEFAULT_QUALITY_CONFIG.enabled;
1006
+ }
1007
+
755
1008
  // src/context/types.ts
756
1009
  var DEFAULT_CONTEXT_CONFIG = {
757
1010
  strategy: {
@@ -776,6 +1029,9 @@ var DEFAULT_CONTEXT_CONFIG = {
776
1029
  relevantMessageWindow: 20
777
1030
  };
778
1031
 
1032
+ // src/context/engine.ts
1033
+ import * as fs2 from "node:fs";
1034
+
779
1035
  // src/context/extractor.ts
780
1036
  var KNOWN_EXTENSIONS = new Set([
781
1037
  "ts",
@@ -827,12 +1083,12 @@ function extractRelevantFiles(messages, maxFiles, windowSize) {
827
1083
  if (part.type === "tool" && part.state) {
828
1084
  const state = part.state;
829
1085
  const input = state.input ?? {};
830
- const path = extractPath(input);
831
- if (path) {
832
- if (!fileMap.has(path)) {
833
- fileMap.set(path, { path, summary: "" });
1086
+ const path2 = extractPath(input);
1087
+ if (path2) {
1088
+ if (!fileMap.has(path2)) {
1089
+ fileMap.set(path2, { path: path2, summary: "" });
834
1090
  }
835
- const existing = fileMap.get(path);
1091
+ const existing = fileMap.get(path2);
836
1092
  if (typeof input.offset === "number") {
837
1093
  const limit = typeof input.limit === "number" ? input.limit : 50;
838
1094
  existing.lines = `${input.offset}-${input.offset + limit}`;
@@ -952,6 +1208,25 @@ function extractErrors(messages, maxErrors, windowSize) {
952
1208
  }
953
1209
  return errors;
954
1210
  }
1211
+ function truncateByTokens(text, maxTokens) {
1212
+ if (!text || maxTokens <= 0)
1213
+ return "";
1214
+ let used = 0;
1215
+ let cut = text.length;
1216
+ for (let i = 0;i < text.length; i++) {
1217
+ const code = text.charCodeAt(i);
1218
+ const isCjk = code >= 19968 && code <= 40959 || code >= 13312 && code <= 19903 || code >= 63744 && code <= 64255 || code >= 12352 && code <= 12543 || code >= 44032 && code <= 55215;
1219
+ used += isCjk ? 1 : 0.25;
1220
+ if (used > maxTokens) {
1221
+ cut = i;
1222
+ break;
1223
+ }
1224
+ }
1225
+ if (cut >= text.length)
1226
+ return text;
1227
+ return text.slice(0, cut) + `
1228
+ … [正文已按 token 预算截断]`;
1229
+ }
955
1230
 
956
1231
  // src/context/formatter.ts
957
1232
  var CONTEXT_MARKER_PATTERN = /<!-- CONTEXT:ID=([a-f0-9-]+) -->/;
@@ -1043,6 +1318,7 @@ class ContextEngine {
1043
1318
  return;
1044
1319
  if (args.strategy === "none")
1045
1320
  return;
1321
+ context.strategy = args.strategy;
1046
1322
  try {
1047
1323
  const windowSize = this.config.relevantMessageWindow;
1048
1324
  const messagesResult = await this.client.session.messages({
@@ -1055,11 +1331,25 @@ class ContextEngine {
1055
1331
  context.decisions = extractDecisions(messages, this.config.maxDecisions, windowSize);
1056
1332
  context.errors = extractErrors(messages, this.config.maxErrors, windowSize);
1057
1333
  }
1334
+ if (args.strategy === "summary") {
1335
+ this.attachTruncatedBodies(context.relevantFiles);
1336
+ }
1058
1337
  if (this.config.dependencyPropagation && this.dependencyCache.size > 0) {
1059
1338
  context.dependencies = Array.from(this.dependencyCache.values()).slice(-this.config.maxDependencies);
1060
1339
  }
1061
1340
  } catch {}
1062
1341
  }
1342
+ attachTruncatedBodies(files) {
1343
+ if (files.length === 0)
1344
+ return;
1345
+ const budgetPerFile = Math.max(1, Math.floor(this.config.summarizeMaxTokens / files.length));
1346
+ for (const file of files) {
1347
+ try {
1348
+ const content = fs2.readFileSync(file.path, "utf-8");
1349
+ file.body = truncateByTokens(content, budgetPerFile);
1350
+ } catch {}
1351
+ }
1352
+ }
1063
1353
  consumeMarkedContext(messageText) {
1064
1354
  const markerMatch = messageText.match(CONTEXT_MARKER_PATTERN);
1065
1355
  if (!markerMatch)
@@ -1078,10 +1368,23 @@ class ContextEngine {
1078
1368
  if (!context)
1079
1369
  return "";
1080
1370
  const parts = [];
1371
+ const isSummary = context.strategy === "summary";
1081
1372
  if (context.relevantFiles.length > 0) {
1082
- parts.push("### \uD83D\uDCC1 相关文件");
1083
- for (const file of context.relevantFiles) {
1084
- parts.push(`- \`${file.path}\`` + (file.summary ? ` — ${file.summary}` : ""));
1373
+ if (isSummary) {
1374
+ parts.push("### \uD83D\uDCC1 相关文件(正文按 token 预算截断)");
1375
+ for (const file of context.relevantFiles) {
1376
+ const loc = file.lines ? ":" + file.lines : "";
1377
+ if (file.body) {
1378
+ parts.push("- `" + file.path + loc + "`:\n```text\n" + file.body + "\n```");
1379
+ } else {
1380
+ parts.push("- `" + file.path + loc + "` — " + (file.summary || "(正文不可用)"));
1381
+ }
1382
+ }
1383
+ } else {
1384
+ parts.push("### \uD83D\uDCC1 相关文件");
1385
+ for (const file of context.relevantFiles) {
1386
+ parts.push("- `" + file.path + "`" + (file.summary ? " — " + file.summary : ""));
1387
+ }
1085
1388
  }
1086
1389
  }
1087
1390
  if (context.decisions.length > 0) {
@@ -1108,27 +1411,31 @@ class ContextEngine {
1108
1411
  ` : "";
1109
1412
  }
1110
1413
  async captureResult(childSessionId, alias, agent) {
1111
- if (!this.config.dependencyPropagation)
1112
- return;
1113
1414
  try {
1114
1415
  const messagesResult = await this.client.session.messages({
1115
1416
  path: { id: childSessionId }
1116
1417
  });
1117
1418
  const messages = messagesResult.data ?? [];
1118
1419
  let keyOutput = "";
1420
+ let tokens;
1119
1421
  for (let i = messages.length - 1;i >= 0; i--) {
1120
- if (messages[i].info?.role === "assistant") {
1121
- for (const part of messages[i].parts ?? []) {
1122
- if (part.type === "text" && part.text) {
1123
- keyOutput = part.text.slice(0, 500).replace(/\n/g, " ");
1124
- break;
1125
- }
1126
- }
1127
- if (keyOutput)
1422
+ const info = messages[i].info;
1423
+ if (!info || info.role !== "assistant")
1424
+ continue;
1425
+ if (typeof info.tokens?.input === "number" && typeof info.tokens?.output === "number") {
1426
+ tokens = { input: info.tokens.input, output: info.tokens.output };
1427
+ }
1428
+ for (const part of messages[i].parts ?? []) {
1429
+ if (part.type === "text" && part.text) {
1430
+ keyOutput = part.text.slice(0, 500).replace(/\n/g, " ");
1128
1431
  break;
1432
+ }
1129
1433
  }
1434
+ if (keyOutput)
1435
+ break;
1130
1436
  }
1131
- if (keyOutput) {
1437
+ const decisions = extractDecisions(messages, this.config.maxDecisions, this.config.relevantMessageWindow);
1438
+ if (keyOutput && this.config.dependencyPropagation) {
1132
1439
  this.dependencyCache.set(alias, {
1133
1440
  alias,
1134
1441
  agent,
@@ -1136,7 +1443,14 @@ class ContextEngine {
1136
1443
  capturedAt: Date.now()
1137
1444
  });
1138
1445
  }
1139
- } catch {}
1446
+ return {
1447
+ output: keyOutput,
1448
+ decisions: decisions.length,
1449
+ ...tokens ? { tokens } : {}
1450
+ };
1451
+ } catch {
1452
+ return null;
1453
+ }
1140
1454
  }
1141
1455
  formatMarker(contextId) {
1142
1456
  return formatContextMarker(contextId);
@@ -1155,15 +1469,15 @@ class ContextEngine {
1155
1469
  }
1156
1470
 
1157
1471
  // src/config/loader.ts
1158
- import * as fs from "node:fs";
1159
- import * as path from "node:path";
1160
- import * as os from "node:os";
1161
- var USER_CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "oh-my-opencode-cohub.json");
1472
+ import * as fs3 from "node:fs";
1473
+ import * as path2 from "node:path";
1474
+ import * as os2 from "node:os";
1475
+ var USER_CONFIG_PATH = path2.join(os2.homedir(), ".config", "opencode", "oh-my-opencode-cohub.json");
1162
1476
  function loadCoHubConfig(projectDir) {
1163
1477
  const base = readJSON(USER_CONFIG_PATH);
1164
1478
  if (!projectDir)
1165
1479
  return base;
1166
- const projectPath = path.join(projectDir, ".opencode", "oh-my-opencode-cohub.json");
1480
+ const projectPath = path2.join(projectDir, ".opencode", "oh-my-opencode-cohub.json");
1167
1481
  const project = readJSON(projectPath);
1168
1482
  if (!project || Object.keys(project).length === 0)
1169
1483
  return base;
@@ -1177,13 +1491,16 @@ function loadCoHubConfig(projectDir) {
1177
1491
  if (project.context) {
1178
1492
  merged.context = { ...base.context, ...project.context };
1179
1493
  }
1494
+ if (project.quality) {
1495
+ merged.quality = { ...base.quality, ...project.quality };
1496
+ }
1180
1497
  return merged;
1181
1498
  }
1182
1499
  function readJSON(filePath) {
1183
1500
  try {
1184
- if (!fs.existsSync(filePath))
1501
+ if (!fs3.existsSync(filePath))
1185
1502
  return {};
1186
- const raw = fs.readFileSync(filePath, "utf-8");
1503
+ const raw = fs3.readFileSync(filePath, "utf-8");
1187
1504
  return JSON.parse(raw);
1188
1505
  } catch {
1189
1506
  return {};
@@ -1194,10 +1511,10 @@ function readJSON(filePath) {
1194
1511
  import { tool } from "@opencode-ai/plugin";
1195
1512
 
1196
1513
  // src/utils/log.ts
1197
- import * as fs2 from "fs/promises";
1198
- import * as path2 from "path";
1199
- import * as os2 from "os";
1200
- var LOG_DIR = path2.join(os2.homedir(), ".local", "share", "opencode", "log");
1514
+ import * as fs4 from "fs/promises";
1515
+ import * as path3 from "path";
1516
+ import * as os3 from "os";
1517
+ var LOG_DIR = path3.join(os3.homedir(), ".local", "share", "opencode", "log");
1201
1518
  var LOG_PREFIX = "oh-my-opencode-cohub";
1202
1519
  var KEEP_DAYS = 7;
1203
1520
  var _today = "";
@@ -1207,7 +1524,7 @@ function getLogPath() {
1207
1524
  const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
1208
1525
  if (today !== _today) {
1209
1526
  _today = today;
1210
- _logPath = path2.join(LOG_DIR, `${LOG_PREFIX}.${today}.log`);
1527
+ _logPath = path3.join(LOG_DIR, `${LOG_PREFIX}.${today}.log`);
1211
1528
  _cleanedToday = false;
1212
1529
  }
1213
1530
  return _logPath;
@@ -1220,7 +1537,7 @@ async function cleanOldLogsOnce() {
1220
1537
  const cutoff = new Date;
1221
1538
  cutoff.setUTCDate(cutoff.getUTCDate() - KEEP_DAYS);
1222
1539
  const cutoffStr = cutoff.toISOString().slice(0, 10).replace(/-/g, "");
1223
- const files = await fs2.readdir(LOG_DIR);
1540
+ const files = await fs4.readdir(LOG_DIR);
1224
1541
  for (const file of files) {
1225
1542
  if (!file.startsWith(LOG_PREFIX + "."))
1226
1543
  continue;
@@ -1229,7 +1546,7 @@ async function cleanOldLogsOnce() {
1229
1546
  if (!match)
1230
1547
  continue;
1231
1548
  if (match[1] < cutoffStr) {
1232
- await fs2.unlink(path2.join(LOG_DIR, file)).catch(() => {});
1549
+ await fs4.unlink(path3.join(LOG_DIR, file)).catch(() => {});
1233
1550
  }
1234
1551
  }
1235
1552
  } catch {}
@@ -1237,7 +1554,7 @@ async function cleanOldLogsOnce() {
1237
1554
  async function appendLog(tag, message, err) {
1238
1555
  try {
1239
1556
  const logPath = getLogPath();
1240
- await fs2.mkdir(LOG_DIR, { recursive: true });
1557
+ await fs4.mkdir(LOG_DIR, { recursive: true });
1241
1558
  const now = new Date().toISOString();
1242
1559
  let line = `[${now}] [oh-my-opencode-cohub] [${tag}] ${message}`;
1243
1560
  if (err instanceof Error) {
@@ -1249,13 +1566,158 @@ async function appendLog(tag, message, err) {
1249
1566
  }
1250
1567
  line += `
1251
1568
  `;
1252
- await fs2.appendFile(logPath, line, "utf-8");
1569
+ await fs4.appendFile(logPath, line, "utf-8");
1253
1570
  await cleanOldLogsOnce();
1254
1571
  } catch {}
1255
1572
  }
1256
1573
 
1574
+ // src/tools/model-stats.ts
1575
+ var DEFAULT_MODEL_STATS_CONFIG = {
1576
+ windowSize: 20,
1577
+ highFailureRate: 0.5,
1578
+ minSamples: 5
1579
+ };
1580
+ var config = { ...DEFAULT_MODEL_STATS_CONFIG };
1581
+ var history = new Map;
1582
+ function recordModelResult(model, category) {
1583
+ const window = history.get(model) ?? [];
1584
+ window.push(category);
1585
+ if (window.length > config.windowSize) {
1586
+ window.splice(0, window.length - config.windowSize);
1587
+ }
1588
+ history.set(model, window);
1589
+ }
1590
+ function getModelStats(model) {
1591
+ const window = history.get(model) ?? [];
1592
+ let successes = 0;
1593
+ let empty = 0;
1594
+ let timeout = 0;
1595
+ let error = 0;
1596
+ let qualityLow = 0;
1597
+ for (const c of window) {
1598
+ if (c === "success") {
1599
+ successes += 1;
1600
+ } else if (c === "empty") {
1601
+ empty += 1;
1602
+ } else if (c === "timeout") {
1603
+ timeout += 1;
1604
+ } else if (c === "error") {
1605
+ error += 1;
1606
+ } else {
1607
+ qualityLow += 1;
1608
+ }
1609
+ }
1610
+ const total = window.length;
1611
+ const failures = total - successes;
1612
+ return {
1613
+ model,
1614
+ total,
1615
+ successes,
1616
+ failures,
1617
+ empty,
1618
+ timeout,
1619
+ error,
1620
+ qualityLow,
1621
+ failureRate: total === 0 ? 0 : failures / total,
1622
+ successRate: total === 0 ? 1 : successes / total
1623
+ };
1624
+ }
1625
+ function orderCouncillorsByFailure(entries, statsProvider = getModelStats, cfg = config) {
1626
+ const isHighRisk = ([, c]) => {
1627
+ const s = statsProvider(c.model);
1628
+ return s.total >= cfg.minSamples && s.failureRate > cfg.highFailureRate;
1629
+ };
1630
+ const preferred = entries.filter((entry) => !isHighRisk(entry));
1631
+ const chosen = preferred.length > 0 ? preferred : [...entries];
1632
+ return chosen.sort((a, b) => statsProvider(a[1].model).failureRate - statsProvider(b[1].model).failureRate);
1633
+ }
1634
+
1635
+ // src/tools/adaptive-params.ts
1636
+ var DEFAULT_ADAPTIVE_CONFIG = {
1637
+ highRate: 0.8,
1638
+ lowRate: 0.5,
1639
+ minSamples: 5,
1640
+ hysteresisSamples: 5,
1641
+ minRetries: 1,
1642
+ maxRetries: 5,
1643
+ minTimeoutMs: 60000,
1644
+ maxTimeoutMs: 300000,
1645
+ defaultRetries: 3,
1646
+ defaultTimeoutMs: 180000,
1647
+ timeoutStepMs: 30000,
1648
+ windowSize: 20
1649
+ };
1650
+ var config2 = { ...DEFAULT_ADAPTIVE_CONFIG };
1651
+ var samples = new Map;
1652
+ function evaluateAdjustment(successRate, sampleCount, cfg = config2) {
1653
+ if (sampleCount < cfg.minSamples)
1654
+ return 0;
1655
+ if (successRate > cfg.highRate)
1656
+ return -1;
1657
+ if (successRate < cfg.lowRate)
1658
+ return 1;
1659
+ return 0;
1660
+ }
1661
+ function clampAdaptiveParams(params, cfg = config2) {
1662
+ return {
1663
+ retries: Math.min(cfg.maxRetries, Math.max(cfg.minRetries, Math.round(params.retries))),
1664
+ timeoutMs: Math.min(cfg.maxTimeoutMs, Math.max(cfg.minTimeoutMs, Math.round(params.timeoutMs)))
1665
+ };
1666
+ }
1667
+ function adjustAdaptiveParams(current, direction, cfg = config2) {
1668
+ if (direction === 0)
1669
+ return clampAdaptiveParams(current, cfg);
1670
+ return clampAdaptiveParams({
1671
+ retries: current.retries + direction,
1672
+ timeoutMs: current.timeoutMs + direction * cfg.timeoutStepMs
1673
+ }, cfg);
1674
+ }
1675
+ function recordAdaptiveSample(key, success, cfg = config2) {
1676
+ let st = samples.get(key);
1677
+ if (!st) {
1678
+ st = {
1679
+ recent: [],
1680
+ current: { retries: cfg.defaultRetries, timeoutMs: cfg.defaultTimeoutMs },
1681
+ lastDirection: 0,
1682
+ streak: 0
1683
+ };
1684
+ samples.set(key, st);
1685
+ }
1686
+ st.recent.push(success);
1687
+ if (st.recent.length > cfg.windowSize) {
1688
+ st.recent.splice(0, st.recent.length - cfg.windowSize);
1689
+ }
1690
+ const total = st.recent.length;
1691
+ if (total < cfg.minSamples)
1692
+ return;
1693
+ const successes = st.recent.filter(Boolean).length;
1694
+ const direction = evaluateAdjustment(successes / total, total, cfg);
1695
+ if (direction === st.lastDirection) {
1696
+ st.streak += 1;
1697
+ } else {
1698
+ st.lastDirection = direction;
1699
+ st.streak = 1;
1700
+ }
1701
+ if (st.streak >= cfg.hysteresisSamples) {
1702
+ st.current = adjustAdaptiveParams(st.current, direction, cfg);
1703
+ st.streak = 0;
1704
+ }
1705
+ }
1706
+ function getAdaptiveParams(key, cfg = config2) {
1707
+ const st = samples.get(key);
1708
+ if (!st)
1709
+ return { retries: cfg.defaultRetries, timeoutMs: cfg.defaultTimeoutMs };
1710
+ return { ...st.current };
1711
+ }
1712
+
1257
1713
  // src/tools/council.ts
1258
1714
  var z = tool.schema;
1715
+ var RETRY_LIMITS = {
1716
+ empty: 3,
1717
+ timeout: 1,
1718
+ error: 0,
1719
+ "quality-low": 1
1720
+ };
1259
1721
 
1260
1722
  class OperationTimeoutError extends Error {
1261
1723
  constructor(message) {
@@ -1285,12 +1747,12 @@ async function abortSession(client, sessionId) {
1285
1747
  appendLog("abortSession", "中止会话失败", err);
1286
1748
  }
1287
1749
  }
1288
- async function promptWithTimeout(client, path3, body, timeoutMs, directory) {
1289
- const sessionId = path3.id;
1750
+ async function promptWithTimeout(client, path4, body, timeoutMs, directory) {
1751
+ const sessionId = path4.id;
1290
1752
  let timer;
1291
1753
  try {
1292
1754
  const promptPromise = client.session.prompt({
1293
- path: path3,
1755
+ path: path4,
1294
1756
  body,
1295
1757
  query: directory ? { directory } : undefined
1296
1758
  });
@@ -1316,6 +1778,7 @@ async function promptWithTimeout(client, path3, body, timeoutMs, directory) {
1316
1778
  }
1317
1779
  async function extractSessionResult(client, sessionId, options) {
1318
1780
  const includeReasoning = options?.includeReasoning ?? true;
1781
+ const lastTextOnly = options?.lastTextOnly === true;
1319
1782
  const messagesResult = await client.session.messages({
1320
1783
  path: { id: sessionId }
1321
1784
  });
@@ -1332,7 +1795,8 @@ async function extractSessionResult(client, sessionId, options) {
1332
1795
  }
1333
1796
  }
1334
1797
  }
1335
- const text = extractedContent.filter((t) => t.length > 0).join(`
1798
+ const texts = extractedContent.filter((t) => t.length > 0);
1799
+ const text = lastTextOnly ? texts.length > 0 ? texts[texts.length - 1] : "" : texts.join(`
1336
1800
 
1337
1801
  `);
1338
1802
  return { text, empty: text.length === 0 };
@@ -1395,18 +1859,254 @@ ${cr.result}`;
1395
1859
  return parts.join(`
1396
1860
  `);
1397
1861
  }
1862
+ function buildDeliberationPrompt(originalPrompt, previousRound, currentName) {
1863
+ const others = previousRound.filter((cr) => cr.status === "completed" && cr.result && cr.name !== currentName).map((cr) => `**${cr.name}** (${shortModelLabel(cr.model)}):
1864
+ ${cr.result}`).join(`
1865
+
1866
+ `);
1867
+ if (!others)
1868
+ return originalPrompt;
1869
+ return [
1870
+ originalPrompt,
1871
+ "",
1872
+ "---",
1873
+ "",
1874
+ "以下是其他成员第一轮意见,请回应并修正或坚持你的立场:",
1875
+ "",
1876
+ others
1877
+ ].join(`
1878
+ `);
1879
+ }
1398
1880
  function formatModelComposition(councillorResults) {
1399
1881
  return councillorResults.map((cr) => `${cr.name}: ${shortModelLabel(cr.model)}`).join(", ");
1400
1882
  }
1883
+ function adaptiveKey(config3) {
1884
+ return `${config3.variant ?? "default"}\x00${config3.model}`;
1885
+ }
1886
+ function classifyFailure(message) {
1887
+ if (message.includes("Empty response from provider"))
1888
+ return "empty";
1889
+ if (message.includes("timed out") || message.includes("OperationTimeoutError"))
1890
+ return "timeout";
1891
+ return "error";
1892
+ }
1893
+ var CONCLUSION_MARKERS = [
1894
+ "综上",
1895
+ "结论",
1896
+ "因此",
1897
+ "总之",
1898
+ "建议",
1899
+ "推荐",
1900
+ "我认为",
1901
+ "最终",
1902
+ "关键结论",
1903
+ "conclusion",
1904
+ "therefore",
1905
+ "in summary",
1906
+ "in conclusion",
1907
+ "recommend",
1908
+ "suggest",
1909
+ "overall"
1910
+ ];
1911
+ var ENTITY_MARKERS = [
1912
+ "决策",
1913
+ "决定",
1914
+ "方案",
1915
+ "采用",
1916
+ "选择",
1917
+ "关键",
1918
+ "实体",
1919
+ "要点",
1920
+ "关键决策",
1921
+ "decision",
1922
+ "entity",
1923
+ "key point",
1924
+ "option",
1925
+ "recommendation",
1926
+ "key entity"
1927
+ ];
1928
+ function extractCouncillorSummary(text) {
1929
+ const sentences = text.split(/(?<=[。!?!?;;])\s*|\n+/).map((s) => s.trim()).filter((s) => s.length > 0);
1930
+ const conclusions = sentences.filter((s) => CONCLUSION_MARKERS.some((m) => s.toLowerCase().includes(m)));
1931
+ const entities = sentences.filter((s) => ENTITY_MARKERS.some((m) => s.toLowerCase().includes(m)));
1932
+ return {
1933
+ conclusions: conclusions.length > 0 ? conclusions : sentences.length > 0 ? [sentences[sentences.length - 1]] : [],
1934
+ entities
1935
+ };
1936
+ }
1937
+ var STOPWORDS = new Set([
1938
+ "the",
1939
+ "a",
1940
+ "an",
1941
+ "and",
1942
+ "or",
1943
+ "of",
1944
+ "to",
1945
+ "in",
1946
+ "on",
1947
+ "for",
1948
+ "with",
1949
+ "is",
1950
+ "are",
1951
+ "was",
1952
+ "were",
1953
+ "be",
1954
+ "been",
1955
+ "this",
1956
+ "that",
1957
+ "it",
1958
+ "as",
1959
+ "at",
1960
+ "by",
1961
+ "from",
1962
+ "我们",
1963
+ "的",
1964
+ "了",
1965
+ "是",
1966
+ "在",
1967
+ "和",
1968
+ "与",
1969
+ "及",
1970
+ "或",
1971
+ "一个",
1972
+ "这个",
1973
+ "进行",
1974
+ "以及",
1975
+ "结论",
1976
+ "综上",
1977
+ "总之",
1978
+ "因此",
1979
+ "建议",
1980
+ "推荐",
1981
+ "最终",
1982
+ "我认为",
1983
+ "关键",
1984
+ "要点",
1985
+ "采用",
1986
+ "方案",
1987
+ "决策",
1988
+ "决定",
1989
+ "选择",
1990
+ "实体",
1991
+ "conclusion",
1992
+ "therefore",
1993
+ "summary",
1994
+ "recommend",
1995
+ "suggest",
1996
+ "overall",
1997
+ "decision",
1998
+ "option",
1999
+ "recommendation",
2000
+ "key",
2001
+ "point",
2002
+ "entity",
2003
+ "error"
2004
+ ]);
2005
+ function tokenize(text) {
2006
+ return text.toLowerCase().split(/[^a-z0-9\u4e00-\u9fff]+/).filter((t) => t.length > 0 && !STOPWORDS.has(t));
2007
+ }
2008
+ function jaccard(x, y) {
2009
+ if (x.length === 0 || y.length === 0)
2010
+ return 0;
2011
+ const xs = new Set(x);
2012
+ const ys = new Set(y);
2013
+ const inter = [...xs].filter((t) => ys.has(t)).length;
2014
+ const union = new Set([...xs, ...ys]).size;
2015
+ return union === 0 ? 0 : inter / union;
2016
+ }
2017
+ function pairwiseAgreement(a, b) {
2018
+ const aConcl = tokenize(a.conclusions.join(" "));
2019
+ const bConcl = tokenize(b.conclusions.join(" "));
2020
+ const aEnt = tokenize(a.entities.join(" "));
2021
+ const bEnt = tokenize(b.entities.join(" "));
2022
+ return 0.5 * jaccard(aConcl, bConcl) + 0.5 * jaccard(aEnt, bEnt);
2023
+ }
2024
+ function computeConsensus(inputs, threshold) {
2025
+ const n = inputs.length;
2026
+ if (n === 0)
2027
+ return { consensus: false, agreementScores: {}, averageAgreement: 0 };
2028
+ if (n === 1) {
2029
+ return { consensus: true, agreementScores: { [inputs[0].name]: 1 }, averageAgreement: 1 };
2030
+ }
2031
+ const agreementScores = {};
2032
+ for (const a of inputs) {
2033
+ let sum = 0;
2034
+ for (const b of inputs) {
2035
+ if (a.name === b.name)
2036
+ continue;
2037
+ sum += pairwiseAgreement(a.summary, b.summary);
2038
+ }
2039
+ agreementScores[a.name] = sum / (n - 1);
2040
+ }
2041
+ const values = Object.values(agreementScores);
2042
+ const averageAgreement = values.reduce((acc, v) => acc + v, 0) / values.length;
2043
+ const agreeing = values.filter((v) => v >= threshold).length;
2044
+ const required = Math.ceil(2 * n / 3);
2045
+ return { consensus: agreeing >= required, agreementScores, averageAgreement };
2046
+ }
2047
+ function decideRetryAction(category, consecutiveStreak, categoryRetriesUsed, budgetAvailable, categoryLimit = RETRY_LIMITS[category]) {
2048
+ if (!budgetAvailable)
2049
+ return "budget-exhausted";
2050
+ if (consecutiveStreak >= 2)
2051
+ return "needs-human";
2052
+ if (categoryRetriesUsed >= categoryLimit)
2053
+ return "give-up";
2054
+ return "retry";
2055
+ }
2056
+ var VARIANT_ORDER = ["max", "high", "medium", "low"];
2057
+ function downgradeVariant(variant) {
2058
+ if (!variant)
2059
+ return;
2060
+ const idx = VARIANT_ORDER.indexOf(variant);
2061
+ if (idx < 0 || idx >= VARIANT_ORDER.length - 1)
2062
+ return;
2063
+ return VARIANT_ORDER[idx + 1];
2064
+ }
2065
+ function appendRetryContext(prompt, category) {
2066
+ if (category === "timeout") {
2067
+ return `${prompt}
2068
+
2069
+ [retry] 上次尝试超时。请精炼作答:先给出结论与关键决策,避免冗长过程。`;
2070
+ }
2071
+ if (category === "quality-low") {
2072
+ return `${prompt}
2073
+
2074
+ [retry] 上次回答缺少明确的结论与关键实体/决策。请以"结论:..."开头,并列出关键决策/实体要点。`;
2075
+ }
2076
+ return prompt;
2077
+ }
2078
+
2079
+ class RetryBudget {
2080
+ retriesLeft;
2081
+ maxElapsedMs;
2082
+ startedAt;
2083
+ constructor(maxTotalRetries, maxElapsedMs, startedAt = Date.now()) {
2084
+ this.retriesLeft = Math.max(0, maxTotalRetries);
2085
+ this.maxElapsedMs = maxElapsedMs;
2086
+ this.startedAt = startedAt;
2087
+ }
2088
+ elapsedMs() {
2089
+ return Date.now() - this.startedAt;
2090
+ }
2091
+ expired() {
2092
+ return this.elapsedMs() >= this.maxElapsedMs;
2093
+ }
2094
+ consumeRetry() {
2095
+ if (this.retriesLeft <= 0 || this.expired())
2096
+ return false;
2097
+ this.retriesLeft -= 1;
2098
+ return true;
2099
+ }
2100
+ }
1401
2101
 
1402
2102
  class CouncilManager {
1403
2103
  client;
1404
2104
  directory;
1405
2105
  config;
1406
- constructor(client, directory, config) {
2106
+ constructor(client, directory, config3) {
1407
2107
  this.client = client;
1408
2108
  this.directory = directory;
1409
- this.config = config;
2109
+ this.config = config3;
1410
2110
  }
1411
2111
  async runCouncil(prompt, presetName, parentSessionId) {
1412
2112
  const resolvedPreset = presetName ?? this.config.default_preset ?? "default";
@@ -1428,8 +2128,10 @@ class CouncilManager {
1428
2128
  }
1429
2129
  const timeout = this.config.timeout ?? 180000;
1430
2130
  const executionMode = this.config.councillor_execution_mode ?? "parallel";
1431
- const maxRetries = this.config.councillor_retries ?? 3;
1432
- const councillorResults = await this.runCouncillors(prompt, preset, parentSessionId, timeout, executionMode, maxRetries);
2131
+ const maxRounds = this.config.max_rounds ?? 2;
2132
+ const consensusThreshold = this.config.consensus_threshold ?? 0.6;
2133
+ const budget = new RetryBudget(this.config.max_total_retries ?? 6, this.config.max_elapsed_ms ?? 3 * timeout);
2134
+ const councillorResults = await this.runCouncillors(prompt, preset, parentSessionId, timeout, executionMode, budget, { maxRounds, consensusThreshold });
1433
2135
  const completedCount = councillorResults.filter((r) => r.status === "completed").length;
1434
2136
  if (completedCount === 0) {
1435
2137
  return {
@@ -1438,23 +2140,56 @@ class CouncilManager {
1438
2140
  councillorResults
1439
2141
  };
1440
2142
  }
2143
+ const consensusInputs = councillorResults.filter((r) => r.status === "completed" && !!r.summary).map((r) => ({ name: r.name, summary: r.summary }));
2144
+ const consensus = computeConsensus(consensusInputs, consensusThreshold);
1441
2145
  const formatted = formatCouncillorResults(prompt, councillorResults);
1442
2146
  return {
1443
2147
  success: true,
1444
2148
  result: formatted,
1445
- councillorResults
2149
+ councillorResults,
2150
+ consensus: consensus.consensus,
2151
+ agreement: consensus.averageAgreement
1446
2152
  };
1447
2153
  }
1448
- async runCouncillors(prompt, councillors, parentSessionId, timeout, executionMode, maxRetries) {
1449
- const entries = Object.entries(councillors);
2154
+ async runCouncillors(prompt, councillors, parentSessionId, timeout, executionMode, budget, options) {
2155
+ const entries = orderCouncillorsByFailure(Object.entries(councillors));
1450
2156
  const results = [];
2157
+ const explicitTimeout = this.config.timeout;
2158
+ const explicitRetries = this.config.councillor_retries;
2159
+ const resolveParams = (config3) => {
2160
+ const p = getAdaptiveParams(adaptiveKey(config3), {
2161
+ ...DEFAULT_ADAPTIVE_CONFIG,
2162
+ defaultTimeoutMs: explicitTimeout ?? timeout,
2163
+ defaultRetries: explicitRetries ?? 3
2164
+ });
2165
+ return {
2166
+ timeout: explicitTimeout ?? p.timeoutMs,
2167
+ retries: explicitRetries ?? p.retries
2168
+ };
2169
+ };
1451
2170
  if (executionMode === "serial") {
1452
- for (const [name, config] of entries) {
1453
- const r = await this.runCouncillorWithRetry(name, config, prompt, parentSessionId, timeout, maxRetries);
1454
- results.push(r);
2171
+ let lastRound = [];
2172
+ for (let round = 1;round <= options.maxRounds; round++) {
2173
+ const roundResults = [];
2174
+ const firstAttemptIsExtra = round > 1;
2175
+ for (const [name, config3] of entries) {
2176
+ const params = resolveParams(config3);
2177
+ const roundPrompt = round > 1 ? buildDeliberationPrompt(prompt, lastRound, name) : prompt;
2178
+ const r = await this.runCouncillorWithRetry(name, config3, roundPrompt, parentSessionId, params.timeout, budget, { firstAttemptIsExtra, retries: params.retries });
2179
+ roundResults.push(r);
2180
+ }
2181
+ lastRound = roundResults;
2182
+ const consensusInputs = roundResults.filter((r) => r.status === "completed" && !!r.summary).map((r) => ({ name: r.name, summary: r.summary }));
2183
+ if (computeConsensus(consensusInputs, options.consensusThreshold).consensus) {
2184
+ break;
2185
+ }
1455
2186
  }
2187
+ results.push(...lastRound);
1456
2188
  } else {
1457
- const promises = entries.map(([name, config]) => this.runCouncillorWithRetry(name, config, prompt, parentSessionId, timeout, maxRetries));
2189
+ const promises = entries.map(([name, config3]) => {
2190
+ const params = resolveParams(config3);
2191
+ return this.runCouncillorWithRetry(name, config3, prompt, parentSessionId, params.timeout, budget, { retries: params.retries });
2192
+ });
1458
2193
  const settled = await Promise.allSettled(promises);
1459
2194
  for (let i = 0;i < settled.length; i++) {
1460
2195
  const s = settled[i];
@@ -1473,39 +2208,80 @@ class CouncilManager {
1473
2208
  }
1474
2209
  return results;
1475
2210
  }
1476
- async runCouncillorWithRetry(name, config, prompt, parentSessionId, timeout, maxRetries) {
1477
- const totalAttempts = 1 + maxRetries;
1478
- for (let attempt = 1;attempt <= totalAttempts; attempt++) {
2211
+ async runCouncillorWithRetry(name, config3, prompt, parentSessionId, timeout, budget, options) {
2212
+ let attemptPrompt = formatCouncillorPrompt(prompt, config3.prompt);
2213
+ let attemptVariant = config3.variant;
2214
+ let lastCategory = null;
2215
+ let streak = 0;
2216
+ const categoryRetries = {};
2217
+ let attempt = 1;
2218
+ for (;; ) {
2219
+ if (budget.expired()) {
2220
+ return { name, model: config3.model, status: "error", error: "budget-exhausted" };
2221
+ }
2222
+ const isExtraAttempt = attempt > 1 || options?.firstAttemptIsExtra === true;
2223
+ if (isExtraAttempt && !budget.consumeRetry()) {
2224
+ return { name, model: config3.model, status: "error", error: "budget-exhausted" };
2225
+ }
2226
+ let category;
2227
+ let errorInfo;
1479
2228
  try {
1480
- const result = await this.runAgentSession({
2229
+ const session = await this.runAgentSession({
1481
2230
  parentSessionId,
1482
- title: `Council ${name} (${shortModelLabel(config.model)})`,
1483
- model: config.model,
1484
- promptText: formatCouncillorPrompt(prompt, config.prompt),
1485
- variant: config.variant,
2231
+ title: `Council ${name} (${shortModelLabel(config3.model)})`,
2232
+ model: config3.model,
2233
+ promptText: attemptPrompt,
2234
+ variant: attemptVariant,
1486
2235
  timeout
1487
2236
  });
1488
- return { name, model: config.model, status: "completed", result };
2237
+ const quality = assessQuality({ output: session.text, decisions: session.summary.entities.length });
2238
+ if (quality.passed) {
2239
+ recordModelResult(config3.model, "success");
2240
+ recordAdaptiveSample(adaptiveKey(config3), true);
2241
+ return { name, model: config3.model, status: "completed", result: session.text, summary: session.summary };
2242
+ }
2243
+ category = quality.failureCategory ?? "quality-low";
2244
+ errorInfo = `Councillor "${name}": quality score ${quality.score} (${category})`;
1489
2245
  } catch (error) {
1490
2246
  const msg = error instanceof Error ? error.message : String(error);
1491
- const isEmptyResponse = msg.includes("Empty response from provider");
1492
- const canRetry = attempt < totalAttempts && isEmptyResponse;
1493
- if (!canRetry) {
1494
- return {
1495
- name,
1496
- model: config.model,
1497
- status: msg.includes("timed out") ? "timed_out" : "failed",
1498
- error: `Councillor "${name}": ${msg}`
1499
- };
1500
- }
2247
+ category = classifyFailure(msg);
2248
+ errorInfo = `Councillor "${name}": ${msg}`;
2249
+ }
2250
+ recordModelResult(config3.model, category);
2251
+ recordAdaptiveSample(adaptiveKey(config3), false);
2252
+ streak = lastCategory === category ? streak + 1 : 1;
2253
+ lastCategory = category;
2254
+ const used = categoryRetries[category] ?? 0;
2255
+ const categoryLimit = category === "empty" ? options?.retries ?? this.config.councillor_retries ?? 3 : RETRY_LIMITS[category];
2256
+ const action = decideRetryAction(category, streak, used, budget.retriesLeft > 0 && !budget.expired(), categoryLimit);
2257
+ if (action === "needs-human") {
2258
+ return {
2259
+ name,
2260
+ model: config3.model,
2261
+ status: "needs_human",
2262
+ error: `${errorInfo} — ${streak} consecutive ${category} failures, needs human review`
2263
+ };
2264
+ }
2265
+ if (action === "budget-exhausted") {
2266
+ return { name, model: config3.model, status: "error", error: "budget-exhausted" };
2267
+ }
2268
+ if (action === "give-up") {
2269
+ return {
2270
+ name,
2271
+ model: config3.model,
2272
+ status: category === "timeout" ? "timed_out" : "failed",
2273
+ error: errorInfo
2274
+ };
2275
+ }
2276
+ categoryRetries[category] = used + 1;
2277
+ if (category === "timeout") {
2278
+ attemptVariant = downgradeVariant(attemptVariant);
2279
+ attemptPrompt = appendRetryContext(attemptPrompt, "timeout");
2280
+ } else if (category === "quality-low") {
2281
+ attemptPrompt = appendRetryContext(attemptPrompt, "quality-low");
1501
2282
  }
2283
+ attempt += 1;
1502
2284
  }
1503
- return {
1504
- name,
1505
- model: config.model,
1506
- status: "failed",
1507
- error: `Councillor "${name}": max retries exhausted`
1508
- };
1509
2285
  }
1510
2286
  async runAgentSession(options) {
1511
2287
  const modelRef = parseModelReference(options.model);
@@ -1545,11 +2321,15 @@ class CouncilManager {
1545
2321
  promptBody.variant = options.variant;
1546
2322
  }
1547
2323
  await promptWithTimeout(this.client, { id: sessionId }, promptBody, options.timeout, this.directory);
1548
- const extraction = await extractSessionResult(this.client, sessionId);
2324
+ const extraction = await extractSessionResult(this.client, sessionId, {
2325
+ includeReasoning: false,
2326
+ lastTextOnly: true
2327
+ });
1549
2328
  if (extraction.empty) {
1550
2329
  throw new Error("[oh-my-opencode-cohub] Empty response from provider");
1551
2330
  }
1552
- return extraction.text;
2331
+ const summary = extractCouncillorSummary(extraction.text);
2332
+ return { text: extraction.text, summary };
1553
2333
  } finally {
1554
2334
  if (sessionId) {
1555
2335
  abortSession(this.client, sessionId).catch(() => {});
@@ -1593,6 +2373,10 @@ function createCouncilTool(ctx, councilManager) {
1593
2373
 
1594
2374
  ---
1595
2375
  *Council: ${completed}/${total} councillors responded (${composition})*`;
2376
+ const agreementNum = typeof result.agreement === "number" ? result.agreement : 0;
2377
+ const consensusLabel = result.consensus === true ? agreementNum >= 0.8 ? "unanimous" : "majority" : "split";
2378
+ output += `
2379
+ *Consensus: ${consensusLabel} (${agreementNum.toFixed(2)})*`;
1596
2380
  return output;
1597
2381
  }
1598
2382
  });
@@ -1600,9 +2384,9 @@ function createCouncilTool(ctx, councilManager) {
1600
2384
  }
1601
2385
 
1602
2386
  // src/index.ts
1603
- import * as fs3 from "node:fs";
1604
- import * as path3 from "node:path";
1605
- import * as os3 from "node:os";
2387
+ import * as fs5 from "node:fs";
2388
+ import * as path4 from "node:path";
2389
+ import * as os4 from "node:os";
1606
2390
  function loadFileOverrides(projectDir) {
1607
2391
  const overrides = {};
1608
2392
  const agentNames = [
@@ -1621,23 +2405,23 @@ function loadFileOverrides(projectDir) {
1621
2405
  ];
1622
2406
  const searchDirs = [];
1623
2407
  if (projectDir) {
1624
- searchDirs.push(path3.join(projectDir, ".opencode", "oh-my-opencode-cohub"));
2408
+ searchDirs.push(path4.join(projectDir, ".opencode", "oh-my-opencode-cohub"));
1625
2409
  }
1626
- searchDirs.push(path3.join(os3.homedir(), ".config", "opencode", "oh-my-opencode-cohub"));
2410
+ searchDirs.push(path4.join(os4.homedir(), ".config", "opencode", "oh-my-opencode-cohub"));
1627
2411
  for (const agent of agentNames) {
1628
2412
  for (const dir of searchDirs) {
1629
- const replacePath = path3.join(dir, `${agent}.md`);
1630
- if (!overrides[agent]?.replace && fs3.existsSync(replacePath)) {
2413
+ const replacePath = path4.join(dir, `${agent}.md`);
2414
+ if (!overrides[agent]?.replace && fs5.existsSync(replacePath)) {
1631
2415
  try {
1632
- overrides[agent] = { ...overrides[agent], replace: fs3.readFileSync(replacePath, "utf-8") };
2416
+ overrides[agent] = { ...overrides[agent], replace: fs5.readFileSync(replacePath, "utf-8") };
1633
2417
  } catch (err) {
1634
2418
  appendLog("loadFileOverrides", "读取replace文件失败", err);
1635
2419
  }
1636
2420
  }
1637
- const appendPath = path3.join(dir, `${agent}_append.md`);
1638
- if (!overrides[agent]?.append && fs3.existsSync(appendPath)) {
2421
+ const appendPath = path4.join(dir, `${agent}_append.md`);
2422
+ if (!overrides[agent]?.append && fs5.existsSync(appendPath)) {
1639
2423
  try {
1640
- overrides[agent] = { ...overrides[agent], append: fs3.readFileSync(appendPath, "utf-8") };
2424
+ overrides[agent] = { ...overrides[agent], append: fs5.readFileSync(appendPath, "utf-8") };
1641
2425
  } catch (err) {
1642
2426
  appendLog("loadFileOverrides", "读取append文件失败", err);
1643
2427
  }
@@ -1668,26 +2452,26 @@ var CoHubPlugin = async (input, options) => {
1668
2452
  }
1669
2453
  const promptOverrides = { ...configOverrides, ...fileOverrides };
1670
2454
  const tracker = new TaskTracker;
1671
- const STATE_DIR = path3.join(os3.homedir(), ".local", "share", "opencode", "storage", "oh-my-opencode-cohub");
1672
- const STATE_FILE = path3.join(STATE_DIR, "tracker-state.json");
2455
+ const STATE_DIR = path4.join(os4.homedir(), ".local", "share", "opencode", "storage", "oh-my-opencode-cohub");
2456
+ const STATE_FILE = path4.join(STATE_DIR, "tracker-state.json");
1673
2457
  function syncTrackerState(sessionId) {
1674
2458
  try {
1675
- if (!fs3.existsSync(STATE_DIR)) {
1676
- fs3.mkdirSync(STATE_DIR, { recursive: true });
2459
+ if (!fs5.existsSync(STATE_DIR)) {
2460
+ fs5.mkdirSync(STATE_DIR, { recursive: true });
1677
2461
  }
1678
2462
  const state = {
1679
2463
  updatedAt: Date.now(),
1680
2464
  runningAgents: tracker.getRunningAgents(sessionId),
1681
2465
  runningCount: tracker.getRunningCount(sessionId)
1682
2466
  };
1683
- fs3.writeFileSync(STATE_FILE, JSON.stringify(state), "utf-8");
2467
+ fs5.writeFileSync(STATE_FILE, JSON.stringify(state), "utf-8");
1684
2468
  } catch {}
1685
2469
  }
1686
- const AGENT_CONFIG_FILE = path3.join(STATE_DIR, "cohub-state.json");
2470
+ const AGENT_CONFIG_FILE = path4.join(STATE_DIR, "cohub-state.json");
1687
2471
  function syncAgentConfig() {
1688
2472
  try {
1689
- if (!fs3.existsSync(STATE_DIR)) {
1690
- fs3.mkdirSync(STATE_DIR, { recursive: true });
2473
+ if (!fs5.existsSync(STATE_DIR)) {
2474
+ fs5.mkdirSync(STATE_DIR, { recursive: true });
1691
2475
  }
1692
2476
  const configs = agents.filter((a) => a.config.model).map((a) => {
1693
2477
  const modelStr = a.config.model;
@@ -1702,7 +2486,7 @@ var CoHubPlugin = async (input, options) => {
1702
2486
  provider
1703
2487
  };
1704
2488
  });
1705
- fs3.writeFileSync(AGENT_CONFIG_FILE, JSON.stringify({ updatedAt: Date.now(), agents: configs }), "utf-8");
2489
+ fs5.writeFileSync(AGENT_CONFIG_FILE, JSON.stringify({ updatedAt: Date.now(), agents: configs }), "utf-8");
1706
2490
  appendLog("syncAgentConfig", `已同步 ${configs.length} 个 agent: ${JSON.stringify(configs.map((c) => ({ name: c.name, model: c.model, variant: c.variant })))}`);
1707
2491
  } catch {}
1708
2492
  }
@@ -1853,12 +2637,18 @@ var CoHubPlugin = async (input, options) => {
1853
2637
  }
1854
2638
  }, 60000);
1855
2639
  const agentConfigs = {};
2640
+ const COUNCIL_ONLY_TOOLS = { council_session: "deny" };
1856
2641
  for (const agent of agents) {
1857
- agentConfigs[agent.name] = {
2642
+ const base = {
1858
2643
  ...agent.config,
1859
2644
  name: agent.name,
1860
2645
  description: agent.description
1861
2646
  };
2647
+ if (agent.name !== "co-council") {
2648
+ const existingPermission = base.permission ?? {};
2649
+ base.permission = { ...existingPermission, ...COUNCIL_ONLY_TOOLS };
2650
+ }
2651
+ agentConfigs[agent.name] = base;
1862
2652
  }
1863
2653
  return {
1864
2654
  name: "oh-my-opencode-cohub",
@@ -1878,10 +2668,17 @@ var CoHubPlugin = async (input, options) => {
1878
2668
  merged.model = pc.model;
1879
2669
  if (pc.variant)
1880
2670
  merged.variant = pc.variant;
2671
+ if (name !== "co-council") {
2672
+ const existingPerm = merged.permission ?? {};
2673
+ merged.permission = { ...existingPerm, ...COUNCIL_ONLY_TOOLS };
2674
+ } else {
2675
+ const existingPerm = merged.permission ?? {};
2676
+ merged.permission = { ...existingPerm, council_session: "allow" };
2677
+ }
1881
2678
  c.agent[name] = merged;
1882
2679
  }
1883
2680
  try {
1884
- fs3.writeFileSync(path3.join(STATE_DIR, "config-hook-ran.json"), JSON.stringify({ ran: true, count: agents.length }));
2681
+ fs5.writeFileSync(path4.join(STATE_DIR, "config-hook-ran.json"), JSON.stringify({ ran: true, count: agents.length }));
1885
2682
  } catch (err) {
1886
2683
  appendLog("config", "双重注册写入文件失败", err);
1887
2684
  }
@@ -1932,19 +2729,19 @@ var CoHubPlugin = async (input, options) => {
1932
2729
  if (details) {
1933
2730
  output.args[targetField] += details;
1934
2731
  }
1935
- const logPath = path3.join(os3.tmpdir(), "opencode", "ctx-diag.log");
2732
+ const logPath = path4.join(os4.tmpdir(), "opencode", "ctx-diag.log");
1936
2733
  try {
1937
- const stat = fs3.statSync(logPath);
2734
+ const stat = fs5.statSync(logPath);
1938
2735
  if (stat.size > 50 * 1024) {
1939
- const lines = fs3.readFileSync(logPath, "utf-8").trim().split(`
2736
+ const lines = fs5.readFileSync(logPath, "utf-8").trim().split(`
1940
2737
  `);
1941
- fs3.writeFileSync(logPath, lines.slice(-30).join(`
2738
+ fs5.writeFileSync(logPath, lines.slice(-30).join(`
1942
2739
  `) + `
1943
2740
  `);
1944
2741
  }
1945
2742
  } catch {}
1946
2743
  const finalPrompt = typeof output.args?.prompt === "string" ? output.args.prompt : "";
1947
- fs3.appendFileSync(logPath, JSON.stringify({
2744
+ fs5.appendFileSync(logPath, JSON.stringify({
1948
2745
  time: new Date().toISOString(),
1949
2746
  session: input2.sessionID?.slice(0, 20) ?? "?",
1950
2747
  subagent: subagentType,
@@ -1992,7 +2789,25 @@ var CoHubPlugin = async (input, options) => {
1992
2789
  syncTrackerState(tracker.currentParentSessionId);
1993
2790
  const job = tracker.getJobBySessionId(sessionId);
1994
2791
  if (job) {
1995
- contextEngine.captureResult(sessionId, job.alias, job.agent);
2792
+ contextEngine.captureResult(sessionId, job.alias, job.agent).then((captured) => {
2793
+ if (!captured)
2794
+ return;
2795
+ if (!isQualityEnabled(userConfig.quality))
2796
+ return;
2797
+ const quality = assessQuality({
2798
+ output: captured.output,
2799
+ decisions: captured.decisions,
2800
+ latencyMs: Date.now() - job.createdAt,
2801
+ tokens: captured.tokens
2802
+ });
2803
+ tracker.updateQuality(sessionId, {
2804
+ score: quality.score,
2805
+ ...quality.failureCategory ? { failureCategory: quality.failureCategory } : {},
2806
+ ...quality.latencyMs !== undefined ? { latencyMs: quality.latencyMs } : {},
2807
+ ...quality.tokens ? { tokens: quality.tokens } : {}
2808
+ });
2809
+ syncTrackerState(tracker.currentParentSessionId);
2810
+ }).catch((err) => appendLog("event", "质量回送失败", err));
1996
2811
  }
1997
2812
  } else if (e.type === "session.deleted") {
1998
2813
  tracker.updateByChildSessionId(sessionId, "errored");
@@ -2096,6 +2911,6 @@ var CoHubPlugin = async (input, options) => {
2096
2911
  var server = CoHubPlugin;
2097
2912
  var src_default = CoHubPlugin;
2098
2913
  export {
2099
- server,
2100
- src_default as default
2914
+ src_default as default,
2915
+ server
2101
2916
  };