alphacouncil-agent 0.9.2 → 0.9.4

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 (79) hide show
  1. package/.claude/commands/alpha.md +22 -3
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.grok/commands/alpha.md +22 -3
  6. package/.opencode/command/alpha.md +22 -3
  7. package/AGENTS.md +27 -4
  8. package/CHANGELOG.md +67 -0
  9. package/CLAUDE.md +41 -7
  10. package/README.ja.md +21 -11
  11. package/README.md +35 -11
  12. package/README.zh-CN.md +31 -11
  13. package/commands/alpha.md +22 -3
  14. package/data/build-profile.v1.json +1 -1
  15. package/data/master-selector-method-locales.v1.mjs +286 -0
  16. package/docs/INSTALL.md +43 -6
  17. package/docs/releases/v0.9.3.md +86 -0
  18. package/docs/releases/v0.9.4.md +79 -0
  19. package/docs/report-contract.md +50 -1
  20. package/knowledge/ai-assisted-solo/experiments/runs/a.json +4 -4
  21. package/knowledge/ai-assisted-solo/experiments/runs/b.json +2 -2
  22. package/knowledge/ai-assisted-solo/experiments/runs/c.json +2 -2
  23. package/knowledge/ai-assisted-solo/experiments/runs/d13.json +16 -16
  24. package/knowledge/ai-assisted-solo/experiments/runs/d26.json +29 -29
  25. package/knowledge/ai-assisted-solo/experiments/runs/e-d13.json +19 -19
  26. package/knowledge/ai-assisted-solo/experiments/runs/e-d26.json +32 -32
  27. package/knowledge/ai-assisted-solo/experiments/runs/h_ai_reference.json +33 -33
  28. package/knowledge/ai-assisted-solo/experiments/simulation-input.json +79 -79
  29. package/knowledge/ai-assisted-solo/experiments/simulation-manifest.json +19 -19
  30. package/knowledge/solo-test/masters/master_ackman/manifest.json +14 -4
  31. package/knowledge/solo-test/masters/master_aschenbrenner/manifest.json +14 -4
  32. package/knowledge/solo-test/masters/master_asness/manifest.json +14 -4
  33. package/knowledge/solo-test/masters/master_buffett/manifest.json +14 -4
  34. package/knowledge/solo-test/masters/master_burry/manifest.json +14 -4
  35. package/knowledge/solo-test/masters/master_cathie_wood/manifest.json +14 -4
  36. package/knowledge/solo-test/masters/master_dalio/manifest.json +14 -4
  37. package/knowledge/solo-test/masters/master_damodaran/manifest.json +14 -4
  38. package/knowledge/solo-test/masters/master_druckenmiller/manifest.json +14 -4
  39. package/knowledge/solo-test/masters/master_duan_yongping/manifest.json +14 -4
  40. package/knowledge/solo-test/masters/master_fisher/manifest.json +14 -4
  41. package/knowledge/solo-test/masters/master_forensic_short/manifest.json +14 -4
  42. package/knowledge/solo-test/masters/master_graham/manifest.json +14 -4
  43. package/knowledge/solo-test/masters/master_jhunjhunwala/manifest.json +14 -4
  44. package/knowledge/solo-test/masters/master_klarman/manifest.json +14 -4
  45. package/knowledge/solo-test/masters/master_li_lu/manifest.json +14 -4
  46. package/knowledge/solo-test/masters/master_lynch/manifest.json +14 -4
  47. package/knowledge/solo-test/masters/master_marks/manifest.json +14 -4
  48. package/knowledge/solo-test/masters/master_munger/manifest.json +14 -4
  49. package/knowledge/solo-test/masters/master_natenberg/manifest.json +14 -4
  50. package/knowledge/solo-test/masters/master_pabrai/manifest.json +14 -4
  51. package/knowledge/solo-test/masters/master_simons/manifest.json +14 -4
  52. package/knowledge/solo-test/masters/master_sinclair/manifest.json +14 -4
  53. package/knowledge/solo-test/masters/master_soros/manifest.json +14 -4
  54. package/knowledge/solo-test/masters/master_taleb/manifest.json +14 -4
  55. package/knowledge/solo-test/masters/master_thorp/manifest.json +14 -4
  56. package/mcp/lib/codex.mjs +3 -3
  57. package/mcp/lib/constants.mjs +61 -25
  58. package/mcp/lib/council-options.mjs +95 -40
  59. package/mcp/lib/council-selection.mjs +95 -9
  60. package/mcp/lib/gates.mjs +78 -11
  61. package/mcp/lib/lang.mjs +109 -2
  62. package/mcp/lib/markdown.mjs +444 -135
  63. package/mcp/lib/master-catalog.mjs +28 -3
  64. package/mcp/lib/orchestrator.mjs +952 -265
  65. package/mcp/lib/packets.mjs +173 -20
  66. package/mcp/lib/personas/engine.mjs +30 -30
  67. package/mcp/lib/personas-v3/compiler.mjs +2 -0
  68. package/mcp/lib/personas-v3/loader.mjs +10 -7
  69. package/mcp/lib/personas-v3/source-acquisition.mjs +10 -3
  70. package/mcp/lib/prompts.mjs +33 -0
  71. package/mcp/lib/rpc.mjs +75 -33
  72. package/mcp/lib/run-store.mjs +23 -0
  73. package/package.json +2 -2
  74. package/schemas/persona-v3.schema.json +37 -2
  75. package/scripts/lib/packaged-host-parity.mjs +54 -5
  76. package/scripts/lib/persona-v3-solo-test-packs.mjs +31 -5
  77. package/scripts/package-smoke.mjs +31 -12
  78. package/scripts/run-tests.mjs +23 -2
  79. package/skills/alphacouncil-agent/SKILL.md +50 -7
