syncstaff-mcp 0.2.3

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.
Files changed (52) hide show
  1. package/README.md +86 -0
  2. package/dist/lib/agent-state.js +119 -0
  3. package/dist/lib/blast.js +462 -0
  4. package/dist/lib/client-config.js +81 -0
  5. package/dist/lib/env-compat.js +66 -0
  6. package/dist/lib/globs.js +0 -0
  7. package/dist/lib/ids.js +24 -0
  8. package/dist/lib/index/aliases.js +244 -0
  9. package/dist/lib/index/call-sites.js +178 -0
  10. package/dist/lib/index/checker-resolver.js +257 -0
  11. package/dist/lib/index/context-card.js +140 -0
  12. package/dist/lib/index/coverage.js +218 -0
  13. package/dist/lib/index/delivery.js +66 -0
  14. package/dist/lib/index/discovery.js +90 -0
  15. package/dist/lib/index/embedding.js +110 -0
  16. package/dist/lib/index/file-index.js +222 -0
  17. package/dist/lib/index/fingerprint.js +0 -0
  18. package/dist/lib/index/git-history.js +136 -0
  19. package/dist/lib/index/graph.js +234 -0
  20. package/dist/lib/index/impact.js +174 -0
  21. package/dist/lib/index/incremental.js +332 -0
  22. package/dist/lib/index/lexical.js +462 -0
  23. package/dist/lib/index/order.js +43 -0
  24. package/dist/lib/index/pages.js +357 -0
  25. package/dist/lib/index/persistence.js +233 -0
  26. package/dist/lib/index/pipeline.js +527 -0
  27. package/dist/lib/index/registry.js +106 -0
  28. package/dist/lib/index/resolve.js +280 -0
  29. package/dist/lib/index/semantic.js +381 -0
  30. package/dist/lib/index/surfaces.js +27 -0
  31. package/dist/lib/index/symbols.js +426 -0
  32. package/dist/lib/index/transformers-embedder.js +73 -0
  33. package/dist/lib/index/typescript-parser.js +532 -0
  34. package/dist/lib/index/vector-cache.js +176 -0
  35. package/dist/lib/index/verification.js +58 -0
  36. package/dist/lib/mcp-compaction.js +241 -0
  37. package/dist/lib/model-roles.js +206 -0
  38. package/dist/lib/path-warnings.js +90 -0
  39. package/dist/lib/protocol.js +95 -0
  40. package/dist/lib/types.js +69 -0
  41. package/dist/lib/version.js +21 -0
  42. package/dist/lib/worktree.js +211 -0
  43. package/dist/mcp/approval.js +0 -0
  44. package/dist/mcp/cloud-connector.js +99 -0
  45. package/dist/mcp/daemon-client.js +156 -0
  46. package/dist/mcp/daemon-protocol.js +100 -0
  47. package/dist/mcp/escalation-waiter.js +183 -0
  48. package/dist/mcp/graph-ops.js +169 -0
  49. package/dist/mcp/index.js +1151 -0
  50. package/dist/mcp/login.js +169 -0
  51. package/dist/mcp/setup.js +90 -0
  52. package/package.json +42 -0
