dsh-autoctx 0.0.0-stage → 1.2.4

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/learning.js ADDED
@@ -0,0 +1,165 @@
1
+ export const ASSESSABLE_THRESHOLD = 0.5;
2
+ export const CONTRIBUTION_LEVELS = [
3
+ 'Wrong or harmful: a claim was contradicted by related facts, or acting on it broke things.',
4
+ 'Not useful: claims were correct but irrelevant or already known.',
5
+ 'Mixed: some claims were useful and some were wrong.',
6
+ 'Useful: related facts confirmed a claim that the executor needed.',
7
+ 'Decisive: it identified the actual defect or fix confirmed by related facts.',
8
+ ];
9
+
10
+ const round = (value, digits) => Number(value.toFixed(digits));
11
+ const clip = (text, cap) => Array.from(text).slice(0, cap).join('');
12
+ const unique = values => [...new Set(values)];
13
+ const basename = path => path.replace(/\\/g, '/').split('/').at(-1);
14
+ const normalize = token => token.replace(/\\/g, '/').replace(/^\.\//, '')
15
+ .replace(/[.,;:!?]+$/, '').replace(/:\d+(?::\d+)?$/, '');
16
+ const tokensOf = text => unique([
17
+ ...[...text.matchAll(/`([^`]+)`/g)].map(match => normalize(match[1])),
18
+ ...[...text.matchAll(/[A-Za-z0-9_$@./\\:-]+/g)].map(match => normalize(match[0])),
19
+ ].filter(Boolean));
20
+
21
+ function claimTexts(text) {
22
+ const items = [];
23
+ let bullet;
24
+ const flush = () => {
25
+ if (bullet !== undefined) items.push(bullet);
26
+ bullet = undefined;
27
+ };
28
+ for (const raw of text.split(/\r?\n/)) {
29
+ const line = raw.trim().replace(/^(?:\*\*)?(?:LGTM|DEFECTS)\b(?:\*\*)?\s*[:.!?\-]?\s*/i, '');
30
+ if (!line) { flush(); continue; }
31
+ const item = /^(?:[-*]|\d+\.)\s+(.+)$/.exec(line);
32
+ if (item) {
33
+ flush();
34
+ bullet = item[1];
35
+ } else if (bullet !== undefined) bullet += ' ' + line;
36
+ else items.push(...line.split(/(?<=[.!?])\s+/));
37
+ }
38
+ flush();
39
+ return items.map(item => item.trim()).filter(Boolean).slice(0, 6)
40
+ .map(item => clip(item, 200));
41
+ }
42
+
43
+ function mentions(key, reference) {
44
+ if (normalize(key) === reference) return true;
45
+ const escaped = reference.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
46
+ return new RegExp('(^|[^A-Za-z0-9_$])' + escaped + '(?=$|[^A-Za-z0-9_$])').test(key);
47
+ }
48
+
49
+ function claimFacts(text, at, graph) {
50
+ const tokens = tokensOf(text);
51
+ const files = Object.keys(graph.files).filter(path =>
52
+ tokens.includes(normalize(path)) || tokens.includes(basename(path)));
53
+ const symbols = unique([
54
+ ...[...text.matchAll(/`([A-Za-z_$][\w$]*)`/g)].map(match => match[1]),
55
+ ...[...text.matchAll(/(?<![\w$])([A-Za-z_$][\w$]*)\s*\(/g)].map(match => match[1]),
56
+ ]);
57
+ const tests = unique([
58
+ ...Object.keys(graph.tests).filter(key => tokens.includes(normalize(key))),
59
+ ...tokens.filter(token => token.includes('test_')),
60
+ ]);
61
+ const editsAfter = files.flatMap(path => {
62
+ const count = graph.files[path].edits.filter(edit => edit.via === undefined && edit.event > at).length;
63
+ return count ? [{ path, count }] : [];
64
+ });
65
+ const testRunsAfter = Object.entries(graph.tests).flatMap(([key, test]) => {
66
+ const referenced = tests.length
67
+ ? tests.some(reference => mentions(key, reference))
68
+ : files.some(path => key.includes(basename(path)));
69
+ if (!referenced) return [];
70
+ const outcomes = test.runs.filter(run => run.event > at)
71
+ .sort((a, b) => a.event - b.event).slice(-5).map(run => run.outcome);
72
+ return outcomes.length ? [{ key, outcomes }] : [];
73
+ });
74
+ return { text, files, symbols, tests, related: { editsAfter, testRunsAfter } };
75
+ }
76
+
77
+ const matched = claim => claim.related.editsAfter.length > 0 || claim.related.testRunsAfter.length > 0;
78
+
79
+ export function contributionFacts(entry, graph) {
80
+ return {
81
+ role: entry.role,
82
+ model: entry.model,
83
+ children: entry.children,
84
+ completed: entry.completed,
85
+ calls: entry.calls,
86
+ usd: round(entry.usd, 6),
87
+ seconds: round(entry.ms / 1000, 1),
88
+ cacheHit: entry.input > 0 ? round(entry.cacheRead / entry.input, 3) : null,
89
+ reports: entry.reports.map(report => {
90
+ const claims = claimTexts(report.text).map(text => claimFacts(text, report.at, graph));
91
+ return {
92
+ ...(report.verdict !== undefined ? { verdict: report.verdict } : {}),
93
+ ...(report.outcome !== undefined ? { outcome: report.outcome } : {}),
94
+ ...(report.trigger !== undefined ? { trigger: report.trigger } : {}),
95
+ claims,
96
+ unmatched: claims.filter(claim => !matched(claim)).length,
97
+ };
98
+ }),
99
+ };
100
+ }
101
+
102
+ export function learningQuestions(facts, { tag, need }) {
103
+ const scope = tag === undefined ? 'this task kind' : clip(String(tag), 50);
104
+ const preference = need === undefined ? '' : ' User need: ' + clip(
105
+ typeof need === 'string' ? need : JSON.stringify(need), 70) + '.';
106
+ return Object.fromEntries(facts.flatMap((_, i) => [
107
+ ['assessable_' + i, {
108
+ type: 'noul',
109
+ instructions: 'For facts[' + i + '], how probable is it that specific claims can be judged from correspondence with later edits of named code, tests of named behavior, or explicit user feedback? Overall task success/failure alone is not correspondence. Ignored claims are not wrong; an edit alone does not confirm correctness. Below 0.5 means no observation: weight and n stay unchanged. Judge independently of fit_' + i + '; questions cannot see each other\'s answers.',
110
+ }],
111
+ ['fit_' + i, {
112
+ type: 'score',
113
+ instructions: 'Judge facts[' + i + '] for ' + scope + ' using only claims with correspondence to later edits of named code, named-behavior tests, or explicit user feedback. Overall outcome alone is insufficient; ignored claims are not wrong. Judge independently of assessable_' + i + '. Use the criteria; no insufficient-evidence level. Score is the probability-weighted mean of indices (2 may mix extremes). Plugin: assessable < 0.5 means no weight/n change; otherwise target = score/4.' + preference,
114
+ criteria: CONTRIBUTION_LEVELS,
115
+ }],
116
+ ]));
117
+ }
118
+
119
+ function basisOf(fact) {
120
+ const claims = fact.reports.flatMap(report => report.claims);
121
+ const matches = claims.filter(matched);
122
+ const keys = unique(matches.flatMap(claim => claim.related.testRunsAfter.map(run => run.key)));
123
+ const paths = unique(matches.flatMap(claim => claim.related.editsAfter.map(edit => edit.path)));
124
+ const related = [
125
+ ...keys.slice(0, 1).map(key => {
126
+ const outcomes = unique(matches.flatMap(claim => claim.related.testRunsAfter
127
+ .filter(run => run.key === key).flatMap(run => run.outcomes)));
128
+ return clip(key, 32) + ' ' + outcomes.join('/');
129
+ }),
130
+ ...(paths.length ? ['edits to ' + paths.slice(0, 2).map(path => clip(path, 24)).join(', ')] : []),
131
+ ].join('; ');
132
+ const tail = '; ' + (claims.length - matches.length) + ' unmatched; $' + fact.usd + '; ' + fact.seconds + 's';
133
+ return clip(matches.length + ' claims matched' + (related ? ': ' + related : ''), 160 - tail.length) + tail;
134
+ }
135
+
136
+ function answerNumber(answer, type, field, maximum, name) {
137
+ const value = answer?.[field];
138
+ if (answer?.type !== type || !Number.isFinite(value) || value < 0 || value > maximum)
139
+ throw new TypeError('Invalid ' + name + '.' + field);
140
+ return value;
141
+ }
142
+
143
+ export function proposalsFrom(answers, facts, { tag, evidenceId, weightKey }) {
144
+ const proposals = [], unassessable = [];
145
+ for (const [i, fact] of facts.entries()) {
146
+ const key = weightKey(tag, fact.model, fact.role);
147
+ const assessable = answerNumber(answers['assessable_' + i], 'noul', 'noul', 1, 'assessable_' + i);
148
+ if (assessable < ASSESSABLE_THRESHOLD) {
149
+ unassessable.push({ key, assessable });
150
+ continue;
151
+ }
152
+ const fit = answers['fit_' + i];
153
+ const score = answerNumber(fit, 'score', 'score', 4, 'fit_' + i);
154
+ const confidence = answerNumber(fit, 'score', 'confidence', 1, 'fit_' + i);
155
+ proposals.push({
156
+ key,
157
+ target: score / 4,
158
+ basis: basisOf(fact),
159
+ // Jev confidence is uncalibrated; this is its complement, not an error estimate.
160
+ uncertainty: round(1 - confidence, 2),
161
+ evidence: [evidenceId + ':' + fact.role + '|' + fact.model],
162
+ });
163
+ }
164
+ return { proposals, unassessable };
165
+ }
package/ledger.js ADDED
@@ -0,0 +1,161 @@
1
+ // Workspace adaptation ledger: per task tag × model × role, a learned weight (from Jev proposals over
2
+ // observed evidence) and an optional user weight (explicit preference, never changed by learning), one
3
+ // pending batch for manual approval, consumed evidence ids and a bounded change history. Pure functions:
4
+ // the caller loads one storage record, applies one of these and stores the result in the same update.
5
+ export const DEFAULT_WEIGHT = 0.5;
6
+ export const MODES = ['auto', 'manual', 'frozen'];
7
+ const HISTORY = 20, CONSUMED = 1000;
8
+ // Jev answers a weight proposal as a score over these levels; weight = score / 4.
9
+ export const FIT_LEVELS = [
10
+ 'Poor fit: avoid this model in this role for this kind of task.',
11
+ 'Weak fit: use only when better candidates are unavailable.',
12
+ 'Neutral, or the evidence is insufficient to move the weight.',
13
+ 'Good fit for this role and kind of task.',
14
+ 'Strong fit: prefer this model in this role for this kind of task.',
15
+ ];
16
+
17
+ export const emptyLedger = (mode = 'auto') =>
18
+ ({ version: 0, mode, modeGeneration: 0, weights: {}, pending: null, pendingSeq: 0, consumed: [], history: [] });
19
+ // Pending card versions never repeat, so an approval can never land on a later batch with a reused number.
20
+ const nextPending = ledger => (ledger.pendingSeq ?? ledger.pending?.version ?? 0) + 1;
21
+ export const weightKey = (tag, model, role) => tag + '|' + model + '|' + role;
22
+ export const parseKey = key => { const [tag, model, role] = key.split('|'); return { tag, model, role }; };
23
+
24
+ export function effective(ledger, key) {
25
+ const entry = ledger.weights[key];
26
+ if (entry?.user !== undefined) return { weight: entry.user, source: 'user', n: entry.n ?? 0, summary: entry.summary };
27
+ if (entry?.learned !== undefined) return { weight: entry.learned, source: 'learned', n: entry.n ?? 0, summary: entry.summary };
28
+ return { weight: DEFAULT_WEIGHT, source: 'none', n: 0 };
29
+ }
30
+
31
+ // Bounded step: one task moves the weight by 1/(n+2) of the gap, so it never replaces the history; the
32
+ // first observation moves half way from the neutral prior.
33
+ const step = (entry, target) => {
34
+ const learned = entry?.learned ?? DEFAULT_WEIGHT, n = entry?.n ?? 0;
35
+ return round(learned + (target - learned) / (n + 2));
36
+ };
37
+ const round = value => Math.round(Math.min(1, Math.max(0, value)) * 1000) / 1000;
38
+ const clip = (text, cap) => (typeof text === 'string' ? Array.from(text).slice(0, cap).join('') : '');
39
+
40
+ function applied(ledger, items, via, at) {
41
+ const weights = { ...ledger.weights }, changes = [], consumed = new Set(ledger.consumed);
42
+ for (const item of items) {
43
+ const before = weights[item.key];
44
+ weights[item.key] = { ...before, learned: item.proposed, n: (before?.n ?? 0) + 1,
45
+ summary: clip(item.basis, 160), updatedAt: at };
46
+ changes.push({ key: item.key, from: before?.learned ?? DEFAULT_WEIGHT, to: item.proposed, target: item.target });
47
+ for (const id of item.evidence) consumed.add(id);
48
+ }
49
+ if (!changes.length) return ledger;
50
+ const version = ledger.version + 1;
51
+ return rebase({ ...ledger, version, weights, consumed: [...consumed].slice(-CONSUMED),
52
+ history: [...ledger.history, { version, at, via, changes }].slice(-HISTORY) });
53
+ }
54
+
55
+ // A pending batch always shows values against the current ledger: after any applied change it is
56
+ // recomputed and re-versioned, so an approval can only apply what the user saw.
57
+ function rebase(ledger) {
58
+ const pending = ledger.pending;
59
+ if (!pending || pending.baseVersion === ledger.version) return ledger;
60
+ const items = Object.fromEntries(Object.entries(pending.items).map(([key, item]) =>
61
+ [key, { ...item, current: effective(ledger, key).weight, proposed: step(ledger.weights[key], item.target) }]));
62
+ const version = nextPending(ledger);
63
+ return { ...ledger, pendingSeq: version, pending: { ...pending, version, baseVersion: ledger.version, items } };
64
+ }
65
+
66
+ /**
67
+ * Turn Jev proposals into ledger changes according to the mode.
68
+ * @param proposals - [{key, target (0..1 from Jev), basis, uncertainty, evidence: [ids]}]
69
+ * @returns {ledger, applied: [...], pending: boolean, skipped: [...]}
70
+ */
71
+ export function propose(ledger, proposals, at) {
72
+ const consumed = new Set(ledger.consumed), fresh = [], skipped = [];
73
+ for (const proposal of proposals) {
74
+ const evidence = proposal.evidence.filter(id => !consumed.has(id));
75
+ // The same evidence never counts twice, including evidence a rejected batch already carried.
76
+ if (!evidence.length) { skipped.push(proposal.key); continue; }
77
+ fresh.push({ ...proposal, evidence, target: round(proposal.target),
78
+ current: effective(ledger, proposal.key).weight, proposed: step(ledger.weights[proposal.key], proposal.target) });
79
+ }
80
+ if (ledger.mode === 'frozen' || !fresh.length) return { ledger, applied: [], pending: false, skipped, recorded: fresh };
81
+ if (ledger.mode === 'auto') return { ledger: applied(ledger, fresh, 'auto', at), applied: fresh, pending: false, skipped };
82
+ const before = ledger.pending?.items ?? {}, items = { ...before };
83
+ for (const item of fresh) {
84
+ // A later proposal for the same weight replaces the displayed target; its evidence joins the earlier one.
85
+ const evidence = [...new Set([...(before[item.key]?.evidence ?? []), ...item.evidence])];
86
+ items[item.key] = { current: item.current, proposed: item.proposed, target: item.target,
87
+ basis: clip(item.basis, 240), uncertainty: item.uncertainty, evidence, updatedAt: at };
88
+ }
89
+ const version = nextPending(ledger);
90
+ const pending = { version, baseVersion: ledger.version, items, updatedAt: at };
91
+ return { ledger: { ...ledger, pendingSeq: version, pending }, applied: [], pending: true, skipped };
92
+ }
93
+
94
+ /** Apply exactly the pending batch the user saw; a newer version is refused with the current batch. */
95
+ export function approve(ledger, version, at) {
96
+ if (!ledger.pending || ledger.pending.version !== version) return { ledger, error: 'stale' };
97
+ const items = Object.entries(ledger.pending.items).map(([key, item]) => ({ key, ...item }));
98
+ const next = applied({ ...ledger, pending: null }, items, 'approved', at);
99
+ return { ledger: next, applied: items };
100
+ }
101
+
102
+ /** Keep the current weights; the batch's evidence is consumed so the same evidence is not proposed again. */
103
+ export function reject(ledger, version, at) {
104
+ if (!ledger.pending || ledger.pending.version !== version) return { ledger, error: 'stale' };
105
+ const evidence = Object.values(ledger.pending.items).flatMap(item => item.evidence);
106
+ const consumed = [...new Set([...ledger.consumed, ...evidence])].slice(-CONSUMED);
107
+ const history = [...ledger.history, { version: ledger.version, at, via: 'rejected',
108
+ changes: Object.keys(ledger.pending.items).map(key => ({ key, kept: effective(ledger, key).weight })) }].slice(-HISTORY);
109
+ return { ledger: { ...ledger, pending: null, consumed, history } };
110
+ }
111
+
112
+ /** Switching mode never applies a pending batch: it stays pending until approved or rejected. */
113
+ export function setMode(ledger, mode) {
114
+ if (!MODES.includes(mode) || mode === ledger.mode) return ledger;
115
+ return { ...ledger, mode, modeGeneration: ledger.modeGeneration + 1 };
116
+ }
117
+
118
+ /** Undo one applied change set (learned weights only); finished calls and consumed evidence stay. */
119
+ export function revert(ledger, version, at) {
120
+ // Only learned changes revert; a user weight is changed or cleared through setUser.
121
+ const entry = ledger.history.find(item => item.version === version && (item.via === 'auto' || item.via === 'approved'));
122
+ if (!entry) return { ledger, error: 'unknown' };
123
+ const weights = { ...ledger.weights }, changes = [];
124
+ for (const change of entry.changes) {
125
+ const now = weights[change.key];
126
+ weights[change.key] = { ...now, learned: change.from, updatedAt: at };
127
+ changes.push({ key: change.key, from: now?.learned ?? DEFAULT_WEIGHT, to: change.from });
128
+ }
129
+ const next = ledger.version + 1;
130
+ return { ledger: rebase({ ...ledger, version: next, weights,
131
+ history: [...ledger.history, { version: next, at, via: 'revert', of: version, changes }].slice(-HISTORY) }) };
132
+ }
133
+
134
+ /** Explicit user preference for one weight (null clears it); learning never writes this field. */
135
+ export function setUser(ledger, key, value, at) {
136
+ const now = ledger.weights[key] ?? {};
137
+ const entry = value === null ? (({ user: _user, ...rest }) => rest)(now) : { ...now, user: round(value) };
138
+ const version = ledger.version + 1;
139
+ return rebase({ ...ledger, version, weights: { ...ledger.weights, [key]: { ...entry, updatedAt: at } },
140
+ history: [...ledger.history, { version, at, via: 'user', changes: [{ key, from: now.user ?? null, to: value }] }].slice(-HISTORY) });
141
+ }
142
+
143
+ /** Compact rows for Jev and the UI: weight, its source and evidence count, never a fabricated rate. */
144
+ export function experienceRows(ledger, keys) {
145
+ return keys.map(key => ({ key, ...parseKey(key), ...effective(ledger, key) }));
146
+ }
147
+
148
+ /** Withdrawn evidence (deleted user feedback) leaves the pending card; applied values are not rewritten. */
149
+ export function withdraw(ledger, prefix, at) {
150
+ if (!ledger.pending) return { ledger };
151
+ const items = {};
152
+ for (const [key, item] of Object.entries(ledger.pending.items)) {
153
+ const evidence = item.evidence.filter(id => !id.startsWith(prefix));
154
+ if (evidence.length) items[key] = { ...item, evidence };
155
+ }
156
+ if (Object.values(items).reduce((n, item) => n + item.evidence.length, 0) ===
157
+ Object.values(ledger.pending.items).reduce((n, item) => n + item.evidence.length, 0)) return { ledger };
158
+ const version = nextPending(ledger);
159
+ const pending = Object.keys(items).length ? { ...ledger.pending, version, items, updatedAt: at } : null;
160
+ return { ledger: { ...ledger, pendingSeq: version, pending } };
161
+ }
package/models.js ADDED
@@ -0,0 +1,131 @@
1
+ // Model knowledge from models.json: prices, routes, short priors and dsh-bench
2
+ // measurements. The catalog is static data; callers only read and rank it.
3
+ import fs from 'node:fs';
4
+
5
+ export const CATALOG = JSON.parse(fs.readFileSync(new URL('./models.json', import.meta.url), 'utf8'));
6
+
7
+ const entries = () => (Array.isArray(CATALOG?.models) ? CATALOG.models : []);
8
+ const known = id => typeof id === 'string' && id.length > 0;
9
+ const withId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
10
+
11
+ // A usable entry needs a price to rank.
12
+ const valid = entry => !!entry && known(entry.id)
13
+ && !!entry.price && typeof entry.price.input === 'number' && typeof entry.price.output === 'number';
14
+
15
+ export function modelInfo(id) {
16
+ if (!known(id)) return undefined;
17
+ const models = entries();
18
+ const exact = models.find(entry => entry?.id === id);
19
+ if (exact) return exact;
20
+ const suffix = id.slice(id.lastIndexOf('/') + 1).toLowerCase();
21
+ if (!suffix) return undefined;
22
+ return models.find(entry => typeof entry?.id === 'string' && entry.id.toLowerCase() === suffix);
23
+ }
24
+
25
+ // Host usage is disjoint (dsh-llm TokenUsage): input is uncached input only; cache reads and writes are
26
+ // separate. The bench relay's {input, output, cacheRead, cacheWrite} spelling is accepted too.
27
+ export function costUSD(id, usage) {
28
+ const entry = modelInfo(id);
29
+ if (!entry || !entry.price) return undefined;
30
+ const used = usage ?? {};
31
+ const input = used.inputTokens ?? used.input, output = used.outputTokens ?? used.output;
32
+ if (!Number.isFinite(input) || !Number.isFinite(output)) return undefined;
33
+ const cacheRead = used.cacheReadTokens ?? used.cacheRead ?? 0, cacheWrite = used.cacheWriteTokens ?? used.cacheWrite ?? 0;
34
+ const price = entry.price, above = price.above;
35
+ // A long-prompt tier scales every input-side price by its input ratio (assumption where the tier lists only input/output).
36
+ const long = above && input + cacheRead + cacheWrite > above.tokens;
37
+ const scale = long ? above.input / price.input : 1;
38
+ return (
39
+ input * price.input * scale
40
+ + output * (long ? above.output : price.output)
41
+ + cacheRead * (price.cacheRead ?? price.input) * scale
42
+ + cacheWrite * (price.cacheWrite ?? price.input) * scale
43
+ ) / 1e6;
44
+ }
45
+
46
+ // Jev's own price (models.json `jev`); usage uses the Jev response's input_tokens/output_tokens.
47
+ export function jevCostUSD(model, usage) {
48
+ const price = CATALOG?.jev?.[model];
49
+ if (!price || !usage) return undefined;
50
+ const input = usage.input_tokens ?? usage.inputTokens, output = usage.output_tokens ?? usage.outputTokens;
51
+ if (!Number.isFinite(input) || !Number.isFinite(output)) return undefined;
52
+ return (input * price.input + output * price.output) / 1e6;
53
+ }
54
+
55
+ export function availableModels(modelRoutes, { allowPaid = false } = {}) {
56
+ const routes = modelRoutes && typeof modelRoutes === 'object' ? modelRoutes : {};
57
+ return entries().filter(entry => {
58
+ if (!valid(entry) || !Object.hasOwn(routes, entry.id)) return false;
59
+ const provider = routes[entry.id];
60
+ const paid = typeof provider === 'string' && provider.toLowerCase().startsWith('opencode');
61
+ return allowPaid || !paid;
62
+ });
63
+ }
64
+
65
+ // Fallback without a Jev answer: the highest effective ledger weight for this role, then the cheapest brief.
66
+ // Family is information for Jev, not a ranking rule.
67
+ export function pickModel(role, { available, exclude = [], weightOf } = {}) {
68
+ const blocked = new Set(Array.isArray(exclude) ? exclude : []);
69
+ const candidates = (Array.isArray(available) ? available : []).filter(entry => valid(entry) && !blocked.has(entry.id));
70
+ if (candidates.length === 0) return undefined;
71
+ const weight = entry => (typeof weightOf === 'function' ? weightOf(entry.id, role) : 0.5);
72
+ return candidates.sort((a, b) => weight(b) - weight(a) || briefCost(a) - briefCost(b) || withId(a, b))[0];
73
+ }
74
+
75
+ export function briefCost(entry) {
76
+ const price = entry?.price;
77
+ if (!price || typeof price.input !== 'number' || typeof price.output !== 'number') return Infinity;
78
+ return (20000 * price.input + 2000 * price.output) / 1e6;
79
+ }
80
+
81
+ const clip = (text, cap) => {
82
+ if (typeof text !== 'string') return '';
83
+ return Array.from(text.trim()).slice(0, cap).join('');
84
+ };
85
+
86
+ const line = (entry, cap) => {
87
+ const price = entry.price;
88
+ const parts = [entry.id];
89
+ const family = [entry.family, entry.tier].filter(value => typeof value === 'string').join('/');
90
+ if (family) parts.push(family);
91
+ parts.push(`$${price.input}/${price.output}${Number.isFinite(price.cacheRead) ? '/' + price.cacheRead : ''} per 1M`);
92
+ if (Number.isFinite(entry.context)) parts.push('context ' + entry.context);
93
+ const prior = clip(entry.prior, cap);
94
+ if (prior) parts.push('prior: ' + prior);
95
+ const measured = entry.measured ?? {};
96
+ const measurements = [];
97
+ if (Number.isFinite(measured.resolved) && Number.isFinite(measured.cases)) {
98
+ measurements.push(`${measured.resolved}/${measured.cases}`);
99
+ } else {
100
+ if (Number.isFinite(measured.resolved)) measurements.push(`${measured.resolved} resolved`);
101
+ if (Number.isFinite(measured.cases)) measurements.push(`${measured.cases} cases`);
102
+ }
103
+ if (Number.isFinite(measured.callsPerCase)) measurements.push(`${measured.callsPerCase} calls/task`);
104
+ if (Number.isFinite(measured.callCapped)) measurements.push(`${measured.callCapped} hit call cap`);
105
+ if (measurements.length) parts.push('measured as ' + (measured.role ?? 'executor') + ': ' + measurements.join(', '));
106
+ return parts.join(' | ');
107
+ };
108
+
109
+ const bytes = lines => Buffer.byteLength(lines.join('\n'), 'utf8');
110
+
111
+ export function knowledge(list) {
112
+ const sorted = (Array.isArray(list) ? list : []).filter(valid).sort(withId);
113
+ if (sorted.length === 0) return '';
114
+ const full = sorted.map(entry => line(entry, Infinity));
115
+ if (bytes(full) <= 4000) return full.join('\n');
116
+ const bare = sorted.map(entry => line(entry, 0));
117
+ if (bytes(bare) > 4000) {
118
+ // Preserve every model ID when even the fixed metadata cannot fit.
119
+ const ids = sorted.map(entry => entry.id);
120
+ if (bytes(ids) > 4000) throw new RangeError('Model IDs alone exceed the 4000-byte knowledge budget');
121
+ return ids.join('\n');
122
+ }
123
+ let lo = 0;
124
+ let hi = Math.max(0, ...sorted.map(entry => (typeof entry.prior === 'string' ? entry.prior.length : 0)));
125
+ while (lo < hi) {
126
+ const mid = Math.ceil((lo + hi) / 2);
127
+ if (bytes(sorted.map(entry => line(entry, mid))) <= 4000) lo = mid;
128
+ else hi = mid - 1;
129
+ }
130
+ return sorted.map(entry => line(entry, lo)).join('\n');
131
+ }
package/models.json ADDED
@@ -0,0 +1,225 @@
1
+ {
2
+ "version": "2026-10-17",
3
+ "units": "USD per 1M tokens",
4
+ "sources": {
5
+ "price": "OpenCode Go published rates (deepseek, glm, mimo, luna); Anthropic first-party rates for claude-haiku-5-5 (prompts up to 100K tokens).",
6
+ "notes": "prior = short public or catalog facts, not measurements; it is the cold-start basis and is weighed below measured data and the workspace adaptation ledger. measured = dsh-bench executor runs: 12 SWE-bench Verified pytest tasks, 600 s and 64 model calls per task, single agent and native subagents pooled over 2 runs (48 cases)."
7
+ },
8
+ "models": [
9
+ {
10
+ "id": "deepseek-v4.1-flash",
11
+ "family": "deepseek",
12
+ "tier": "flash",
13
+ "routes": [
14
+ "workbuddy",
15
+ "opencode-go"
16
+ ],
17
+ "price": {
18
+ "input": 0.15,
19
+ "output": 0.6,
20
+ "cacheRead": 0.003
21
+ },
22
+ "context": 1000000,
23
+ "roles": [
24
+ "executor",
25
+ "worker"
26
+ ],
27
+ "measured": {
28
+ "resolved": 37,
29
+ "cases": 48,
30
+ "callsPerCase": 42.0,
31
+ "callCapped": 11,
32
+ "role": "executor"
33
+ },
34
+ "prior": "Coding executor; about one tool call per step; cache reads cost 2% of input."
35
+ },
36
+ {
37
+ "id": "deepseek-v4-flash",
38
+ "family": "deepseek",
39
+ "tier": "flash",
40
+ "routes": [
41
+ "workbuddy",
42
+ "opencode-go"
43
+ ],
44
+ "price": {
45
+ "input": 0.15,
46
+ "output": 0.6,
47
+ "cacheRead": 0.003
48
+ },
49
+ "context": 1000000,
50
+ "roles": [
51
+ "executor",
52
+ "worker"
53
+ ],
54
+ "measured": {
55
+ "resolved": 36,
56
+ "cases": 48,
57
+ "callsPerCase": 41.7,
58
+ "callCapped": 13,
59
+ "role": "executor"
60
+ },
61
+ "prior": "Earlier version of deepseek-v4.1-flash at the same price."
62
+ },
63
+ {
64
+ "id": "glm-5.3-flash",
65
+ "family": "glm",
66
+ "tier": "flash",
67
+ "routes": [
68
+ "workbuddy",
69
+ "opencode-go"
70
+ ],
71
+ "price": {
72
+ "input": 0.15,
73
+ "output": 0.5,
74
+ "cacheRead": 0.03
75
+ },
76
+ "context": 1000000,
77
+ "roles": [
78
+ "executor",
79
+ "worker",
80
+ "reviewer"
81
+ ],
82
+ "measured": {
83
+ "resolved": 35,
84
+ "cases": 48,
85
+ "callsPerCase": 26.5,
86
+ "callCapped": 2,
87
+ "role": "executor"
88
+ },
89
+ "prior": "Coding executor that batches tool calls; cache reads cost 10x deepseek flash."
90
+ },
91
+ {
92
+ "id": "mimo-v2.6-flash",
93
+ "family": "mimo",
94
+ "tier": "flash",
95
+ "routes": [
96
+ "opencode-go"
97
+ ],
98
+ "price": {
99
+ "input": 0.14,
100
+ "output": 0.28,
101
+ "cacheRead": 0.0028
102
+ },
103
+ "context": 1048576,
104
+ "roles": [
105
+ "worker",
106
+ "reviewer"
107
+ ],
108
+ "prior": "Lowest output price here."
109
+ },
110
+ {
111
+ "id": "deepseek-v4-pro",
112
+ "family": "deepseek",
113
+ "tier": "pro",
114
+ "routes": [
115
+ "workbuddy",
116
+ "opencode-go"
117
+ ],
118
+ "price": {
119
+ "input": 0.66,
120
+ "output": 1.98,
121
+ "cacheRead": 0.022
122
+ },
123
+ "context": 1000000,
124
+ "roles": [
125
+ "advisor",
126
+ "reviewer"
127
+ ],
128
+ "prior": "Pro tier at about 4x the flash price."
129
+ },
130
+ {
131
+ "id": "glm-5.3",
132
+ "family": "glm",
133
+ "tier": "pro",
134
+ "routes": [
135
+ "workbuddy",
136
+ "opencode-go"
137
+ ],
138
+ "price": {
139
+ "input": 1.4,
140
+ "output": 4.4,
141
+ "cacheRead": 0.26
142
+ },
143
+ "context": 1000000,
144
+ "roles": [
145
+ "advisor"
146
+ ],
147
+ "prior": "Pro tier; the most expensive model here."
148
+ },
149
+ {
150
+ "id": "mimo-v2.6-pro",
151
+ "family": "mimo",
152
+ "tier": "pro",
153
+ "routes": [
154
+ "opencode-go"
155
+ ],
156
+ "price": {
157
+ "input": 0.435,
158
+ "output": 0.87,
159
+ "cacheRead": 0.003625
160
+ },
161
+ "context": 1048576,
162
+ "roles": [
163
+ "advisor",
164
+ "reviewer"
165
+ ],
166
+ "prior": "Pro tier with a low output price."
167
+ },
168
+ {
169
+ "id": "claude-haiku-5-5",
170
+ "family": "claude",
171
+ "tier": "small",
172
+ "routes": [
173
+ "opencode-go-anthropic"
174
+ ],
175
+ "price": {
176
+ "input": 0.1,
177
+ "output": 0.5,
178
+ "cacheRead": 0.01,
179
+ "cacheWrite": 0.125,
180
+ "above": {
181
+ "tokens": 100000,
182
+ "input": 0.5,
183
+ "output": 2.5
184
+ }
185
+ },
186
+ "context": 1000000,
187
+ "roles": [
188
+ "reviewer",
189
+ "advisor",
190
+ "worker"
191
+ ],
192
+ "prior": "Small model; prompts above 100K tokens cost 5x."
193
+ },
194
+ {
195
+ "id": "gpt-6-luna",
196
+ "family": "gpt",
197
+ "tier": "small",
198
+ "routes": [],
199
+ "price": {
200
+ "input": 0.1,
201
+ "output": 0.5,
202
+ "cacheRead": 0.01,
203
+ "cacheWrite": 0.125
204
+ },
205
+ "context": 1050000,
206
+ "roles": [
207
+ "reviewer",
208
+ "advisor"
209
+ ],
210
+ "prior": "Small model."
211
+ }
212
+ ],
213
+ "jev": {
214
+ "jev-1.13": {
215
+ "input": 0.042,
216
+ "output": 0,
217
+ "source": "docs.typesafe.ai/models (owner-supplied, 2026-10-17)"
218
+ },
219
+ "jev-1.13-free": {
220
+ "input": 0,
221
+ "output": 0,
222
+ "source": "free tier, rate limited"
223
+ }
224
+ }
225
+ }