alphacouncil-agent 1.0.3 → 1.0.13
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-insider_sec.md +19 -0
- package/.claude/commands/alpha.md +48 -19
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.grok/agents/alphacouncil-insider_sec.md +19 -0
- package/.grok/commands/alpha.md +48 -19
- package/.opencode/agent/alphacouncil-insider_sec.md +19 -0
- package/.opencode/command/alpha.md +48 -19
- package/CHANGELOG.md +307 -0
- package/CLAUDE.md +55 -13
- package/README.ja.md +42 -16
- package/README.md +70 -27
- package/README.zh-CN.md +53 -20
- package/SECURITY.md +11 -1
- package/commands/alpha.md +48 -19
- package/data/build-profile.v1.json +1 -1
- package/docs/INSTALL.md +9 -6
- package/docs/examples/final_report.SOX.zh.md +442 -0
- package/docs/report-contract.md +27 -3
- package/knowledge/ai-assisted-solo/experiments/runs/a.json +4 -4
- package/knowledge/ai-assisted-solo/experiments/runs/b.json +2 -2
- package/knowledge/ai-assisted-solo/experiments/runs/c.json +2 -2
- package/knowledge/ai-assisted-solo/experiments/runs/d13.json +16 -16
- package/knowledge/ai-assisted-solo/experiments/runs/d26.json +30 -30
- package/knowledge/ai-assisted-solo/experiments/runs/e-d13.json +19 -19
- package/knowledge/ai-assisted-solo/experiments/runs/e-d26.json +33 -33
- package/knowledge/ai-assisted-solo/experiments/runs/h_ai_reference.json +34 -34
- package/knowledge/ai-assisted-solo/experiments/simulation-input.json +84 -84
- package/knowledge/ai-assisted-solo/experiments/simulation-manifest.json +19 -19
- package/knowledge/solo-test/masters/master_ackman/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_aschenbrenner/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_asness/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_bogle/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_buffett/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_burry/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_cathie_wood/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_dalio/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_damodaran/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_druckenmiller/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_duan_yongping/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_fisher/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_forensic_short/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_graham/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_jhunjhunwala/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_klarman/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_li_lu/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_lynch/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_marks/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_munger/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_natenberg/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_pabrai/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_simons/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_sinclair/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_soros/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_taleb/manifest.json +8 -8
- package/knowledge/solo-test/masters/master_thorp/manifest.json +8 -8
- package/mcp/lib/constants.mjs +82 -4
- package/mcp/lib/council-options.mjs +8 -4
- package/mcp/lib/council-selection.mjs +79 -1
- package/mcp/lib/fundamentals.mjs +50 -10
- package/mcp/lib/gates.mjs +42 -0
- package/mcp/lib/grounding.mjs +27 -1
- package/mcp/lib/industry.mjs +9 -1
- package/mcp/lib/markdown.mjs +25 -1
- package/mcp/lib/orchestrator.mjs +187 -24
- package/mcp/lib/personas/engine.mjs +57 -12
- package/mcp/lib/personas-v3/runtime.mjs +28 -0
- package/mcp/lib/prompts.mjs +72 -3
- package/mcp/lib/rpc.mjs +93 -7
- package/mcp/lib/run-store.mjs +3 -0
- package/mcp/lib/screen.mjs +46 -46
- package/mcp/lib/sec.mjs +25 -10
- package/mcp/lib/voice-from-decision.mjs +13 -5
- package/mcp/lib/weights.mjs +9 -2
- package/mcp/lib/xbrl-series.mjs +31 -5
- package/package.json +2 -1
- package/personas/analysts/insider_sec.md +38 -0
- package/scripts/lib/persona-v3-solo-test-packs.mjs +14 -8
- package/scripts/review-persona-v3-solo-formulas.mjs +5 -3
- package/skills/alphacouncil-agent/SKILL.md +80 -19
|
@@ -80,10 +80,10 @@
|
|
|
80
80
|
"identity": {
|
|
81
81
|
"maturity": "operator_lens",
|
|
82
82
|
"operator_label": {
|
|
83
|
-
"en": "Simons Lens
|
|
84
|
-
"ja": "Simons Lens
|
|
85
|
-
"ko": "Simons Lens
|
|
86
|
-
"zh": "
|
|
83
|
+
"en": "Simons Lens",
|
|
84
|
+
"ja": "Simons Lens・メソッドレンズ",
|
|
85
|
+
"ko": "Simons Lens 방법론 렌즈",
|
|
86
|
+
"zh": "西蒙斯视角"
|
|
87
87
|
},
|
|
88
88
|
"persona_id": "master_simons",
|
|
89
89
|
"public_label": {
|
|
@@ -123,10 +123,10 @@
|
|
|
123
123
|
"zh": "系统化信号、统计套利、组合研究与执行成本。"
|
|
124
124
|
},
|
|
125
125
|
"identity": {
|
|
126
|
-
"en": "Simons Lens;
|
|
127
|
-
"ja": "Simons Lens
|
|
128
|
-
"ko": "Simons Lens.
|
|
129
|
-
"zh": "
|
|
126
|
+
"en": "Simons Lens; a method model, not the person's own words",
|
|
127
|
+
"ja": "Simons Lens。メソッドモデルであり、本人の発言や現在の見解ではない。",
|
|
128
|
+
"ko": "Simons Lens. 방법론 모델이며, 본인의 발언이나 현재 견해가 아니다.",
|
|
129
|
+
"zh": "西蒙斯视角;方法模型,非本人言论"
|
|
130
130
|
},
|
|
131
131
|
"method": {
|
|
132
132
|
"en": "Admit only statistically testable signals that survive leakage controls, multiple testing, out-of-sample evaluation, turnover and trading costs.",
|
|
@@ -78,10 +78,10 @@
|
|
|
78
78
|
"identity": {
|
|
79
79
|
"maturity": "operator_lens",
|
|
80
80
|
"operator_label": {
|
|
81
|
-
"en": "Sinclair Lens (Volatility Trading and Execution)
|
|
82
|
-
"ja": "Sinclair Lens (Volatility Trading and Execution)
|
|
83
|
-
"ko": "Sinclair Lens (Volatility Trading and Execution)
|
|
84
|
-
"zh": "
|
|
81
|
+
"en": "Sinclair Lens (Volatility Trading and Execution)",
|
|
82
|
+
"ja": "Sinclair Lens (Volatility Trading and Execution)・メソッドレンズ",
|
|
83
|
+
"ko": "Sinclair Lens (Volatility Trading and Execution) 방법론 렌즈",
|
|
84
|
+
"zh": "辛克莱视角(波动率交易与执行)"
|
|
85
85
|
},
|
|
86
86
|
"persona_id": "master_sinclair",
|
|
87
87
|
"public_label": {
|
|
@@ -121,10 +121,10 @@
|
|
|
121
121
|
"zh": "波动率预测、期权执行、优势度量与仓位管理。"
|
|
122
122
|
},
|
|
123
123
|
"identity": {
|
|
124
|
-
"en": "Sinclair Lens (Volatility Trading and Execution);
|
|
125
|
-
"ja": "Sinclair Lens (Volatility Trading and Execution)
|
|
126
|
-
"ko": "Sinclair Lens (Volatility Trading and Execution).
|
|
127
|
-
"zh": "
|
|
124
|
+
"en": "Sinclair Lens (Volatility Trading and Execution); a method model, not the person's own words",
|
|
125
|
+
"ja": "Sinclair Lens (Volatility Trading and Execution)。メソッドモデルであり、本人の発言や現在の見解ではない。",
|
|
126
|
+
"ko": "Sinclair Lens (Volatility Trading and Execution). 방법론 모델이며, 본인의 발언이나 현재 견해가 아니다.",
|
|
127
|
+
"zh": "辛克莱视角(波动率交易与执行);方法模型,非本人言论"
|
|
128
128
|
},
|
|
129
129
|
"method": {
|
|
130
130
|
"en": "Forecast realized volatility, compare it with implied volatility, subtract spreads and slippage, and size only a robust executable edge.",
|
|
@@ -79,10 +79,10 @@
|
|
|
79
79
|
"identity": {
|
|
80
80
|
"maturity": "operator_lens",
|
|
81
81
|
"operator_label": {
|
|
82
|
-
"en": "Soros Lens
|
|
83
|
-
"ja": "Soros Lens
|
|
84
|
-
"ko": "Soros Lens
|
|
85
|
-
"zh": "
|
|
82
|
+
"en": "Soros Lens",
|
|
83
|
+
"ja": "Soros Lens・メソッドレンズ",
|
|
84
|
+
"ko": "Soros Lens 방법론 렌즈",
|
|
85
|
+
"zh": "索罗斯视角"
|
|
86
86
|
},
|
|
87
87
|
"persona_id": "master_soros",
|
|
88
88
|
"public_label": {
|
|
@@ -122,10 +122,10 @@
|
|
|
122
122
|
"zh": "全球宏观、反身性、繁荣萧条循环与拥挤交易。"
|
|
123
123
|
},
|
|
124
124
|
"identity": {
|
|
125
|
-
"en": "Soros Lens;
|
|
126
|
-
"ja": "Soros Lens
|
|
127
|
-
"ko": "Soros Lens.
|
|
128
|
-
"zh": "
|
|
125
|
+
"en": "Soros Lens; a method model, not the person's own words",
|
|
126
|
+
"ja": "Soros Lens。メソッドモデルであり、本人の発言や現在の見解ではない。",
|
|
127
|
+
"ko": "Soros Lens. 방법론 모델이며, 본인의 발언이나 현재 견해가 아니다.",
|
|
128
|
+
"zh": "索罗斯视角;方法模型,非本人言论"
|
|
129
129
|
},
|
|
130
130
|
"method": {
|
|
131
131
|
"en": "Map reflexive feedback among price, financing, fundamentals, policy and participant beliefs, then identify the observable break condition.",
|
|
@@ -82,10 +82,10 @@
|
|
|
82
82
|
"identity": {
|
|
83
83
|
"maturity": "operator_lens",
|
|
84
84
|
"operator_label": {
|
|
85
|
-
"en": "Taleb Lens (Convexity and Tails)
|
|
86
|
-
"ja": "Taleb Lens (Convexity and Tails)
|
|
87
|
-
"ko": "Taleb Lens (Convexity and Tails)
|
|
88
|
-
"zh": "
|
|
85
|
+
"en": "Taleb Lens (Convexity and Tails)",
|
|
86
|
+
"ja": "Taleb Lens (Convexity and Tails)・メソッドレンズ",
|
|
87
|
+
"ko": "Taleb Lens (Convexity and Tails) 방법론 렌즈",
|
|
88
|
+
"zh": "塔勒布视角(凸性与尾部)"
|
|
89
89
|
},
|
|
90
90
|
"persona_id": "master_taleb",
|
|
91
91
|
"public_label": {
|
|
@@ -125,10 +125,10 @@
|
|
|
125
125
|
"zh": "尾部风险、凸性、期权与脆弱性分析。"
|
|
126
126
|
},
|
|
127
127
|
"identity": {
|
|
128
|
-
"en": "Taleb Lens (Convexity and Tails);
|
|
129
|
-
"ja": "Taleb Lens (Convexity and Tails)
|
|
130
|
-
"ko": "Taleb Lens (Convexity and Tails).
|
|
131
|
-
"zh": "
|
|
128
|
+
"en": "Taleb Lens (Convexity and Tails); a method model, not the person's own words",
|
|
129
|
+
"ja": "Taleb Lens (Convexity and Tails)。メソッドモデルであり、本人の発言や現在の見解ではない。",
|
|
130
|
+
"ko": "Taleb Lens (Convexity and Tails). 방법론 모델이며, 본인의 발언이나 현재 견해가 아니다.",
|
|
131
|
+
"zh": "塔勒布视角(凸性与尾部);方法模型,非本人言论"
|
|
132
132
|
},
|
|
133
133
|
"method": {
|
|
134
134
|
"en": "Reject ruin and hidden concavity first, then evaluate whether a payoff is robust or positively convex after liquidity, tail pricing and execution friction.",
|
|
@@ -82,10 +82,10 @@
|
|
|
82
82
|
"identity": {
|
|
83
83
|
"maturity": "operator_lens",
|
|
84
84
|
"operator_label": {
|
|
85
|
-
"en": "Thorp Lens
|
|
86
|
-
"ja": "Thorp Lens
|
|
87
|
-
"ko": "Thorp Lens
|
|
88
|
-
"zh": "
|
|
85
|
+
"en": "Thorp Lens",
|
|
86
|
+
"ja": "Thorp Lens・メソッドレンズ",
|
|
87
|
+
"ko": "Thorp Lens 방법론 렌즈",
|
|
88
|
+
"zh": "索普视角"
|
|
89
89
|
},
|
|
90
90
|
"persona_id": "master_thorp",
|
|
91
91
|
"public_label": {
|
|
@@ -125,10 +125,10 @@
|
|
|
125
125
|
"zh": "概率优势、仓位管理、套利与破产风险。"
|
|
126
126
|
},
|
|
127
127
|
"identity": {
|
|
128
|
-
"en": "Thorp Lens;
|
|
129
|
-
"ja": "Thorp Lens
|
|
130
|
-
"ko": "Thorp Lens.
|
|
131
|
-
"zh": "
|
|
128
|
+
"en": "Thorp Lens; a method model, not the person's own words",
|
|
129
|
+
"ja": "Thorp Lens。メソッドモデルであり、本人の発言や現在の見解ではない。",
|
|
130
|
+
"ko": "Thorp Lens. 방법론 모델이며, 본인의 발언이나 현재 견해가 아니다.",
|
|
131
|
+
"zh": "索普视角;方法模型,非本人言论"
|
|
132
132
|
},
|
|
133
133
|
"method": {
|
|
134
134
|
"en": "Independently estimate edge and odds, then constrain Kelly-style sizing by estimation error, dependence, liquidity and risk of ruin.",
|
package/mcp/lib/constants.mjs
CHANGED
|
@@ -174,12 +174,27 @@ export const LIMITS = Object.freeze({
|
|
|
174
174
|
SIGKILL_GRACE_MS: 5000,
|
|
175
175
|
/** Default per-subagent Codex timeout. */
|
|
176
176
|
CODEX_TIMEOUT_MS: Number(process.env.ALPHACOUNCIL_AGENT_TIMEOUT_MS) || 600000,
|
|
177
|
-
/**
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Non-overridable public ceiling for a plugin-managed full council: the slowest pace's
|
|
179
|
+
* budget. The ceiling a given call is actually held to is its pace's `total_ms`, which is
|
|
180
|
+
* lower for `fast` and `normal`; this is only the outer bound of the schema.
|
|
181
|
+
*/
|
|
182
|
+
FULL_HARD_MAX_MS: 60 * 60 * 1000,
|
|
183
|
+
/**
|
|
184
|
+
* An operator cap on the full-council budget, or null when unset.
|
|
185
|
+
*
|
|
186
|
+
* This used to double as the default budget with a hard 30-minute clamp, which silently held
|
|
187
|
+
* the 60-minute pace to 30. The default now comes from the selected pace; this only ever
|
|
188
|
+
* lowers it, which is what "operators may only lower it" was always meant to say.
|
|
189
|
+
*/
|
|
190
|
+
FULL_TOTAL_OVERRIDE_MS: Number.isFinite(Number(process.env.ALPHACOUNCIL_FULL_TOTAL_MS))
|
|
191
|
+
&& Number(process.env.ALPHACOUNCIL_FULL_TOTAL_MS) > 0
|
|
192
|
+
? Math.max(1_000, Number(process.env.ALPHACOUNCIL_FULL_TOTAL_MS))
|
|
193
|
+
: null,
|
|
194
|
+
/** Fallback budget for a code path that has no run budget to read. Not a ceiling. */
|
|
180
195
|
FULL_TOTAL_MS: Math.max(1_000, Math.min(
|
|
181
196
|
Number(process.env.ALPHACOUNCIL_FULL_TOTAL_MS) || 30 * 60 * 1000,
|
|
182
|
-
|
|
197
|
+
60 * 60 * 1000,
|
|
183
198
|
)),
|
|
184
199
|
/** Deterministic grounding is useful, but it may not hold the whole run hostage. */
|
|
185
200
|
FULL_GROUNDING_MS: 30 * 1000,
|
|
@@ -268,3 +283,66 @@ export const LIMITS = Object.freeze({
|
|
|
268
283
|
/** Age after which a leftover Codex output file is swept at startup. */
|
|
269
284
|
STALE_OUTPUT_MS: 24 * 60 * 60 * 1000,
|
|
270
285
|
});
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Full-council pace profiles.
|
|
289
|
+
*
|
|
290
|
+
* A total budget alone does not change how deep an analysis goes. What bounds each worker is
|
|
291
|
+
* its per-stage cap, so raising only the total leaves a 60-minute run finishing in twenty
|
|
292
|
+
* minutes with forty idle, and lowering only the total starves the later stages and terminates
|
|
293
|
+
* `incomplete` with the debate missing. Each pace therefore carries a complete, self-consistent
|
|
294
|
+
* set of caps, and `COUNCIL_PACE_STAGE_TOTAL` proves the stages fit inside the total.
|
|
295
|
+
*
|
|
296
|
+
* Bull and bear run together inside a round, so three rounds cost `3 * debate_ms`, not six.
|
|
297
|
+
*
|
|
298
|
+
* `slow` is where depth actually comes from: six minutes per side per round instead of 150
|
|
299
|
+
* seconds, and twelve minutes per evidence seat instead of six.
|
|
300
|
+
*/
|
|
301
|
+
export const COUNCIL_PACES = Object.freeze({
|
|
302
|
+
fast: Object.freeze({
|
|
303
|
+
pace: "fast",
|
|
304
|
+
total_ms: 15 * 60 * 1000,
|
|
305
|
+
grounding_ms: 20 * 1000,
|
|
306
|
+
evidence_ms: 210 * 1000,
|
|
307
|
+
master_ms: 60 * 1000,
|
|
308
|
+
debate_ms: 90 * 1000,
|
|
309
|
+
pm_ms: 120 * 1000,
|
|
310
|
+
finalize_reserve_ms: 45 * 1000,
|
|
311
|
+
}),
|
|
312
|
+
normal: Object.freeze({
|
|
313
|
+
pace: "normal",
|
|
314
|
+
total_ms: 30 * 60 * 1000,
|
|
315
|
+
grounding_ms: 30 * 1000,
|
|
316
|
+
evidence_ms: 6 * 60 * 1000,
|
|
317
|
+
master_ms: 2 * 60 * 1000,
|
|
318
|
+
debate_ms: 150 * 1000,
|
|
319
|
+
pm_ms: 180 * 1000,
|
|
320
|
+
finalize_reserve_ms: 45 * 1000,
|
|
321
|
+
}),
|
|
322
|
+
slow: Object.freeze({
|
|
323
|
+
pace: "slow",
|
|
324
|
+
total_ms: 60 * 60 * 1000,
|
|
325
|
+
grounding_ms: 45 * 1000,
|
|
326
|
+
evidence_ms: 12 * 60 * 1000,
|
|
327
|
+
master_ms: 4 * 60 * 1000,
|
|
328
|
+
debate_ms: 6 * 60 * 1000,
|
|
329
|
+
pm_ms: 8 * 60 * 1000,
|
|
330
|
+
finalize_reserve_ms: 60 * 1000,
|
|
331
|
+
}),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
export const DEFAULT_COUNCIL_PACE = "normal";
|
|
335
|
+
export const COUNCIL_PACE_NAMES = Object.freeze(Object.keys(COUNCIL_PACES));
|
|
336
|
+
|
|
337
|
+
/** Serial worst case for one full council at a pace: grounding, evidence wave, method wave, three debate rounds, PM, persistence. */
|
|
338
|
+
export const COUNCIL_PACE_STAGE_TOTAL = (profile) => profile.grounding_ms
|
|
339
|
+
+ profile.evidence_ms
|
|
340
|
+
+ profile.master_ms
|
|
341
|
+
+ (3 * profile.debate_ms)
|
|
342
|
+
+ profile.pm_ms
|
|
343
|
+
+ profile.finalize_reserve_ms;
|
|
344
|
+
|
|
345
|
+
/** The pace a call runs at. An unknown or absent name is the default, never an error. */
|
|
346
|
+
export function councilPaceProfile(name) {
|
|
347
|
+
return COUNCIL_PACES[String(name || "").toLowerCase()] || COUNCIL_PACES[DEFAULT_COUNCIL_PACE];
|
|
348
|
+
}
|
|
@@ -100,11 +100,15 @@ export function councilOptions({ language = "English" } = {}) {
|
|
|
100
100
|
best_for: field(v3Selection.best_for, "best_for"),
|
|
101
101
|
} : selectorCard(persona, language)),
|
|
102
102
|
maturity: v3?.maturity || pack?.kind || "prompt_lens",
|
|
103
|
+
// The label names what a seat IS. It used to lead with "provisional", which read as a
|
|
104
|
+
// standing warning next to all twenty-seven names in the selector and repeated a review
|
|
105
|
+
// status the assurance section already reports. The machine-verified admission level is
|
|
106
|
+
// unchanged and still published as `admission_level` below.
|
|
103
107
|
maturity_label: copy({
|
|
104
|
-
en: v3?.maturity === "method_model" ? "Validated method model" : v3?.maturity === "candidate" ? "Candidate method" : "
|
|
105
|
-
zh: v3?.maturity === "method_model" ? "已验证方法模型" : v3?.maturity === "candidate" ? "候选方法" : "
|
|
106
|
-
ja: v3?.maturity === "method_model" ? "検証済みメソッドモデル" : v3?.maturity === "candidate" ? "候補メソッド" : "
|
|
107
|
-
ko: v3?.maturity === "method_model" ? "검증된 방법론 모델" : v3?.maturity === "candidate" ? "후보 방법론" : "
|
|
108
|
+
en: v3?.maturity === "method_model" ? "Validated method model" : v3?.maturity === "candidate" ? "Candidate method" : "Operator method lens",
|
|
109
|
+
zh: v3?.maturity === "method_model" ? "已验证方法模型" : v3?.maturity === "candidate" ? "候选方法" : "方法透镜",
|
|
110
|
+
ja: v3?.maturity === "method_model" ? "検証済みメソッドモデル" : v3?.maturity === "candidate" ? "候補メソッド" : "メソッド・レンズ",
|
|
111
|
+
ko: v3?.maturity === "method_model" ? "검증된 방법론 모델" : v3?.maturity === "candidate" ? "후보 방법론" : "방법론 렌즈",
|
|
108
112
|
}),
|
|
109
113
|
runtime_level: v3?.admission?.level || (pack ? "v2_operator" : "v1_prompt"),
|
|
110
114
|
admission_level: v3?.admission?.level || (pack ? "operator_lens" : "prompt_lens"),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { COUNCIL_MODES, LIMITS, SELECTIONS_DIR } from "./constants.mjs";
|
|
4
|
+
import { COUNCIL_MODES, COUNCIL_PACE_NAMES, COUNCIL_PACE_STAGE_TOTAL, DEFAULT_COUNCIL_PACE, LIMITS, SELECTIONS_DIR, councilPaceProfile } from "./constants.mjs";
|
|
5
5
|
import { councilOptions } from "./council-options.mjs";
|
|
6
6
|
import { invalidParams } from "./errors.mjs";
|
|
7
7
|
import { readJson, writeJson } from "./fsutil.mjs";
|
|
@@ -128,6 +128,36 @@ export function catalogSnapshot(language = "English") {
|
|
|
128
128
|
return { ...catalog, catalog_hash: digest(catalog) };
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* The pace menu shown at the selection gate, with what each tier costs and what it buys.
|
|
133
|
+
*
|
|
134
|
+
* `total_ms` is a hard ceiling, not a forecast, so both are published: `expected_ms` is the
|
|
135
|
+
* serial worst case if every stage uses its whole cap, which is the honest answer to "how long
|
|
136
|
+
* will this take". A reader given only the ceiling reads it as the estimate and thinks every
|
|
137
|
+
* fast run takes fifteen minutes.
|
|
138
|
+
*/
|
|
139
|
+
export function councilPaceMenu(mode = "full") {
|
|
140
|
+
if (councilMode(mode) === "quick") return [];
|
|
141
|
+
return COUNCIL_PACE_NAMES.map((name) => {
|
|
142
|
+
const profile = councilPaceProfile(name);
|
|
143
|
+
return {
|
|
144
|
+
pace: name,
|
|
145
|
+
is_default: name === DEFAULT_COUNCIL_PACE,
|
|
146
|
+
hard_ceiling_ms: profile.total_ms,
|
|
147
|
+
hard_ceiling_minutes: Math.round(profile.total_ms / 60000),
|
|
148
|
+
expected_ms: COUNCIL_PACE_STAGE_TOTAL(profile),
|
|
149
|
+
expected_minutes: Math.round(COUNCIL_PACE_STAGE_TOTAL(profile) / 60000),
|
|
150
|
+
evidence_seconds_per_seat: Math.round(profile.evidence_ms / 1000),
|
|
151
|
+
debate_seconds_per_round: Math.round(profile.debate_ms / 1000),
|
|
152
|
+
// Same contract at every tier: this is what changes and what does not.
|
|
153
|
+
buys: {
|
|
154
|
+
en: `${Math.round(profile.evidence_ms / 60000 * 10) / 10} min per evidence seat, ${Math.round(profile.debate_ms / 1000)}s per debate round per side`,
|
|
155
|
+
zh: `每个证据席 ${Math.round(profile.evidence_ms / 60000 * 10) / 10} 分钟,每轮辩论每侧 ${Math.round(profile.debate_ms / 1000)} 秒`,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
131
161
|
export function beginCouncilSelection(args = {}, { now = Date.now() } = {}) {
|
|
132
162
|
ensureStore();
|
|
133
163
|
// Bound expiry cleanup to selections/ and selections/receipts/. It is intentionally
|
|
@@ -160,6 +190,10 @@ export function beginCouncilSelection(args = {}, { now = Date.now() } = {}) {
|
|
|
160
190
|
selected_master_ids: [],
|
|
161
191
|
preselected_master_ids: preselected,
|
|
162
192
|
selection_mode: null,
|
|
193
|
+
// A pace named in the request is a prefill, exactly like a named master: it highlights the
|
|
194
|
+
// row and never confirms it. The confirmed value lands here at confirm time.
|
|
195
|
+
preselected_council_pace: COUNCIL_PACE_NAMES.includes(String(args.council_pace || "")) ? String(args.council_pace) : null,
|
|
196
|
+
council_pace: null,
|
|
163
197
|
selection_receipt: null,
|
|
164
198
|
created_at: createdAt,
|
|
165
199
|
updated_at: createdAt,
|
|
@@ -183,6 +217,11 @@ export function beginCouncilSelection(args = {}, { now = Date.now() } = {}) {
|
|
|
183
217
|
masters: catalog.masters,
|
|
184
218
|
master_rosters: catalog.master_rosters,
|
|
185
219
|
preselected_master_ids: preselected,
|
|
220
|
+
// Ask the pace in the same interaction as the catalog: two decisions, one question. Quick
|
|
221
|
+
// gets an empty menu because it is a smaller contract rather than a slower one.
|
|
222
|
+
pace_options: councilPaceMenu(mode),
|
|
223
|
+
default_council_pace: mode === "quick" ? null : DEFAULT_COUNCIL_PACE,
|
|
224
|
+
preselected_council_pace: record.preselected_council_pace,
|
|
186
225
|
actions: mode === "quick" ? ["explicit_selection"] : ["explicit_selection", "select_all"],
|
|
187
226
|
};
|
|
188
227
|
}
|
|
@@ -341,6 +380,27 @@ function confirmCouncilSelectionUnlocked(args = {}, { now = Date.now() } = {}) {
|
|
|
341
380
|
reason: "MASTER_CATALOG_NOT_ACKNOWLEDGED",
|
|
342
381
|
});
|
|
343
382
|
}
|
|
383
|
+
// The pace is the second decision taken at this gate. Quick has no pace to take, and an
|
|
384
|
+
// unrecognised name is rejected rather than quietly run at some other depth -- a user who
|
|
385
|
+
// asked for fifteen minutes must not silently get an hour, or the reverse.
|
|
386
|
+
const quickSelection = (record.council_mode || "full") === "quick";
|
|
387
|
+
if (args.council_pace !== undefined && args.council_pace !== null) {
|
|
388
|
+
if (quickSelection) {
|
|
389
|
+
throw invalidParams("council_pace applies to the full council only. Quick is a smaller contract, not a slower one.", {
|
|
390
|
+
reason: "QUICK_PACE_FORBIDDEN",
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (!COUNCIL_PACE_NAMES.includes(String(args.council_pace))) {
|
|
394
|
+
throw invalidParams(`council_pace must be one of ${COUNCIL_PACE_NAMES.join(", ")}.`, {
|
|
395
|
+
reason: "INVALID_COUNCIL_PACE",
|
|
396
|
+
allowed: COUNCIL_PACE_NAMES,
|
|
397
|
+
pace_options: councilPaceMenu(record.council_mode),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const chosenPace = quickSelection
|
|
402
|
+
? null
|
|
403
|
+
: String(args.council_pace || record.preselected_council_pace || DEFAULT_COUNCIL_PACE);
|
|
344
404
|
const resolved = resolveConfirmation(args, record);
|
|
345
405
|
if (record.council_mode === "quick" && resolved.ids.length > QUICK_MASTER_MAX) {
|
|
346
406
|
throw invalidParams(`Quick council accepts at most ${QUICK_MASTER_MAX} selected masters.`, {
|
|
@@ -378,6 +438,7 @@ function confirmCouncilSelectionUnlocked(args = {}, { now = Date.now() } = {}) {
|
|
|
378
438
|
selection_receipt: receipt,
|
|
379
439
|
confirmed_at: confirmedAt,
|
|
380
440
|
updated_at: confirmedAt,
|
|
441
|
+
council_pace: chosenPace,
|
|
381
442
|
});
|
|
382
443
|
const receiptRecord = {
|
|
383
444
|
schema_version: 1,
|
|
@@ -386,6 +447,10 @@ function confirmCouncilSelectionUnlocked(args = {}, { now = Date.now() } = {}) {
|
|
|
386
447
|
status: "confirmed",
|
|
387
448
|
symbol: record.symbol,
|
|
388
449
|
council_mode: record.council_mode || "full",
|
|
450
|
+
// The pace is part of what the user approved for this run, so it travels inside the receipt
|
|
451
|
+
// and is checked at consumption like every other bound field. A caller cannot approve a
|
|
452
|
+
// 15-minute run and then execute an hour of it.
|
|
453
|
+
council_pace: chosenPace,
|
|
389
454
|
catalog_hash: record.catalog_hash,
|
|
390
455
|
request_hash: record.request_hash,
|
|
391
456
|
intent_hash: record.intent_hash,
|
|
@@ -435,6 +500,7 @@ function confirmationResult(record) {
|
|
|
435
500
|
.map((master) => [master.id, master.pack_hash]),
|
|
436
501
|
),
|
|
437
502
|
selected_count: record.selected_master_ids.length,
|
|
503
|
+
council_pace: record.council_pace || null,
|
|
438
504
|
expires_at: record.expires_at,
|
|
439
505
|
};
|
|
440
506
|
}
|
|
@@ -460,6 +526,7 @@ function consumeCouncilSelectionUnlocked(args = {}, { now = Date.now() } = {}) {
|
|
|
460
526
|
["selection_receipt", receipt.selection_receipt, selection.selection_receipt],
|
|
461
527
|
["symbol", receipt.symbol, selection.symbol],
|
|
462
528
|
["council_mode", receipt.council_mode || "full", selection.council_mode || "full"],
|
|
529
|
+
["council_pace", receipt.council_pace || null, selection.council_pace || null],
|
|
463
530
|
["catalog_hash", receipt.catalog_hash, selection.catalog_hash],
|
|
464
531
|
["request_hash", receipt.request_hash, selection.request_hash],
|
|
465
532
|
["intent_hash", receipt.intent_hash, selection.intent_hash],
|
|
@@ -532,8 +599,16 @@ function consumeCouncilSelectionUnlocked(args = {}, { now = Date.now() } = {}) {
|
|
|
532
599
|
}
|
|
533
600
|
const intentHash = digest({ symbol, language, prompt, council_mode: mode });
|
|
534
601
|
if (receipt.intent_hash !== intentHash || selection.intent_hash !== intentHash) {
|
|
602
|
+
// Say which field moved. The receipt binds symbol, language, prompt and mode verbatim, so a
|
|
603
|
+
// single reworded character invalidates it -- and without this the caller cannot tell a
|
|
604
|
+
// retyped prompt from a wrong symbol, and retries the same broken call.
|
|
535
605
|
throw invalidParams("The selection receipt belongs to a different prompt or language intent.", {
|
|
536
606
|
reason: "MASTER_SELECTION_INTENT_MISMATCH",
|
|
607
|
+
expected_intent_hash: receipt.intent_hash || selection.intent_hash || null,
|
|
608
|
+
submitted_intent_hash: intentHash,
|
|
609
|
+
bound_fields: ["symbol", "language", "prompt", "council_mode"],
|
|
610
|
+
submitted: { symbol, language, council_mode: mode, prompt_length: prompt.length },
|
|
611
|
+
remedy: "Re-send the exact symbol, prompt, language and council_mode used in begin_council_selection, or start a new selection.",
|
|
537
612
|
});
|
|
538
613
|
}
|
|
539
614
|
if (expired(receipt, now) || expired(selection, now)) {
|
|
@@ -593,6 +668,9 @@ function consumedResult(selection, receipt) {
|
|
|
593
668
|
intent_hash: selection.intent_hash,
|
|
594
669
|
selection_mode: selection.selection_mode,
|
|
595
670
|
council_mode: selection.council_mode || "full",
|
|
671
|
+
// The pace approved at the gate must survive consumption, or the run silently falls back to
|
|
672
|
+
// the default and the approved tier is lost with nothing in the record showing the switch.
|
|
673
|
+
council_pace: selection.council_pace || null,
|
|
596
674
|
selected_master_ids: [...selection.selected_master_ids],
|
|
597
675
|
selected_master_pack_hashes: { ...(receipt.selected_master_pack_hashes || {}) },
|
|
598
676
|
selected_count: selection.selected_master_ids.length,
|
package/mcp/lib/fundamentals.mjs
CHANGED
|
@@ -182,17 +182,40 @@ function ownerEarnings(series, cik, gaps, proxy) {
|
|
|
182
182
|
});
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Total liabilities, tagged where the filer tags it and reconstructed exactly where not.
|
|
187
|
+
*
|
|
188
|
+
* Many filers never tag `Liabilities`; they tag the balance-sheet total and equity. The
|
|
189
|
+
* subtraction is only safe against the INCLUDING-non-controlling-interests equity tag -- with
|
|
190
|
+
* the parent-only tag, minority interest lands inside "liabilities" and nothing says so. When
|
|
191
|
+
* that tag is absent the figure stays missing, which is why this returns null rather than
|
|
192
|
+
* reaching for the nearest number.
|
|
193
|
+
*/
|
|
194
|
+
function totalLiabilitiesFor(series, gaps, factId) {
|
|
195
|
+
const direct = alignLatest(series, ["current_assets", "total_liabilities"]);
|
|
196
|
+
if (direct.ok) {
|
|
197
|
+
return { alignment: direct, value: direct.entries.total_liabilities.value, basis: "filed_total_liabilities" };
|
|
198
|
+
}
|
|
199
|
+
const derived = alignLatest(series, ["current_assets", "liabilities_and_equity", "equity_including_nci"]);
|
|
200
|
+
if (!derived.ok) {
|
|
201
|
+
gaps.push(alignmentGap(factId, direct, ["current_assets", "total_liabilities"]));
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
alignment: derived,
|
|
206
|
+
value: derived.entries.liabilities_and_equity.value - derived.entries.equity_including_nci.value,
|
|
207
|
+
basis: "balance_sheet_total_less_equity_including_noncontrolling_interests",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
185
211
|
/** 2. Graham NCAV: current assets - total liabilities. */
|
|
186
212
|
function netCurrentAssetValue(series, cik, gaps) {
|
|
187
213
|
const factId = "financial.net_current_asset_value";
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
gaps.push(alignmentGap(factId, alignment, concepts));
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
214
|
+
const resolved = totalLiabilitiesFor(series, gaps, factId);
|
|
215
|
+
if (!resolved) return null;
|
|
216
|
+
const alignment = resolved.alignment;
|
|
194
217
|
const currentAssets = alignment.entries.current_assets.value;
|
|
195
|
-
const liabilities =
|
|
218
|
+
const liabilities = resolved.value;
|
|
196
219
|
return buildMetric({
|
|
197
220
|
factId,
|
|
198
221
|
valueKind: "monetary",
|
|
@@ -207,6 +230,9 @@ function netCurrentAssetValue(series, cik, gaps) {
|
|
|
207
230
|
assumptions: [
|
|
208
231
|
"Graham NCAV as filed: current assets less ALL liabilities, with no haircut applied to receivables or inventory",
|
|
209
232
|
"an unclassified balance sheet (banks, insurers, many REITs) files no AssetsCurrent, which is reported as a gap rather than substituted",
|
|
233
|
+
resolved.basis === "filed_total_liabilities"
|
|
234
|
+
? "total liabilities taken from the filer's own Liabilities tag"
|
|
235
|
+
: "the filer tags no Liabilities total, so it is the balance-sheet total less equity INCLUDING non-controlling interests, which is exact rather than approximate",
|
|
210
236
|
],
|
|
211
237
|
});
|
|
212
238
|
}
|
|
@@ -465,10 +491,24 @@ function trailingYearOverYear(companyFacts, asOf) {
|
|
|
465
491
|
},
|
|
466
492
|
};
|
|
467
493
|
}
|
|
468
|
-
|
|
469
|
-
|
|
494
|
+
// Search backwards for the most recent pair of clean four-quarter blocks rather than
|
|
495
|
+
// insisting the last eight filings are them. One restated quarter, one stub period or one
|
|
496
|
+
// duplicated filing at the end used to discard a company's entire revenue history -- INTC and
|
|
497
|
+
// GLW both failed here while holding years of usable quarters behind the ragged edge. Each
|
|
498
|
+
// window is still checked exactly as strictly; only the search moved.
|
|
499
|
+
let latest = null;
|
|
500
|
+
let prior = null;
|
|
501
|
+
let offset = 0;
|
|
502
|
+
for (; offset <= quarters.length - 8; offset += 1) {
|
|
503
|
+
const end = quarters.length - 1 - offset;
|
|
504
|
+
latest = fourQuarters(quarters, end);
|
|
505
|
+
prior = fourQuarters(quarters, end - 4);
|
|
506
|
+
if (latest && prior) break;
|
|
507
|
+
latest = null;
|
|
508
|
+
prior = null;
|
|
509
|
+
}
|
|
470
510
|
if (!latest || !prior) {
|
|
471
|
-
return { ...empty, gap: { code: "period_misaligned", detail:
|
|
511
|
+
return { ...empty, gap: { code: "period_misaligned", detail: `no contiguous pair of four-quarter blocks in the ${quarters.length} tagged quarters` } };
|
|
472
512
|
}
|
|
473
513
|
if (!(prior.total > 0)) {
|
|
474
514
|
return { ...empty, gap: { code: "non_positive_base", detail: `prior-year four-quarter revenue is ${prior.total}` } };
|
package/mcp/lib/gates.mjs
CHANGED
|
@@ -260,6 +260,48 @@ function readerLanguageAudit(assigned, language) {
|
|
|
260
260
|
};
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Which contract sections an author still owes, checked against a submitted report body.
|
|
265
|
+
*
|
|
266
|
+
* `validateFinalReport` runs against the assembled report, after the system has appended the
|
|
267
|
+
* price snapshot, the master bench and any instrument-structure block, so it can only answer
|
|
268
|
+
* once the report already exists. A PM that submits no report body at all cannot be told so
|
|
269
|
+
* until then: on a real run the first submission carried no `report_markdown`, the report was
|
|
270
|
+
* assembled from the summary fallback, and the gate came back with 21 missing sections after
|
|
271
|
+
* the whole PM turn had been spent. This answers the same question at submission time, and
|
|
272
|
+
* deliberately ignores the sections the system owns.
|
|
273
|
+
*/
|
|
274
|
+
const SYSTEM_OWNED_REPORT_SECTIONS = new Set(["master_bench", "instrument_structure"]);
|
|
275
|
+
|
|
276
|
+
export function authoredReportSectionGaps(markdown, run) {
|
|
277
|
+
const gaps = validateFinalReport(markdown, run).missing
|
|
278
|
+
.filter((entry) => /^(missing|placeholder) section: |^section too thin: /.test(entry));
|
|
279
|
+
return gaps.filter((entry) => ![...SYSTEM_OWNED_REPORT_SECTIONS]
|
|
280
|
+
.some((id) => entry.includes(`section: ${id}`) || entry.includes(`section: ${id} (`)));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The heading a report must carry for each contract section, in the run's language. */
|
|
284
|
+
export function requiredReportSectionAliases(run) {
|
|
285
|
+
const quick = run?.council_mode === "quick";
|
|
286
|
+
const sections = quick ? QUICK_REPORT_SECTIONS : REPORT_SECTIONS;
|
|
287
|
+
const benchRan = ((run?.masters || []).length > 0) || ((run?.master_opinions || []).length > 0);
|
|
288
|
+
const fundOrIndex = isFundOrIndex(run?.grounding?.instrument);
|
|
289
|
+
const key = languageKey(run?.language);
|
|
290
|
+
const index = { zh: 0, en: 1, ja: 2, ko: 3 }[key] ?? 1;
|
|
291
|
+
return sections
|
|
292
|
+
.filter((section) => !SYSTEM_OWNED_REPORT_SECTIONS.has(section.id))
|
|
293
|
+
.filter((section) => !section.when_masters || benchRan)
|
|
294
|
+
.filter((section) => !section.when_fund_or_index || fundOrIndex)
|
|
295
|
+
.map((section) => ({
|
|
296
|
+
id: section.id,
|
|
297
|
+
minimum_body_characters: section.min_body,
|
|
298
|
+
// The catalog lists zh, en, ja and ko variants; offer the one the run reads in, and fall
|
|
299
|
+
// back to the English alias so the message is never empty.
|
|
300
|
+
suggested_heading: section.aliases[index] || section.aliases[1] || section.aliases[0],
|
|
301
|
+
accepted_headings: section.aliases,
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
304
|
+
|
|
263
305
|
export function validateFinalReport(markdown, run) {
|
|
264
306
|
const text = String(markdown || "");
|
|
265
307
|
const headings = parseHeadings(text);
|
package/mcp/lib/grounding.mjs
CHANGED
|
@@ -357,8 +357,25 @@ export async function gatherGrounding({
|
|
|
357
357
|
// Only the market-level block. A company's own leverage and growth come from its filings,
|
|
358
358
|
// and taking them from the index would be silently answering a different question.
|
|
359
359
|
const marketFacts = (r.value.facts || []).filter((fact) => MARKET_LEVEL_FACTS.has(fact.fact_id));
|
|
360
|
-
if (marketFacts.length) out.market_valuation = { ...r.value, facts: marketFacts };
|
|
360
|
+
if (marketFacts.length) { out.market_valuation = { ...r.value, facts: marketFacts }; return; }
|
|
361
|
+
// A fetch that succeeds but yields no market-level fact used to vanish here. Downstream,
|
|
362
|
+
// Marks and Damodaran declined with `unmet: index.aggregate_earnings_yield` and the reader
|
|
363
|
+
// was never told why the yardstick was missing -- an unexplained abstention reads as a
|
|
364
|
+
// verdict. Record it as the data gap it is.
|
|
365
|
+
out.unavailable.push(
|
|
366
|
+
`market valuation for ${BROAD_MARKET_INDEX}: fetched, but none of ${[...MARKET_LEVEL_FACTS].join(", ")} was derivable`
|
|
367
|
+
+ " (seats that measure a company against the market will decline for lack of grounding, not for lack of a view)",
|
|
368
|
+
);
|
|
361
369
|
}));
|
|
370
|
+
} else if (symbol && !isFundOrIndex(out.instrument) && symbolMarket?.id === "US") {
|
|
371
|
+
// Every other block on this path names the cutoff it could not serve; this one used to
|
|
372
|
+
// skip in silence, so a historical run lost the yardstick with nothing said about it and
|
|
373
|
+
// Marks and Damodaran again declined for a reason the reader could not see.
|
|
374
|
+
out.unavailable.push(
|
|
375
|
+
`market valuation for ${BROAD_MARKET_INDEX}: the index aggregate is published only as a current snapshot`
|
|
376
|
+
+ " with no point-in-time archive, so it was not fetched for a historical cutoff"
|
|
377
|
+
+ " (seats that measure a company against the market will decline for lack of grounding, not for lack of a view)",
|
|
378
|
+
);
|
|
362
379
|
}
|
|
363
380
|
|
|
364
381
|
// A fund or index has no issuer financials, so this is where its evidence comes from
|
|
@@ -389,6 +406,15 @@ export async function gatherGrounding({
|
|
|
389
406
|
if (news.value.available) out.basket_news = news.value;
|
|
390
407
|
out.unavailable.push(...(news.value.unavailable || []));
|
|
391
408
|
})());
|
|
409
|
+
} else if (symbol && isFundOrIndex(out.instrument)) {
|
|
410
|
+
// The same silence as the market-valuation block above, and worse here: a basket's
|
|
411
|
+
// holdings, look-through aggregates and news are the whole of its evidence, so a
|
|
412
|
+
// historical cutoff left the seats with a price and nothing to explain the emptiness.
|
|
413
|
+
out.unavailable.push(
|
|
414
|
+
`holdings, look-through aggregates and basket news for ${symbol}: issuer holdings and index`
|
|
415
|
+
+ " aggregates are published only as current snapshots with no point-in-time archive,"
|
|
416
|
+
+ " so they were not fetched for a historical cutoff",
|
|
417
|
+
);
|
|
392
418
|
}
|
|
393
419
|
|
|
394
420
|
if (symbol && isFundOrIndex(out.instrument)) {
|
package/mcp/lib/industry.mjs
CHANGED
|
@@ -169,7 +169,12 @@ export function sicGroupFor(sic) {
|
|
|
169
169
|
return matches.sort((a, b) => (a.range[1] - a.range[0]) - (b.range[1] - b.range[0]))[0];
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// Refreshed on the same cadence as the TWSE dataset: a resident MCP process that never
|
|
173
|
+
// re-reads the SEC ticker file silently loses every new listing and rename from peer
|
|
174
|
+
// matching, with no gap recorded anywhere.
|
|
175
|
+
const UNIVERSE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
172
176
|
let universeCache = null;
|
|
177
|
+
let universeCachedAt = 0;
|
|
173
178
|
|
|
174
179
|
/**
|
|
175
180
|
* Every US filer whose SIC matches a query, with no curation involved.
|
|
@@ -186,7 +191,10 @@ export async function peersBySic({ cik, limit = 25 } = {}) {
|
|
|
186
191
|
}
|
|
187
192
|
const group = sicGroupFor(anchor.sic);
|
|
188
193
|
|
|
189
|
-
if (!universeCache)
|
|
194
|
+
if (!universeCache || Date.now() - universeCachedAt > UNIVERSE_TTL_MS) {
|
|
195
|
+
universeCache = await fetchUniverse();
|
|
196
|
+
universeCachedAt = Date.now();
|
|
197
|
+
}
|
|
190
198
|
// Company names are the only universe-wide signal available without 10k requests.
|
|
191
199
|
const words = anchor.name.toLowerCase().split(/\s+/).filter((w) => w.length > 4 && !/corp|inc|company|holdings|group|technologies/.test(w));
|
|
192
200
|
const nameMatched = words.length
|