package/mcp/lib/gates.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  import { DEBATE_ROLES, LIMITS, PLACEHOLDER_BODIES, QUICK_REPORT_SECTIONS, REPORT_SECTIONS } from "./constants.mjs";
2
2
  import { denseLength, headingIncludesAlias, normalizeHeading, parseHeadings } from "./headings.mjs";
3
- import { isChineseLanguage } from "./lang.mjs";
3
+ import { isChineseLanguage, languageKey, readerLanguageStatus } from "./lang.mjs";
4
4
 
5
5
  export function withDisclaimer(markdown, language) {
6
6
  const text = typeof markdown === "string" ? markdown : "";
7
- if (/##\s*(Disclaimer|免责声明)/i.test(text)) return text;
8
- const note = isChineseLanguage(language)
9
- ? "\n\n---\n\n## 免责声明\n\n本报告由 AI 自动生成,**仅供教育与研究**,**不构成投资建议**,不构成任何证券买卖推荐或要约。AI 分析可能不完整、过时或错误。投资决策前请自行核实并咨询持牌专业人士。作者不对任何损失承担责任。"
10
- : "\n\n---\n\n## Disclaimer\n\nThis report is AI-generated for **educational and research purposes only**. It is **not investment advice**, not a recommendation to buy or sell any security, and not a solicitation. AI analysis can be incomplete, outdated, or wrong. Do your own research and consult a licensed professional before any investment decision. The authors accept no liability for any loss.";
7
+ if (/##\s*(Disclaimer|免责声明|免責事項|면책)/iu.test(text)) return text;
8
+ const note = {
9
+ zh: "\n\n---\n\n## 免责声明\n\n本报告由 AI 自动生成,**仅供教育与研究**,**不构成投资建议**,也不构成任何证券买卖推荐或要约。AI 分析可能不完整、过时或错误。投资决策前请自行核实并咨询持牌专业人士。作者不对任何损失承担责任。",
10
+ en: "\n\n---\n\n## Disclaimer\n\nThis report is AI-generated for **educational and research purposes only**. It is **not investment advice**, not a recommendation to buy or sell any security, and not a solicitation. AI analysis can be incomplete, outdated, or wrong. Do your own research and consult a licensed professional before any investment decision. The authors accept no liability for any loss.",
11
+ ja: "\n\n---\n\n## 免責事項\n\n本レポートは AI が生成した**教育・調査目的のみ**の資料で、**投資助言ではありません**。証券の売買推奨や勧誘でもありません。AI の分析は不完全、古い、または誤っている可能性があります。投資判断の前にご自身で確認し、資格を有する専門家へ相談してください。作成者は損失について責任を負いません。",
12
+ ko: "\n\n---\n\n## 면책 조항\n\n이 보고서는 AI가 생성한 **교육 및 연구 목적의 자료**이며 **투자 조언이 아닙니다**. 증권 매매 권유나 청약도 아닙니다. AI 분석은 불완전하거나 오래되었거나 틀릴 수 있습니다. 투자 결정 전에 직접 확인하고 자격을 갖춘 전문가와 상의하십시오. 작성자는 손실에 대해 책임을 지지 않습니다.",
13
+ }[languageKey(language)];
11
14
  return `${text}${note}`;
12
15
  }
13
16
 
@@ -18,9 +21,12 @@ export function withVerificationBanner(markdown, gate, language) {
18
21
  const lines = pairs.length
19
22
  ? pairs.map((item) => `- ${item.task}: ${item.source_id}`).join("\n")
20
23
  : "- (unspecified)";
21
- const banner = isChineseLanguage(language)
22
- ? `\n\n---\n\n## 来源核验 / Source Verification Gate\n\n**状态:needs_verification。** 以下重大论断引用了不存在的来源 ID,本次运行尚未通过来源核验:\n\n${lines}\n`
23
- : `\n\n---\n\n## Source Verification Gate / 来源核验\n\n**Status: needs_verification.** The following material claims cite source IDs that are not present in any evidence packet; this run has NOT passed source verification:\n\n${lines}\n`;
24
+ const banner = {
25
+ zh: `\n\n---\n\n## 来源核验\n\n**状态:needs_verification。** 以下重大论断引用了证据包中不存在的来源 ID;本轮尚未通过来源核验:\n\n${lines}\n`,
26
+ en: `\n\n---\n\n## Source Verification Gate\n\n**Status: needs_verification.** The following material claims cite source IDs that are not present in any evidence packet; this run has NOT passed source verification:\n\n${lines}\n`,
27
+ ja: `\n\n---\n\n## 出典検証ゲート\n\n**状態:needs_verification。** 次の重要な主張は証拠パケットに存在しない出典 ID を参照しています。この実行は出典検証を通過していません:\n\n${lines}\n`,
28
+ ko: `\n\n---\n\n## 출처 검증 게이트\n\n**상태: needs_verification.** 다음 중요 주장은 증거 패킷에 없는 출처 ID를 참조합니다. 이 실행은 출처 검증을 통과하지 못했습니다:\n\n${lines}\n`,
29
+ }[languageKey(language)];
24
30
  return `${text}${banner}`;
25
31
  }
26
32
 
@@ -139,9 +145,12 @@ export function withCompletenessBanner(markdown, completeness, language) {
139
145
  // Naming the skipped seats matters more than the flag: "incomplete" without a list
140
146
  // invites the reader to assume it was something minor.
141
147
  const msLine = ms.length ? ms.map((id) => `- ${id}`).join("\n") : "";
142
- const banner = isChineseLanguage(language)
143
- ? `> [!WARNING]\n## 流程未完成 / Incomplete Council Run\n\n**状态:incomplete。** 本次运行未跑完完整委员会流程,结论不可信。\n\n未完成的证据角色:\n${evLine}\n\n未完成的辩论角色:\n${dbLine}\n${msLine ? `\n未给出意见的大师席位:\n${msLine}\n` : ""}`
144
- : `> [!WARNING]\n## Incomplete Council Run / 流程未完成\n\n**Status: incomplete.** This run did NOT execute the full council workflow; the conclusion is unreliable.\n\nMissing evidence roles:\n${evLine}\n\nMissing debate roles:\n${dbLine}\n${msLine ? `\nMaster seats that gave no opinion:\n${msLine}\n` : ""}`;
148
+ const banner = {
149
+ zh: `> [!WARNING]\n## 委员会流程未完成\n\n**状态:incomplete。** 本轮没有完成全部委员会流程,结论不可靠。\n\n未完成的证据席:\n${evLine}\n\n未完成的辩论席:\n${dbLine}\n${msLine ? `\n未给出意见的方法席:\n${msLine}\n` : ""}`,
150
+ en: `> [!WARNING]\n## Incomplete Council Run\n\n**Status: incomplete.** This run did NOT execute the full council workflow; the conclusion is unreliable.\n\nMissing evidence roles:\n${evLine}\n\nMissing debate roles:\n${dbLine}\n${msLine ? `\nMethod seats that gave no opinion:\n${msLine}\n` : ""}`,
151
+ ja: `> [!WARNING]\n## 委員会プロセス未完了\n\n**状態:incomplete。** 委員会の全工程を完了していないため、結論は信頼できません。\n\n未完了の証拠席:\n${evLine}\n\n未完了の討論席:\n${dbLine}\n${msLine ? `\n意見を記録できなかったメソッド席:\n${msLine}\n` : ""}`,
152
+ ko: `> [!WARNING]\n## 위원회 실행 미완료\n\n**상태: incomplete.** 전체 위원회 절차가 완료되지 않아 결론을 신뢰할 수 없습니다.\n\n미완료 증거 좌석:\n${evLine}\n\n미완료 토론 좌석:\n${dbLine}\n${msLine ? `\n의견을 기록하지 못한 방법론 좌석:\n${msLine}\n` : ""}`,
153
+ }[languageKey(language)];
145
154
  return `${banner}\n\n---\n\n${text}`;
146
155
  }
147
156
 
@@ -182,6 +191,54 @@ const isPlaceholder = (body) => {
182
191
  return PLACEHOLDER_BODIES.includes(compact);
183
192
  };
184
193
 
194
+ const LANGUAGE_CORE_SECTIONS = new Set([
195
+ "conclusion", "analyst_work_log", "debate_record", "master_bench", "market_expectations",
196
+ "earnings_call", "news", "valuation", "price_levels", "risks", "position", "data_gaps",
197
+ "invalidation",
198
+ ]);
199
+
200
+ function readerLanguageAudit(assigned, language) {
201
+ const requested = languageKey(language);
202
+ const mismatched = [];
203
+ let targetCharacters = 0;
204
+ let observedCharacters = 0;
205
+ const bodies = [];
206
+ for (const id of LANGUAGE_CORE_SECTIONS) {
207
+ const heading = assigned.get(id);
208
+ const body = heading?.body || "";
209
+ if (!body.trim()) continue;
210
+ bodies.push(body);
211
+ const section = readerLanguageStatus(body, language, { minimumTargetCharacters: 2, minimumRatio: 0.05 });
212
+ targetCharacters += section.target_characters;
213
+ observedCharacters += section.reader_characters;
214
+ // Pure Han fragments are inconclusive across Chinese/Japanese. Let the whole
215
+ // report establish the locale, but reject sections with positive other-language
216
+ // evidence so one translated heading cannot hide a foreign-language body.
217
+ if (![requested, "shared_han", "undetermined"].includes(section.observed_locale)) mismatched.push(id);
218
+
219
+ const titleProbe = readerLanguageStatus(heading?.title || "", language, { minimumTargetCharacters: 1, minimumRatio: 0 });
220
+ const { scripts = {} } = titleProbe;
221
+ let titleLocale = "undetermined";
222
+ if ((scripts.hangul || 0) > 0) titleLocale = "ko";
223
+ else if ((scripts.kana || 0) > 0 || (titleProbe.japanese_markers || 0) > 0) titleLocale = "ja";
224
+ else if ((titleProbe.chinese_markers || 0) > 0) titleLocale = "zh";
225
+ else if ((scripts.latin || 0) > 0 && (scripts.han || 0) === 0) titleLocale = "en";
226
+ else if ((scripts.han || 0) > 0) titleLocale = "shared_han";
227
+ const sharedHanAllowed = titleLocale === "shared_han" && ["zh", "ja"].includes(requested);
228
+ if (![requested, "undetermined"].includes(titleLocale) && !sharedHanAllowed) mismatched.push(`heading:${id}`);
229
+ }
230
+ const whole = readerLanguageStatus(bodies.join("\n"), language, { minimumTargetCharacters: 12, minimumRatio: 0.08 });
231
+ const languageStatus = mismatched.length || whole.status !== "passed" ? "failed" : "passed";
232
+ return {
233
+ requested_locale: requested,
234
+ observed_locale: whole.observed_locale,
235
+ language_status: languageStatus,
236
+ target_script_characters: whole.target_characters || targetCharacters,
237
+ reader_characters_checked: observedCharacters,
238
+ mismatched_sections: mismatched,
239
+ };
240
+ }
241
+
185
242
  export function validateFinalReport(markdown, run) {
186
243
  const text = String(markdown || "");
187
244
  const headings = parseHeadings(text);
@@ -231,6 +288,10 @@ export function validateFinalReport(markdown, run) {
231
288
  : quick ? LIMITS.REPORT_MIN_CHARS_QUICK : LIMITS.REPORT_MIN_CHARS;
232
289
  if (denseLength(text) < minLength) missing.push(`report too short: minimum ${minLength} non-space characters`);
233
290
  const execution = completenessStatus(run || {});
291
+ const languageAudit = readerLanguageAudit(assigned, run?.language);
292
+ if (languageAudit.language_status !== "passed") {
293
+ missing.push(`report reader language mismatch: requested=${languageAudit.requested_locale}; sections=${languageAudit.mismatched_sections.join(",") || "insufficient target-language text"}`);
294
+ }
234
295
  if (quick) {
235
296
  const markerCount = (text.match(/alphacouncil:quick-scope:v1:begin/gu) || []).length;
236
297
  if (markerCount !== 1) missing.push("missing system-owned quick_v1 scope marker");
@@ -263,6 +324,12 @@ export function validateFinalReport(markdown, run) {
263
324
  evidence_coverage: execution.evidence_coverage,
264
325
  degraded_evidence: execution.degraded_evidence,
265
326
  degraded_debate: execution.degraded_debate,
327
+ requested_locale: languageAudit.requested_locale,
328
+ observed_locale: languageAudit.observed_locale,
329
+ language_status: languageAudit.language_status,
330
+ language_mismatched_sections: languageAudit.mismatched_sections,
331
+ target_script_characters: languageAudit.target_script_characters,
332
+ reader_characters_checked: languageAudit.reader_characters_checked,
266
333
  status: missing.length ? "needs_revision" : "passed",
267
334
  missing,
268
335
  sections,
package/mcp/lib/lang.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  export function normalizeLanguage(value) {
2
2
  const text = String(value || "").trim();
3
- if (!text || /^auto|default|same|follow|跟随|默认$/i.test(text)) return "";
3
+ if (!text || /^(?:auto|default|same|follow|跟随|默认)$/i.test(text)) return "";
4
4
  if (/^(zh|zh-cn|cn|chinese|中文|简体中文|繁体中文)$/i.test(text)) return "中文";
5
5
  if (/^(en|en-us|english|英文)$/i.test(text)) return "English";
6
6
  if (/^(ja|jp|ja-jp|japanese|日文|日本語)$/i.test(text)) return "日本語";
7
- if (/^(ko|kr|korean|韩文|韓文|한국어)$/i.test(text)) return "한국어";
7
+ if (/^(ko|kr|ko-kr|korean|韩文|韓文|한국어)$/i.test(text)) return "한국어";
8
8
  return text.slice(0, 40);
9
9
  }
10
10
 
@@ -22,3 +22,110 @@ export function resolveLanguage(args = {}) {
22
22
  export function isChineseLanguage(language) {
23
23
  return /中文|chinese|zh/i.test(String(language || ""));
24
24
  }
25
+
26
+ export function languageKey(language) {
27
+ const text = String(language || "");
28
+ if (/中文|chinese|zh/i.test(text)) return "zh";
29
+ if (/日本語|japanese|ja/i.test(text)) return "ja";
30
+ if (/한국어|korean|ko/i.test(text)) return "ko";
31
+ return "en";
32
+ }
33
+
34
+ export function localized(language, messages) {
35
+ const key = languageKey(language);
36
+ return messages?.[key] ?? messages?.en ?? "";
37
+ }
38
+
39
+ const count = (value, pattern) => (value.match(pattern) || []).length;
40
+
41
+ /**
42
+ * Detect reader prose without pretending that Han characters alone distinguish
43
+ * Chinese from Japanese. URLs, stable ids, tickers and JSON keys are removed first.
44
+ * The result is deliberately tri-state: an all-Han fragment can be shared_script,
45
+ * while a whole report or worker packet must still provide positive target-language
46
+ * evidence before it is accepted.
47
+ */
48
+ export function readerLanguageStatus(text, language, { minimumTargetCharacters = 4, minimumRatio = 0.08 } = {}) {
49
+ const requested = languageKey(language);
50
+ const value = String(text || "").replace(
51
+ /https?:\/\/\S+|`[^`]+`|\b[a-z]+(?:_[a-z0-9]+)+\b|\b[A-Z0-9_:-]{3,}\b/gu,
52
+ "",
53
+ );
54
+ const scripts = {
55
+ latin: count(value, /[A-Za-z]/gu),
56
+ han: count(value, /\p{Script=Han}/gu),
57
+ kana: count(value, /[\p{Script=Hiragana}\p{Script=Katakana}]/gu),
58
+ hangul: count(value, /\p{Script=Hangul}/gu),
59
+ };
60
+ const scriptCharacters = scripts.latin + scripts.han + scripts.kana + scripts.hangul;
61
+ const englishWords = count(value, /\b[A-Za-z][A-Za-z'-]{1,}\b/gu);
62
+ // Strong Japanese orthography covers legitimate finance fragments that contain
63
+ // only Kanji, such as "売上高100億円、営業利益20億円。".
64
+ const japaneseMarkers = count(
65
+ value,
66
+ /売上高|営業利益|経常利益|前年同期|億円|兆円|株価|一株当たり|見通し|割安|割高/gu,
67
+ );
68
+ // zh-CN needs positive Chinese evidence rather than treating every Han-only
69
+ // fragment as Chinese. The character set intentionally uses simplified forms;
70
+ // common Chinese function words also cover natural traditional-Chinese prose.
71
+ const chineseMarkers = count(
72
+ value,
73
+ /[这们为与后发资价长净现应从过还将对门间时来买卖稳处务审计报损证据实认风险经营让给开关币额节把已核验断确录场环恶状况财变达点则无并区进增论结师话量闻值仓催剂数议项标属声说]|的|了|是|在|和|以及|因此|但是|如果|本轮|本节|未知|不可用/gu,
74
+ );
75
+ const ratios = Object.fromEntries(Object.entries(scripts).map(([key, hits]) => [
76
+ key,
77
+ scriptCharacters ? hits / scriptCharacters : 0,
78
+ ]));
79
+
80
+ let observed = "undetermined";
81
+ if (scripts.hangul >= 2 && ratios.hangul >= 0.2) observed = "ko";
82
+ else if ((scripts.kana >= 2 && (scripts.kana / Math.max(1, scripts.kana + scripts.han)) >= 0.03) || japaneseMarkers >= 2) observed = "ja";
83
+ else if (scripts.latin >= 12 && englishWords >= 3 && ratios.latin >= 0.6) observed = "en";
84
+ else if (scripts.han >= 4 && scripts.kana === 0 && scripts.hangul === 0 && ratios.han >= 0.6) {
85
+ observed = chineseMarkers > 0 ? "zh" : "shared_han";
86
+ }
87
+
88
+ const targets = {
89
+ en: scripts.latin,
90
+ zh: scripts.han,
91
+ ja: scripts.kana + japaneseMarkers,
92
+ ko: scripts.hangul,
93
+ };
94
+ const target = targets[requested] || 0;
95
+ const targetRatio = {
96
+ en: ratios.latin,
97
+ zh: ratios.han,
98
+ ja: scriptCharacters ? (scripts.kana + Math.min(scripts.han, japaneseMarkers * 4)) / scriptCharacters : 0,
99
+ ko: ratios.hangul,
100
+ }[requested] || 0;
101
+ const positive = {
102
+ en: observed === "en",
103
+ zh: observed === "zh" && japaneseMarkers < 2,
104
+ ja: observed === "ja",
105
+ ko: observed === "ko",
106
+ }[requested] === true;
107
+ const enough = requested === "en"
108
+ ? scripts.latin >= Math.max(12, minimumTargetCharacters) && englishWords >= 3 && targetRatio >= Math.max(0.6, minimumRatio)
109
+ : target >= minimumTargetCharacters && targetRatio >= minimumRatio;
110
+ return {
111
+ status: positive && enough ? "passed" : "failed",
112
+ requested_locale: requested,
113
+ observed_locale: observed,
114
+ target_characters: target,
115
+ reader_characters: scriptCharacters,
116
+ ratio: targetRatio,
117
+ scripts,
118
+ english_words: englishWords,
119
+ japanese_markers: japaneseMarkers,
120
+ chinese_markers: chineseMarkers,
121
+ };
122
+ }
123
+
124
+ export function assertReaderLanguage(text, language, label = "worker output") {
125
+ const result = readerLanguageStatus(text, language);
126
+ if (result.status === "passed") return result;
127
+ const error = new Error(`${label} reader language mismatch: requested=${result.requested_locale}; target_characters=${result.target_characters}; ratio=${result.ratio}`);
128
+ error.code = "READER_LANGUAGE_MISMATCH";
129
+ error.data = result;
130
+ throw error;
131
+ }