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.
- package/.claude/agents/alphacouncil-bear_researcher.md +39 -0
- package/.claude/agents/alphacouncil-bull_researcher.md +40 -0
- package/.claude/agents/alphacouncil-earnings_deep_dive.md +49 -0
- package/.claude/agents/alphacouncil-forward_expectations.md +48 -0
- package/.claude/agents/alphacouncil-ib_event_analysis.md +40 -0
- package/.claude/agents/alphacouncil-insider_sec.md +39 -0
- package/.claude/agents/alphacouncil-macro_regime.md +31 -0
- package/.claude/agents/alphacouncil-market_data.md +37 -0
- package/.claude/agents/alphacouncil-market_narrative.md +45 -0
- package/.claude/agents/alphacouncil-news_industry_management.md +49 -0
- package/.claude/agents/alphacouncil-portfolio_manager.md +22 -0
- package/.claude/agents/alphacouncil-quant_factor.md +38 -0
- package/.claude/agents/alphacouncil-social_pulse.md +49 -0
- package/.claude/agents/alphacouncil-valuation_long_short.md +45 -0
- package/.claude-plugin/marketplace.json +30 -0
- package/.claude-plugin/plugin.json +28 -0
- package/.codex-plugin/plugin.json +32 -0
- package/.grok/agents/alphacouncil-bear_researcher.md +41 -0
- package/.grok/agents/alphacouncil-bull_researcher.md +42 -0
- package/.grok/agents/alphacouncil-earnings_deep_dive.md +50 -0
- package/.grok/agents/alphacouncil-forward_expectations.md +49 -0
- package/.grok/agents/alphacouncil-ib_event_analysis.md +41 -0
- package/.grok/agents/alphacouncil-insider_sec.md +40 -0
- package/.grok/agents/alphacouncil-macro_regime.md +32 -0
- package/.grok/agents/alphacouncil-market_data.md +38 -0
- package/.grok/agents/alphacouncil-market_narrative.md +46 -0
- package/.grok/agents/alphacouncil-news_industry_management.md +50 -0
- package/.grok/agents/alphacouncil-portfolio_manager.md +24 -0
- package/.grok/agents/alphacouncil-quant_factor.md +39 -0
- package/.grok/agents/alphacouncil-social_pulse.md +50 -0
- package/.grok/agents/alphacouncil-valuation_long_short.md +46 -0
- package/.grok/config.toml +4 -0
- package/.mcp.json +11 -0
- package/.opencode/agent/alphacouncil-bear_researcher.md +45 -0
- package/.opencode/agent/alphacouncil-bull_researcher.md +46 -0
- package/.opencode/agent/alphacouncil-earnings_deep_dive.md +54 -0
- package/.opencode/agent/alphacouncil-forward_expectations.md +53 -0
- package/.opencode/agent/alphacouncil-ib_event_analysis.md +45 -0
- package/.opencode/agent/alphacouncil-insider_sec.md +44 -0
- package/.opencode/agent/alphacouncil-macro_regime.md +36 -0
- package/.opencode/agent/alphacouncil-market_data.md +42 -0
- package/.opencode/agent/alphacouncil-market_narrative.md +50 -0
- package/.opencode/agent/alphacouncil-news_industry_management.md +54 -0
- package/.opencode/agent/alphacouncil-portfolio_manager.md +28 -0
- package/.opencode/agent/alphacouncil-quant_factor.md +43 -0
- package/.opencode/agent/alphacouncil-social_pulse.md +54 -0
- package/.opencode/agent/alphacouncil-valuation_long_short.md +50 -0
- package/AGENTS.md +96 -0
- package/CHANGELOG.md +112 -0
- package/CLAUDE.md +144 -0
- package/LICENSE +21 -0
- package/README.ja.md +282 -0
- package/README.md +361 -0
- package/README.zh-CN.md +282 -0
- package/data/industry-map.json +74 -0
- package/docs/attribution.md +99 -0
- package/docs/personas.md +59 -0
- package/docs/report-contract.md +110 -0
- package/mcp/lib/codex.mjs +152 -0
- package/mcp/lib/constants.mjs +139 -0
- package/mcp/lib/council-options.mjs +111 -0
- package/mcp/lib/errors.mjs +46 -0
- package/mcp/lib/feeds.mjs +140 -0
- package/mcp/lib/fsutil.mjs +69 -0
- package/mcp/lib/gates.mjs +208 -0
- package/mcp/lib/grounding.mjs +252 -0
- package/mcp/lib/headings.mjs +65 -0
- package/mcp/lib/industry.mjs +208 -0
- package/mcp/lib/lang.mjs +24 -0
- package/mcp/lib/macro.mjs +167 -0
- package/mcp/lib/markdown.mjs +337 -0
- package/mcp/lib/markets-kr-jp.mjs +180 -0
- package/mcp/lib/markets.mjs +261 -0
- package/mcp/lib/narrative.mjs +235 -0
- package/mcp/lib/options.mjs +216 -0
- package/mcp/lib/orchestrator.mjs +541 -0
- package/mcp/lib/output-modes.mjs +144 -0
- package/mcp/lib/packets.mjs +256 -0
- package/mcp/lib/personas/frontmatter.mjs +80 -0
- package/mcp/lib/personas/registry.mjs +215 -0
- package/mcp/lib/preflight.mjs +174 -0
- package/mcp/lib/prompts.mjs +151 -0
- package/mcp/lib/quotes.mjs +108 -0
- package/mcp/lib/rpc.mjs +737 -0
- package/mcp/lib/run-store.mjs +140 -0
- package/mcp/lib/screen.mjs +283 -0
- package/mcp/lib/sec.mjs +131 -0
- package/mcp/lib/social.mjs +261 -0
- package/mcp/lib/tables.mjs +150 -0
- package/mcp/lib/text.mjs +27 -0
- package/mcp/lib/weights.mjs +150 -0
- package/mcp/server.mjs +92 -0
- package/opencode.json +20 -0
- package/package.json +61 -0
- package/personas/_shared/_debate_base.md +46 -0
- package/personas/_shared/_evidence_base.md +56 -0
- package/personas/_shared/_master_base.md +77 -0
- package/personas/analysts/earnings_deep_dive.md +118 -0
- package/personas/analysts/forward_expectations.md +115 -0
- package/personas/analysts/ib_event_analysis.md +100 -0
- package/personas/analysts/insider_sec.md +98 -0
- package/personas/analysts/macro_regime.md +66 -0
- package/personas/analysts/market_data.md +94 -0
- package/personas/analysts/market_narrative.md +94 -0
- package/personas/analysts/news_industry_management.md +117 -0
- package/personas/analysts/quant_factor.md +97 -0
- package/personas/analysts/social_pulse.md +102 -0
- package/personas/analysts/valuation_long_short.md +109 -0
- package/personas/debate/bear_researcher.md +96 -0
- package/personas/debate/bull_researcher.md +98 -0
- package/personas/debate/portfolio_manager.md +64 -0
- package/personas/masters/masters-adversarial/burry.md +137 -0
- package/personas/masters/masters-adversarial/dalio.md +139 -0
- package/personas/masters/masters-adversarial/druckenmiller.md +144 -0
- package/personas/masters/masters-adversarial/short_seller.md +145 -0
- package/personas/masters/masters-adversarial/soros.md +137 -0
- package/personas/masters/masters-modern/aschenbrenner.md +147 -0
- package/personas/masters/masters-options/natenberg.md +168 -0
- package/personas/masters/masters-options/sinclair.md +166 -0
- package/personas/masters/masters-options/taleb.md +163 -0
- package/personas/masters/masters-quant/asness.md +142 -0
- package/personas/masters/masters-quant/simons.md +143 -0
- package/personas/masters/masters-quant/thorp.md +143 -0
- package/personas/masters/masters-value/buffett.md +123 -0
- package/personas/masters/masters-value/duan_yongping.md +142 -0
- package/personas/masters/masters-value/li_lu.md +146 -0
- package/personas/masters/masters-value/munger.md +117 -0
- package/personas/masters/masters-value-classic/fisher.md +140 -0
- package/personas/masters/masters-value-classic/graham.md +137 -0
- package/personas/masters/masters-value-classic/klarman.md +138 -0
- package/personas/masters/masters-value-classic/lynch.md +133 -0
- package/personas/masters/masters-value-classic/marks.md +145 -0
- package/personas/verifiers/rederivation.md +97 -0
- package/personas/verifiers/refuter.md +99 -0
- package/personas/verifiers/source_fidelity.md +91 -0
- package/skills/agent-skills-governance/SKILL.md +42 -0
- package/skills/alphacouncil-agent/SKILL.md +259 -0
- package/skills/investment-banking/SKILL.md +70 -0
- package/skills/public-equity-investing/SKILL.md +74 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CODEX_CMD, DATA_DIR, LIMITS } from "./constants.mjs";
|
|
5
|
+
import { appendLimited } from "./text.mjs";
|
|
6
|
+
|
|
7
|
+
export function quoteCmdArg(value) {
|
|
8
|
+
const text = String(value);
|
|
9
|
+
if (!text) return "\"\"";
|
|
10
|
+
if (!/[ \t"&|<>^]/.test(text)) return text;
|
|
11
|
+
return `"${text.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/g, "$1$1")}"`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function codexInvocation(args, platform = process.platform, env = process.env) {
|
|
15
|
+
const fullArgs = [...args, "-"];
|
|
16
|
+
if (platform === "win32") {
|
|
17
|
+
return {
|
|
18
|
+
command: env.ComSpec || "cmd.exe",
|
|
19
|
+
args: ["/d", "/s", "/c", [env.ALPHACOUNCIL_AGENT_CODEX_CMD || CODEX_CMD, ...fullArgs].map(quoteCmdArg).join(" ")],
|
|
20
|
+
options: { detached: false, windowsHide: true },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return { command: CODEX_CMD, args: fullArgs, options: { detached: true } };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function stopChild(child, force = false) {
|
|
27
|
+
if (!child.pid) return;
|
|
28
|
+
if (process.platform === "win32") {
|
|
29
|
+
const args = ["/pid", String(child.pid), "/t"];
|
|
30
|
+
if (force) args.push("/f");
|
|
31
|
+
const killer = spawn("taskkill", args, { stdio: "ignore", windowsHide: true });
|
|
32
|
+
killer.on("error", () => child.kill(force ? "SIGKILL" : "SIGTERM"));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
37
|
+
} catch {
|
|
38
|
+
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function runCodex(prompt, timeoutMs, onStart = () => {}, onHeartbeat = () => {}) {
|
|
43
|
+
return new Promise((resolvePromise) => {
|
|
44
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
45
|
+
const outFile = join(DATA_DIR, `codex-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
|
|
46
|
+
const args = [
|
|
47
|
+
"--search",
|
|
48
|
+
"-s",
|
|
49
|
+
"read-only",
|
|
50
|
+
"-a",
|
|
51
|
+
"never",
|
|
52
|
+
"exec",
|
|
53
|
+
"--ephemeral",
|
|
54
|
+
"--skip-git-repo-check",
|
|
55
|
+
"-C",
|
|
56
|
+
DATA_DIR,
|
|
57
|
+
"-o",
|
|
58
|
+
outFile,
|
|
59
|
+
];
|
|
60
|
+
const invocation = codexInvocation(args);
|
|
61
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
62
|
+
cwd: DATA_DIR,
|
|
63
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
64
|
+
...invocation.options,
|
|
65
|
+
});
|
|
66
|
+
child.stdin.on("error", () => {});
|
|
67
|
+
child.stdin.end(prompt, "utf8");
|
|
68
|
+
onStart({ pid: child.pid, output: outFile });
|
|
69
|
+
const startedAt = Date.now();
|
|
70
|
+
let stdout = "";
|
|
71
|
+
let stderr = "";
|
|
72
|
+
let timedOut = false;
|
|
73
|
+
let settled = false;
|
|
74
|
+
let killTimer = null;
|
|
75
|
+
const finish = (value) => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
settled = true;
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
clearInterval(heartbeat);
|
|
80
|
+
if (killTimer) clearTimeout(killTimer);
|
|
81
|
+
// The caller already has the text by now. Nothing deleted this before, so every
|
|
82
|
+
// analyst of every run left one file behind in DATA_DIR forever.
|
|
83
|
+
try {
|
|
84
|
+
unlinkSync(outFile);
|
|
85
|
+
} catch {
|
|
86
|
+
// Codex may never have created it (spawn error, immediate timeout).
|
|
87
|
+
}
|
|
88
|
+
resolvePromise(value);
|
|
89
|
+
};
|
|
90
|
+
const heartbeat = setInterval(() => {
|
|
91
|
+
onHeartbeat({ pid: child.pid, output: outFile, elapsed_ms: Date.now() - startedAt });
|
|
92
|
+
}, LIMITS.HEARTBEAT_MS);
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
timedOut = true;
|
|
95
|
+
stopChild(child);
|
|
96
|
+
killTimer = setTimeout(() => {
|
|
97
|
+
stopChild(child, true);
|
|
98
|
+
}, LIMITS.SIGKILL_GRACE_MS);
|
|
99
|
+
}, timeoutMs);
|
|
100
|
+
// Drain both pipes; switch to streaming logs if a progress UI needs live CLI output.
|
|
101
|
+
child.stdout.on("data", (chunk) => { stdout = appendLimited(stdout, chunk.toString()); });
|
|
102
|
+
child.stderr.on("data", (chunk) => { stderr = appendLimited(stderr, chunk.toString()); });
|
|
103
|
+
child.on("error", (error) => {
|
|
104
|
+
finish({ ok: false, code: null, text: "", stderr: String(error.message || error), stdout, outFile, timedOut });
|
|
105
|
+
});
|
|
106
|
+
child.on("close", (code) => {
|
|
107
|
+
let text = "";
|
|
108
|
+
if (existsSync(outFile)) text = readFileSync(outFile, "utf8");
|
|
109
|
+
finish({ ok: code === 0 && text.trim().length > 0, code, text, stderr, stdout, outFile, timedOut });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Delete Codex output files left behind by older versions (and by any run killed
|
|
116
|
+
* between spawn and finish). Called once at server start; never throws.
|
|
117
|
+
*/
|
|
118
|
+
export function sweepStaleOutputs(now = Date.now()) {
|
|
119
|
+
let removed = 0;
|
|
120
|
+
let entries;
|
|
121
|
+
try {
|
|
122
|
+
entries = readdirSync(DATA_DIR);
|
|
123
|
+
} catch {
|
|
124
|
+
return removed;
|
|
125
|
+
}
|
|
126
|
+
for (const name of entries) {
|
|
127
|
+
if (!/^codex-\d+-[0-9a-f]+\.txt$/.test(name)) continue;
|
|
128
|
+
const path = join(DATA_DIR, name);
|
|
129
|
+
try {
|
|
130
|
+
if (now - statSync(path).mtimeMs < LIMITS.STALE_OUTPUT_MS) continue;
|
|
131
|
+
unlinkSync(path);
|
|
132
|
+
removed += 1;
|
|
133
|
+
} catch {
|
|
134
|
+
// Another process may have removed it, or it may not be ours to delete.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return removed;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function mapLimit(items, limit, worker) {
|
|
141
|
+
const results = new Array(items.length);
|
|
142
|
+
let next = 0;
|
|
143
|
+
async function runOne() {
|
|
144
|
+
while (next < items.length) {
|
|
145
|
+
const index = next;
|
|
146
|
+
next += 1;
|
|
147
|
+
results[index] = await worker(items[index], index);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runOne));
|
|
151
|
+
return results;
|
|
152
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
export const DATA_DIR = process.env.ALPHACOUNCIL_AGENT_DATA_DIR || join(os.homedir(), ".alphacouncil-agent");
|
|
6
|
+
export const RUNS_DIR = join(DATA_DIR, "runs");
|
|
7
|
+
export const SERVER_NAME = "alphacouncil-agent";
|
|
8
|
+
// Single source of truth for the version. Resolved from import.meta.url, never process.cwd(),
|
|
9
|
+
// because hosts launch this server from arbitrary working directories.
|
|
10
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
11
|
+
export const CODEX_CMD = process.env.ALPHACOUNCIL_AGENT_CODEX_CMD || "codex";
|
|
12
|
+
// Eight roles, down from eleven. The three removed were merged rather than dropped:
|
|
13
|
+
// the earnings call is part of the earnings read, sell-side revisions are part of the
|
|
14
|
+
// forward-expectations read, and "industry voices" and "industry news" were searching the
|
|
15
|
+
// same ground. Fewer, wider roles means fewer duplicated searches for the same facts.
|
|
16
|
+
export const DEFAULT_TASKS = [
|
|
17
|
+
"market_data",
|
|
18
|
+
"earnings_deep_dive",
|
|
19
|
+
"forward_expectations",
|
|
20
|
+
"quant_factor",
|
|
21
|
+
"valuation_long_short",
|
|
22
|
+
"news_industry_management",
|
|
23
|
+
"insider_sec",
|
|
24
|
+
"ib_event_analysis",
|
|
25
|
+
];
|
|
26
|
+
export const RATINGS = ["Buy", "Overweight", "Hold", "Underweight", "Sell"];
|
|
27
|
+
export const DEBATE_ROLES = ["bull_researcher", "bear_researcher", "portfolio_manager"];
|
|
28
|
+
export const MASTER_STANCES = ["constructive", "cautious", "opposed", "out_of_scope"];
|
|
29
|
+
// product_design / creative_production / sales were removed: each described itself as
|
|
30
|
+
// "not for investment summary". Unknown values fall back to public_equity, so a host
|
|
31
|
+
// still passing an old name degrades cleanly instead of erroring.
|
|
32
|
+
export const OUTPUT_MODES = [
|
|
33
|
+
"chat",
|
|
34
|
+
"documents",
|
|
35
|
+
"pdf",
|
|
36
|
+
"presentations",
|
|
37
|
+
"data_analytics",
|
|
38
|
+
"public_equity",
|
|
39
|
+
"investment_banking",
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* The report contract, as a structured registry.
|
|
43
|
+
*
|
|
44
|
+
* `aliases` are matched against normalized headings; the LONGEST matching alias wins, so
|
|
45
|
+
* a heading like "Quant Factor / Technical Risk View" is assigned to `quant` rather than
|
|
46
|
+
* being allowed to satisfy `risks`. `min_body` is the number of non-space characters the
|
|
47
|
+
* section body must carry, calibrated against the manager fallback report, which emits
|
|
48
|
+
* several legitimately terse sections ("## Confidence\nmedium").
|
|
49
|
+
*/
|
|
50
|
+
export const REPORT_SECTIONS = [
|
|
51
|
+
{ id: "conclusion", aliases: ["结论", "conclusion"], min_body: 6 },
|
|
52
|
+
{ id: "analyst_work_log", aliases: ["分析师工作记录", "analyst work log"], min_body: 12, per_task: true },
|
|
53
|
+
{ id: "debate_record", aliases: ["多空辩论记录", "bull bear debate", "debate record"], min_body: 20 },
|
|
54
|
+
{ id: "market_expectations", aliases: ["市场预期", "market expectations"], min_body: 8 },
|
|
55
|
+
{ id: "analyst_rating", aliases: ["分析师评级", "analyst rating", "target price"], min_body: 8 },
|
|
56
|
+
{ id: "earnings_call", aliases: ["电话会", "earnings call"], min_body: 8 },
|
|
57
|
+
{ id: "quant", aliases: ["量化", "quant"], min_body: 8 },
|
|
58
|
+
{ id: "news", aliases: ["新闻", "news"], min_body: 8 },
|
|
59
|
+
{ id: "short_interest", aliases: ["short interest", "borrow"], min_body: 8 },
|
|
60
|
+
{ id: "strategic_transaction", aliases: ["战略交易", "strategic transaction", "banking event"], min_body: 8 },
|
|
61
|
+
{ id: "valuation", aliases: ["估值", "valuation"], min_body: 8 },
|
|
62
|
+
// Required, and deliberately not called "target price". A single number pretends to a
|
|
63
|
+
// precision nobody has; what a reader needs is the price at which the case changes.
|
|
64
|
+
// Three master lenses already demand this -- Marks asks at what price it stops being
|
|
65
|
+
// "leave it", Graham asks for a calculable floor, Thorp asks for a size -- and the PM
|
|
66
|
+
// used to be able to skip it with "the cycle position is undetermined".
|
|
67
|
+
{ id: "price_levels", aliases: ["价位", "入场价位", "price levels", "entry level", "价格条件"], min_body: 30 },
|
|
68
|
+
{ id: "catalysts", aliases: ["催化剂", "catalyst"], min_body: 8 },
|
|
69
|
+
{ id: "risks", aliases: ["风险", "risk"], min_body: 8 },
|
|
70
|
+
{ id: "position", aliases: ["仓位", "position"], min_body: 8 },
|
|
71
|
+
{ id: "short_term", aliases: ["短线", "short term"], min_body: 6 },
|
|
72
|
+
{ id: "medium_term", aliases: ["中期", "medium term"], min_body: 6 },
|
|
73
|
+
{ id: "long_term", aliases: ["长期", "long term"], min_body: 6 },
|
|
74
|
+
{ id: "data_gaps", aliases: ["数据缺口", "data gaps", "unavailable data"], min_body: 8 },
|
|
75
|
+
{ id: "invalidation", aliases: ["反证", "invalidation"], min_body: 8 },
|
|
76
|
+
{ id: "confidence", aliases: ["置信", "confidence"], min_body: 3 },
|
|
77
|
+
{ id: "source_table", aliases: ["来源表", "source table"], min_body: 6 },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** Bodies that look like a section but say nothing. */
|
|
81
|
+
export const PLACEHOLDER_BODIES = [
|
|
82
|
+
"", "-", "n/a", "na", "tbd", "todo", "none", "- none", "待补充", "- 待补充", "无", "- 无",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/** @deprecated Superseded by REPORT_SECTIONS; kept only for external readers. */
|
|
86
|
+
export const REPORT_SECTION_TERMS = [
|
|
87
|
+
["结论", "Conclusion"],
|
|
88
|
+
["分析师工作记录", "Analyst Work Log"],
|
|
89
|
+
["多空辩论记录", "Bull/Bear Debate"],
|
|
90
|
+
["市场预期", "Market Expectations"],
|
|
91
|
+
["分析师评级", "Analyst Rating"],
|
|
92
|
+
["电话会", "Earnings Call"],
|
|
93
|
+
["量化", "Quant"],
|
|
94
|
+
["新闻", "News"],
|
|
95
|
+
["short interest", "Short Interest"],
|
|
96
|
+
["战略交易", "Strategic Transaction"],
|
|
97
|
+
["估值", "Valuation"],
|
|
98
|
+
["催化剂", "Catalyst"],
|
|
99
|
+
["风险", "Risk"],
|
|
100
|
+
["仓位", "Position"],
|
|
101
|
+
["短线", "Short-Term"],
|
|
102
|
+
["中期", "Medium-Term"],
|
|
103
|
+
["长期", "Long-Term"],
|
|
104
|
+
["数据缺口", "Data Gaps"],
|
|
105
|
+
["反证", "Invalidation"],
|
|
106
|
+
["置信", "Confidence"],
|
|
107
|
+
["来源表", "Source Table"],
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every tunable that used to be a bare number inline. Frozen so a typo is a TypeError
|
|
112
|
+
* in strict mode rather than a silent global retune.
|
|
113
|
+
*/
|
|
114
|
+
export const LIMITS = Object.freeze({
|
|
115
|
+
/** Rolling cap on captured child stdout/stderr. */
|
|
116
|
+
LOG_TAIL_BYTES: 20000,
|
|
117
|
+
/** Cap applied when a log is cleaned for display or storage. */
|
|
118
|
+
CLEAN_LOG_BYTES: 4000,
|
|
119
|
+
/** Cap for one-line summaries in handoffs and fallback reports. */
|
|
120
|
+
CLIP_CHARS: 520,
|
|
121
|
+
/** How often a long-running Codex child reports that it is still alive. */
|
|
122
|
+
HEARTBEAT_MS: 30000,
|
|
123
|
+
/** Grace period between SIGTERM and SIGKILL for a timed-out child. */
|
|
124
|
+
SIGKILL_GRACE_MS: 5000,
|
|
125
|
+
/** Default per-subagent Codex timeout. */
|
|
126
|
+
CODEX_TIMEOUT_MS: Number(process.env.ALPHACOUNCIL_AGENT_TIMEOUT_MS) || 600000,
|
|
127
|
+
CONCURRENCY_MIN: 1,
|
|
128
|
+
CONCURRENCY_MAX: 6,
|
|
129
|
+
CONCURRENCY_DEFAULT: Number(process.env.ALPHACOUNCIL_AGENT_CONCURRENCY) || 3,
|
|
130
|
+
/** Minimum non-space characters before a final report is considered a real report. */
|
|
131
|
+
REPORT_MIN_CHARS: 1600,
|
|
132
|
+
REPORT_MIN_CHARS_DRY: 600,
|
|
133
|
+
/** Timeout for one keyless quote fetch. */
|
|
134
|
+
QUOTE_FETCH_MS: 8000,
|
|
135
|
+
/** Cap on symbols per get_quote call. */
|
|
136
|
+
QUOTE_MAX_SYMBOLS: 25,
|
|
137
|
+
/** Age after which a leftover Codex output file is swept at startup. */
|
|
138
|
+
STALE_OUTPUT_MS: 24 * 60 * 60 * 1000,
|
|
139
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { registry, selectRoster, personaTitle } from "./personas/registry.mjs";
|
|
2
|
+
import { DEFAULT_TASKS } from "./constants.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The menu a host shows before a run starts.
|
|
6
|
+
*
|
|
7
|
+
* A council can be four seats or thirty-eight, and the difference is minutes and money the
|
|
8
|
+
* user is spending. Choosing silently on their behalf is the wrong default in both
|
|
9
|
+
* directions: pick small and the bench they were promised never runs, pick everything and
|
|
10
|
+
* a quick question costs a full fan-out.
|
|
11
|
+
*
|
|
12
|
+
* MCP cannot draw a dialog, so this supplies the menu as data and the runtime skill
|
|
13
|
+
* requires the host to ask. That works identically in Claude Code, Codex, OpenCode and
|
|
14
|
+
* Grok Build, because all four can ask a question in chat.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Rough per-seat cost, used only to rank presets. Not a promise about wall-clock time. */
|
|
18
|
+
const SEAT_MINUTES = { analyst: 1.5, master: 0.8, verifier: 0.5, debate: 1.5 };
|
|
19
|
+
|
|
20
|
+
const estimate = ({ analysts = 0, masters = 0, verifiers = 0, debate = 3 }) => {
|
|
21
|
+
const seats = analysts + masters + verifiers + debate;
|
|
22
|
+
const minutes = analysts * SEAT_MINUTES.analyst + masters * SEAT_MINUTES.master
|
|
23
|
+
+ verifiers * SEAT_MINUTES.verifier + debate * SEAT_MINUTES.debate;
|
|
24
|
+
return { seats, rough_minutes: Math.round(minutes) };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function councilOptions({ language = "English" } = {}) {
|
|
28
|
+
const chinese = /中文|chinese|zh/i.test(String(language));
|
|
29
|
+
const reg = registry();
|
|
30
|
+
|
|
31
|
+
const allAnalysts = reg.ids("analyst").map((id) => reg.get(id)).filter((p) => p.enabled);
|
|
32
|
+
const masterRosters = [...new Set(
|
|
33
|
+
reg.ids("master").flatMap((id) => reg.get(id).rosters || []),
|
|
34
|
+
)].filter((r) => r !== "masters-core").sort();
|
|
35
|
+
|
|
36
|
+
const analystChoices = allAnalysts.map((p) => ({
|
|
37
|
+
id: p.id,
|
|
38
|
+
title: personaTitle(p, language),
|
|
39
|
+
in_default: DEFAULT_TASKS.includes(p.id),
|
|
40
|
+
covers: (p.tags || []).join(", "),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
const rosterChoices = masterRosters.map((roster) => {
|
|
44
|
+
const members = selectRoster(reg, { kind: "master", roster });
|
|
45
|
+
return {
|
|
46
|
+
roster,
|
|
47
|
+
count: members.length,
|
|
48
|
+
members: members.map((m) => ({ id: m.id, title: personaTitle(m, language) })),
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const allMasters = reg.ids("master").length;
|
|
53
|
+
const verifiers = reg.ids("verifier");
|
|
54
|
+
|
|
55
|
+
const presets = [
|
|
56
|
+
{
|
|
57
|
+
id: "quick",
|
|
58
|
+
label: chinese ? "快速:4 位分析师 + 辩论" : "Quick: 4 analysts + debate",
|
|
59
|
+
analysts: DEFAULT_TASKS.slice(0, 4),
|
|
60
|
+
masters_roster: null,
|
|
61
|
+
verify: false,
|
|
62
|
+
...estimate({ analysts: 4 }),
|
|
63
|
+
good_for: chinese
|
|
64
|
+
? "只想要一个方向性看法,接受没有大师视角、没有交叉验证"
|
|
65
|
+
: "A directional read only. No master lenses and no cross-verification.",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "standard",
|
|
69
|
+
label: chinese ? "标准:8 位分析师 + 核心大师 + 辩论" : "Standard: 8 analysts + core bench + debate",
|
|
70
|
+
analysts: DEFAULT_TASKS,
|
|
71
|
+
masters_roster: "masters-core",
|
|
72
|
+
verify: false,
|
|
73
|
+
...estimate({ analysts: DEFAULT_TASKS.length, masters: allMasters }),
|
|
74
|
+
good_for: chinese
|
|
75
|
+
? "默认推荐。覆盖完整证据面并让全部大师议席发言"
|
|
76
|
+
: "The recommended default. Full evidence coverage with every master lens reporting.",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "deep",
|
|
80
|
+
label: chinese ? "深度:全部分析师 + 全部大师 + 交叉验证 + 辩论" : "Deep: every analyst + every master + verification + debate",
|
|
81
|
+
analysts: allAnalysts.map((p) => p.id),
|
|
82
|
+
masters_roster: "masters-core",
|
|
83
|
+
verify: true,
|
|
84
|
+
...estimate({ analysts: allAnalysts.length, masters: allMasters, verifiers: verifiers.length * 3 }),
|
|
85
|
+
good_for: chinese
|
|
86
|
+
? "要下真钱的时候用。每条承重论断都会被回源、独立重算和反面检索"
|
|
87
|
+
: "For a decision with real money behind it. Every load-bearing claim is re-sourced, re-derived and attacked.",
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
language,
|
|
93
|
+
presets,
|
|
94
|
+
analysts: analystChoices,
|
|
95
|
+
default_analysts: DEFAULT_TASKS,
|
|
96
|
+
master_rosters: rosterChoices,
|
|
97
|
+
all_masters_count: allMasters,
|
|
98
|
+
verifiers: verifiers.map((id) => ({ id, title: personaTitle(reg.get(id), language) })),
|
|
99
|
+
how_to_ask: chinese ? [
|
|
100
|
+
"开始跑之前,把上面的预设念给用户,让他们选一个,或者自己点名要哪些分析师和哪个大师名册。",
|
|
101
|
+
"用户已经说清楚要什么(点了名册、说了「全部」、或说了「快点」),就不要再问——重复确认是打扰。",
|
|
102
|
+
"预估时间是相对量级,不是承诺。说清楚「大师多一倍,时间大概多这些」,不要报一个精确分钟数。",
|
|
103
|
+
"用户说「全都要」时照做,但先说一句它会明显更慢,让他们知道自己在换什么。",
|
|
104
|
+
] : [
|
|
105
|
+
"Before starting, read the presets to the user and let them pick one, or name analysts and a master roster themselves.",
|
|
106
|
+
"If the user already said what they want -- named a roster, said 'everything', said 'be quick' -- do not ask. A confirmation they did not need is an interruption.",
|
|
107
|
+
"The estimates are relative magnitudes, not promises. Say that twice the bench costs roughly this much more; do not quote an exact number of minutes.",
|
|
108
|
+
"If they say 'run everything', do it -- but say once that it will be noticeably slower, so they know what they are trading.",
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** JSON-RPC 2.0 reserved error codes. */
|
|
2
|
+
export const RpcCode = {
|
|
3
|
+
PARSE_ERROR: -32700,
|
|
4
|
+
INVALID_REQUEST: -32600,
|
|
5
|
+
METHOD_NOT_FOUND: -32601,
|
|
6
|
+
INVALID_PARAMS: -32602,
|
|
7
|
+
INTERNAL_ERROR: -32603,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An error that already knows which JSON-RPC code it should surface as.
|
|
12
|
+
*
|
|
13
|
+
* Without this the RPC layer has to guess, and it used to guess INVALID_PARAMS for
|
|
14
|
+
* everything -- so a missing run directory or a failed network fetch was reported to
|
|
15
|
+
* the host as "you passed bad parameters".
|
|
16
|
+
*/
|
|
17
|
+
export class RpcError extends Error {
|
|
18
|
+
constructor(code, message, data) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "RpcError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.data = data;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const invalidParams = (message, data) => new RpcError(RpcCode.INVALID_PARAMS, message, data);
|
|
27
|
+
export const internalError = (message, data) => new RpcError(RpcCode.INTERNAL_ERROR, message, data);
|
|
28
|
+
export const methodNotFound = (message, data) => new RpcError(RpcCode.METHOD_NOT_FOUND, message, data);
|
|
29
|
+
|
|
30
|
+
/** Map any thrown value to a JSON-RPC error payload. */
|
|
31
|
+
export function toRpcError(error) {
|
|
32
|
+
const debug = process.env.ALPHACOUNCIL_DEBUG === "1";
|
|
33
|
+
if (error instanceof RpcError) {
|
|
34
|
+
return {
|
|
35
|
+
code: error.code,
|
|
36
|
+
message: error.message,
|
|
37
|
+
...(error.data !== undefined || debug ? { data: { ...(error.data ?? {}), ...(debug ? { stack: error.stack } : {}) } } : {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
41
|
+
return {
|
|
42
|
+
code: RpcCode.INTERNAL_ERROR,
|
|
43
|
+
message,
|
|
44
|
+
...(debug && error instanceof Error ? { data: { stack: error.stack } } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { LIMITS } from "./constants.mjs";
|
|
2
|
+
import { secUserAgent } from "./sec.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal RSS/Atom reading, zero dependencies.
|
|
6
|
+
*
|
|
7
|
+
* The rule that makes this layer worth having is the recency gate: every item must carry a
|
|
8
|
+
* timestamp that parses and falls inside the requested window, or it is reported as
|
|
9
|
+
* excluded rather than quietly included. Without it a "latest news" section fills with
|
|
10
|
+
* three-year-old articles that read exactly like today's, and no reader can tell.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const tag = (xml, name) => {
|
|
14
|
+
const m = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, "i").exec(xml);
|
|
15
|
+
return m ? decode(m[1]) : null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Atom links carry the URL in an attribute rather than in the element body. */
|
|
19
|
+
const atomLink = (xml) => {
|
|
20
|
+
const m = /<link[^>]*href=["']([^"']+)["'][^>]*>/i.exec(xml);
|
|
21
|
+
return m ? decode(m[1]) : null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function decode(text) {
|
|
25
|
+
return String(text)
|
|
26
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
|
|
27
|
+
.replace(/<[^>]+>/g, "")
|
|
28
|
+
.replace(/</g, "<").replace(/>/g, ">")
|
|
29
|
+
.replace(/"/g, '"').replace(/'|'/g, "'")
|
|
30
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)))
|
|
31
|
+
.replace(/&/g, "&")
|
|
32
|
+
.replace(/\s+/g, " ")
|
|
33
|
+
.trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Parse RSS <item> and Atom <entry> into one shape. */
|
|
37
|
+
export function parseFeed(xml, { source = null } = {}) {
|
|
38
|
+
const text = String(xml || "");
|
|
39
|
+
const blocks = text.match(/<(item|entry)\b[\s\S]*?<\/\1>/gi) || [];
|
|
40
|
+
return blocks.map((block) => {
|
|
41
|
+
const published = tag(block, "pubDate") || tag(block, "published") || tag(block, "updated") || null;
|
|
42
|
+
const at = published ? Date.parse(published) : NaN;
|
|
43
|
+
return {
|
|
44
|
+
title: tag(block, "title"),
|
|
45
|
+
link: tag(block, "link") || atomLink(block),
|
|
46
|
+
published_raw: published,
|
|
47
|
+
published_at: Number.isFinite(at) ? new Date(at).toISOString() : null,
|
|
48
|
+
source,
|
|
49
|
+
};
|
|
50
|
+
}).filter((item) => item.title);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Split items by whether their timestamp is inside the window.
|
|
55
|
+
*
|
|
56
|
+
* Undated items go to excluded, not to included. A headline with no date cannot be shown
|
|
57
|
+
* as recent, and guessing its date from position in the feed is how stale news gets
|
|
58
|
+
* presented as current.
|
|
59
|
+
*/
|
|
60
|
+
export function applyRecencyGate(items, { days = 14, asOf = null } = {}) {
|
|
61
|
+
const now = asOf ? Date.parse(`${asOf}T23:59:59Z`) : Date.now();
|
|
62
|
+
const floor = now - days * 86400000;
|
|
63
|
+
const included = [];
|
|
64
|
+
const excluded = [];
|
|
65
|
+
for (const item of items) {
|
|
66
|
+
const at = item.published_at ? Date.parse(item.published_at) : NaN;
|
|
67
|
+
if (!Number.isFinite(at)) excluded.push({ ...item, excluded_because: "no parsable timestamp" });
|
|
68
|
+
else if (at < floor) excluded.push({ ...item, excluded_because: `older than ${days}d` });
|
|
69
|
+
else if (at > now + 86400000) excluded.push({ ...item, excluded_because: "timestamp in the future" });
|
|
70
|
+
else included.push(item);
|
|
71
|
+
}
|
|
72
|
+
included.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at));
|
|
73
|
+
return { included, excluded };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function fetchFeed(url, { source = null, timeoutMs = LIMITS.QUOTE_FETCH_MS * 2 } = {}) {
|
|
77
|
+
const ctrl = new AbortController();
|
|
78
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(url, {
|
|
81
|
+
signal: ctrl.signal,
|
|
82
|
+
// SEC requires a contact in the UA and returns 403 HTML without one; harmless elsewhere.
|
|
83
|
+
headers: { "User-Agent": secUserAgent(), Accept: "application/rss+xml, application/atom+xml, application/xml, text/xml, */*" },
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) return { url, source, ok: false, reason: `HTTP ${res.status}`, items: [] };
|
|
86
|
+
return { url, source, ok: true, items: parseFeed(await res.text(), { source }) };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return { url, source, ok: false, reason: String(error?.name === "AbortError" ? "timed out" : error?.message || error), items: [] };
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fetch several feeds concurrently; a dead feed is reported, never silently dropped. */
|
|
95
|
+
export async function fetchFeeds(specs, { days = 14, asOf = null } = {}) {
|
|
96
|
+
const results = await Promise.all(specs.map((s) => fetchFeed(s.url, { source: s.source })));
|
|
97
|
+
const all = results.flatMap((r) => r.items);
|
|
98
|
+
const { included, excluded } = applyRecencyGate(all, { days, asOf });
|
|
99
|
+
return {
|
|
100
|
+
items: included,
|
|
101
|
+
feeds: results.map(({ url, source, ok, reason, items }) => ({ source, url, ok, reason, item_count: items.length })),
|
|
102
|
+
excluded_outside_window: excluded.length,
|
|
103
|
+
excluded_sample: excluded.slice(0, 5).map((e) => ({ title: e.title, published_at: e.published_at, why: e.excluded_because })),
|
|
104
|
+
unreachable: results.filter((r) => !r.ok).map((r) => ({ source: r.source, reason: r.reason })),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- named feeds ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
export const tickerNewsFeed = (symbol) => ({
|
|
111
|
+
source: "Yahoo Finance RSS",
|
|
112
|
+
url: `https://feeds.finance.yahoo.com/rss/2.0/headline?s=${encodeURIComponent(symbol)}®ion=US&lang=en-US`,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export const queryNewsFeed = (query) => ({
|
|
116
|
+
source: "Google News RSS",
|
|
117
|
+
url: `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=en-US&gl=US&ceid=US:en`,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** A company's own filings, which are the one news source that cannot be spun. */
|
|
121
|
+
export const filingsFeed = (cik, forms = "8-K") => ({
|
|
122
|
+
source: `SEC EDGAR ${forms}`,
|
|
123
|
+
url: "https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany"
|
|
124
|
+
+ `&CIK=${encodeURIComponent(String(cik).padStart(10, "0"))}`
|
|
125
|
+
+ `&type=${encodeURIComponent(forms)}&dateb=&owner=include&count=40&output=atom`,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Broad feeds used to read what the market is talking about rather than what one company
|
|
130
|
+
* did. Deliberately mixes a regulator, a central bank and general market press: a theme
|
|
131
|
+
* that only appears in press is a story, one that also appears at the Fed is a policy fact.
|
|
132
|
+
*/
|
|
133
|
+
export const MARKET_FEEDS = [
|
|
134
|
+
{ source: "Federal Reserve press", url: "https://www.federalreserve.gov/feeds/press_all.xml", kind: "policy" },
|
|
135
|
+
{ source: "SEC press", url: "https://www.sec.gov/news/pressreleases.rss", kind: "policy" },
|
|
136
|
+
{ source: "WSJ Markets", url: "https://feeds.a.dj.com/rss/RSSMarketsMain.xml", kind: "press" },
|
|
137
|
+
{ source: "WSJ World News", url: "https://feeds.a.dj.com/rss/RSSWorldNews.xml", kind: "press" },
|
|
138
|
+
{ source: "CNBC Finance", url: "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=10000664", kind: "press" },
|
|
139
|
+
{ source: "Yahoo Finance", url: "https://finance.yahoo.com/news/rssindex", kind: "press" },
|
|
140
|
+
];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { internalError, invalidParams } from "./errors.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Write JSON atomically.
|
|
6
|
+
*
|
|
7
|
+
* collectEvidence commits packets from up to CONCURRENCY_MAX workers, and each commit
|
|
8
|
+
* rewrites evidence.json. A plain writeFileSync leaves a window where a concurrent
|
|
9
|
+
* reader (or a crash) sees a half-written file; write-then-rename does not.
|
|
10
|
+
*/
|
|
11
|
+
export function writeJson(path, value) {
|
|
12
|
+
const tmp = `${path}.tmp`;
|
|
13
|
+
const body = `${JSON.stringify(value, null, 2)}\n`;
|
|
14
|
+
try {
|
|
15
|
+
writeFileSync(tmp, body);
|
|
16
|
+
renameSync(tmp, path);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
try {
|
|
19
|
+
unlinkSync(tmp);
|
|
20
|
+
} catch {
|
|
21
|
+
// The temp file may never have been created; nothing to clean up.
|
|
22
|
+
}
|
|
23
|
+
throw internalError(`failed to write ${path}: ${error.message}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readJson(path) {
|
|
28
|
+
let text;
|
|
29
|
+
try {
|
|
30
|
+
text = readFileSync(path, "utf8");
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error.code === "ENOENT") throw invalidParams(`not found: ${path}`);
|
|
33
|
+
throw internalError(`failed to read ${path}: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(text);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw internalError(`corrupt JSON at ${path}: ${error.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read newline-delimited JSON, skipping unparseable lines.
|
|
44
|
+
*
|
|
45
|
+
* events.jsonl is append-only and a crashed run can leave one truncated line. That must
|
|
46
|
+
* not make the whole run unreadable, so bad lines are counted and reported instead of
|
|
47
|
+
* thrown.
|
|
48
|
+
*/
|
|
49
|
+
export function readJsonl(path) {
|
|
50
|
+
let text;
|
|
51
|
+
try {
|
|
52
|
+
text = readFileSync(path, "utf8");
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.code === "ENOENT") return { entries: [], parse_errors: 0 };
|
|
55
|
+
throw internalError(`failed to read ${path}: ${error.message}`);
|
|
56
|
+
}
|
|
57
|
+
const entries = [];
|
|
58
|
+
let parse_errors = 0;
|
|
59
|
+
for (const line of text.split("\n")) {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (!trimmed) continue;
|
|
62
|
+
try {
|
|
63
|
+
entries.push(JSON.parse(trimmed));
|
|
64
|
+
} catch {
|
|
65
|
+
parse_errors += 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { entries, parse_errors };
|
|
69
|
+
}
|