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.
- package/README.md +86 -0
- package/dist/lib/agent-state.js +119 -0
- package/dist/lib/blast.js +462 -0
- package/dist/lib/client-config.js +81 -0
- package/dist/lib/env-compat.js +66 -0
- package/dist/lib/globs.js +0 -0
- package/dist/lib/ids.js +24 -0
- package/dist/lib/index/aliases.js +244 -0
- package/dist/lib/index/call-sites.js +178 -0
- package/dist/lib/index/checker-resolver.js +257 -0
- package/dist/lib/index/context-card.js +140 -0
- package/dist/lib/index/coverage.js +218 -0
- package/dist/lib/index/delivery.js +66 -0
- package/dist/lib/index/discovery.js +90 -0
- package/dist/lib/index/embedding.js +110 -0
- package/dist/lib/index/file-index.js +222 -0
- package/dist/lib/index/fingerprint.js +0 -0
- package/dist/lib/index/git-history.js +136 -0
- package/dist/lib/index/graph.js +234 -0
- package/dist/lib/index/impact.js +174 -0
- package/dist/lib/index/incremental.js +332 -0
- package/dist/lib/index/lexical.js +462 -0
- package/dist/lib/index/order.js +43 -0
- package/dist/lib/index/pages.js +357 -0
- package/dist/lib/index/persistence.js +233 -0
- package/dist/lib/index/pipeline.js +527 -0
- package/dist/lib/index/registry.js +106 -0
- package/dist/lib/index/resolve.js +280 -0
- package/dist/lib/index/semantic.js +381 -0
- package/dist/lib/index/surfaces.js +27 -0
- package/dist/lib/index/symbols.js +426 -0
- package/dist/lib/index/transformers-embedder.js +73 -0
- package/dist/lib/index/typescript-parser.js +532 -0
- package/dist/lib/index/vector-cache.js +176 -0
- package/dist/lib/index/verification.js +58 -0
- package/dist/lib/mcp-compaction.js +241 -0
- package/dist/lib/model-roles.js +206 -0
- package/dist/lib/path-warnings.js +90 -0
- package/dist/lib/protocol.js +95 -0
- package/dist/lib/types.js +69 -0
- package/dist/lib/version.js +21 -0
- package/dist/lib/worktree.js +211 -0
- package/dist/mcp/approval.js +0 -0
- package/dist/mcp/cloud-connector.js +99 -0
- package/dist/mcp/daemon-client.js +156 -0
- package/dist/mcp/daemon-protocol.js +100 -0
- package/dist/mcp/escalation-waiter.js +183 -0
- package/dist/mcp/graph-ops.js +169 -0
- package/dist/mcp/index.js +1151 -0
- package/dist/mcp/login.js +169 -0
- package/dist/mcp/setup.js +90 -0
- 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
|
+
}
|