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.
@@ -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: { type: "object" },
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(`Recorded visible decision ${args.role} for ${result.run.symbol}: ${result.run.run_id}`, result));
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
  }
@@ -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
- const prior = byPeriod.get(entry.end);
85
- // Keep the most recently filed value for a period: restatements supersede.
86
- if (!prior || new Date(entry.filed) > new Date(prior.filed)) byPeriod.set(entry.end, entry);
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 (byPeriod.size === 0) continue;
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
- return null;
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. */