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,261 @@
1
+ import { LIMITS } from "./constants.mjs";
2
+ import { parseFeed, applyRecencyGate } from "./feeds.mjs";
3
+
4
+ /**
5
+ * Keyless social signal: retail positioning and technical-community discussion.
6
+ *
7
+ * The goal was an X hotspot layer. As of 2026-07-26 that is not obtainable without paying:
8
+ * Nitter is dead (search returns nothing on every surviving instance), the X API bills per
9
+ * post, xAI's x_search bills per call, StockTwits sits behind Cloudflare, and Bluesky's
10
+ * searchPosts requires auth. All tested, none assumed.
11
+ *
12
+ * What IS free, and was verified working:
13
+ * - Reddit subreddit and search RSS (rate-limited; 429s under load, so it backs off)
14
+ * - Hacker News via the Algolia index
15
+ * - Bluesky getAuthorFeed for a named account, which needs no auth even though search does
16
+ * - X single-post verification by id via cdn.syndication.twimg.com
17
+ *
18
+ * The output says plainly what this is and is not. Reddit and HN are retail and engineer
19
+ * opinion; they are NOT the professional FinTwit layer, and treating them as a proxy for it
20
+ * is the mistake this module is written to prevent.
21
+ */
22
+
23
+ const UA = "alphacouncil-agent/0.4 (open-source equity research; contact via repository issues)";
24
+
25
+ async function getText(url, { timeoutMs = LIMITS.QUOTE_FETCH_MS * 2, accept = "*/*" } = {}) {
26
+ const ctrl = new AbortController();
27
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
28
+ try {
29
+ const res = await fetch(url, { signal: ctrl.signal, headers: { "User-Agent": UA, Accept: accept } });
30
+ if (res.status === 429) return { ok: false, status: 429, reason: "rate limited" };
31
+ if (!res.ok) return { ok: false, status: res.status, reason: `HTTP ${res.status}` };
32
+ return { ok: true, status: res.status, text: await res.text() };
33
+ } catch (error) {
34
+ return { ok: false, status: 0, reason: String(error?.name === "AbortError" ? "timed out" : error?.message || error) };
35
+ } finally {
36
+ clearTimeout(timer);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Reddit answers 429 readily and recovers within seconds. One retry after a pause turns a
42
+ * dead source into a working one; without it the layer reports "unavailable" for a source
43
+ * that is merely busy, which understates coverage in a way nobody would notice.
44
+ */
45
+ async function getTextWithBackoff(url, opts = {}) {
46
+ const first = await getText(url, opts);
47
+ if (first.ok || first.status !== 429) return first;
48
+ await new Promise((r) => setTimeout(r, 2500));
49
+ return getText(url, opts);
50
+ }
51
+
52
+ /** Subreddits whose subject matter is equities. Deliberately conservative and well-known. */
53
+ export const DEFAULT_SUBREDDITS = ["stocks", "investing", "SecurityAnalysis", "wallstreetbets", "options"];
54
+
55
+ export async function fetchReddit({ subreddits = DEFAULT_SUBREDDITS, query = null, days = 7, asOf = null } = {}) {
56
+ const subs = (subreddits || DEFAULT_SUBREDDITS).slice(0, 6);
57
+ // A site-wide search for a ticker name returns whatever Reddit happens to be discussing:
58
+ // a live run for "Micron" came back with medical posts. Searching inside the equity
59
+ // subreddits keeps the source on topic instead of quietly filling with noise.
60
+ const specs = query
61
+ ? [{
62
+ source: `Reddit search in r/${subs.join("+")}`,
63
+ url: `https://www.reddit.com/r/${subs.map(encodeURIComponent).join("+")}/search.rss`
64
+ + `?q=${encodeURIComponent(query)}&restrict_sr=1&sort=new&t=month`,
65
+ }]
66
+ : subs.map((s) => ({ source: `r/${s}`, url: `https://www.reddit.com/r/${encodeURIComponent(s)}/.rss` }));
67
+
68
+ const results = [];
69
+ // Sequential on purpose: Reddit rate-limits aggressively and parallel requests trip it.
70
+ for (const spec of specs) {
71
+ const res = await getTextWithBackoff(spec.url, { accept: "application/atom+xml, application/xml" });
72
+ results.push({
73
+ ...spec,
74
+ ok: res.ok,
75
+ reason: res.reason,
76
+ items: res.ok ? parseFeed(res.text, { source: spec.source }) : [],
77
+ });
78
+ }
79
+ const gate = applyRecencyGate(results.flatMap((r) => r.items), { days, asOf });
80
+ // Reddit search matches post bodies as well as titles, so a result whose title omits the
81
+ // term is a legitimate hit, not noise -- filtering those out would silently drop real
82
+ // matches. Marking where the term matched lets a reader see which ones need opening
83
+ // instead of judging relevance from a title that was never the thing that matched.
84
+ if (query) {
85
+ const term = String(query).toLowerCase();
86
+ gate.included = gate.included.map((item) => ({
87
+ ...item,
88
+ matched_in: String(item.title || "").toLowerCase().includes(term) ? "title" : "body (open the link to see the mention)",
89
+ }));
90
+ }
91
+ return {
92
+ platform: "reddit",
93
+ what_this_is: "Retail investor discussion. Useful for crowding and for what a popular narrative sounds like. "
94
+ + "It is not professional analysis and must never be cited as evidence about a business.",
95
+ ...gate,
96
+ feeds: results.map(({ source, ok, reason, items }) => ({ source, ok, reason, item_count: items.length })),
97
+ };
98
+ }
99
+
100
+ export async function fetchHackerNews({ query, days = 7, asOf = null, hits = 30 } = {}) {
101
+ if (!query) return { platform: "hackernews", ok: false, reason: "a query is required", included: [], excluded: [] };
102
+ const now = asOf ? Date.parse(`${asOf}T23:59:59Z`) : Date.now();
103
+ const since = Math.floor((now - days * 86400000) / 1000);
104
+ // Algolia applies typo tolerance by default, which made "Micron" match "microkernels" and
105
+ // "Microsoft" in a live run. Quoting the phrase and disabling typo tolerance is the fix;
106
+ // without it the layer looks well-sourced while discussing a different subject entirely.
107
+ const url = `https://hn.algolia.com/api/v1/search_by_date?query=${encodeURIComponent(`"${query}"`)}`
108
+ + `&tags=story&numericFilters=created_at_i>${since}&hitsPerPage=${Math.min(hits, 50)}`
109
+ + "&typoTolerance=false&advancedSyntax=true";
110
+ const res = await getText(url, { accept: "application/json" });
111
+ if (!res.ok) return { platform: "hackernews", ok: false, reason: res.reason, included: [], excluded: [] };
112
+ let json;
113
+ try { json = JSON.parse(res.text); } catch { return { platform: "hackernews", ok: false, reason: "unparsable response", included: [], excluded: [] }; }
114
+ const included = (json.hits || []).map((h) => ({
115
+ title: h.title || h.story_title,
116
+ link: h.url || `https://news.ycombinator.com/item?id=${h.objectID}`,
117
+ published_at: h.created_at,
118
+ source: "Hacker News",
119
+ points: h.points ?? 0,
120
+ comments: h.num_comments ?? 0,
121
+ discussion: `https://news.ycombinator.com/item?id=${h.objectID}`,
122
+ })).filter((h) => h.title);
123
+ const term = String(query).toLowerCase();
124
+ const onTopic = included.filter((h) => h.title.toLowerCase().includes(term));
125
+ const offTopic = included.length - onTopic.length;
126
+ return {
127
+ platform: "hackernews",
128
+ ok: true,
129
+ what_this_is: "Engineer and technical-community discussion. Its value is the comment thread, where people "
130
+ + "who build the thing being discussed correct the article. Weak on financials, occasionally very strong "
131
+ + "on whether a technical claim is real.",
132
+ total_matching: json.nbHits ?? included.length,
133
+ included: onTopic,
134
+ // Belt and braces: the service can still return a near-match, and a near-match on a
135
+ // ticker name is a different company.
136
+ dropped_off_topic: offTopic,
137
+ excluded: [],
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Bluesky, by account only.
143
+ *
144
+ * searchPosts returns 403 without auth, so cashtag discovery is not possible. getAuthorFeed
145
+ * is public, which makes a curated account list workable -- and a curated list was the right
146
+ * design anyway: cashtag search is where the promotional accounts live.
147
+ */
148
+ export async function fetchBluesky({ handles = [], days = 7, asOf = null, limit = 20 } = {}) {
149
+ if (!handles.length) {
150
+ return {
151
+ platform: "bluesky", ok: true, configured: false, included: [], excluded: [],
152
+ note: "No handles configured. Bluesky search requires authentication, but reading a named account does not, "
153
+ + "so this source activates as soon as handles are supplied. Add them under social_handles.",
154
+ };
155
+ }
156
+ const out = [];
157
+ const failed = [];
158
+ for (const handle of handles.slice(0, 12)) {
159
+ const url = "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed"
160
+ + `?actor=${encodeURIComponent(handle)}&limit=${Math.min(limit, 50)}`;
161
+ const res = await getText(url, { accept: "application/json" });
162
+ if (!res.ok) { failed.push({ handle, reason: res.reason }); continue; }
163
+ let json;
164
+ try { json = JSON.parse(res.text); } catch { failed.push({ handle, reason: "unparsable response" }); continue; }
165
+ for (const entry of json.feed || []) {
166
+ const post = entry.post;
167
+ if (!post?.record?.text) continue;
168
+ out.push({
169
+ title: post.record.text.slice(0, 280),
170
+ link: `https://bsky.app/profile/${handle}`,
171
+ published_at: post.record.createdAt || null,
172
+ source: `bluesky:${handle}`,
173
+ likes: post.likeCount ?? null,
174
+ reposts: post.repostCount ?? null,
175
+ replies: post.replyCount ?? null,
176
+ });
177
+ }
178
+ }
179
+ const gate = applyRecencyGate(out, { days, asOf });
180
+ return {
181
+ platform: "bluesky", ok: true, configured: true,
182
+ what_this_is: "Named accounts only, because search needs auth. Whatever these accounts are worth is what "
183
+ + "this source is worth -- the curation is the signal, and it is the user's, not this tool's.",
184
+ ...gate,
185
+ handles_failed: failed,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Verify one X post really exists and says what it was quoted as saying.
191
+ *
192
+ * The endpoint is undocumented and unsupported, so it is used only for verification, never
193
+ * as a data source. It closes a specific hole: a decoded snowflake timestamp proves nothing,
194
+ * because any made-up 19-digit id decodes to a plausible date. Existence has to be checked.
195
+ */
196
+ export async function verifyXPost(id) {
197
+ const clean = String(id || "").trim();
198
+ if (!/^\d{1,20}$/.test(clean)) return { id, exists: false, reason: "not a numeric post id" };
199
+ const res = await getText(`https://cdn.syndication.twimg.com/tweet-result?id=${clean}&lang=en&token=a`, { accept: "application/json" });
200
+ if (!res.ok) {
201
+ // A 404 here is the meaningful answer: the id does not exist.
202
+ return { id: clean, exists: false, reason: res.status === 404 ? "no such post" : res.reason, checked: true };
203
+ }
204
+ let json;
205
+ try { json = JSON.parse(res.text); } catch { return { id: clean, exists: false, reason: "unparsable response", checked: true }; }
206
+ if (!json?.text) return { id: clean, exists: false, reason: "response carries no post text", checked: true };
207
+ return {
208
+ id: clean,
209
+ exists: true,
210
+ text: json.text,
211
+ created_at: json.created_at ?? null,
212
+ author: json.user?.screen_name ?? null,
213
+ favorite_count: json.favorite_count ?? null,
214
+ note: "Verified against an undocumented endpoint. Use to confirm a quoted post, not as a data feed.",
215
+ };
216
+ }
217
+
218
+ export async function getSocialPulse({ query = null, symbol = null, subreddits, handles = [], days = 7, asOf = null } = {}) {
219
+ const term = query || symbol;
220
+ const [reddit, hn, bsky] = await Promise.all([
221
+ fetchReddit({ subreddits, query: term, days, asOf }).catch((e) => ({ platform: "reddit", ok: false, reason: String(e?.message || e), included: [] })),
222
+ term ? fetchHackerNews({ query: term, days, asOf }) : Promise.resolve({ platform: "hackernews", ok: true, skipped: "no query given", included: [] }),
223
+ fetchBluesky({ handles, days, asOf }).catch((e) => ({ platform: "bluesky", ok: false, reason: String(e?.message || e), included: [] })),
224
+ ]);
225
+
226
+ const counts = {
227
+ reddit: reddit.included?.length ?? 0,
228
+ hackernews: hn.included?.length ?? 0,
229
+ bluesky: bsky.included?.length ?? 0,
230
+ };
231
+
232
+ return {
233
+ as_of: asOf || new Date().toISOString().slice(0, 10),
234
+ window_days: days,
235
+ query: term,
236
+ platforms: { reddit, hackernews: hn, bluesky: bsky },
237
+ counts,
238
+ total: counts.reddit + counts.hackernews + counts.bluesky,
239
+ // Stated in the payload, not only in a persona, because the payload is what gets quoted.
240
+ coverage_limits: [
241
+ "X / Twitter has no free discovery channel as of 2026-07. Nitter search is dead on every "
242
+ + "surviving instance, the X API bills per post retrieved, and xAI's x_search bills per call. "
243
+ + "This layer therefore does NOT cover professional FinTwit, which is where most of the "
244
+ + "genuinely early equity discussion happens. Treating Reddit as a substitute for it is wrong.",
245
+ "StockTwits is behind Cloudflare and Bluesky search requires authentication; neither is "
246
+ + "reachable without credentials, so neither is used.",
247
+ "Reddit and Hacker News are retail and engineer opinion. They evidence what a narrative "
248
+ + "sounds like and how crowded it is. They are not evidence about a business.",
249
+ "Engagement counts are unavailable on Reddit RSS. Where a count is absent it is reported "
250
+ + "absent rather than estimated.",
251
+ ],
252
+ how_to_use: [
253
+ "Look for CONTRADICTION, not agreement. A view held loudly here that the filings do not support "
254
+ + "is a crowding signal; agreement with the filings adds nothing you did not have.",
255
+ "A claim from this layer may never enter the conclusion on its own. It is a lead, to be "
256
+ + "confirmed against a filing or a dated source, or else recorded in open_questions.",
257
+ "Volume of mentions measures attention, not correctness, and the loudest posts are the most "
258
+ + "emotional ones. Do not rank by engagement and then read the top as representative.",
259
+ ],
260
+ };
261
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Markdown tables for tool output.
3
+ *
4
+ * Hosts render the `content[0].text` of a tool result as markdown, so a table there is a
5
+ * table the user actually sees. Structured data still goes in structuredContent for the
6
+ * model; this is the human-facing half, and a screen result with seven metrics is far
7
+ * easier to check as a table than as a sentence.
8
+ */
9
+
10
+ const escape = (value) => String(value ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
11
+
12
+ /**
13
+ * @param {string[]} headers
14
+ * @param {Array<Array<unknown>>} rows
15
+ * @param {object} [options]
16
+ * @param {string} [options.title]
17
+ * @param {string} [options.empty] shown instead of an empty table
18
+ */
19
+ /**
20
+ * Reduce any cell to display text.
21
+ *
22
+ * A {en, zh} label reaching a template literal renders as "[object Object]", which sits
23
+ * next to real numbers and reads as a broken field rather than a missing one -- so the
24
+ * reader distrusts the surrounding data without being able to say why. Two shipped that
25
+ * way before this existed, in the grounding block's skipped rules and its macro readings.
26
+ */
27
+ const cellText = (cell, zh) => {
28
+ if (cell == null) return "";
29
+ if (typeof cell === "object" && !Array.isArray(cell)) {
30
+ if ("en" in cell || "zh" in cell) return (zh ? cell.zh : cell.en) ?? cell.en ?? cell.zh ?? "";
31
+ return JSON.stringify(cell);
32
+ }
33
+ return String(cell);
34
+ };
35
+
36
+ export function table(headers, rows, { title, empty = "_(no rows)_", zh = false } = {}) {
37
+ const parts = title ? [`**${title}**`, ""] : [];
38
+ if (!rows.length) return [...parts, empty].join("\n");
39
+ parts.push(`| ${headers.map(escape).join(" | ")} |`);
40
+ parts.push(`|${headers.map(() => "---").join("|")}|`);
41
+ for (const row of rows) parts.push(`| ${row.map((cell) => escape(cellText(cell, zh))).join(" | ")} |`);
42
+ return parts.join("\n");
43
+ }
44
+
45
+ /**
46
+ * Status markers. A glyph plus a word: the glyph gives the row a shape you can scan, the
47
+ * word survives a terminal that renders the glyph badly.
48
+ */
49
+ export const mark = (ok) => (ok ? "✅ pass" : "❌ **FAIL**");
50
+ export const SKIPPED = "⚪ skipped";
51
+ export const skippedMark = (zh) => (zh ? "⚪ 跳过" : SKIPPED);
52
+
53
+ /** A label that may be a plain string or a {en, zh} pair. */
54
+ export const label = (value, zh) => (typeof value === "string" ? value : (zh ? value?.zh : value?.en) ?? "");
55
+
56
+ /** Threshold with its direction, so "27.17% vs 15" cannot be read backwards. */
57
+ export function threshold(value, direction, unit) {
58
+ const arrow = direction === "max" ? "≤" : direction === "min" ? "≥" : "";
59
+ return `${arrow}${arrow ? " " : ""}${metricValue(value, unit)}`;
60
+ }
61
+
62
+ /** Compact money, so a table column stays readable. */
63
+ export function money(value, currency = "USD") {
64
+ if (value === null || value === undefined) return "n/a";
65
+ const abs = Math.abs(value);
66
+ if (abs >= 1e12) return `${(value / 1e12).toFixed(2)}T ${currency}`;
67
+ if (abs >= 1e9) return `${(value / 1e9).toFixed(2)}bn ${currency}`;
68
+ if (abs >= 1e6) return `${(value / 1e6).toFixed(1)}m ${currency}`;
69
+ return `${value.toLocaleString("en-US")} ${currency}`;
70
+ }
71
+
72
+ export function metricValue(value, unit) {
73
+ if (value === null || value === undefined) return "n/a";
74
+ if (unit === "%") return `${value}%`;
75
+ if (unit === "USD") return money(value);
76
+ return unit ? `${value} ${unit}` : String(value);
77
+ }
78
+
79
+
80
+ /**
81
+ * One dashboard for a whole grounding payload.
82
+ *
83
+ * A run currently answers across several tool calls, and the reader has to hold five
84
+ * results in their head to see the picture. This renders the lot as sections of a single
85
+ * document: what is established, what could not be fetched, and who is in play with which
86
+ * data behind them.
87
+ */
88
+ export function groundingDashboard(g, language = "English") {
89
+ const zh = /中文|chinese|zh/i.test(String(language));
90
+ const t = (en, cn) => (zh ? cn : en);
91
+ const out = [];
92
+
93
+ out.push(`# ${t("Research dashboard", "研究总览")}${g.quote?.symbol ? ` — ${g.quote.symbol}` : ""}`);
94
+
95
+ const facts = [];
96
+ if (g.filer) facts.push([t("Filer", "主体"), `${g.filer.name} (SIC ${g.filer.sic ?? "?"})`, g.filer.sic_description ?? "-", "SEC"]);
97
+ if (g.quote) {
98
+ facts.push([t("Quote", "行情"), `${g.quote.price}${g.quote.currency ? " " + g.quote.currency : ""}`,
99
+ g.quote.change_pct != null ? `${g.quote.change_pct > 0 ? "+" : ""}${g.quote.change_pct}%` : "-",
100
+ `${g.quote.source} ${t("(~15m delayed)", "(延迟约15分钟)")}`]);
101
+ }
102
+ if (g.market?.financials) {
103
+ const f = g.market.financials;
104
+ facts.push([t("Latest filing", "最新申报"), `${f.gregorian_year ?? f.period?.year}Q${f.period?.quarter}`,
105
+ `${t("revenue", "营收")} ${f.revenue?.toLocaleString() ?? "n/a"} ${f.currency}`, f.source]);
106
+ }
107
+ if (facts.length) out.push("", table([t("Item", "项目"), t("Value", "数值"), t("Detail", "细节"), t("Source", "来源")], facts, { title: t("Established facts", "已确立的事实") }));
108
+
109
+ if (g.screen) {
110
+ const rows = g.screen.metrics.map((m) => [
111
+ label(m.label, zh), metricValue(m.value, m.unit), threshold(m.threshold, m.direction, m.unit), mark(m.passed),
112
+ ]);
113
+ for (const sk of g.screen.skipped || []) rows.push([
114
+ typeof sk === "string" ? sk : label(sk.label, zh),
115
+ t("not computable", "无法计算"), "-", skippedMark(zh),
116
+ ]);
117
+ out.push("", table([t("Rule", "规则"), t("Measured", "实测"), t("Threshold", "阈值"), t("Result", "结果")], rows,
118
+ { title: `${t("Mechanical screen", "硬指标筛选")} — ${g.screen.verdict} (${g.screen.rules_computed}/${g.screen.rules_total})` }));
119
+ }
120
+
121
+ if (g.macro?.derived?.length) {
122
+ out.push("", table([t("Reading", "读数"), t("Value", "数值")],
123
+ g.macro.derived.map((d) => [label(d.label, zh), String(d.value)]), { title: t("Macro", "宏观") }));
124
+ }
125
+
126
+ if (g.coverage?.rows?.length) {
127
+ out.push("", table(
128
+ [t("Symbol", "标的"), t("Market", "市场"), t("Structured financials", "结构化财务"), t("Blocker", "阻碍")],
129
+ g.coverage.rows.map((r) => [
130
+ r.symbol, r.market,
131
+ zh ? { yes: "有", "summary only": "仅摘要", no: "无" }[r.structured_financials] ?? r.structured_financials
132
+ : r.structured_financials,
133
+ r.needs_env || (r.reason ? r.reason.slice(0, 40) : "-"),
134
+ ]),
135
+ { title: t("Data coverage", "数据覆盖") }));
136
+ }
137
+
138
+ if (g.industry?.participants?.length) {
139
+ out.push("", table([t("Layer", "环节"), t("Company", "公司"), t("Symbol", "代码"), t("Market", "市场")],
140
+ g.industry.participants.map((p) => [p.layer?.[zh ? "zh" : "en"] ?? "-", p.name, p.symbol ?? t("unlisted", "未上市"), p.market ?? "-"]),
141
+ { title: t("Value chain", "产业链") }));
142
+ }
143
+
144
+ if (g.unavailable?.length) {
145
+ out.push("", `**${t("Data gaps — do not fill these from memory", "数据缺口 — 禁止用记忆填补")}**`, "",
146
+ ...g.unavailable.map((u) => `- ${u}`));
147
+ }
148
+
149
+ return out.join("\n");
150
+ }
@@ -0,0 +1,27 @@
1
+ import { LIMITS } from "./constants.mjs";
2
+
3
+ export function appendLimited(base, chunk, max = LIMITS.LOG_TAIL_BYTES) {
4
+ const next = `${base}${chunk}`;
5
+ return next.length > max ? next.slice(-max) : next;
6
+ }
7
+
8
+ export function cleanLog(value, max = LIMITS.CLEAN_LOG_BYTES) {
9
+ return String(value || "")
10
+ .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
11
+ .slice(-max);
12
+ }
13
+
14
+ export function fence(value, lang = "text") {
15
+ const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
16
+ return `~~~${lang}\n${text.replaceAll("~~~", "~~~\\u200b")}\n~~~`;
17
+ }
18
+
19
+ export function bullets(items) {
20
+ if (!Array.isArray(items) || items.length === 0) return "- None";
21
+ return items.map((item) => `- ${typeof item === "string" ? item : JSON.stringify(item)}`).join("\n");
22
+ }
23
+
24
+ export function clip(text, max = LIMITS.CLIP_CHARS) {
25
+ const value = String(text || "").replace(/\s+/g, " ").trim();
26
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
27
+ }
@@ -0,0 +1,150 @@
1
+ import { registry, selectRoster } from "./personas/registry.mjs";
2
+
3
+ /**
4
+ * How much each seat counts in the synthesis, and why.
5
+ *
6
+ * Two layers, both visible in the report:
7
+ *
8
+ * 1. A declared base weight per persona. Never hidden, always overridable.
9
+ * 2. An adjustment driven by whether that seat's cited sources survived verification.
10
+ * A seat whose evidence was contradicted loses standing automatically -- it does not
11
+ * get to keep its share of the decision because its philosophy is well regarded.
12
+ *
13
+ * What this is NOT: an optimised allocation. Nobody can honestly claim to have found the
14
+ * best weighting for a committee of language models, because measuring that would require
15
+ * a return backtest of LLM judgment, and such a backtest is invalidated by look-ahead
16
+ * bias -- the model already knows what happened after the as-of date. The weights are a
17
+ * transparent, editable prior, and the report says so.
18
+ */
19
+
20
+ /** Verifier outcomes that reduce a seat's standing, and by how much. */
21
+ const VERDICT_PENALTY = {
22
+ contradicted: 1.0,
23
+ disagree: 1.0,
24
+ refuted: 1.0,
25
+ source_does_not_mention: 0.75,
26
+ partial: 0.4,
27
+ weakened: 0.4,
28
+ cannot_confirm: 0.3,
29
+ source_unreachable: 0.3,
30
+ superseded_by_newer: 0.3,
31
+ };
32
+
33
+ /** Floor so a penalised seat is quieter but never silently erased. */
34
+ const MIN_WEIGHT_FRACTION = 0.15;
35
+
36
+ export function baseWeight(persona) {
37
+ const declared = persona?.default_weight;
38
+ return Number.isFinite(declared) && declared > 0 ? declared : 1;
39
+ }
40
+
41
+ /**
42
+ * @param {object[]} verdicts [{ seat, verdict }] from the Stage 2b verifiers
43
+ * @returns {{factor:number, reasons:string[]}}
44
+ */
45
+ export function verificationAdjustment(seat, verdicts = []) {
46
+ const mine = verdicts.filter((v) => v?.seat === seat && v?.verdict);
47
+ if (mine.length === 0) return { factor: 1, reasons: [] };
48
+
49
+ let penalty = 0;
50
+ const reasons = [];
51
+ for (const { verdict } of mine) {
52
+ const hit = VERDICT_PENALTY[verdict];
53
+ if (!hit) continue;
54
+ penalty += hit;
55
+ reasons.push(verdict);
56
+ }
57
+ if (penalty === 0) return { factor: 1, reasons: [] };
58
+
59
+ // Penalty is per checked claim, so normalise by how many claims were checked: one bad
60
+ // verdict out of ten is not the same as one out of one.
61
+ const factor = Math.max(MIN_WEIGHT_FRACTION, 1 - penalty / mine.length);
62
+ return { factor: Number(factor.toFixed(3)), reasons };
63
+ }
64
+
65
+ /**
66
+ * Resolve final weights for every seat in a run.
67
+ *
68
+ * @param {object} run
69
+ * @param {object[]} [run.master_opinions]
70
+ * @param {object[]} [run.verifier_verdicts] [{ seat, verdict, claim }]
71
+ * @param {object} [overrides] seat id -> weight, from the caller
72
+ */
73
+ export function resolveSeatWeights(run, overrides = {}) {
74
+ const reg = registry();
75
+ const seats = [];
76
+
77
+ const debateIds = reg.ids("debate").filter((id) => id !== "portfolio_manager");
78
+ const masterIds = (run.master_opinions || []).map((o) => o.master);
79
+
80
+ for (const id of [...debateIds, ...masterIds]) {
81
+ const persona = reg.get(id);
82
+ if (!persona) continue;
83
+ const declared = baseWeight(persona);
84
+ const override = Number.isFinite(overrides[id]) && overrides[id] >= 0 ? overrides[id] : null;
85
+ const base = override ?? declared;
86
+ const { factor, reasons } = verificationAdjustment(id, run.verifier_verdicts);
87
+
88
+ // A master that ruled itself out of scope should not carry weight into a verdict it
89
+ // declined to give. That is not a penalty; it is taking the seat at its word.
90
+ const opinion = (run.master_opinions || []).find((o) => o.master === id);
91
+ const outOfScope = opinion?.stance === "out_of_scope";
92
+
93
+ seats.push({
94
+ seat: id,
95
+ kind: persona.kind,
96
+ declared_weight: declared,
97
+ override_weight: override,
98
+ verification_factor: factor,
99
+ verification_reasons: reasons,
100
+ out_of_scope: outOfScope,
101
+ effective_weight: outOfScope ? 0 : Number((base * factor).toFixed(3)),
102
+ stance: opinion?.stance,
103
+ });
104
+ }
105
+
106
+ const total = seats.reduce((sum, s) => sum + s.effective_weight, 0);
107
+ for (const seat of seats) {
108
+ seat.share = total > 0 ? Number((seat.effective_weight / total).toFixed(4)) : 0;
109
+ }
110
+ return { seats, total_effective_weight: Number(total.toFixed(3)) };
111
+ }
112
+
113
+ /** A table the PM must reproduce in the report, so the weighting is never invisible. */
114
+ export function weightTableMarkdown(resolved, language = "English") {
115
+ const chinese = /中文|chinese|zh/i.test(String(language));
116
+ const { seats } = resolved;
117
+ if (!seats.length) return "";
118
+
119
+ const header = chinese
120
+ ? "| 席位 | 立场 | 声明权重 | 核验系数 | 生效权重 | 占比 | 调整原因 |"
121
+ : "| Seat | Stance | Declared | Verification | Effective | Share | Why adjusted |";
122
+ const rule = "|---|---|---|---|---|---|---|";
123
+ const rows = seats.map((s) => {
124
+ const why = s.out_of_scope
125
+ ? (chinese ? "自述超出判断范围" : "declared out of scope")
126
+ : (s.verification_reasons.length
127
+ ? s.verification_reasons.join(", ")
128
+ : (chinese ? "无" : "-"));
129
+ const stance = s.stance || (chinese ? "(辩论角色)" : "(debate role)");
130
+ const declared = s.override_weight !== null
131
+ ? `${s.override_weight} ${chinese ? "(覆盖)" : "(override)"}`
132
+ : String(s.declared_weight);
133
+ return `| ${s.seat} | ${stance} | ${declared} | ${s.verification_factor} | ${s.effective_weight} | ${(s.share * 100).toFixed(1)}% | ${why} |`;
134
+ });
135
+
136
+ const note = chinese
137
+ ? "\n权重是可编辑的先验,不是最优解。没人能诚实地宣称找到了 LLM 委员会的最佳配比——要衡量它需要对 LLM 判断做收益回测,而那种回测被前视偏差证伪(模型已经知道分析基准日之后发生了什么)。引用被核验推翻的席位会自动降权,但不会被完全抹掉。"
138
+ : "\nThese weights are an editable prior, not an optimum. Nobody can honestly claim to have found the best weighting for a committee of language models: measuring that would need a return backtest of LLM judgment, and such a backtest is invalidated by look-ahead bias -- the model already knows what happened after the as-of date. Seats whose cited evidence failed verification are down-weighted automatically, but never silently erased.";
139
+
140
+ return [header, rule, ...rows].join("\n") + "\n" + note;
141
+ }
142
+
143
+ /** Seats available for a roster, for tool schemas and previews. */
144
+ export function rosterWeights(rosterName) {
145
+ const reg = registry();
146
+ return selectRoster(reg, { kind: "master", roster: rosterName }).map((p) => ({
147
+ seat: p.id,
148
+ declared_weight: baseWeight(p),
149
+ }));
150
+ }