@@ -0,0 +1,176 @@
1
+ import { createHash } from "node:crypto";
2
+ import { distillBody, EMBEDDED_BODY_CHARS } from "./pages.js";
3
+ const instances = new Set();
4
+ const anonymousEmbedderIds = new WeakMap();
5
+ let nextAnonymousEmbedderId = 1;
6
+ /**
7
+ * Return a stable identity for one embedder instance.
8
+ *
9
+ * Providers may expose an id/model, but custom test and application
10
+ * embedders often do not. In that case identity is deliberately scoped to the
11
+ * object instance: reusing vectors across two unknown implementations would
12
+ * be less safe than doing a second embedding pass.
13
+ */
14
+ export function embedderIdentity(embedder) {
15
+ const candidate = embedder;
16
+ if (typeof candidate.id === "string" && candidate.id)
17
+ return candidate.id;
18
+ if (typeof candidate.provider === "string" && typeof candidate.model === "string") {
19
+ return `${candidate.provider}:${candidate.model}:${candidate.dimensions}`;
20
+ }
21
+ const object = embedder;
22
+ let id = anonymousEmbedderIds.get(object);
23
+ if (!id) {
24
+ id = `anonymous-${nextAnonymousEmbedderId++}`;
25
+ anonymousEmbedderIds.set(object, id);
26
+ }
27
+ return `${id}:${embedder.dimensions}`;
28
+ }
29
+ /**
30
+ * Opt into the Repowise-shaped document representation for experiments.
31
+ *
32
+ * The default stays unchanged so a candidate representation cannot silently
33
+ * alter production retrieval or invalidate a deployed vector cache. The
34
+ * benchmark process can enable this per run and the document hash naturally
35
+ * keeps the representations in separate cache entries. `facts` is the
36
+ * narrower candidate; `1` additionally includes the bounded vocabulary.
37
+ */
38
+ const vectorDocumentFactsMode = () => process.env.KEEL_VECTOR_DOCUMENT_FACTS ?? "";
39
+ /**
40
+ * The exact text sent to the embedder for one page.
41
+ *
42
+ * Exported so the vector leg and this cache cannot disagree about it: the
43
+ * cache key is derived from this string, so any drift between the two would
44
+ * silently serve vectors of text that was never embedded.
45
+ *
46
+ * `summary` alone is close to a constant — "X is a typescript file with 3
47
+ * exports and 5 imports" — so embedding title-plus-summary produced vectors
48
+ * that differed only by path, and the vector leg was ranking on filenames
49
+ * while appearing to do semantic search. The body prefix is what gives the
50
+ * embedding actual subject matter.
51
+ *
52
+ * The prefix is bounded because sentence embedders truncate hard (BGE-small
53
+ * at 512 tokens); sending a whole file would silently discard most of it and
54
+ * cost the encode anyway.
55
+ */
56
+ export const documentText = (page) => {
57
+ const factsMode = vectorDocumentFactsMode();
58
+ const structuralFacts = factsMode === "facts" || factsMode === "1" ? page.content.trim() : "";
59
+ const vocabulary = factsMode === "1" ? page.lexical_vocabulary?.trim() ?? "" : "";
60
+ return [
61
+ page.title,
62
+ page.summary,
63
+ structuralFacts,
64
+ vocabulary,
65
+ distillBody(page.body ?? "").slice(0, EMBEDDED_BODY_CHARS),
66
+ ].filter(Boolean).join(" ").trimEnd();
67
+ };
68
+ /** The identity of the text sent to the embedder, independent of source bytes. */
69
+ export function documentSummaryHash(page) {
70
+ return createHash("sha256").update(documentText(page)).digest("hex");
71
+ }
72
+ function cacheKey(page, embedder) {
73
+ return `${embedderIdentity(embedder)}\0${documentSummaryHash(page)}`;
74
+ }
75
+ /**
76
+ * Cache document vectors by retrieval-summary identity and embedder identity.
77
+ *
78
+ * The page source hash is intentionally absent. Editing a function body can
79
+ * change the parser/index fingerprint without changing the bounded summary
80
+ * that the vector leg embeds; reusing that vector is safe and avoids paying
81
+ * the model again. `page_id` remains the natural key in the returned lookup.
82
+ *
83
+ * The promise is cached while work is in flight as well as after it resolves,
84
+ * so concurrent queries cannot start duplicate model calls. Failed work is
85
+ * removed and may be retried after the provider recovers.
86
+ */
87
+ export class VectorCache {
88
+ entries = new Map();
89
+ store;
90
+ constructor(store) {
91
+ this.store = store ?? null;
92
+ instances.add(this);
93
+ }
94
+ async documentVectors(_fingerprint, pages, embedder) {
95
+ const embedDocument = embedder.embedDocument ?? embedder.embed;
96
+ const vectors = await Promise.all(pages.map(async (page) => [
97
+ page.page_id,
98
+ await this.documentVector(page, embedder, embedDocument),
99
+ ]));
100
+ return new Map(vectors);
101
+ }
102
+ async documentVector(page, embedder, embedDocument) {
103
+ const key = cacheKey(page, embedder);
104
+ const existing = this.entries.get(key);
105
+ if (existing)
106
+ return existing.promise;
107
+ let persisted = null;
108
+ try {
109
+ persisted = this.store?.getVector(key) ?? null;
110
+ }
111
+ catch {
112
+ // Persistence is an optimization. A locked or corrupt cache must not
113
+ // make a local retrieval answer unavailable.
114
+ }
115
+ const promise = persisted
116
+ ? Promise.resolve(persisted)
117
+ : embedDocument.call(embedder, documentText(page)).then((vector) => {
118
+ try {
119
+ this.store?.putVector(key, vector);
120
+ }
121
+ catch {
122
+ // Keep the freshly computed vector usable when the cache is unwritable.
123
+ }
124
+ return vector;
125
+ });
126
+ const entry = { key, promise };
127
+ this.entries.set(key, entry);
128
+ try {
129
+ return await promise;
130
+ }
131
+ catch (error) {
132
+ if (this.entries.get(key) === entry)
133
+ this.entries.delete(key);
134
+ throw error;
135
+ }
136
+ }
137
+ /**
138
+ * Kept for incremental-index callers for API compatibility.
139
+ *
140
+ * Fingerprints no longer identify vector inputs: summary hashes do. A
141
+ * fingerprint change therefore must not evict vectors whose summaries are
142
+ * unchanged; `clear()` remains available when a caller is disposing a whole
143
+ * cache or changing provider policy.
144
+ */
145
+ invalidateFingerprint(_fingerprint) {
146
+ // Deliberately empty; summary-hash identity makes this invalidation too broad.
147
+ }
148
+ /** Drop all entries, useful when a caller releases a local index. */
149
+ clear() {
150
+ this.entries.clear();
151
+ }
152
+ /** Number of fingerprints/embedder pairs currently retained. */
153
+ get size() {
154
+ return this.entries.size;
155
+ }
156
+ /** Remove this cache from the process-wide invalidation registry. */
157
+ dispose() {
158
+ this.clear();
159
+ instances.delete(this);
160
+ }
161
+ }
162
+ /** The default cache for retrieval callers that do not need a private one. */
163
+ export const defaultVectorCache = new VectorCache();
164
+ /**
165
+ * Retain the incremental-index hook without invalidating summary-stable vectors.
166
+ *
167
+ * Incremental indexing calls this hook because the cache is derived from the
168
+ * index, just like the graph and retrieval pages. Keeping it process-wide
169
+ * lets callers use private caches without making the indexer know their
170
+ * ownership, while `dispose()` prevents abandoned clients from retaining
171
+ * entries forever.
172
+ */
173
+ export function invalidateVectorCache(fingerprint) {
174
+ for (const cache of instances)
175
+ cache.invalidateFingerprint(fingerprint);
176
+ }
@@ -0,0 +1,58 @@
1
+ import { buildCheckerCallGraph, callersOf, loadCheckerProgram } from "./checker-resolver.js";
2
+ import { byCodeUnit } from "./order.js";
3
+ /**
4
+ * Verify candidate call sites; never discard an unverified candidate.
5
+ *
6
+ * ATTRIBUTION. `callersOf` answers "who calls this symbol", filtering on the
7
+ * symbol and its declaring file and nothing else. Asking it once per candidate
8
+ * and promoting on a non-empty result therefore promotes *every* candidate
9
+ * whenever the symbol has any caller anywhere in the repository: the answer
10
+ * never reads `candidate.path`, so a file with no connection to the symbol is
11
+ * promoted on the strength of some other file's call, and then carries that
12
+ * other file's call sites as its own "evidence". That is the sham this phase
13
+ * was written against — a verification claim that cannot fail — and it was
14
+ * invisible because the tests only ever exercised the degraded paths, where
15
+ * no candidate is promoted at all.
16
+ *
17
+ * The edge already names its own origin, so the fix is to read it: a candidate
18
+ * is promoted only on an edge whose `from` is that candidate.
19
+ *
20
+ * NO EDGE, NO CLAIM. A promoted candidate always carries at least one call
21
+ * site (R9.2). A file that declares the implicated symbol but has no callers
22
+ * stays unverified rather than being promoted on an empty evidence list — the
23
+ * checker learned nothing there that the symbol index had not already said,
24
+ * and claiming verification with nothing to show is the failure R9.3 calls
25
+ * worse than no verification at all.
26
+ */
27
+ export async function verifyCandidates(root, candidates, options = {}) {
28
+ const started = Date.now();
29
+ const budget = options.budgetMs ?? 5000;
30
+ const unavailable = (reason) => ({
31
+ candidates: candidates.map((candidate) => ({ ...candidate, verified: false, evidence: [], verification: reason })),
32
+ verified: false,
33
+ caveat: reason === "budget_exceeded" ? `checker exceeded ${budget}ms budget` : "no TypeScript project available; candidates remain unverified",
34
+ elapsed_ms: Date.now() - started,
35
+ });
36
+ const backend = await loadCheckerProgram(root);
37
+ if (!backend)
38
+ return unavailable("unavailable");
39
+ const graph = buildCheckerCallGraph(backend.ts, root, backend.configs);
40
+ if (Date.now() - started > budget)
41
+ return unavailable("budget_exceeded");
42
+ const verified = candidates.map((candidate) => {
43
+ const incoming = callersOf(graph, candidate.symbol, candidate.declared_in);
44
+ const references = incoming.filter((edge) => edge.from === candidate.path);
45
+ if (references.length > 0) {
46
+ return { ...candidate, verified: true, evidence: references, verification: "checker", relation: "references" };
47
+ }
48
+ // A file is never among its own callers, so the declaring file can only be
49
+ // promoted here. Its evidence is the incoming calls that make the
50
+ // declaration worth editing; with none, it stays unverified.
51
+ if (candidate.path === candidate.declared_in && incoming.length > 0) {
52
+ return { ...candidate, verified: true, evidence: incoming, verification: "checker", relation: "declares" };
53
+ }
54
+ return { ...candidate, verified: false, evidence: [], verification: "checker" };
55
+ });
56
+ verified.sort((a, b) => Number(b.verified) - Number(a.verified) || (b.score ?? 0) - (a.score ?? 0) || byCodeUnit(a.path, b.path));
57
+ return { candidates: verified, verified: true, caveat: null, elapsed_ms: Date.now() - started };
58
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Shrinking tool responses before they reach a model.
3
+ *
4
+ * Every function here is pure, and that is the point: the MCP adapter is a
5
+ * process that connects on import, so nothing inside it can be unit tested.
6
+ * These were measured against 40 sessions of real transcripts — 960,307
7
+ * response tokens across 1,837 keel tool calls, against roughly 6,000 tokens
8
+ * per session of static tool schemas and protocol instructions. Responses
9
+ * outweigh definitions about four to one, so this is where the budget is.
10
+ *
11
+ * The rule they share: a response should carry what the caller does not
12
+ * already have. Echoing back a body the caller just sent, or a path list
13
+ * printed twice in one payload, costs a model's context and tells it nothing.
14
+ */
15
+ /**
16
+ * Elide the arbiter's prose on a verdict that asks nothing of the reader.
17
+ *
18
+ * The arbiter is an LLM and writes like one: a multi-paragraph argument on
19
+ * every declaration. Measured across 234 declarations, 213 of them — 91% —
20
+ * came back `clear`, and those carried 69,496 tokens of reasoning that changed
21
+ * nothing, because the only action a clear verdict permits is the one the
22
+ * agent already intended. The 21 non-clear verdicts carried 9,075 tokens that
23
+ * an agent must act on, and those are kept in full.
24
+ *
25
+ * This is the one trim here that is conditional rather than structural, and
26
+ * deliberately so: the rule is "drop what cannot change the reader's next
27
+ * move", and on a conflict or an escalation the reasoning is precisely what
28
+ * does change it.
29
+ */
30
+ function compactArbiter(arbiter) {
31
+ if (!arbiter || typeof arbiter !== "object")
32
+ return arbiter;
33
+ if (arbiter.verdict !== "clear")
34
+ return arbiter;
35
+ const { reasoning, suggested_options, ...rest } = arbiter;
36
+ return {
37
+ ...rest,
38
+ ...(reasoning === undefined
39
+ ? {}
40
+ : { reasoning: "elided on a clear verdict — intent_get(intent_id) returns it in full" }),
41
+ };
42
+ }
43
+ /**
44
+ * Strip the historical argument from a charter, keeping every binding word.
45
+ *
46
+ * An invariant's `text` binds and is returned intact. Its `rationale` is the
47
+ * case that was made for it when it was ratified — written for the human
48
+ * weighing an amendment, not for the agent obeying the rule — and across
49
+ * measured sessions the rationales came to 11,268 tokens against 7,255 for the
50
+ * invariant texts themselves. An agent that needs the argument is proposing an
51
+ * amendment, which is a human-facing path with the full record attached.
52
+ */
53
+ export function compactCharter(response) {
54
+ const charter = response?.charter;
55
+ if (!charter || !Array.isArray(charter.invariants))
56
+ return response;
57
+ let elided = false;
58
+ const invariants = charter.invariants.map((inv) => {
59
+ if (!inv || typeof inv !== "object" || inv.rationale == null)
60
+ return inv;
61
+ const { rationale, ...rest } = inv;
62
+ elided = true;
63
+ return rest;
64
+ });
65
+ return {
66
+ ...response,
67
+ charter: {
68
+ ...charter,
69
+ invariants,
70
+ ...(elided
71
+ ? { rationales: "elided — every invariant text is intact; ratification rationales are on the board and in the amendment record" }
72
+ : {}),
73
+ },
74
+ };
75
+ }
76
+ /**
77
+ * Compact one intent for a listing.
78
+ *
79
+ * A call site report is the declaring client's own analysis: every place a
80
+ * changed symbol is used, with the source line quoted at each. It is the right
81
+ * thing to send up — the server cannot compute it without reading code, which
82
+ * the charter forbids — and the wrong thing to send back. Measured 29 Aug, two
83
+ * active intents carried 87 quoted call sites and made intent_list_active the
84
+ * largest single response of the session, most of it returned to the very agent
85
+ * that produced it.
86
+ *
87
+ * A reader of this list is deciding one thing: whether to stay away. That needs
88
+ * the file set and how much of it is guesswork, not the source. `intent_get`
89
+ * serves the full report to anyone who wants it.
90
+ */
91
+ export function compactIntent(intent) {
92
+ if (!intent || typeof intent !== "object")
93
+ return intent;
94
+ const interfaces = Array.isArray(intent.interfaces_touched)
95
+ ? intent.interfaces_touched.map((touched) => {
96
+ const report = touched?.call_site_report;
97
+ if (!report || !Array.isArray(report.call_sites))
98
+ return touched;
99
+ const sites = report.call_sites;
100
+ return {
101
+ ...touched,
102
+ call_site_report: {
103
+ analyzer: report.analyzer,
104
+ status: report.status,
105
+ truncated: report.truncated,
106
+ call_sites_total: sites.length,
107
+ call_sites_unconfirmed: sites.filter((s) => s?.confidence === "unconfirmed").length,
108
+ paths: [...new Set(sites.map((s) => s?.path).filter(Boolean))].sort(),
109
+ detail: "call sites elided — intent_get(intent_id) returns them in full",
110
+ },
111
+ };
112
+ })
113
+ : intent.interfaces_touched;
114
+ // A listing flattens the arbiter to a bare `arbiter_reasoning` string, so the
115
+ // same 91%-of-verdicts-are-clear argument applies here in a different shape.
116
+ const reasoningElided = typeof intent.arbiter_reasoning === "string" && intent.verdict === "clear"
117
+ ? { arbiter_reasoning: "elided on a clear verdict — intent_get(intent_id) returns it in full" }
118
+ : {};
119
+ return { ...intent, interfaces_touched: interfaces, ...reasoningElided };
120
+ }
121
+ /**
122
+ * Compact a declaration response.
123
+ *
124
+ * `overlaps` is a list of other agents' intents, each carrying the same call
125
+ * site reports `compactIntent` exists to elide — in the largest declaration
126
+ * measured it was 7,993 of 14,098 characters, 57% of the payload. The reader
127
+ * of an overlap is deciding whether to stay away, which is the exact decision
128
+ * compactIntent was written for; it was simply never wired to this path.
129
+ */
130
+ export function compactDeclaration(declared) {
131
+ if (!declared || typeof declared !== "object")
132
+ return declared;
133
+ return {
134
+ ...declared,
135
+ ...(declared.arbiter ? { arbiter: compactArbiter(declared.arbiter) } : {}),
136
+ ...(Array.isArray(declared.overlaps)
137
+ ? { overlaps: declared.overlaps.map(compactIntent) }
138
+ : {}),
139
+ };
140
+ }
141
+ /**
142
+ * Drop the blast radius file list from a lease grant.
143
+ *
144
+ * `POST /leases` returns `secondary_paths` — the files the radius widened the
145
+ * lease onto — and then returns the identical list again inside
146
+ * `blast_radius.files_added_to_lease`. One response, the same 29 paths twice.
147
+ * The count is kept so the summary still reconciles with the array above it.
148
+ */
149
+ export function compactLeaseGrant(grant) {
150
+ if (!grant || typeof grant !== "object")
151
+ return grant;
152
+ const radius = grant.blast_radius;
153
+ if (!radius || typeof radius !== "object" || !Array.isArray(radius.files_added_to_lease)) {
154
+ return grant;
155
+ }
156
+ const { files_added_to_lease, ...rest } = radius;
157
+ return {
158
+ ...grant,
159
+ blast_radius: { ...rest, files_added_to_lease_count: files_added_to_lease.length },
160
+ };
161
+ }
162
+ /**
163
+ * What a board write returns.
164
+ *
165
+ * board_create and board_update used to return the stored row, which meant a
166
+ * caller that sent a two-field patch received the whole markdown body back —
167
+ * measured at 3,769 chars average for patches under 300 chars, and 252k tokens
168
+ * across 40 sessions, almost all of it text the caller either just sent or
169
+ * already had.
170
+ *
171
+ * `version` is the field that has to come back: it is the `expected_version`
172
+ * of the caller's next update. `status` confirms the transition actually
173
+ * applied, which is the other thing a writer checks. Anyone who wants the
174
+ * stored row can board_get it.
175
+ */
176
+ export function boardWriteReceipt(item) {
177
+ if (!item || typeof item !== "object")
178
+ return item;
179
+ const { id, version, status, detail } = item;
180
+ return {
181
+ id,
182
+ version,
183
+ status,
184
+ ...(typeof detail === "string" ? { detail_chars: detail.length } : {}),
185
+ };
186
+ }
187
+ /**
188
+ * Fields every board row carries that no reader of a listing uses.
189
+ *
190
+ * `etag`, `version` and `position` belong to the update path, and board_get is
191
+ * already documented as where you obtain an etag before board_update — so a
192
+ * listing that omits it cannot mislead anyone into a stale write. `project_id`
193
+ * and `team_id` repeat the project on every row of a single-project listing.
194
+ * `detail_truncated` restates what detail_chars already says.
195
+ *
196
+ * `created_at` and `created_by` joined them after measurement: a listing is
197
+ * read to decide what to work on next, and neither when a row was written nor
198
+ * the ULID of the agent that wrote it moves that decision. `updated_at` stays
199
+ * because staleness does. `claimed_by` stays because it signals contention.
200
+ */
201
+ export const BOARD_ROW_NOISE = [
202
+ "project_id",
203
+ "team_id",
204
+ "etag",
205
+ "version",
206
+ "position",
207
+ "detail_truncated",
208
+ "created_at",
209
+ "created_by",
210
+ "claimed_at",
211
+ ];
212
+ /**
213
+ * How much of a body a listing row previews.
214
+ *
215
+ * The server sends 200 characters, and that stays the HTTP contract — the web
216
+ * dashboard renders from it. Measured over real listings that excerpt was the
217
+ * single largest field at 32% of row content, and most of it is preamble:
218
+ * findings on this board open "Found <date> ... **Resolved <date>**" before
219
+ * they say anything that distinguishes one row from another. 120 characters
220
+ * clears the preamble and keeps the affordance; `detail_chars` still tells the
221
+ * caller what a board_get would cost.
222
+ */
223
+ export const BOARD_EXCERPT_CHARS = 120;
224
+ /** Drop the scaffolding, and drop keys that are simply absent. */
225
+ export function compactBoardItem(item) {
226
+ if (!item || typeof item !== "object")
227
+ return item;
228
+ const out = {};
229
+ for (const [key, value] of Object.entries(item)) {
230
+ if (BOARD_ROW_NOISE.includes(key))
231
+ continue;
232
+ if (value === null)
233
+ continue;
234
+ if (key === "detail_excerpt" && typeof value === "string") {
235
+ out[key] = value.slice(0, BOARD_EXCERPT_CHARS);
236
+ continue;
237
+ }
238
+ out[key] = value;
239
+ }
240
+ return out;
241
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Model roles: which assistant does what, and which model does it.
3
+ *
4
+ * Before this, four separate decisions picked four models in four different
5
+ * ways — a hardcoded string in the chat agent (in two call sites), a project
6
+ * setting for the intent arbiter, an environment variable for the chief of
7
+ * staff's judgment, and a constant nobody had wired. Changing one told you
8
+ * nothing about the others, and nothing anywhere listed what the system was
9
+ * actually paying for.
10
+ *
11
+ * This makes the roles first-class. Each is a named job with a description, a
12
+ * default, and one place to change it. Plug any model into any role; the server
13
+ * resolves the assignment and the callers ask for a role rather than a model.
14
+ *
15
+ * Two things this deliberately is not:
16
+ *
17
+ * - **Not a provider catalogue.** Providers are `anthropic` or
18
+ * `openai_compatible`, because the wire format is the only thing that
19
+ * actually differs. A list of vendor names would need a release per vendor.
20
+ * - **Not a secret store.** An assignment names the environment variable that
21
+ * holds a key, never the key. Assignments are returned by the settings API
22
+ * and rendered in the dashboard, so they must be safe to show.
23
+ */
24
+ export const MODEL_ROLES = ["chat", "arbiter", "cos_judge"];
25
+ /**
26
+ * The roles, and why each is defaulted the way it is.
27
+ *
28
+ * The defaults are not uniform on purpose: cost matters where a role runs on
29
+ * every request, and judgment matters where its output is durable.
30
+ */
31
+ export const ROLE_CATALOG = {
32
+ chat: {
33
+ role: "chat",
34
+ title: "Chief of staff — chat",
35
+ description: "Answers questions about the project in the dashboard's Ask panel and in Slack. Reads the board and can propose changes for you to confirm.",
36
+ cadence: "Every question a person asks.",
37
+ defaultModel: "claude-opus-5",
38
+ whenUnavailable: "The Ask panel replies that it has no answer. Nothing else is affected.",
39
+ },
40
+ arbiter: {
41
+ role: "arbiter",
42
+ title: "Collision arbiter",
43
+ description: "Judges whether two agents declaring overlapping work actually conflict, on every intent_declare.",
44
+ cadence: "Every declaration by every agent — the highest-volume role.",
45
+ defaultModel: "claude-haiku-4-5",
46
+ whenUnavailable: "Falls back to the heuristic check, which catches interface overlaps but not subtler conflicts.",
47
+ },
48
+ cos_judge: {
49
+ role: "cos_judge",
50
+ title: "Chief of staff — judgment",
51
+ description: "Decides open questions on the board and judges whether stalled work still serves the goal. Its verdicts are written to the board.",
52
+ cadence: "Once a day, over at most a dozen items.",
53
+ defaultModel: "claude-sonnet-5",
54
+ whenUnavailable: "The chief of staff runs rules-only: it still tidies, coordinates and reports, but decides nothing.",
55
+ },
56
+ };
57
+ const DEFAULT_ANTHROPIC_KEY_ENV = "ANTHROPIC_API_KEY";
58
+ /** A model id that is not a Claude one is assumed to speak the OpenAI dialect. */
59
+ const inferProvider = (model) => /^claude[-.]/i.test(model) ? "anthropic" : "openai_compatible";
60
+ /**
61
+ * Resolve one role.
62
+ *
63
+ * Precedence is settings, then environment, then the catalogue default —
64
+ * most specific wins. Settings are per project and editable in the dashboard;
65
+ * environment is per server and set by whoever runs it; the default is what
66
+ * ships.
67
+ *
68
+ * Environment names are `NAIB_MODEL_<ROLE>_MODEL` and friends, with the
69
+ * `KEEL_`/`CHARTER_` aliases honoured directly so this works whether or not
70
+ * `aliasEnv` has run.
71
+ *
72
+ * Never throws. A misconfiguration disables one role and explains itself; the
73
+ * caller degrades per `whenUnavailable` rather than failing.
74
+ */
75
+ export function resolveRole(role, settings, env = {}) {
76
+ const definition = ROLE_CATALOG[role];
77
+ if (!definition) {
78
+ return { assignment: null, disabledReason: `unknown model role ${JSON.stringify(role)}` };
79
+ }
80
+ const upper = role.toUpperCase();
81
+ const scoped = (scope, suffix) => {
82
+ for (const prefix of ["NAIB_", "KEEL_", "CHARTER_"]) {
83
+ const v = env[`${prefix}MODEL_${scope}_${suffix}`]?.trim();
84
+ if (v)
85
+ return v;
86
+ }
87
+ return undefined;
88
+ };
89
+ /**
90
+ * Per-role first, then the server-wide default.
91
+ *
92
+ * `NAIB_MODEL_DEFAULT_*` exists because "plug whatever model in" should not
93
+ * mean repeating the same endpoint and key variable once per role, and
94
+ * repeating them again when a role is added.
95
+ */
96
+ const readEnv = (suffix) => scoped(upper, suffix) ?? scoped("DEFAULT", suffix);
97
+ const fromSettings = settings?.[role] ?? {};
98
+ const trimmed = (v) => {
99
+ const t = v?.trim();
100
+ return t ? t : undefined;
101
+ };
102
+ const settingsModel = trimmed(fromSettings.model);
103
+ const envModel = readEnv("MODEL");
104
+ /**
105
+ * The default's endpoint and key belong to the default's *model*, and must
106
+ * not follow a role that points somewhere else.
107
+ *
108
+ * Setting `NAIB_MODEL_DEFAULT_*` to GLM and then overriding one role to a
109
+ * Claude model used to leave that role resolving to Anthropic while still
110
+ * inheriting `GLM_API_KEY`, so every request came back
111
+ * `401 invalid x-api-key` — naming a key the operator had never pointed at
112
+ * Anthropic. Transport is inherited only when the role ends up on the same
113
+ * provider the default describes.
114
+ */
115
+ const defaultScopeModel = scoped("DEFAULT", "MODEL");
116
+ const model = settingsModel ?? envModel ?? definition.defaultModel;
117
+ const source = settingsModel
118
+ ? "settings"
119
+ : envModel
120
+ ? "environment"
121
+ : "default";
122
+ const explicitProvider = trimmed(fromSettings.provider) ?? scoped(upper, "PROVIDER") ?? scoped("DEFAULT", "PROVIDER");
123
+ if (explicitProvider && explicitProvider !== "anthropic" && explicitProvider !== "openai_compatible") {
124
+ return {
125
+ assignment: null,
126
+ disabledReason: `${definition.title}: unknown provider ${JSON.stringify(explicitProvider)} — expected anthropic or openai_compatible`,
127
+ };
128
+ }
129
+ const provider = explicitProvider ?? inferProvider(model);
130
+ // Whether this role may inherit the default's transport: only when the
131
+ // default names a model on the same provider, so a Claude role never picks
132
+ // up a GLM endpoint or key.
133
+ const inheritsDefaultTransport = defaultScopeModel !== undefined && inferProvider(defaultScopeModel) === provider;
134
+ const readTransport = (suffix) => scoped(upper, suffix) ?? (inheritsDefaultTransport ? scoped("DEFAULT", suffix) : undefined);
135
+ if (provider === "anthropic") {
136
+ return {
137
+ assignment: {
138
+ role,
139
+ model,
140
+ provider,
141
+ apiKeyEnv: trimmed(fromSettings.api_key_env) ?? readTransport("API_KEY_ENV") ?? DEFAULT_ANTHROPIC_KEY_ENV,
142
+ source,
143
+ },
144
+ disabledReason: null,
145
+ };
146
+ }
147
+ // Neither of these can be guessed. A default base URL would mean sending the
148
+ // board, and people's questions, to whatever host that default named — the
149
+ // wrong direction to fail in.
150
+ const baseUrl = trimmed(fromSettings.base_url) ?? readTransport("BASE_URL");
151
+ const apiKeyEnv = trimmed(fromSettings.api_key_env) ?? readTransport("API_KEY_ENV");
152
+ if (!baseUrl) {
153
+ return {
154
+ assignment: null,
155
+ disabledReason: `${definition.title}: ${model} needs a base URL (an OpenAI-compatible /chat/completions endpoint)`,
156
+ };
157
+ }
158
+ if (!apiKeyEnv) {
159
+ return {
160
+ assignment: null,
161
+ disabledReason: `${definition.title}: ${model} needs the name of the environment variable holding its key`,
162
+ };
163
+ }
164
+ return { assignment: { role, model, provider, baseUrl, apiKeyEnv, source }, disabledReason: null };
165
+ }
166
+ /**
167
+ * Resolve every role at once, for the settings screen.
168
+ *
169
+ * Returns a row per role whether or not it is usable, because "this one is
170
+ * misconfigured" is exactly what an operator opening this screen needs to see.
171
+ */
172
+ export function resolveAllRoles(settings, env = {}) {
173
+ return MODEL_ROLES.map((role) => {
174
+ const resolution = resolveRole(role, settings, env);
175
+ return {
176
+ ...ROLE_CATALOG[role],
177
+ ...resolution,
178
+ // Whether the named variable actually holds anything — the single most
179
+ // common reason a correctly-configured role still does not run. The value
180
+ // itself is never read out.
181
+ keyPresent: Boolean(resolution.assignment && env[resolution.assignment.apiKeyEnv]?.trim()),
182
+ };
183
+ });
184
+ }
185
+ /** Validate a settings blob before storing it, so bad config cannot be saved. */
186
+ export function validateRoleSettings(value) {
187
+ if (value === null || typeof value !== "object" || Array.isArray(value))
188
+ return false;
189
+ for (const [role, setting] of Object.entries(value)) {
190
+ if (!MODEL_ROLES.includes(role))
191
+ return false;
192
+ if (setting === null || typeof setting !== "object" || Array.isArray(setting))
193
+ return false;
194
+ const s = setting;
195
+ for (const [key, v] of Object.entries(s)) {
196
+ if (!["model", "provider", "base_url", "api_key_env"].includes(key))
197
+ return false;
198
+ if (v !== undefined && typeof v !== "string")
199
+ return false;
200
+ }
201
+ if (typeof s.provider === "string" && !["anthropic", "openai_compatible"].includes(s.provider)) {
202
+ return false;
203
+ }
204
+ }
205
+ return true;
206
+ }