alphacouncil-agent 0.5.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 (139) hide show
  1. package/.claude/agents/alphacouncil-bear_researcher.md +39 -0
  2. package/.claude/agents/alphacouncil-bull_researcher.md +40 -0
  3. package/.claude/agents/alphacouncil-earnings_deep_dive.md +49 -0
  4. package/.claude/agents/alphacouncil-forward_expectations.md +48 -0
  5. package/.claude/agents/alphacouncil-ib_event_analysis.md +40 -0
  6. package/.claude/agents/alphacouncil-insider_sec.md +39 -0
  7. package/.claude/agents/alphacouncil-macro_regime.md +31 -0
  8. package/.claude/agents/alphacouncil-market_data.md +37 -0
  9. package/.claude/agents/alphacouncil-market_narrative.md +45 -0
  10. package/.claude/agents/alphacouncil-news_industry_management.md +49 -0
  11. package/.claude/agents/alphacouncil-portfolio_manager.md +22 -0
  12. package/.claude/agents/alphacouncil-quant_factor.md +38 -0
  13. package/.claude/agents/alphacouncil-social_pulse.md +49 -0
  14. package/.claude/agents/alphacouncil-valuation_long_short.md +45 -0
  15. package/.claude-plugin/marketplace.json +30 -0
  16. package/.claude-plugin/plugin.json +28 -0
  17. package/.codex-plugin/plugin.json +32 -0
  18. package/.grok/agents/alphacouncil-bear_researcher.md +41 -0
  19. package/.grok/agents/alphacouncil-bull_researcher.md +42 -0
  20. package/.grok/agents/alphacouncil-earnings_deep_dive.md +50 -0
  21. package/.grok/agents/alphacouncil-forward_expectations.md +49 -0
  22. package/.grok/agents/alphacouncil-ib_event_analysis.md +41 -0
  23. package/.grok/agents/alphacouncil-insider_sec.md +40 -0
  24. package/.grok/agents/alphacouncil-macro_regime.md +32 -0
  25. package/.grok/agents/alphacouncil-market_data.md +38 -0
  26. package/.grok/agents/alphacouncil-market_narrative.md +46 -0
  27. package/.grok/agents/alphacouncil-news_industry_management.md +50 -0
  28. package/.grok/agents/alphacouncil-portfolio_manager.md +24 -0
  29. package/.grok/agents/alphacouncil-quant_factor.md +39 -0
  30. package/.grok/agents/alphacouncil-social_pulse.md +50 -0
  31. package/.grok/agents/alphacouncil-valuation_long_short.md +46 -0
  32. package/.grok/config.toml +4 -0
  33. package/.mcp.json +11 -0
  34. package/.opencode/agent/alphacouncil-bear_researcher.md +45 -0
  35. package/.opencode/agent/alphacouncil-bull_researcher.md +46 -0
  36. package/.opencode/agent/alphacouncil-earnings_deep_dive.md +54 -0
  37. package/.opencode/agent/alphacouncil-forward_expectations.md +53 -0
  38. package/.opencode/agent/alphacouncil-ib_event_analysis.md +45 -0
  39. package/.opencode/agent/alphacouncil-insider_sec.md +44 -0
  40. package/.opencode/agent/alphacouncil-macro_regime.md +36 -0
  41. package/.opencode/agent/alphacouncil-market_data.md +42 -0
  42. package/.opencode/agent/alphacouncil-market_narrative.md +50 -0
  43. package/.opencode/agent/alphacouncil-news_industry_management.md +54 -0
  44. package/.opencode/agent/alphacouncil-portfolio_manager.md +28 -0
  45. package/.opencode/agent/alphacouncil-quant_factor.md +43 -0
  46. package/.opencode/agent/alphacouncil-social_pulse.md +54 -0
  47. package/.opencode/agent/alphacouncil-valuation_long_short.md +50 -0
  48. package/AGENTS.md +96 -0
  49. package/CHANGELOG.md +112 -0
  50. package/CLAUDE.md +144 -0
  51. package/LICENSE +21 -0
  52. package/README.ja.md +282 -0
  53. package/README.md +361 -0
  54. package/README.zh-CN.md +282 -0
  55. package/data/industry-map.json +74 -0
  56. package/docs/attribution.md +99 -0
  57. package/docs/personas.md +59 -0
  58. package/docs/report-contract.md +110 -0
  59. package/mcp/lib/codex.mjs +152 -0
  60. package/mcp/lib/constants.mjs +139 -0
  61. package/mcp/lib/council-options.mjs +111 -0
  62. package/mcp/lib/errors.mjs +46 -0
  63. package/mcp/lib/feeds.mjs +140 -0
  64. package/mcp/lib/fsutil.mjs +69 -0
  65. package/mcp/lib/gates.mjs +208 -0
  66. package/mcp/lib/grounding.mjs +252 -0
  67. package/mcp/lib/headings.mjs +65 -0
  68. package/mcp/lib/industry.mjs +208 -0
  69. package/mcp/lib/lang.mjs +24 -0
  70. package/mcp/lib/macro.mjs +167 -0
  71. package/mcp/lib/markdown.mjs +337 -0
  72. package/mcp/lib/markets-kr-jp.mjs +180 -0
  73. package/mcp/lib/markets.mjs +261 -0
  74. package/mcp/lib/narrative.mjs +235 -0
  75. package/mcp/lib/options.mjs +216 -0
  76. package/mcp/lib/orchestrator.mjs +541 -0
  77. package/mcp/lib/output-modes.mjs +144 -0
  78. package/mcp/lib/packets.mjs +256 -0
  79. package/mcp/lib/personas/frontmatter.mjs +80 -0
  80. package/mcp/lib/personas/registry.mjs +215 -0
  81. package/mcp/lib/preflight.mjs +174 -0
  82. package/mcp/lib/prompts.mjs +151 -0
  83. package/mcp/lib/quotes.mjs +108 -0
  84. package/mcp/lib/rpc.mjs +737 -0
  85. package/mcp/lib/run-store.mjs +140 -0
  86. package/mcp/lib/screen.mjs +283 -0
  87. package/mcp/lib/sec.mjs +131 -0
  88. package/mcp/lib/social.mjs +261 -0
  89. package/mcp/lib/tables.mjs +150 -0
  90. package/mcp/lib/text.mjs +27 -0
  91. package/mcp/lib/weights.mjs +150 -0
  92. package/mcp/server.mjs +92 -0
  93. package/opencode.json +20 -0
  94. package/package.json +61 -0
  95. package/personas/_shared/_debate_base.md +46 -0
  96. package/personas/_shared/_evidence_base.md +56 -0
  97. package/personas/_shared/_master_base.md +77 -0
  98. package/personas/analysts/earnings_deep_dive.md +118 -0
  99. package/personas/analysts/forward_expectations.md +115 -0
  100. package/personas/analysts/ib_event_analysis.md +100 -0
  101. package/personas/analysts/insider_sec.md +98 -0
  102. package/personas/analysts/macro_regime.md +66 -0
  103. package/personas/analysts/market_data.md +94 -0
  104. package/personas/analysts/market_narrative.md +94 -0
  105. package/personas/analysts/news_industry_management.md +117 -0
  106. package/personas/analysts/quant_factor.md +97 -0
  107. package/personas/analysts/social_pulse.md +102 -0
  108. package/personas/analysts/valuation_long_short.md +109 -0
  109. package/personas/debate/bear_researcher.md +96 -0
  110. package/personas/debate/bull_researcher.md +98 -0
  111. package/personas/debate/portfolio_manager.md +64 -0
  112. package/personas/masters/masters-adversarial/burry.md +137 -0
  113. package/personas/masters/masters-adversarial/dalio.md +139 -0
  114. package/personas/masters/masters-adversarial/druckenmiller.md +144 -0
  115. package/personas/masters/masters-adversarial/short_seller.md +145 -0
  116. package/personas/masters/masters-adversarial/soros.md +137 -0
  117. package/personas/masters/masters-modern/aschenbrenner.md +147 -0
  118. package/personas/masters/masters-options/natenberg.md +168 -0
  119. package/personas/masters/masters-options/sinclair.md +166 -0
  120. package/personas/masters/masters-options/taleb.md +163 -0
  121. package/personas/masters/masters-quant/asness.md +142 -0
  122. package/personas/masters/masters-quant/simons.md +143 -0
  123. package/personas/masters/masters-quant/thorp.md +143 -0
  124. package/personas/masters/masters-value/buffett.md +123 -0
  125. package/personas/masters/masters-value/duan_yongping.md +142 -0
  126. package/personas/masters/masters-value/li_lu.md +146 -0
  127. package/personas/masters/masters-value/munger.md +117 -0
  128. package/personas/masters/masters-value-classic/fisher.md +140 -0
  129. package/personas/masters/masters-value-classic/graham.md +137 -0
  130. package/personas/masters/masters-value-classic/klarman.md +138 -0
  131. package/personas/masters/masters-value-classic/lynch.md +133 -0
  132. package/personas/masters/masters-value-classic/marks.md +145 -0
  133. package/personas/verifiers/rederivation.md +97 -0
  134. package/personas/verifiers/refuter.md +99 -0
  135. package/personas/verifiers/source_fidelity.md +91 -0
  136. package/skills/agent-skills-governance/SKILL.md +42 -0
  137. package/skills/alphacouncil-agent/SKILL.md +259 -0
  138. package/skills/investment-banking/SKILL.md +70 -0
  139. package/skills/public-equity-investing/SKILL.md +74 -0
