alphacouncil-agent 0.5.5 → 0.8.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-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +120 -0
- package/data/social-handles.json +25 -0
- package/docs/persona-v2-spec.md +278 -0
- package/docs/personas.md +1 -1
- package/docs/plans/0.8.0-master-models.md +155 -0
- package/docs/roadmap.md +104 -0
- package/knowledge/masters/master_buffett/manifest.json +209 -0
- package/knowledge/masters/master_duan_yongping/manifest.json +201 -0
- package/knowledge/masters/master_marks/manifest.json +182 -0
- package/knowledge/masters/master_taleb/manifest.json +182 -0
- package/mcp/lib/constants.mjs +4 -0
- package/mcp/lib/gates.mjs +3 -1
- package/mcp/lib/markdown.mjs +189 -0
- package/mcp/lib/orchestrator.mjs +73 -7
- package/mcp/lib/packets.mjs +42 -1
- package/mcp/lib/personas-v2/ablation.mjs +148 -0
- package/mcp/lib/personas-v2/bridge.mjs +187 -0
- package/mcp/lib/personas-v2/loader.mjs +118 -0
- package/mcp/lib/personas-v2/memory.mjs +156 -0
- package/mcp/lib/personas-v2/policy.mjs +187 -0
- package/mcp/lib/personas-v2/slice.mjs +96 -0
- package/mcp/lib/rpc.mjs +56 -6
- package/mcp/lib/screen.mjs +9 -1
- package/mcp/lib/sec.mjs +62 -8
- package/mcp/lib/social.mjs +32 -1
- package/package.json +6 -1
- package/personas/masters/masters-adversarial/{short_seller.md → forensic_short.md} +20 -5
- package/schemas/persona-v2.schema.json +180 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persona memory, with the time boundary that makes a backtest mean anything.
|
|
3
|
+
*
|
|
4
|
+
* Five layers: doctrine (immutable outside a release), episodic (append-only judgments),
|
|
5
|
+
* belief (current positions, pointing back at episodes), postmortem (written only after the
|
|
6
|
+
* horizon expires), working (discarded at the end of a run).
|
|
7
|
+
*
|
|
8
|
+
* The rule everything else exists to serve:
|
|
9
|
+
*
|
|
10
|
+
* public_at <= as_of AND memory_created_at <= as_of
|
|
11
|
+
*
|
|
12
|
+
* Both clauses. The first keeps a model from reading a filing that had not been published.
|
|
13
|
+
* The second keeps it from reading its own diary -- a note written in 2026 is not available
|
|
14
|
+
* to a run dated 2024, however true it was. Drop the second clause and a model launders
|
|
15
|
+
* hindsight through its own memory, every backtest built on it is fiction, and the returns
|
|
16
|
+
* look wonderful for exactly the wrong reason.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const LAYERS = ["doctrine", "episodic", "belief", "postmortem", "working"];
|
|
20
|
+
export const MEMORY_LAYERS = Object.freeze(LAYERS);
|
|
21
|
+
|
|
22
|
+
const day = 24 * 60 * 60 * 1000;
|
|
23
|
+
const toTime = (value) => {
|
|
24
|
+
const t = Date.parse(String(value ?? ""));
|
|
25
|
+
return Number.isFinite(t) ? t : null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class LeakError extends Error {
|
|
29
|
+
constructor(message) { super(message); this.name = "LeakError"; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Both clauses, applied to one record.
|
|
34
|
+
*
|
|
35
|
+
* A record missing either timestamp is *excluded*: an undated memory cannot be shown to be
|
|
36
|
+
* in the past, and admitting it "because it is probably fine" is the whole failure mode.
|
|
37
|
+
*/
|
|
38
|
+
export function isVisible(record, asOf) {
|
|
39
|
+
const cutoff = toTime(asOf);
|
|
40
|
+
if (cutoff === null) throw new LeakError(`as_of is not a date: ${JSON.stringify(asOf)}`);
|
|
41
|
+
const published = toTime(record?.public_at);
|
|
42
|
+
const created = toTime(record?.memory_created_at);
|
|
43
|
+
if (published === null || created === null) return false;
|
|
44
|
+
return published <= cutoff && created <= cutoff;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The reading side of the leak rule. Everything a persona sees passes through here. */
|
|
48
|
+
export function visibleMemory(records, asOf, { layer } = {}) {
|
|
49
|
+
return (records || [])
|
|
50
|
+
.filter((r) => (layer ? r.layer === layer : true))
|
|
51
|
+
.filter((r) => isVisible(r, asOf));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Refuses to write a record that would be invisible to the run that created it.
|
|
56
|
+
*
|
|
57
|
+
* A memory dated after its own run is either a clock bug or hindsight being backdated
|
|
58
|
+
* forwards; both should stop the run rather than settle quietly into the archive.
|
|
59
|
+
*/
|
|
60
|
+
export function assertWritable(record, asOf) {
|
|
61
|
+
if (!LAYERS.includes(record?.layer)) throw new LeakError(`unknown memory layer: ${JSON.stringify(record?.layer)}`);
|
|
62
|
+
const created = toTime(record?.memory_created_at);
|
|
63
|
+
const cutoff = toTime(asOf);
|
|
64
|
+
if (created === null) throw new LeakError("memory_created_at is required");
|
|
65
|
+
if (cutoff === null) throw new LeakError(`as_of is not a date: ${JSON.stringify(asOf)}`);
|
|
66
|
+
if (created > cutoff) throw new LeakError(`memory_created_at ${record.memory_created_at} is after as_of ${asOf}`);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One judgment, as it was made, with the evidence it was allowed to see. */
|
|
71
|
+
export function episode({ persona_id, symbol, as_of, decision, evidence_ids = [], invalidation = [], horizon_days }) {
|
|
72
|
+
return {
|
|
73
|
+
layer: "episodic",
|
|
74
|
+
persona_id,
|
|
75
|
+
symbol,
|
|
76
|
+
as_of,
|
|
77
|
+
public_at: as_of,
|
|
78
|
+
memory_created_at: as_of,
|
|
79
|
+
stance: decision?.stance,
|
|
80
|
+
reason: decision?.reason,
|
|
81
|
+
score: decision?.score ? { score: decision.score.score, max_possible: decision.score.max_possible, coverage: decision.score.coverage } : null,
|
|
82
|
+
evidence_ids,
|
|
83
|
+
invalidation,
|
|
84
|
+
horizon_days: horizon_days ?? null,
|
|
85
|
+
expires_at: horizon_days ? new Date(toTime(as_of) + horizon_days * day).toISOString().slice(0, 10) : null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A postmortem may not be written before the horizon it is judging has elapsed.
|
|
91
|
+
*
|
|
92
|
+
* Grading a call the day after making it is how a system convinces itself it was right: at
|
|
93
|
+
* that distance the outcome is still mostly noise, and the note it leaves behind becomes a
|
|
94
|
+
* belief that steers every later run.
|
|
95
|
+
*/
|
|
96
|
+
export function canWritePostmortem(ep, now) {
|
|
97
|
+
const expires = toTime(ep?.expires_at);
|
|
98
|
+
const t = toTime(now);
|
|
99
|
+
if (expires === null) return { allowed: false, reason: "no_horizon" };
|
|
100
|
+
if (t === null) return { allowed: false, reason: "no_clock" };
|
|
101
|
+
return t >= expires ? { allowed: true } : { allowed: false, reason: "horizon_not_reached", expires_at: ep.expires_at };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function postmortem({ episode: ep, now, outcome, failure_mode, rule_updates = [] }) {
|
|
105
|
+
const gate = canWritePostmortem(ep, now);
|
|
106
|
+
if (!gate.allowed) throw new LeakError(`postmortem refused: ${gate.reason}${gate.expires_at ? ` (expires ${gate.expires_at})` : ""}`);
|
|
107
|
+
return {
|
|
108
|
+
layer: "postmortem",
|
|
109
|
+
persona_id: ep.persona_id,
|
|
110
|
+
symbol: ep.symbol,
|
|
111
|
+
as_of: ep.as_of,
|
|
112
|
+
public_at: now,
|
|
113
|
+
memory_created_at: now,
|
|
114
|
+
outcome,
|
|
115
|
+
// Which of the four it was matters more than whether the call made money: data, method,
|
|
116
|
+
// timing and leakage each imply a different repair.
|
|
117
|
+
failure_mode: failure_mode ?? null,
|
|
118
|
+
rule_updates,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Current beliefs, each pointing back at the episode that formed it.
|
|
124
|
+
*
|
|
125
|
+
* Decayed rather than deleted: a stale belief is reported as stale so the reader can see
|
|
126
|
+
* the model is running on an old read, which is different from having no view.
|
|
127
|
+
*/
|
|
128
|
+
export function currentBeliefs(records, asOf, { decay_days } = {}) {
|
|
129
|
+
const cutoff = toTime(asOf);
|
|
130
|
+
const visible = visibleMemory(records, asOf, { layer: "belief" });
|
|
131
|
+
const latest = new Map();
|
|
132
|
+
for (const record of visible) {
|
|
133
|
+
const prev = latest.get(record.claim_id);
|
|
134
|
+
if (!prev || toTime(record.memory_created_at) > toTime(prev.memory_created_at)) latest.set(record.claim_id, record);
|
|
135
|
+
}
|
|
136
|
+
return [...latest.values()].map((record) => {
|
|
137
|
+
const age = decay_days ? Math.floor((cutoff - toTime(record.memory_created_at)) / day) : null;
|
|
138
|
+
return { ...record, age_days: age, stale: decay_days ? age > decay_days : false };
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* What a persona is allowed to carry into a run at `as_of`.
|
|
144
|
+
*
|
|
145
|
+
* Working memory is never returned: it belongs to the run that made it and must not become
|
|
146
|
+
* a long-term belief by default.
|
|
147
|
+
*/
|
|
148
|
+
export function recallFor(pack, records, asOf) {
|
|
149
|
+
const decay = pack?.memory_policy?.belief_decay_days;
|
|
150
|
+
return {
|
|
151
|
+
episodic: visibleMemory(records, asOf, { layer: "episodic" }),
|
|
152
|
+
beliefs: currentBeliefs(records, asOf, { decay_days: decay }),
|
|
153
|
+
postmortems: visibleMemory(records, asOf, { layer: "postmortem" }),
|
|
154
|
+
excluded_by_leak_rule: (records || []).filter((r) => r.layer !== "working" && !isVisible(r, asOf)).length,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deterministic half of a method model.
|
|
3
|
+
*
|
|
4
|
+
* Everything here runs before any model call and decides the things a language model must
|
|
5
|
+
* not decide: whether the method can evaluate this security at all, what it scores, which
|
|
6
|
+
* vetoes fire, and therefore what stance gets narrated. The model writes prose about a
|
|
7
|
+
* verdict it did not choose.
|
|
8
|
+
*
|
|
9
|
+
* Pure by construction: no network, no clock, no filesystem. Same inputs, same decision.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const OPS = {
|
|
13
|
+
">=": (a, b) => a >= b,
|
|
14
|
+
">": (a, b) => a > b,
|
|
15
|
+
"<=": (a, b) => a <= b,
|
|
16
|
+
"<": (a, b) => a < b,
|
|
17
|
+
"==": (a, b) => a === b,
|
|
18
|
+
"!=": (a, b) => a !== b,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** `a.b.c` against a plain object. Returns undefined rather than throwing on a gap. */
|
|
22
|
+
export function readPath(facts, path) {
|
|
23
|
+
return String(path || "").split(".").reduce((node, key) => (
|
|
24
|
+
node && typeof node === "object" && key in node ? node[key] : undefined
|
|
25
|
+
), facts);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MISSING = (v) => v === undefined || v === null || v === "" || (typeof v === "number" && !Number.isFinite(v));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Can this method evaluate this security at all?
|
|
32
|
+
*
|
|
33
|
+
* A requirement whose input is missing fails the gate. That is deliberate and is the
|
|
34
|
+
* opposite of the mechanical screen's rule for scoring: there, a missing input is `skipped`
|
|
35
|
+
* and must never be read as a pass. Here, missing inputs are precisely the reason a method
|
|
36
|
+
* should decline, and declining early costs nothing and says more than an essay would.
|
|
37
|
+
*/
|
|
38
|
+
export function evaluateEligibility(pack, facts) {
|
|
39
|
+
const requires = pack?.decision_policy?.eligibility?.requires || [];
|
|
40
|
+
const unmet = [];
|
|
41
|
+
for (const requirement of requires) {
|
|
42
|
+
const parsed = parseCondition(requirement);
|
|
43
|
+
if (!parsed) { unmet.push({ requirement, reason: "unparsable" }); continue; }
|
|
44
|
+
const actual = readPath(facts, parsed.path);
|
|
45
|
+
if (MISSING(actual)) { unmet.push({ requirement, reason: "missing_input", path: parsed.path }); continue; }
|
|
46
|
+
if (!compare(actual, parsed.op, parsed.value)) {
|
|
47
|
+
unmet.push({ requirement, reason: "not_met", path: parsed.path, actual });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { eligible: unmet.length === 0, unmet };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `path >= 4`, `path` (truthy), `!path` (falsy). Small on purpose: a condition language
|
|
55
|
+
* that grows an evaluator grows a way to be wrong quietly.
|
|
56
|
+
*/
|
|
57
|
+
export function parseCondition(text) {
|
|
58
|
+
const raw = String(text || "").trim();
|
|
59
|
+
if (!raw) return null;
|
|
60
|
+
const m = /^([A-Za-z0-9_.]+)\s*(>=|<=|==|!=|>|<)\s*(.+)$/.exec(raw);
|
|
61
|
+
if (m) return { path: m[1], op: m[2], value: literal(m[3]) };
|
|
62
|
+
if (/^![A-Za-z0-9_.]+$/.test(raw)) return { path: raw.slice(1), op: "==", value: false };
|
|
63
|
+
if (/^[A-Za-z0-9_.]+$/.test(raw)) return { path: raw, op: "==", value: true };
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function literal(text) {
|
|
68
|
+
const t = String(text).trim().replace(/^["']|["']$/g, "");
|
|
69
|
+
if (t === "true") return true;
|
|
70
|
+
if (t === "false") return false;
|
|
71
|
+
const n = Number(t);
|
|
72
|
+
return Number.isFinite(n) && t !== "" ? n : t;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function compare(actual, op, expected) {
|
|
76
|
+
if (typeof expected === "boolean") return op === "!=" ? Boolean(actual) !== expected : Boolean(actual) === expected;
|
|
77
|
+
const fn = OPS[op];
|
|
78
|
+
return fn ? fn(actual, expected) : false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Score the method's own rules.
|
|
83
|
+
*
|
|
84
|
+
* A rule whose input is missing is `uncomputable` -- never a miss, never a hit. Folding it
|
|
85
|
+
* into either one is how a screen reports "6/7 passed" while hiding that the seventh was
|
|
86
|
+
* never evaluated. `max_possible` therefore excludes uncomputable rules, so the ratio
|
|
87
|
+
* describes what was actually measured, and `coverage` reports how much of the method ran.
|
|
88
|
+
*/
|
|
89
|
+
export function scoreMethod(pack, facts) {
|
|
90
|
+
const scoring = pack?.decision_policy?.scoring;
|
|
91
|
+
if (!scoring) return { score: 0, max_possible: 0, declared_max: 0, coverage: 0, hits: [], misses: [], uncomputable: [] };
|
|
92
|
+
const hits = [];
|
|
93
|
+
const misses = [];
|
|
94
|
+
const uncomputable = [];
|
|
95
|
+
let score = 0;
|
|
96
|
+
let max_possible = 0;
|
|
97
|
+
|
|
98
|
+
for (const rule of scoring.rules || []) {
|
|
99
|
+
const actual = readPath(facts, rule.metric);
|
|
100
|
+
if (MISSING(actual)) { uncomputable.push({ id: rule.id, metric: rule.metric }); continue; }
|
|
101
|
+
max_possible += rule.points;
|
|
102
|
+
if (compare(actual, rule.op, rule.value)) {
|
|
103
|
+
score += rule.points;
|
|
104
|
+
hits.push({ id: rule.id, metric: rule.metric, actual, threshold: rule.value, points: rule.points, provenance: rule.provenance });
|
|
105
|
+
} else {
|
|
106
|
+
misses.push({ id: rule.id, metric: rule.metric, actual, threshold: rule.value, points: rule.points, provenance: rule.provenance });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const declared_max = scoring.max_score || 0;
|
|
110
|
+
return {
|
|
111
|
+
score,
|
|
112
|
+
max_possible,
|
|
113
|
+
declared_max,
|
|
114
|
+
coverage: declared_max ? max_possible / declared_max : 0,
|
|
115
|
+
hits,
|
|
116
|
+
misses,
|
|
117
|
+
uncomputable,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Vetoes are absolute: one fires and the stance is opposed regardless of score. */
|
|
122
|
+
export function evaluateVetoes(pack, facts) {
|
|
123
|
+
const triggered = [];
|
|
124
|
+
for (const veto of pack?.decision_policy?.vetoes || []) {
|
|
125
|
+
const parsed = parseCondition(veto.condition);
|
|
126
|
+
if (!parsed) continue;
|
|
127
|
+
const actual = readPath(facts, parsed.path);
|
|
128
|
+
if (MISSING(actual)) continue;
|
|
129
|
+
if (compare(actual, parsed.op, parsed.value)) {
|
|
130
|
+
triggered.push({ id: veto.id, condition: veto.condition, actual, source_ids: veto.source_ids || [] });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return triggered;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Highest band whose floor the ratio clears. */
|
|
137
|
+
export function stanceFromRatio(pack, ratio) {
|
|
138
|
+
const bands = [...(pack?.decision_policy?.stance_bands || [])].sort((a, b) => b.min_ratio - a.min_ratio);
|
|
139
|
+
for (const band of bands) if (ratio >= band.min_ratio) return band.stance;
|
|
140
|
+
return "out_of_scope";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The whole deterministic decision. `narratable` tells the caller whether spending a model
|
|
145
|
+
* call is warranted at all -- an ineligible method has nothing for a model to say.
|
|
146
|
+
*/
|
|
147
|
+
export function decide(pack, facts, { min_coverage = 0.5 } = {}) {
|
|
148
|
+
const eligibility = evaluateEligibility(pack, facts);
|
|
149
|
+
if (!eligibility.eligible) {
|
|
150
|
+
return {
|
|
151
|
+
persona_id: pack?.persona_id,
|
|
152
|
+
stance: "out_of_scope",
|
|
153
|
+
reason: "eligibility",
|
|
154
|
+
eligibility,
|
|
155
|
+
narratable: false,
|
|
156
|
+
score: null,
|
|
157
|
+
vetoes_triggered: [],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const scored = scoreMethod(pack, facts);
|
|
161
|
+
// Enough of the method has to have run for its score to mean anything. A rule set that
|
|
162
|
+
// could only evaluate a fifth of itself has not judged the company, it has sampled it.
|
|
163
|
+
if (scored.declared_max && scored.coverage < min_coverage) {
|
|
164
|
+
return {
|
|
165
|
+
persona_id: pack?.persona_id,
|
|
166
|
+
stance: "out_of_scope",
|
|
167
|
+
reason: "insufficient_coverage",
|
|
168
|
+
eligibility,
|
|
169
|
+
score: scored,
|
|
170
|
+
narratable: false,
|
|
171
|
+
vetoes_triggered: [],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const vetoes_triggered = evaluateVetoes(pack, facts);
|
|
175
|
+
const ratio = scored.max_possible ? scored.score / scored.max_possible : 0;
|
|
176
|
+
const stance = vetoes_triggered.length ? "opposed" : stanceFromRatio(pack, ratio);
|
|
177
|
+
return {
|
|
178
|
+
persona_id: pack?.persona_id,
|
|
179
|
+
stance,
|
|
180
|
+
reason: vetoes_triggered.length ? "veto" : "score",
|
|
181
|
+
eligibility,
|
|
182
|
+
score: scored,
|
|
183
|
+
ratio,
|
|
184
|
+
vetoes_triggered,
|
|
185
|
+
narratable: true,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Information asymmetry, enforced.
|
|
3
|
+
*
|
|
4
|
+
* The frozen fact pack is shared and identical for every model -- four methods must not
|
|
5
|
+
* discover four market caps in the name of independence. What differs is what each one is
|
|
6
|
+
* allowed to look at on top of it, and that is the only lever in this system that produces
|
|
7
|
+
* disagreement from something other than tone.
|
|
8
|
+
*
|
|
9
|
+
* A model that never sees the price cannot anchor on the drawdown. That is the point, and
|
|
10
|
+
* it only holds if the exclusion is applied here rather than requested in a prompt.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Facts every model shares and none may overwrite. */
|
|
14
|
+
export const FROZEN_KEYS = Object.freeze(["quote", "filer", "screen", "as_of", "symbol"]);
|
|
15
|
+
|
|
16
|
+
export class SliceError extends Error {
|
|
17
|
+
constructor(message) { super(message); this.name = "SliceError"; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The evidence one model may read: the frozen pack, plus only the analyst packets its
|
|
22
|
+
* research policy names.
|
|
23
|
+
*
|
|
24
|
+
* @returns {{frozen: object, packets: object[], excluded: string[], slice: string[]}}
|
|
25
|
+
*/
|
|
26
|
+
export function sliceFor(pack, { frozen = {}, packets = [] } = {}) {
|
|
27
|
+
const slice = pack?.research_policy?.evidence_slice;
|
|
28
|
+
// No declared slice means the model sees everything. That is a legitimate configuration,
|
|
29
|
+
// but it is recorded so a differentiation result can be read in the right light: seats
|
|
30
|
+
// sharing all evidence should not then be credited for agreeing.
|
|
31
|
+
const unrestricted = !Array.isArray(slice) || slice.length === 0;
|
|
32
|
+
const allowed = new Set(unrestricted ? (packets || []).map((p) => p.task) : slice);
|
|
33
|
+
const kept = (packets || []).filter((p) => allowed.has(p.task));
|
|
34
|
+
const excluded = (packets || []).filter((p) => !allowed.has(p.task)).map((p) => p.task);
|
|
35
|
+
return {
|
|
36
|
+
persona_id: pack?.persona_id,
|
|
37
|
+
frozen: pickFrozen(frozen),
|
|
38
|
+
packets: kept,
|
|
39
|
+
slice: unrestricted ? "unrestricted" : [...slice],
|
|
40
|
+
excluded,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pickFrozen(frozen) {
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const key of FROZEN_KEYS) if (key in (frozen || {})) out[key] = frozen[key];
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A model may disagree with a derived number. It may not silently replace a filed one.
|
|
52
|
+
*
|
|
53
|
+
* Recomputation is where independence is supposed to live, so the check is on the write
|
|
54
|
+
* rather than on the intent: a private figure that contradicts the frozen pack is kept and
|
|
55
|
+
* flagged as a dispute, never merged over the top of it.
|
|
56
|
+
*/
|
|
57
|
+
export function reconcile(frozen, recomputed) {
|
|
58
|
+
const disputes = [];
|
|
59
|
+
const accepted = {};
|
|
60
|
+
for (const [path, value] of Object.entries(recomputed || {})) {
|
|
61
|
+
const filed = FROZEN_KEYS.some((key) => path === key || path.startsWith(`${key}.`))
|
|
62
|
+
? readPath(frozen, path)
|
|
63
|
+
: undefined;
|
|
64
|
+
if (filed !== undefined && filed !== null && filed !== value) {
|
|
65
|
+
disputes.push({ path, filed, recomputed: value });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
accepted[path] = value;
|
|
69
|
+
}
|
|
70
|
+
return { accepted, disputes };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readPath(node, path) {
|
|
74
|
+
return String(path).split(".").reduce((n, k) => (n && typeof n === "object" && k in n ? n[k] : undefined), node);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Round one carries no name and no style: evidence, computation, decision, confidence,
|
|
79
|
+
* the abandonment condition, and where it is most likely wrong. Identity is attached
|
|
80
|
+
* afterwards, so the debate argues with a judgment before it knows whose it is.
|
|
81
|
+
*/
|
|
82
|
+
export function anonymize(opinion) {
|
|
83
|
+
const {
|
|
84
|
+
persona_id, master, display_name, voice, // eslint-disable-line no-unused-vars
|
|
85
|
+
...rest
|
|
86
|
+
} = opinion || {};
|
|
87
|
+
return { ...rest, submitted_as: "anonymous" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Two anonymous submissions are comparable only if identity really is gone. */
|
|
91
|
+
export function assertAnonymous(submission) {
|
|
92
|
+
for (const key of ["persona_id", "master", "display_name", "voice"]) {
|
|
93
|
+
if (key in (submission || {})) throw new SliceError(`anonymous submission still carries ${key}`);
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
package/mcp/lib/rpc.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import readline from "node:readline";
|
|
4
|
-
import { LIMITS, OUTPUT_MODES, SERVER_NAME, VERSION } from "./constants.mjs";
|
|
4
|
+
import { LIMITS, MASTER_STANCES, OUTPUT_MODES, SERVER_NAME, VERSION } from "./constants.mjs";
|
|
5
5
|
import { RpcCode, methodNotFound, invalidParams, toRpcError } from "./errors.mjs";
|
|
6
|
-
import { readJson, readJsonl } from "./fsutil.mjs";
|
|
6
|
+
import { readJson, readJsonl, writeJson } from "./fsutil.mjs";
|
|
7
7
|
import { resolveLanguage } from "./lang.mjs";
|
|
8
8
|
import { sweepStaleOutputs } from "./codex.mjs";
|
|
9
9
|
import { sourceManifest } from "./gates.mjs";
|
|
@@ -38,6 +38,36 @@ export function sendError(id, code, message, data) {
|
|
|
38
38
|
send({ jsonrpc: "2.0", id, error: { code, message, ...(data === undefined ? {} : { data }) } });
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* What a `record_*` call returns: progress, not the whole run.
|
|
43
|
+
*
|
|
44
|
+
* These handlers used to echo the entire run object -- every packet, every master opinion,
|
|
45
|
+
* the full grounding block -- on every call. The payload therefore grew with each recording,
|
|
46
|
+
* and late in a twenty-one-seat run a single response passed 240k characters. On any host
|
|
47
|
+
* that keeps tool results in the transcript that is a context-exhaustion bug, and it gets
|
|
48
|
+
* worse exactly when the run is most nearly finished.
|
|
49
|
+
*
|
|
50
|
+
* The caller needs to know what landed and what is still outstanding. The full state is on
|
|
51
|
+
* disk in status.json and is one read away for anyone who wants it.
|
|
52
|
+
*/
|
|
53
|
+
export function recordAck(run, extra = {}) {
|
|
54
|
+
return {
|
|
55
|
+
run_id: run.run_id,
|
|
56
|
+
symbol: run.symbol,
|
|
57
|
+
status: run.status,
|
|
58
|
+
phase: run.phase,
|
|
59
|
+
recorded_tasks: (run.packets || []).map((p) => p.task),
|
|
60
|
+
pending_tasks: (run.tasks || []).filter((t) => !(run.packets || []).some((p) => p.task === t)),
|
|
61
|
+
recorded_masters: (run.master_opinions || []).map((o) => o.master),
|
|
62
|
+
pending_masters: (run.masters || []).filter((m) => !(run.master_opinions || []).some((o) => o.master === m)),
|
|
63
|
+
completeness: run.completeness,
|
|
64
|
+
missing_evidence_count: run.missing_evidence_count,
|
|
65
|
+
missing_debate_count: run.missing_debate_count,
|
|
66
|
+
status_json: join(runPath(run.run_id), "status.json"),
|
|
67
|
+
...extra,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
41
71
|
export function jsonContent(text, structuredContent = {}) {
|
|
42
72
|
return {
|
|
43
73
|
content: [{ type: "text", text }],
|
|
@@ -136,7 +166,11 @@ export function tools() {
|
|
|
136
166
|
properties: {
|
|
137
167
|
run_id: { type: "string" },
|
|
138
168
|
master: { type: "string", enum: masterIds },
|
|
139
|
-
packet: {
|
|
169
|
+
packet: {
|
|
170
|
+
type: "object",
|
|
171
|
+
description: `The master_opinion packet. stance MUST be one of ${MASTER_STANCES.join(" | ")} -- anything else is recorded as out_of_scope and carries zero weight, so a seat whose stance does not survive normalization silently stops voting. Common synonyms (long/bullish, neutral/hold, short/bearish/avoid, n/a/abstain) are mapped for you.`,
|
|
172
|
+
properties: { stance: { type: "string", enum: MASTER_STANCES } },
|
|
173
|
+
},
|
|
140
174
|
thread_id: { type: "string" },
|
|
141
175
|
},
|
|
142
176
|
required: ["run_id", "master", "packet"],
|
|
@@ -326,6 +360,13 @@ export async function handleToolCall(id, params) {
|
|
|
326
360
|
saveRun(run);
|
|
327
361
|
}
|
|
328
362
|
const specs = visibleAgentSpecs(run, args.prompt || "");
|
|
363
|
+
// Planning settles every seat whose method cannot reach this security, writing those
|
|
364
|
+
// opinions directly. They have to be persisted here or the completeness gate would keep
|
|
365
|
+
// waiting for a report from a seat that will never be spawned.
|
|
366
|
+
if (specs.masters_declined?.length) {
|
|
367
|
+
saveRun(run);
|
|
368
|
+
writeJson(join(runPath(run.run_id), "evidence.json"), run);
|
|
369
|
+
}
|
|
329
370
|
// Returned with the plan, not left to a separate opt-in call: a host that skips the
|
|
330
371
|
// preflight is exactly the host whose subagents will fail silently.
|
|
331
372
|
const preflight = preflightNetworkPermissions({ roster: args.roster || "default" });
|
|
@@ -347,19 +388,28 @@ export async function handleToolCall(id, params) {
|
|
|
347
388
|
}
|
|
348
389
|
if (name === "record_visible_packet") {
|
|
349
390
|
const run = recordVisiblePacket(args);
|
|
350
|
-
sendResult(id, jsonContent(`Recorded visible evidence packet ${args.task} for ${run.symbol}: ${run.run_id}`, run));
|
|
391
|
+
sendResult(id, jsonContent(`Recorded visible evidence packet ${args.task} for ${run.symbol}: ${run.run_id}`, recordAck(run)));
|
|
351
392
|
return;
|
|
352
393
|
}
|
|
353
394
|
if (name === "record_visible_decision") {
|
|
354
395
|
const result = recordVisibleDecision(args);
|
|
355
|
-
sendResult(id, jsonContent(
|
|
396
|
+
sendResult(id, jsonContent(
|
|
397
|
+
`Recorded visible decision ${args.role} for ${result.run.symbol}: ${result.run.run_id}`,
|
|
398
|
+
// decision and opinion are small and are what a caller reads back; only the full
|
|
399
|
+
// run object is dropped.
|
|
400
|
+
recordAck(result.run, {
|
|
401
|
+
decision: result.decision,
|
|
402
|
+
report_quality: result.report_quality?.status,
|
|
403
|
+
missing_report_items: result.report_quality?.missing || [],
|
|
404
|
+
}),
|
|
405
|
+
));
|
|
356
406
|
return;
|
|
357
407
|
}
|
|
358
408
|
if (name === "record_master_opinion") {
|
|
359
409
|
const result = recordMasterOpinion(args);
|
|
360
410
|
sendResult(id, jsonContent(
|
|
361
411
|
`Recorded master opinion ${args.master} (${result.opinion.stance}) for ${result.run.symbol}: ${result.recorded}/${result.expected} master seats in.`,
|
|
362
|
-
result,
|
|
412
|
+
recordAck(result.run, { opinion: result.opinion, recorded: result.recorded, expected: result.expected }),
|
|
363
413
|
));
|
|
364
414
|
return;
|
|
365
415
|
}
|
package/mcp/lib/screen.mjs
CHANGED
|
@@ -17,8 +17,16 @@ const pct = (x) => (x === null ? null : Number((x * 100).toFixed(2)));
|
|
|
17
17
|
const last = (series, n) => series.slice(-n);
|
|
18
18
|
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* XBRL reports share counts under the `shares` unit, not `USD`. Requesting the default unit
|
|
22
|
+
* for a share concept returns nothing, so the dilution rule reported `skipped` for every
|
|
23
|
+
* company that has ever been screened -- seven rules advertised, six ever computed, and the
|
|
24
|
+
* skip looked exactly like a genuine data gap.
|
|
25
|
+
*/
|
|
26
|
+
const UNIT_BY_CONCEPT = { sharesOutstanding: "shares" };
|
|
27
|
+
|
|
20
28
|
function values(facts, key, asOf) {
|
|
21
|
-
const found = annualSeries(facts, CONCEPTS[key], { asOf });
|
|
29
|
+
const found = annualSeries(facts, CONCEPTS[key], { asOf, unit: UNIT_BY_CONCEPT[key] || "USD" });
|
|
22
30
|
return found ? found.series.map((e) => ({ end: e.end, filed: e.filed, val: e.val })) : [];
|
|
23
31
|
}
|
|
24
32
|
|
package/mcp/lib/sec.mjs
CHANGED
|
@@ -70,26 +70,80 @@ export async function fetchCompanyFacts(cik) {
|
|
|
70
70
|
* Tries several tags because the same economic quantity has different names depending on
|
|
71
71
|
* when and under which taxonomy a company filed.
|
|
72
72
|
*/
|
|
73
|
+
/**
|
|
74
|
+
* How long a reported period is, in days. Instant facts (balance-sheet items) have no start.
|
|
75
|
+
*/
|
|
76
|
+
const spanDays = (entry) =>
|
|
77
|
+
entry.start ? Math.round((Date.parse(entry.end) - Date.parse(entry.start)) / 86400000) : null;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Is this entry an annual figure?
|
|
81
|
+
*
|
|
82
|
+
* A 10-K carries quarterly and stub periods alongside the annual ones. Keying only on the
|
|
83
|
+
* end date treated each as its own year: Lumentum's 2015 produced ten "years" from one
|
|
84
|
+
* fiscal year -- nine quarters and stubs plus the real 363-day period. Every multi-year rule
|
|
85
|
+
* then averaged quarterly income against annual equity, silently.
|
|
86
|
+
*
|
|
87
|
+
* Fiscal years run 52 or 53 weeks, so the window has to be wider than 365 exactly.
|
|
88
|
+
*/
|
|
89
|
+
const ANNUAL_MIN_DAYS = 300;
|
|
90
|
+
const ANNUAL_MAX_DAYS = 400;
|
|
91
|
+
const isAnnual = (entry) => {
|
|
92
|
+
const days = spanDays(entry);
|
|
93
|
+
// Instant facts have no duration: shares outstanding, equity, total assets.
|
|
94
|
+
if (days === null) return true;
|
|
95
|
+
return days >= ANNUAL_MIN_DAYS && days <= ANNUAL_MAX_DAYS;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** The fiscal year a period belongs to, taken from its end date. */
|
|
99
|
+
const fiscalYear = (entry) => Number(String(entry.end).slice(0, 4));
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Annual history for a concept, merged across every alias.
|
|
103
|
+
*
|
|
104
|
+
* Merging matters as much as the annual filter. The function used to return at the first
|
|
105
|
+
* alias with any data, and revenue moved to RevenueFromContractWithCustomerExcludingAssessedTax
|
|
106
|
+
* when ASC 606 was adopted -- so a company reporting under the new tag since 2022 looked like
|
|
107
|
+
* it had four years of history, which then fired a "listed under ten years" exemption on a
|
|
108
|
+
* company that had been public for a decade.
|
|
109
|
+
*
|
|
110
|
+
* Aliases are ordered by preference, so an earlier alias wins where both cover a year.
|
|
111
|
+
*/
|
|
73
112
|
export function annualSeries(facts, tags, { asOf = null, unit = "USD" } = {}) {
|
|
74
113
|
const cutoff = asOf ? new Date(asOf).getTime() : null;
|
|
114
|
+
const byYear = new Map();
|
|
115
|
+
let usedTags = [];
|
|
116
|
+
|
|
75
117
|
for (const tag of tags) {
|
|
76
118
|
const entries = facts?.facts?.["us-gaap"]?.[tag]?.units?.[unit];
|
|
77
119
|
if (!Array.isArray(entries) || entries.length === 0) continue;
|
|
120
|
+
let contributed = false;
|
|
78
121
|
|
|
79
|
-
const byPeriod = new Map();
|
|
80
122
|
for (const entry of entries) {
|
|
81
123
|
if (entry.form !== "10-K" || !entry.end || !Number.isFinite(entry.val)) continue;
|
|
124
|
+
if (!isAnnual(entry)) continue;
|
|
82
125
|
// Look-ahead guard: a filing is only usable once it was actually filed.
|
|
83
126
|
if (cutoff && new Date(entry.filed).getTime() > cutoff) continue;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
127
|
+
|
|
128
|
+
const year = fiscalYear(entry);
|
|
129
|
+
const prior = byYear.get(year);
|
|
130
|
+
if (!prior) {
|
|
131
|
+
byYear.set(year, { ...entry, tag });
|
|
132
|
+
contributed = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// An earlier alias always wins the year; within one alias, the latest filing wins,
|
|
136
|
+
// because a restatement supersedes what it restates.
|
|
137
|
+
if (prior.tag === tag && new Date(entry.filed) > new Date(prior.filed)) {
|
|
138
|
+
byYear.set(year, { ...entry, tag });
|
|
139
|
+
}
|
|
87
140
|
}
|
|
88
|
-
if (
|
|
89
|
-
const series = [...byPeriod.values()].sort((a, b) => new Date(a.end) - new Date(b.end));
|
|
90
|
-
return { tag, unit, series };
|
|
141
|
+
if (contributed) usedTags.push(tag);
|
|
91
142
|
}
|
|
92
|
-
|
|
143
|
+
|
|
144
|
+
if (byYear.size === 0) return null;
|
|
145
|
+
const series = [...byYear.values()].sort((a, b) => new Date(a.end) - new Date(b.end));
|
|
146
|
+
return { tag: usedTags[0], tags: usedTags, unit, series };
|
|
93
147
|
}
|
|
94
148
|
|
|
95
149
|
/** Concept aliases, ordered by preference. */
|