alphacouncil-agent 0.6.0 → 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 +89 -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/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/social.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
1
3
|
import { LIMITS } from "./constants.mjs";
|
|
2
4
|
import { parseFeed, applyRecencyGate } from "./feeds.mjs";
|
|
3
5
|
|
|
@@ -215,7 +217,26 @@ export async function verifyXPost(id) {
|
|
|
215
217
|
};
|
|
216
218
|
}
|
|
217
219
|
|
|
218
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Curated Bluesky accounts, from the caller, the environment, or the shipped file.
|
|
222
|
+
*
|
|
223
|
+
* The file ships empty: handles that were never opened and checked would be invented
|
|
224
|
+
* sources inside a tool whose job is refusing invented sources.
|
|
225
|
+
*/
|
|
226
|
+
export function defaultSocialHandles() {
|
|
227
|
+
const fromEnv = String(process.env.ALPHACOUNCIL_SOCIAL_HANDLES || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
228
|
+
if (fromEnv.length) return fromEnv;
|
|
229
|
+
try {
|
|
230
|
+
const file = fileURLToPath(new URL("../../data/social-handles.json", import.meta.url));
|
|
231
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
232
|
+
return Array.isArray(parsed?.handles) ? parsed.handles.filter((h) => typeof h === "string" && h.trim()) : [];
|
|
233
|
+
} catch {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function getSocialPulse({ query = null, symbol = null, subreddits, handles, days = 7, asOf = null } = {}) {
|
|
239
|
+
handles = handles?.length ? handles : defaultSocialHandles();
|
|
219
240
|
const term = query || symbol;
|
|
220
241
|
const [reddit, hn, bsky] = await Promise.all([
|
|
221
242
|
fetchReddit({ subreddits, query: term, days, asOf }).catch((e) => ({ platform: "reddit", ok: false, reason: String(e?.message || e), included: [] })),
|
|
@@ -242,6 +263,16 @@ export async function getSocialPulse({ query = null, symbol = null, subreddits,
|
|
|
242
263
|
+ "surviving instance, the X API bills per post retrieved, and xAI's x_search bills per call. "
|
|
243
264
|
+ "This layer therefore does NOT cover professional FinTwit, which is where most of the "
|
|
244
265
|
+ "genuinely early equity discussion happens. Treating Reddit as a substitute for it is wrong.",
|
|
266
|
+
// Written from what actually ran, so a report cannot inherit a stale claim in either
|
|
267
|
+
// direction: neither "no professional layer" once accounts are configured, nor the
|
|
268
|
+
// reverse once they are removed.
|
|
269
|
+
bsky.configured === false
|
|
270
|
+
? "No Bluesky accounts are configured, so the one free professional-adjacent channel is "
|
|
271
|
+
+ "inactive. Add verified handles to data/social-handles.json or ALPHACOUNCIL_SOCIAL_HANDLES. "
|
|
272
|
+
+ "The file ships empty deliberately: unverified handles would be invented sources."
|
|
273
|
+
: `Bluesky ran over ${(bsky.handles_read || []).length || (bsky.included || []).length ? "the configured accounts" : "no reachable accounts"}`
|
|
274
|
+
+ ", by named account only. It is a curated sample, never a search of the platform, so "
|
|
275
|
+
+ "absence of a topic here is not evidence the topic is absent.",
|
|
245
276
|
"StockTwits is behind Cloudflare and Bluesky search requires authentication; neither is "
|
|
246
277
|
+ "reachable without credentials, so neither is used.",
|
|
247
278
|
"Reddit and Hacker News are retail and engineer opinion. They evidence what a narrative "
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alphacouncil-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Multi-agent public-equity research workflow plugin for Codex & Claude Code: sourced evidence packets, bull/bear debate, and a portfolio-manager Buy/Overweight/Hold/Underweight/Sell decision.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"mcp/",
|
|
39
39
|
"personas/",
|
|
40
|
+
"knowledge/",
|
|
40
41
|
"data/",
|
|
41
42
|
"skills/",
|
|
42
43
|
"docs/report-contract.md",
|
|
@@ -62,6 +63,10 @@
|
|
|
62
63
|
".opencode/command/",
|
|
63
64
|
".grok/commands/",
|
|
64
65
|
"docs/INSTALL.md",
|
|
66
|
+
"docs/plans/",
|
|
67
|
+
"docs/roadmap.md",
|
|
68
|
+
"schemas/",
|
|
69
|
+
"docs/persona-v2-spec.md",
|
|
65
70
|
"SECURITY.md",
|
|
66
71
|
"CONTRIBUTING.md"
|
|
67
72
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---json
|
|
2
2
|
{
|
|
3
3
|
"schema_version": 1,
|
|
4
|
-
"id": "
|
|
4
|
+
"id": "master_forensic_short",
|
|
5
5
|
"kind": "master",
|
|
6
6
|
"order": 40,
|
|
7
7
|
"enabled": true,
|
|
@@ -9,10 +9,7 @@
|
|
|
9
9
|
"masters-adversarial",
|
|
10
10
|
"masters-core"
|
|
11
11
|
],
|
|
12
|
-
"title": {
|
|
13
|
-
"zh": "做空者视角",
|
|
14
|
-
"en": "Short Seller Lens"
|
|
15
|
-
},
|
|
12
|
+
"title": { "zh": "法务会计做空视角", "en": "Forensic Short Seller Lens" },
|
|
16
13
|
"model_tier": "deep",
|
|
17
14
|
"default_weight": 1.2,
|
|
18
15
|
"tags": [
|
|
@@ -94,6 +91,15 @@
|
|
|
94
91
|
|
|
95
92
|
如果你找不到做空论点,就写「无做空论点」,并给多头一句话:**在什么价位上,连我都会承认这笔多头是划算的?**
|
|
96
93
|
|
|
94
|
+
## 你和伯里席位的分工
|
|
95
|
+
|
|
96
|
+
同一个名册里有伯里。**你们猎的不是同一种东西,别互相重复。**
|
|
97
|
+
|
|
98
|
+
- **你猎会计造假**:附注里缺了什么、口径为什么变、审计师为什么换、应计质量如何。你的证据是可以回源核对的具体科目。
|
|
99
|
+
- **伯里猎结构性错价**:资本结构、契约条款、被机械性原因错误定价的资产。他成名那笔是做多信用违约互换,标的是被系统性低估的信用风险,**不是揭发一家公司做假账**。
|
|
100
|
+
|
|
101
|
+
如果你发现的问题伯里也能从资本结构看出来,说明你没走到自己该走的深度 —— 回到附注去。
|
|
102
|
+
|
|
97
103
|
<!-- lang:en -->
|
|
98
104
|
You are the short seller. Your job is not to disagree; it is to find **specific, falsifiable** problems.
|
|
99
105
|
|
|
@@ -143,3 +149,12 @@ The price question for a short is not how far it falls but how far it can rise b
|
|
|
143
149
|
- **Cost of carry**: annualised borrow times expected holding period is the hurdle you must beat. Almost every short thesis omits this.
|
|
144
150
|
|
|
145
151
|
If you find no short thesis, write "no short thesis" and give the long side one sentence: **at what price would even you concede the long is a good deal?**
|
|
152
|
+
|
|
153
|
+
## How you divide the work with the Burry seat
|
|
154
|
+
|
|
155
|
+
Burry sits on the same roster. **You do not hunt the same thing, so do not duplicate him.**
|
|
156
|
+
|
|
157
|
+
- **You hunt accounting fraud**: what is absent from the notes, why a definition changed, why the auditor changed, how the accruals look. Your evidence is a specific line item someone can check at source.
|
|
158
|
+
- **Burry hunts structural mispricing**: capital structure, covenants, an asset mispriced for a mechanical reason. His famous trade was long credit default swaps on systematically underpriced credit risk, **not exposing a company's fraudulent books**.
|
|
159
|
+
|
|
160
|
+
If your finding is something Burry could also reach from the capital structure, you have not gone as deep as this seat is for. Go back to the notes.
|