@@ -0,0 +1,208 @@
1
+ import { DEBATE_ROLES, LIMITS, PLACEHOLDER_BODIES, REPORT_SECTIONS } from "./constants.mjs";
2
+ import { denseLength, normalizeHeading, parseHeadings } from "./headings.mjs";
3
+ import { isChineseLanguage } from "./lang.mjs";
4
+
5
+ export function withDisclaimer(markdown, language) {
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.";
11
+ return `${text}${note}`;
12
+ }
13
+
14
+ export function withVerificationBanner(markdown, gate, language) {
15
+ const text = typeof markdown === "string" ? markdown : "";
16
+ if (!gate || gate.verification !== "needs_verification") return text;
17
+ const pairs = gate.missing_claim_source_ids || [];
18
+ const lines = pairs.length
19
+ ? pairs.map((item) => `- ${item.task}: ${item.source_id}`).join("\n")
20
+ : "- (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
+ return `${text}${banner}`;
25
+ }
26
+
27
+ export function scopedSourceId(task, id, index = 0) {
28
+ const raw = String(id || `S${index + 1}`).trim() || `S${index + 1}`;
29
+ return raw.includes(":") ? raw : `${task}:${raw}`;
30
+ }
31
+
32
+ export function sourceManifest(run) {
33
+ const sources = [];
34
+ const known = new Set();
35
+ for (const packet of run.packets || []) {
36
+ for (const source of packet.sources || []) {
37
+ if (!source?.id) continue;
38
+ known.add(source.id);
39
+ sources.push({ task: packet.task, ...source });
40
+ }
41
+ }
42
+ const missing_claim_source_ids = [];
43
+ for (const packet of run.packets || []) {
44
+ for (const claim of packet.claims || []) {
45
+ for (const id of claim.source_ids || []) {
46
+ if (!known.has(id)) missing_claim_source_ids.push({ task: packet.task, source_id: id });
47
+ }
48
+ }
49
+ }
50
+ return {
51
+ run_id: run.run_id,
52
+ symbol: run.symbol,
53
+ as_of: run.as_of,
54
+ source_count: sources.length,
55
+ sources,
56
+ missing_claim_source_ids,
57
+ };
58
+ }
59
+
60
+ export function verificationStatus(run) {
61
+ const missing = sourceManifest(run).missing_claim_source_ids;
62
+ return {
63
+ verification: missing.length ? "needs_verification" : "passed",
64
+ missing_claim_source_ids: missing,
65
+ };
66
+ }
67
+
68
+ export function taskState(run, task) {
69
+ return run.task_status?.[task] || { task, status: "pending" };
70
+ }
71
+
72
+ export function agentState(run, role) {
73
+ return run.agent_status?.[role] || { role, status: "pending" };
74
+ }
75
+
76
+ export function completenessStatus(run) {
77
+ const tasks = Array.isArray(run.tasks) ? run.tasks : [];
78
+ const missing_evidence = tasks.filter((task) => taskState(run, task).status !== "completed");
79
+ // All three debate roles, including portfolio_manager. SKILL.md and the
80
+ // record_visible_decision tool description have always promised the PM is enforced;
81
+ // the gate only ever checked the two researchers, so a run that skipped the PM
82
+ // entirely could still report itself complete.
83
+ const missing_debate = DEBATE_ROLES.filter((role) => agentState(run, role).status !== "completed");
84
+ // A run that selected a master bench and recorded no opinions used to report itself
85
+ // complete. That let the most expensive stage be skipped silently while the report still
86
+ // read as a finished committee -- and a bench nobody consulted is worse than no bench,
87
+ // because the reader believes the verdict survived twenty-one lenses.
88
+ const selected = Array.isArray(run.masters) ? run.masters : [];
89
+ const recorded = new Set((run.master_opinions || []).map((o) => o.master));
90
+ const missing_masters = selected.filter((id) => !recorded.has(id));
91
+ const complete = missing_evidence.length === 0 && missing_debate.length === 0 && missing_masters.length === 0;
92
+ return {
93
+ completeness: complete ? "complete" : "incomplete",
94
+ missing_evidence,
95
+ missing_debate,
96
+ missing_masters,
97
+ missing_evidence_count: missing_evidence.length,
98
+ missing_debate_count: missing_debate.length,
99
+ missing_masters_count: missing_masters.length,
100
+ };
101
+ }
102
+
103
+ export function withCompletenessBanner(markdown, completeness, language) {
104
+ const text = typeof markdown === "string" ? markdown : "";
105
+ if (!completeness || completeness.completeness !== "incomplete") return text;
106
+ const ev = completeness.missing_evidence || [];
107
+ const db = completeness.missing_debate || [];
108
+ const ms = completeness.missing_masters || [];
109
+ const evLine = ev.length ? ev.map((task) => `- ${task}`).join("\n") : "- (none)";
110
+ const dbLine = db.length ? db.map((role) => `- ${role}`).join("\n") : "- (none)";
111
+ // Naming the skipped seats matters more than the flag: "incomplete" without a list
112
+ // invites the reader to assume it was something minor.
113
+ const msLine = ms.length ? ms.map((id) => `- ${id}`).join("\n") : "";
114
+ const banner = isChineseLanguage(language)
115
+ ? `> [!WARNING]\n## 流程未完成 / Incomplete Council Run\n\n**状态:incomplete。** 本次运行未跑完完整委员会流程,结论不可信。\n\n未完成的证据角色:\n${evLine}\n\n未完成的辩论角色:\n${dbLine}\n${msLine ? `\n未给出意见的大师席位:\n${msLine}\n` : ""}`
116
+ : `> [!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` : ""}`;
117
+ return `${banner}\n\n---\n\n${text}`;
118
+ }
119
+
120
+ /**
121
+ * Assign each heading to at most one required section, longest matching alias first.
122
+ *
123
+ * Without the longest-alias rule, "Quant Factor / Technical Risk View" would satisfy the
124
+ * risks section as well as the quant section, and a report could pass while genuinely
125
+ * having no risks section at all.
126
+ */
127
+ function assignHeadings(headings) {
128
+ const assigned = new Map();
129
+ for (const heading of headings) {
130
+ const normalized = normalizeHeading(heading.title);
131
+ if (!normalized) continue;
132
+ let best = null;
133
+ for (const section of REPORT_SECTIONS) {
134
+ for (const alias of section.aliases) {
135
+ const needle = normalizeHeading(alias);
136
+ if (!needle || !normalized.includes(needle)) continue;
137
+ if (!best || needle.length > best.needleLength) {
138
+ best = { section, needleLength: needle.length };
139
+ }
140
+ }
141
+ }
142
+ if (!best) continue;
143
+ const existing = assigned.get(best.section.id);
144
+ // Keep the richest body when a report repeats a section.
145
+ if (!existing || denseLength(heading.body) > denseLength(existing.body)) {
146
+ assigned.set(best.section.id, heading);
147
+ }
148
+ }
149
+ return assigned;
150
+ }
151
+
152
+ const isPlaceholder = (body) => {
153
+ const compact = String(body || "").replace(/^[-*+]\s*/, "").trim().toLowerCase();
154
+ return PLACEHOLDER_BODIES.includes(compact);
155
+ };
156
+
157
+ export function validateFinalReport(markdown, run) {
158
+ const text = String(markdown || "");
159
+ const headings = parseHeadings(text);
160
+ const assigned = assignHeadings(headings);
161
+ const missing = [];
162
+ const sections = [];
163
+
164
+ for (const section of REPORT_SECTIONS) {
165
+ const heading = assigned.get(section.id);
166
+ if (!heading) {
167
+ missing.push(`missing section: ${section.id}`);
168
+ sections.push({ id: section.id, status: "missing" });
169
+ continue;
170
+ }
171
+ const bodyChars = denseLength(heading.body);
172
+ if (isPlaceholder(heading.body)) {
173
+ missing.push(`placeholder section: ${section.id} ("${heading.title}")`);
174
+ sections.push({ id: section.id, status: "placeholder", heading: heading.title, line: heading.line, body_chars: bodyChars });
175
+ continue;
176
+ }
177
+ if (bodyChars < section.min_body) {
178
+ missing.push(`section too thin: ${section.id} ("${heading.title}") has ${bodyChars} of ${section.min_body} required characters`);
179
+ sections.push({ id: section.id, status: "too_thin", heading: heading.title, line: heading.line, body_chars: bodyChars });
180
+ continue;
181
+ }
182
+ sections.push({ id: section.id, status: "ok", heading: heading.title, line: heading.line, body_chars: bodyChars });
183
+ }
184
+
185
+ // Scoped to the analyst work log body. The old check searched the whole document, so
186
+ // a task id appearing once in the source table satisfied "this analyst was reported".
187
+ const workLog = assigned.get("analyst_work_log");
188
+ const workLogBody = (workLog?.body || "").toLowerCase();
189
+ for (const task of run.tasks || []) {
190
+ if (!workLogBody.includes(String(task).toLowerCase())) {
191
+ missing.push(`missing analyst work log entry: ${task}`);
192
+ }
193
+ }
194
+
195
+ const sourceCount = (run.packets || []).reduce((sum, packet) => sum + (packet.sources?.length || 0), 0);
196
+ if (sourceCount > 0 && !/[a-z_]+:s\d+/i.test(text)) missing.push("missing scoped source IDs such as market_data:S1");
197
+ const minLength = run.dry_run ? LIMITS.REPORT_MIN_CHARS_DRY : LIMITS.REPORT_MIN_CHARS;
198
+ if (denseLength(text) < minLength) missing.push(`report too short: minimum ${minLength} non-space characters`);
199
+
200
+ return {
201
+ schema_version: 2,
202
+ status: missing.length ? "needs_revision" : "passed",
203
+ missing,
204
+ sections,
205
+ checked_at: new Date().toISOString(),
206
+ required_sections: REPORT_SECTIONS.map((section) => section.id),
207
+ };
208
+ }
@@ -0,0 +1,252 @@
1
+ import { fetchQuote } from "./quotes.mjs";
2
+ import { getMacroSnapshot } from "./macro.mjs";
3
+ import { screenTicker } from "./screen.mjs";
4
+ import { resolveIndustry, industryCoverage } from "./industry.mjs";
5
+ import { fetchSubmissions, fetchUniverse } from "./sec.mjs";
6
+ import { fetchMarketFinancials, coverageFor, marketFor } from "./markets.mjs";
7
+
8
+ /**
9
+ * Hard facts, assembled before any analyst starts searching.
10
+ *
11
+ * The gap this closes: the tools already knew Micron's filed cash flow and the current
12
+ * 10-year yield, and the analysts did not. Each one went off and searched from nothing,
13
+ * free to report a number from a news summary that contradicts the filing without anyone
14
+ * noticing. Grounding puts the deterministic facts in the prompt first and changes what
15
+ * the search is FOR -- from producing numbers to explaining and challenging numbers that
16
+ * are already established.
17
+ *
18
+ * The discipline that makes this work rather than just adding context: a searched number
19
+ * never silently overwrites a filed one. A contradiction is reported as a contradiction.
20
+ */
21
+
22
+ async function safely(label, fn) {
23
+ try {
24
+ return { ok: true, value: await fn() };
25
+ } catch (error) {
26
+ // A gap stays a visible gap. Grounding that silently omits a failed source would be
27
+ // worse than no grounding, because the prompt would look complete.
28
+ return { ok: false, error: `${label}: ${String(error?.message || error)}` };
29
+ }
30
+ }
31
+
32
+ /**
33
+ * @param {object} options
34
+ * @param {string} [options.symbol] exchange ticker, for the quote
35
+ * @param {string} [options.cik] SEC CIK, for filings and the screen
36
+ * @param {string} [options.industry] industry query, for the chain map
37
+ * @param {boolean} [options.macro] include the macro snapshot
38
+ * @param {string} [options.asOf] only use filings filed by this date
39
+ */
40
+ export async function gatherGrounding({ symbol, cik, industry, macro = true, asOf = null } = {}) {
41
+ // Without this, a caller that has a ticker but not a CIK gets no filer profile and no
42
+ // mechanical screen -- the filings half of "established facts" disappears and nothing
43
+ // in the output says it was skipped.
44
+ if (!cik && symbol && marketFor(symbol)?.id === "US") {
45
+ cik = await fetchUniverse()
46
+ .then((rows) => rows.find((r) => String(r.ticker).toUpperCase() === String(symbol).toUpperCase())?.cik)
47
+ .catch(() => undefined);
48
+ }
49
+ const jobs = [];
50
+ const out = { as_of: asOf, gathered_at: new Date().toISOString(), unavailable: [] };
51
+
52
+ if (symbol) {
53
+ jobs.push(safely("quote", () => fetchQuote(symbol)).then((r) => {
54
+ if (r.ok && !r.value?.error) out.quote = r.value;
55
+ else out.unavailable.push(r.ok ? `quote: ${r.value.error}` : r.error);
56
+ }));
57
+ }
58
+
59
+ if (cik) {
60
+ jobs.push(safely("filer profile", () => fetchSubmissions(cik)).then((r) => {
61
+ if (r.ok) out.filer = r.value;
62
+ else out.unavailable.push(r.error);
63
+ }));
64
+ jobs.push(safely("screen", () => screenTicker({ cik, ticker: symbol, asOf })).then((r) => {
65
+ if (!r.ok) { out.unavailable.push(r.error); return; }
66
+ const s = r.value;
67
+ out.screen = {
68
+ verdict: s.verdict,
69
+ rules_computed: s.evaluated_count,
70
+ rules_total: s.rules.length,
71
+ // Only the computed rules: a skipped rule is not a fact about the company.
72
+ metrics: s.rules.filter((x) => !x.skipped).map((x) => ({
73
+ rule: x.id, label: x.label, value: x.value, unit: x.unit, threshold: x.threshold, direction: x.direction, passed: x.passed,
74
+ })),
75
+ failures: s.failures.map((f) => ({ rule: f.id, value: f.value, unit: f.unit, threshold: f.threshold })),
76
+ exemptions: s.exemptions,
77
+ skipped: s.rules.filter((x) => x.skipped).map((x) => ({ rule: x.id, label: x.label })),
78
+ };
79
+ }));
80
+ }
81
+
82
+ if (macro) {
83
+ jobs.push(safely("macro", () => getMacroSnapshot({ blocks: ["rates", "dollar_liquidity", "commodities"] })).then((r) => {
84
+ if (!r.ok) { out.unavailable.push(r.error); return; }
85
+ out.macro = {
86
+ derived: r.value.derived.filter((d) => d.available).map((d) => ({ id: d.id, label: d.label, value: d.value })),
87
+ unavailable: r.value.unavailable,
88
+ };
89
+ }));
90
+ }
91
+
92
+ // Non-US symbols never reach the SEC path, so without this they arrived at the analyst
93
+ // with nothing but a price.
94
+ if (symbol && marketFor(symbol)?.id !== "US") {
95
+ jobs.push(safely("market financials", () => fetchMarketFinancials(symbol)).then((r) => {
96
+ if (!r.ok) { out.unavailable.push(r.error); return; }
97
+ out.market = r.value;
98
+ if (!r.value.financials) out.unavailable.push(`structured financials for ${symbol}: ${r.value.guidance}`);
99
+ }));
100
+ }
101
+
102
+ if (industry) {
103
+ const curated = resolveIndustry(industry);
104
+ out.industry = {
105
+ query: industry,
106
+ coverage: industryCoverage(industry),
107
+ ...(curated
108
+ ? {
109
+ id: curated.id,
110
+ title: curated.title,
111
+ participants: curated.layers.flatMap((l) => l.participants.map((p) => ({ ...p, layer: l.layer }))),
112
+ demand_drivers: curated.demand_drivers,
113
+ key_questions: curated.key_questions,
114
+ cyclicality: curated.cyclicality,
115
+ }
116
+ : {}),
117
+ };
118
+ }
119
+
120
+ await Promise.all(jobs);
121
+
122
+ // Coverage across every symbol in play, so a report cannot quietly become US-only.
123
+ const inPlay = [symbol, ...(out.industry?.participants || []).map((p) => p.symbol)].filter(Boolean);
124
+ if (inPlay.length) out.coverage = coverageFor([...new Set(inPlay)]);
125
+ return out;
126
+ }
127
+
128
+ const fmt = (value, unit) => {
129
+ if (value === null || value === undefined) return "n/a";
130
+ if (unit === "%") return `${value}%`;
131
+ if (unit === "USD") return Math.abs(value) >= 1e9 ? `$${(value / 1e9).toFixed(2)}bn` : `$${value.toLocaleString("en-US")}`;
132
+ return unit ? `${value} ${unit}` : String(value);
133
+ };
134
+
135
+ /**
136
+ * Render grounding as a prompt block.
137
+ *
138
+ * The instructions are the substance here. Facts alone would just be more context for a
139
+ * model to paraphrase; what changes behaviour is telling it what these facts are FOR and
140
+ * what it may not do with them.
141
+ */
142
+ /**
143
+ * A skipped rule arrives either as a bare id or as {rule, label}. Handling only the object
144
+ * form silently rendered an empty list -- which reads as "nothing was skipped", the exact
145
+ * opposite of what the line exists to say.
146
+ */
147
+ const skippedName = (entry, chinese) =>
148
+ (typeof entry === "string" ? entry : localized(entry?.label, chinese) || entry?.rule) || String(entry ?? "");
149
+
150
+ /** Render a bilingual label. Every {en, zh} value must pass through here before display. */
151
+ const localized = (label, chinese) => {
152
+ if (label == null) return "";
153
+ if (typeof label === "string") return label;
154
+ return (chinese ? label.zh : label.en) ?? label.en ?? label.zh ?? "";
155
+ };
156
+
157
+ export function groundingBlock(grounding, language = "English") {
158
+ const chinese = /中文|chinese|zh/i.test(String(language));
159
+ if (!grounding || (!grounding.quote && !grounding.screen && !grounding.macro && !grounding.industry)) return "";
160
+
161
+ const lines = [];
162
+ const head = chinese
163
+ ? "## 已确立的事实(来自申报原文与交易所数据,不是你的记忆)"
164
+ : "## Established facts (from filings and exchange data, not from your memory)";
165
+ lines.push(head);
166
+
167
+ if (grounding.filer) {
168
+ lines.push(chinese
169
+ ? `- 主体:${grounding.filer.name}|SIC ${grounding.filer.sic ?? "未知"}(${grounding.filer.sic_description ?? "-"})|交易所 ${grounding.filer.exchanges.join(", ") || "未知"}`
170
+ : `- Filer: ${grounding.filer.name} | SIC ${grounding.filer.sic ?? "unknown"} (${grounding.filer.sic_description ?? "-"}) | exchange ${grounding.filer.exchanges.join(", ") || "unknown"}`);
171
+ }
172
+ if (grounding.quote) {
173
+ const q = grounding.quote;
174
+ lines.push(chinese
175
+ ? `- 行情(延迟约15分钟,${q.source}):${q.symbol} ${q.price}${q.currency ? " " + q.currency : ""}${q.change_pct != null ? `,${q.change_pct > 0 ? "+" : ""}${q.change_pct}%` : ""}`
176
+ : `- Quote (~15m delayed, ${q.source}): ${q.symbol} ${q.price}${q.currency ? " " + q.currency : ""}${q.change_pct != null ? `, ${q.change_pct > 0 ? "+" : ""}${q.change_pct}%` : ""}`);
177
+ }
178
+ if (grounding.screen) {
179
+ const s = grounding.screen;
180
+ lines.push(chinese
181
+ ? `- 硬指标筛选:${s.verdict === "survives" ? "通过" : "淘汰"}(${s.rules_computed}/${s.rules_total} 条可算)`
182
+ : `- Mechanical screen: ${s.verdict} (${s.rules_computed} of ${s.rules_total} rules computable)`);
183
+ for (const m of s.metrics) {
184
+ lines.push(` - ${localized(m.label, chinese)}: ${fmt(m.value, m.unit)} (${m.direction === "max" ? "max" : "min"} ${m.threshold}) ${m.passed ? "pass" : "FAIL"}`);
185
+ }
186
+ if (s.skipped.length) {
187
+ lines.push(chinese
188
+ ? ` - 无法从申报计算,未按通过处理:${s.skipped.map((x) => skippedName(x, chinese)).join("、")}`
189
+ : ` - Not computable from filings and NOT treated as passes: ${s.skipped.map((x) => skippedName(x, chinese)).join(", ")}`);
190
+ }
191
+ }
192
+ if (grounding.macro?.derived?.length) {
193
+ lines.push(chinese ? "- 宏观读数:" : "- Macro readings:");
194
+ // Derived labels are {en, zh}; interpolating the object printed "[object Object]"
195
+ // next to a real number, which reads as a broken field rather than a missing one.
196
+ for (const d of grounding.macro.derived) {
197
+ const name = localized(d.label, chinese) || d.id;
198
+ lines.push(` - ${name}: ${d.value}`);
199
+ }
200
+ }
201
+ if (grounding.market?.financials) {
202
+ const f = grounding.market.financials;
203
+ lines.push(chinese
204
+ ? `- ${f.source} 申报(${f.gregorian_year ?? f.period.year}Q${f.period.quarter},${f.currency} ${f.unit}):营收 ${f.revenue?.toLocaleString() ?? "n/a"}|毛利 ${f.gross_profit?.toLocaleString() ?? "n/a"}|营业利益 ${f.operating_income?.toLocaleString() ?? "n/a"}|EPS ${f.eps ?? "n/a"}`
205
+ : `- ${f.source} filing (${f.gregorian_year ?? f.period.year}Q${f.period.quarter}, ${f.currency} ${f.unit}): revenue ${f.revenue?.toLocaleString() ?? "n/a"} | gross profit ${f.gross_profit?.toLocaleString() ?? "n/a"} | operating income ${f.operating_income?.toLocaleString() ?? "n/a"} | EPS ${f.eps ?? "n/a"}`);
206
+ }
207
+ if (grounding.coverage?.rows?.length) {
208
+ const none = grounding.coverage.rows.filter((r) => r.structured_financials === "no").map((r) => r.symbol);
209
+ if (none.length) {
210
+ lines.push(chinese
211
+ ? `- 以下标的没有结构化财务源,任何关于它们的财务数字必须来自你读到的原始文件并注明出处:${none.join("、")}`
212
+ : `- No structured financial feed for: ${none.join(", ")}. Any financial figure for these must come from a primary document you actually read, and be cited as such.`);
213
+ }
214
+ }
215
+ if (grounding.industry?.participants?.length) {
216
+ const names = grounding.industry.participants.map((p) => `${p.name}${p.symbol ? ` (${p.symbol})` : " (unlisted)"}`);
217
+ lines.push(chinese
218
+ ? `- 产业链参与者(含非美,名单为人工维护):${names.join("、")}`
219
+ : `- Value-chain participants (includes non-US; list is hand-maintained): ${names.join(", ")}`);
220
+ }
221
+ if (grounding.unavailable?.length) {
222
+ lines.push(chinese ? `- 取不到的数据(属于数据缺口,禁止用记忆补):${grounding.unavailable.join(";")}`
223
+ : `- Could not be retrieved -- these are data gaps and must NOT be filled from memory: ${grounding.unavailable.join("; ")}`);
224
+ }
225
+
226
+ lines.push("");
227
+ lines.push(chinese
228
+ ? [
229
+ "**这些事实改变你搜索的目的。** 它们已经确立,不需要你再去找一遍。你联网搜索是为了:",
230
+ "1. **解释**这些数字为什么是这样——是什么业务变化导致的?",
231
+ "2. **补上**它们没覆盖的:最新一季、指引、管理层表态、竞争与监管动向、非美同业。",
232
+ "3. **挑战**它们:有没有公开信息说明这些数字会在下一期发生方向性变化?",
233
+ "",
234
+ "铁律:",
235
+ "- **搜到的数字不得覆盖申报数字。** 两者冲突时,两个都写出来、都给来源、明确指出这是冲突,并说明可能的口径差异(期间、GAAP/non-GAAP、币种、是否含一次性项)。让读者看到分歧,不要替读者选。",
236
+ "- 上面标为「无法计算」或「取不到」的,是真实的数据缺口。写进 open_questions,不要用你的背景知识填。",
237
+ "- 引用上面的事实时注明来自申报/交易所,与你搜到的来源分开标注。",
238
+ ].join("\n")
239
+ : [
240
+ "**These facts change what your search is for.** They are already established; do not go and re-find them. Search in order to:",
241
+ "1. **Explain** why the numbers look like this -- what change in the business produced them?",
242
+ "2. **Extend** what they do not cover: the latest quarter, guidance, management commentary, competitive and regulatory developments, non-US peers.",
243
+ "3. **Challenge** them: is there public information indicating these figures change direction next period?",
244
+ "",
245
+ "Hard rules:",
246
+ "- **A searched number never overwrites a filed number.** Where they conflict, report BOTH with their sources, say plainly that they conflict, and identify the likely reason (different period, GAAP vs non-GAAP, currency, inclusion of one-off items). Show the reader the disagreement rather than resolving it for them.",
247
+ "- Anything marked not computable or not retrieved above is a genuine data gap. Put it in open_questions; do not fill it from background knowledge.",
248
+ "- When you cite a fact from above, mark it as filing or exchange data, distinct from sources you found by searching.",
249
+ ].join("\n"));
250
+
251
+ return lines.join("\n");
252
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Minimal Markdown heading parser.
3
+ *
4
+ * The report quality gate used to lowercase the whole document and ask
5
+ * `haystack.includes("risk")`, so any report that happened to contain the word "risk"
6
+ * anywhere passed the "Risks section" check. Structure has to be parsed to be checked.
7
+ */
8
+
9
+ const ATX = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
10
+ const FENCE = /^(\s*)(```|~~~)/;
11
+
12
+ /**
13
+ * @returns {{level:number,title:string,body:string,line:number}[]}
14
+ * `body` runs from just after the heading to the next heading of the same or higher
15
+ * level (i.e. a subsection stays inside its parent's body).
16
+ */
17
+ export function parseHeadings(markdown) {
18
+ const lines = String(markdown || "").split(/\r?\n/);
19
+ const found = [];
20
+ let fence = null;
21
+
22
+ for (let i = 0; i < lines.length; i += 1) {
23
+ const line = lines[i];
24
+ const fenceMatch = FENCE.exec(line);
25
+ if (fenceMatch) {
26
+ const marker = fenceMatch[2];
27
+ if (fence === null) fence = marker;
28
+ else if (fence === marker) fence = null;
29
+ continue;
30
+ }
31
+ if (fence !== null) continue; // A '#' inside a code fence is not a heading.
32
+ const match = ATX.exec(line);
33
+ if (!match || !match[2]) continue;
34
+ found.push({ level: match[1].length, title: match[2], line: i + 1, start: i + 1 });
35
+ }
36
+
37
+ return found.map((heading, index) => {
38
+ let end = lines.length;
39
+ for (let j = index + 1; j < found.length; j += 1) {
40
+ if (found[j].level <= heading.level) {
41
+ end = found[j].start - 1;
42
+ break;
43
+ }
44
+ }
45
+ const { start, ...rest } = heading;
46
+ return { ...rest, body: lines.slice(start, end).join("\n").trim() };
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Fold a heading or alias into a comparable form: lowercase, punctuation and digits
52
+ * collapsed to spaces, CJK left intact (CJK has no word boundaries to preserve).
53
+ */
54
+ export function normalizeHeading(text) {
55
+ return String(text || "")
56
+ .toLowerCase()
57
+ .replace(/[^\p{Letter}\p{Number}]+/gu, " ")
58
+ .replace(/\s+/g, " ")
59
+ .trim();
60
+ }
61
+
62
+ /** Non-whitespace character count, the only length measure that survives CJK. */
63
+ export function denseLength(text) {
64
+ return String(text || "").replace(/\s+/g, "").length;
65
+ }