@chatpanel/events 0.85.0 → 0.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent.js +249 -0
- package/attribution.js +132 -0
- package/client-prefs.js +10 -1
- package/engine.js +131 -0
- package/gate.js +74 -0
- package/index.js +13 -2
- package/job.js +149 -0
- package/model-candidates.js +358 -0
- package/model-ledger.js +228 -0
- package/model-picker.js +3 -1
- package/package.json +21 -1
- package/project.js +170 -0
- package/recruit.js +419 -0
- package/route-strategies.js +232 -0
- package/scm-connection.js +180 -0
- package/scorecard.js +148 -4
- package/team-run.js +41 -6
- package/team-tool.js +16 -6
- package/team-trail.js +6 -0
- package/team.js +104 -9
- package/voice-speaker.js +98 -0
package/model-ledger.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// The MODEL LEDGER — an engine's record, the scorecard pattern applied to engines
|
|
2
|
+
// (architecture-pillars.md §13.2).
|
|
3
|
+
//
|
|
4
|
+
// One chained, store-attested ledger per ENGINE — keyed like the scorecard's `byEngine`
|
|
5
|
+
// (`model:<provider>/<model>`, `harness:<id>`), because the same model at two providers is
|
|
6
|
+
// two records: availability, cost and latency are the provider's, not the model's. Entries
|
|
7
|
+
// are FACTS the runner and the gateway observe, never claims:
|
|
8
|
+
//
|
|
9
|
+
// call one turn: time to first token, total, tokens in/out, cost when priced, was
|
|
10
|
+
// the JSON valid, were the tool calls valid, did it come back empty / refused
|
|
11
|
+
// / truncated, did it succeed
|
|
12
|
+
// declined it did not answer, and why (unavailable · auth · rate · credits · timeout ·
|
|
13
|
+
// context) — the availability signal
|
|
14
|
+
// rotated-from a task left it for another engine mid-run
|
|
15
|
+
// rating a task's verdict, attributed to the engine that served it (and to the agent)
|
|
16
|
+
// capability a proof: it was asked for X and it did / did not deliver
|
|
17
|
+
// price what a token costs here — from the provider's list or typed by a person
|
|
18
|
+
//
|
|
19
|
+
// `summarizeEngine(entries)` → the ENGINE CARD: availability, reliability, latency, cost,
|
|
20
|
+
// capability proofs (a capability with three failed proofs is WITHDRAWN until a person
|
|
21
|
+
// re-enables it), quality by job kind, the last refs. model-candidates.js `applyCard` hands
|
|
22
|
+
// the card to `applyOverride`: observed quality / latency / cost replace the name-based
|
|
23
|
+
// guess wherever there is enough history (≥ `minCalls`), the guess stays as the prior until
|
|
24
|
+
// then, and the result says which it used (`observed[]`). Reach is never learned, only
|
|
25
|
+
// typed — a ledger cannot move a model closer than the URL says.
|
|
26
|
+
//
|
|
27
|
+
// Hashing, attestation and chain verification are scorecard.js's, unchanged: the same store
|
|
28
|
+
// marks both, the same `verifyChain` checks both.
|
|
29
|
+
|
|
30
|
+
import { canonical, sha256, engineKey, normalizeEngine } from './scorecard.js';
|
|
31
|
+
export { verifyChain, attest, verifyAttested } from './scorecard.js';
|
|
32
|
+
|
|
33
|
+
export const LEDGER_VERSION = 1;
|
|
34
|
+
export const LEDGER_ENTRY_KINDS = Object.freeze(['call', 'declined', 'rotated-from', 'rating', 'capability', 'price']);
|
|
35
|
+
export const DECLINE_REASONS = Object.freeze(['unavailable', 'auth', 'rate', 'credits', 'timeout', 'context', 'other']);
|
|
36
|
+
export const STRUCTURED = Object.freeze(['ok', 'bad', 'n/a']);
|
|
37
|
+
/** Failed proofs before a capability leaves the card. */
|
|
38
|
+
export const WITHDRAW_AFTER = 3;
|
|
39
|
+
/** Calls before an observed number outranks the name-based guess. Small; configurable. */
|
|
40
|
+
export const DEFAULT_MIN_CALLS = 5;
|
|
41
|
+
|
|
42
|
+
const n0 = (v) => Math.max(0, Math.round(Number(v) || 0));
|
|
43
|
+
const money = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? undefined : Math.max(0, Number(v)));
|
|
44
|
+
const clamp01 = (n) => Math.max(0, Math.min(1, Number(n) || 0));
|
|
45
|
+
const bool = (v) => v === true;
|
|
46
|
+
const str = (v, n = 120) => (v == null || v === '' ? undefined : String(v).slice(0, n));
|
|
47
|
+
const strip = (o) => { for (const k of Object.keys(o)) if (o[k] === undefined) delete o[k]; return o; };
|
|
48
|
+
|
|
49
|
+
/** The ledger's key for an engine — the scorecard's `engineKey`, so the two join. */
|
|
50
|
+
export function ledgerKey(engine) { return engineKey(engine); }
|
|
51
|
+
|
|
52
|
+
/** A call fact, normalized. Rates are computed later; here every field is a plain observation. */
|
|
53
|
+
export function normalizeCall(c) {
|
|
54
|
+
const src = c && typeof c === 'object' ? c : {};
|
|
55
|
+
return strip({
|
|
56
|
+
ok: src.ok !== false,
|
|
57
|
+
ttftMs: src.ttftMs != null ? n0(src.ttftMs) : undefined,
|
|
58
|
+
totalMs: src.totalMs != null ? n0(src.totalMs) : undefined,
|
|
59
|
+
tokensIn: src.tokensIn != null ? n0(src.tokensIn) : undefined,
|
|
60
|
+
tokensOut: src.tokensOut != null ? n0(src.tokensOut) : undefined,
|
|
61
|
+
// The total when the split is unknown (a harness reports one number, or none).
|
|
62
|
+
tokens: src.tokens != null && src.tokensIn == null && src.tokensOut == null ? n0(src.tokens) : undefined,
|
|
63
|
+
cost: money(src.cost),
|
|
64
|
+
structured: STRUCTURED.includes(src.structured) ? src.structured : 'n/a',
|
|
65
|
+
toolCalls: src.toolCalls && typeof src.toolCalls === 'object' ? { asked: n0(src.toolCalls.asked), valid: Math.min(n0(src.toolCalls.asked), n0(src.toolCalls.valid)) } : undefined,
|
|
66
|
+
empty: bool(src.empty) || undefined,
|
|
67
|
+
refused: bool(src.refused) || undefined,
|
|
68
|
+
truncated: bool(src.truncated) || undefined,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A new entry chained onto `prev`. `fact.engine` is required and keyed; the rest is by kind.
|
|
74
|
+
* Pure apart from the digest; the store attests.
|
|
75
|
+
*/
|
|
76
|
+
export async function makeLedgerEntry(fact, prev, { now = () => Date.now(), subtle } = {}) {
|
|
77
|
+
if (!fact || typeof fact !== 'object') throw new Error('model-ledger: an entry needs a fact');
|
|
78
|
+
if (!LEDGER_ENTRY_KINDS.includes(fact.kind)) throw new Error(`model-ledger: kind must be one of ${LEDGER_ENTRY_KINDS.join(', ')}`);
|
|
79
|
+
const engine = normalizeEngine(fact.engine);
|
|
80
|
+
if (!engine) throw new Error('model-ledger: engine required');
|
|
81
|
+
const key = engineKey(engine);
|
|
82
|
+
if (prev && prev.key !== key) throw new Error(`model-ledger: entry for ${key} chained onto ${prev.key}`);
|
|
83
|
+
const e = strip({
|
|
84
|
+
v: LEDGER_VERSION,
|
|
85
|
+
seq: prev ? prev.seq + 1 : 0,
|
|
86
|
+
key,
|
|
87
|
+
engine,
|
|
88
|
+
kind: fact.kind,
|
|
89
|
+
at: Number(fact.at) || now(),
|
|
90
|
+
runId: str(fact.runId), taskId: str(fact.taskId), agentId: str(fact.agentId), jobKind: str(fact.jobKind, 60),
|
|
91
|
+
call: fact.kind === 'call' ? normalizeCall(fact.call) : undefined,
|
|
92
|
+
declined: fact.kind === 'declined' ? { reason: DECLINE_REASONS.includes(fact.declined?.reason) ? fact.declined.reason : 'other', ...(fact.declined?.error ? { error: String(fact.declined.error).slice(0, 300) } : {}) } : undefined,
|
|
93
|
+
rotated: fact.kind === 'rotated-from' ? strip({ to: engineKey(fact.rotated?.to) || undefined, reason: str(fact.rotated?.reason, 300) }) : undefined,
|
|
94
|
+
rating: fact.kind === 'rating' ? strip({ by: String(fact.rating?.by || 'person').slice(0, 40), score: clamp01(fact.rating?.score), jobKind: str(fact.rating?.jobKind || fact.jobKind, 60), agentId: str(fact.rating?.agentId || fact.agentId) }) : undefined,
|
|
95
|
+
capability: fact.kind === 'capability' ? { id: String(fact.capability?.id || '').slice(0, 40), proved: bool(fact.capability?.proved) } : undefined,
|
|
96
|
+
price: fact.kind === 'price' ? strip({ per1kIn: money(fact.price?.per1kIn) ?? 0, per1kOut: money(fact.price?.per1kOut) ?? 0, source: fact.price?.source === 'user' ? 'user' : 'provider', currency: str(fact.price?.currency, 8) }) : undefined,
|
|
97
|
+
refs: Array.isArray(fact.refs) && fact.refs.length ? fact.refs.map(String).slice(0, 12) : undefined,
|
|
98
|
+
prev: prev ? prev.hash : null,
|
|
99
|
+
});
|
|
100
|
+
if (e.kind === 'capability' && !e.capability.id) throw new Error('model-ledger: capability.id required');
|
|
101
|
+
const { hash: _h, sig: _s, ...hashable } = e;
|
|
102
|
+
e.hash = await sha256(canonical(hashable), { subtle });
|
|
103
|
+
return e;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const percentile = (xs, p) => {
|
|
107
|
+
if (!xs.length) return null;
|
|
108
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
109
|
+
return s[Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1))];
|
|
110
|
+
};
|
|
111
|
+
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
|
112
|
+
const rate = (n, of) => (of ? Math.round((n / of) * 1000) / 1000 : null);
|
|
113
|
+
const r3 = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The engine card. `minCalls` marks it `observed` once there is enough history; `now`
|
|
117
|
+
* bounds the by-hour availability band (the last 24 h) and the "declining right now" check.
|
|
118
|
+
*/
|
|
119
|
+
export function summarizeEngine(entries, { minCalls = DEFAULT_MIN_CALLS, now = Date.now(), recent = 5 } = {}) {
|
|
120
|
+
const list = (entries || []).filter((e) => e && e.kind);
|
|
121
|
+
const calls = list.filter((e) => e.kind === 'call');
|
|
122
|
+
const declines = list.filter((e) => e.kind === 'declined');
|
|
123
|
+
const attempts = calls.length + declines.length;
|
|
124
|
+
// Availability: declines over attempts, and the last 24 hours in bands of one.
|
|
125
|
+
const byHour = Array.from({ length: 24 }, () => ({ calls: 0, declines: 0 }));
|
|
126
|
+
for (const e of [...calls, ...declines]) {
|
|
127
|
+
const h = Math.floor((now - e.at) / 3600000);
|
|
128
|
+
if (h >= 0 && h < 24) byHour[23 - h][e.kind === 'call' ? 'calls' : 'declines'] += 1;
|
|
129
|
+
}
|
|
130
|
+
const declinesBy = {};
|
|
131
|
+
for (const e of declines) declinesBy[e.declined.reason] = (declinesBy[e.declined.reason] || 0) + 1;
|
|
132
|
+
// Declining right now: the last three attempts all declined, within the last hour.
|
|
133
|
+
const lastThree = [...calls, ...declines].sort((a, b) => a.at - b.at).slice(-3);
|
|
134
|
+
const decliningNow = lastThree.length === 3 && lastThree.every((e) => e.kind === 'declined' && now - e.at < 3600000);
|
|
135
|
+
// Reliability: each a rate over the calls it applies to.
|
|
136
|
+
const withJson = calls.filter((e) => e.call.structured !== 'n/a');
|
|
137
|
+
const withTools = calls.filter((e) => e.call.toolCalls?.asked);
|
|
138
|
+
const reliability = {
|
|
139
|
+
failRate: rate(calls.filter((e) => !e.call.ok).length, calls.length),
|
|
140
|
+
empty: rate(calls.filter((e) => e.call.empty).length, calls.length),
|
|
141
|
+
refused: rate(calls.filter((e) => e.call.refused).length, calls.length),
|
|
142
|
+
truncated: rate(calls.filter((e) => e.call.truncated).length, calls.length),
|
|
143
|
+
badJson: rate(withJson.filter((e) => e.call.structured === 'bad').length, withJson.length),
|
|
144
|
+
badToolCall: rate(withTools.reduce((n, e) => n + (e.call.toolCalls.asked - e.call.toolCalls.valid), 0), withTools.reduce((n, e) => n + e.call.toolCalls.asked, 0)),
|
|
145
|
+
};
|
|
146
|
+
// Latency.
|
|
147
|
+
const ttft = calls.map((e) => e.call.ttftMs).filter((v) => v != null);
|
|
148
|
+
const total = calls.map((e) => e.call.totalMs).filter((v) => v != null);
|
|
149
|
+
const latency = { ttft: { p50: percentile(ttft, 50), p95: percentile(ttft, 95), n: ttft.length }, total: { p50: percentile(total, 50), p95: percentile(total, 95), n: total.length } };
|
|
150
|
+
// Cost: the latest price entry prices every call that reported tokens; a call that
|
|
151
|
+
// reported its own cost is taken as is; otherwise the mean tokens per call stands in.
|
|
152
|
+
const price = list.filter((e) => e.kind === 'price').at(-1)?.price || null;
|
|
153
|
+
const costs = calls.map((e) => (e.call.cost != null ? e.call.cost : price && (e.call.tokensIn != null || e.call.tokensOut != null) ? ((e.call.tokensIn || 0) * price.per1kIn + (e.call.tokensOut || 0) * price.per1kOut) / 1000 : null)).filter((v) => v != null);
|
|
154
|
+
const tokens = calls.map((e) => (e.call.tokensIn || 0) + (e.call.tokensOut || 0) + (e.call.tokens || 0)).filter((v) => v > 0);
|
|
155
|
+
const cost = { perTask: r3(mean(costs)), priced: costs.length, tokensPerTask: tokens.length ? Math.round(mean(tokens)) : null, ...(price ? { per1kIn: price.per1kIn, per1kOut: price.per1kOut, source: price.source } : {}) };
|
|
156
|
+
// Capability proofs: asked vs proved; withdrawn after WITHDRAW_AFTER failures unless a
|
|
157
|
+
// later proof succeeded (a person re-enabling it is a proof they record).
|
|
158
|
+
const proofs = {};
|
|
159
|
+
for (const e of list.filter((x) => x.kind === 'capability')) {
|
|
160
|
+
const p = proofs[e.capability.id] || (proofs[e.capability.id] = { asked: 0, proved: 0, failedSince: 0 });
|
|
161
|
+
p.asked += 1;
|
|
162
|
+
if (e.capability.proved) { p.proved += 1; p.failedSince = 0; } else p.failedSince += 1;
|
|
163
|
+
}
|
|
164
|
+
const capabilities = {
|
|
165
|
+
proved: Object.keys(proofs).filter((id) => proofs[id].proved > 0 && proofs[id].failedSince < WITHDRAW_AFTER).sort(),
|
|
166
|
+
withdrawn: Object.keys(proofs).filter((id) => proofs[id].failedSince >= WITHDRAW_AFTER).sort(),
|
|
167
|
+
proofs: Object.fromEntries(Object.entries(proofs).map(([id, p]) => [id, { asked: p.asked, proved: p.proved }])),
|
|
168
|
+
};
|
|
169
|
+
// Quality: mean rating, overall and by job kind.
|
|
170
|
+
const ratings = list.filter((e) => e.kind === 'rating');
|
|
171
|
+
const byJobKind = {};
|
|
172
|
+
for (const e of ratings) { const k = e.rating.jobKind || 'any'; (byJobKind[k] = byJobKind[k] || []).push(e.rating.score); }
|
|
173
|
+
const quality = {
|
|
174
|
+
overall: { avg: r3(mean(ratings.map((e) => e.rating.score))), count: ratings.length },
|
|
175
|
+
byJobKind: Object.fromEntries(Object.entries(byJobKind).map(([k, xs]) => [k, { avg: r3(mean(xs)), count: xs.length }])),
|
|
176
|
+
};
|
|
177
|
+
const rotatedFrom = list.filter((e) => e.kind === 'rotated-from').length;
|
|
178
|
+
return {
|
|
179
|
+
key: list[0]?.key || null,
|
|
180
|
+
engine: list[0]?.engine || null,
|
|
181
|
+
entries: list.length,
|
|
182
|
+
calls: calls.length,
|
|
183
|
+
declines: declines.length,
|
|
184
|
+
observed: calls.length >= minCalls,
|
|
185
|
+
availability: { rate: attempts ? r3(1 - declines.length / attempts) : null, attempts, declinesBy, byHour, decliningNow },
|
|
186
|
+
reliability,
|
|
187
|
+
latency,
|
|
188
|
+
cost,
|
|
189
|
+
capabilities,
|
|
190
|
+
quality,
|
|
191
|
+
rotatedFrom,
|
|
192
|
+
refs: [...new Set(list.flatMap((e) => e.refs || []))].slice(-recent),
|
|
193
|
+
since: list[0]?.at || null,
|
|
194
|
+
last: list.at(-1)?.at || null,
|
|
195
|
+
head: list.at(-1)?.hash || null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The override a card yields for model-candidates.js `applyOverride` — only the fields it
|
|
201
|
+
* has enough history for. `quality` is the mean rating (for `jobKind` when the card has
|
|
202
|
+
* ratings for it, else overall); `latencyMs` the observed p50 to first token (total when no
|
|
203
|
+
* ttft was recorded); `costPer1k` from the price when one is known; `available: false`
|
|
204
|
+
* only while it is declining right now. Returns `{ override, observed }`.
|
|
205
|
+
*
|
|
206
|
+
* Lives here, beside the card it reads, so recruit.js and a store without a router can use
|
|
207
|
+
* it; `applyCard` (the override over the guess) stays in model-candidates.js beside
|
|
208
|
+
* `applyOverride`, the seam it feeds.
|
|
209
|
+
*/
|
|
210
|
+
export function cardOverride(card, { minCalls = DEFAULT_MIN_CALLS, jobKind = null } = {}) {
|
|
211
|
+
const override = {}; const observed = [];
|
|
212
|
+
if (!card) return { override, observed };
|
|
213
|
+
const q = (jobKind && card.quality?.byJobKind?.[jobKind]?.count >= minCalls) ? card.quality.byJobKind[jobKind] : card.quality?.overall;
|
|
214
|
+
if (q && q.count >= minCalls && q.avg != null) { override.quality = q.avg; observed.push('quality'); }
|
|
215
|
+
const lat = card.latency?.ttft?.n >= minCalls ? card.latency.ttft.p50 : card.latency?.total?.n >= minCalls ? card.latency.total.p50 : null;
|
|
216
|
+
if (lat != null) { override.latencyMs = lat; observed.push('latencyMs'); }
|
|
217
|
+
// Six places, not three: a per-1k price is often 0.0004, and rounding it to 0 made a paid model read as free.
|
|
218
|
+
if (card.cost?.per1kIn != null && card.cost?.per1kOut != null) { override.costPer1k = Math.round(((card.cost.per1kIn + card.cost.per1kOut) / 2) * 1e6) / 1e6; observed.push('costPer1k'); }
|
|
219
|
+
if (card.availability?.decliningNow) { override.available = false; observed.push('available'); }
|
|
220
|
+
return { override, observed };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// `applyCard` — the card over the name-based guess — lives in model-candidates.js beside
|
|
224
|
+
// `applyOverride`, the seam it feeds; this module stays importable by a store that has no
|
|
225
|
+
// router (the gateway vendors it with scorecard.js only).
|
|
226
|
+
// Agent scores normalised by engine (§13.3) live beside the card they adjust: scorecard.js
|
|
227
|
+
// `adjustSummary` and `fit(job, type, summary, { qualityOf })`.
|
|
228
|
+
export { adjustSummary } from './scorecard.js';
|
package/model-picker.js
CHANGED
|
@@ -102,7 +102,9 @@ export function groupModels(models, { selectedId = '' } = {}) {
|
|
|
102
102
|
if (agents.length) {
|
|
103
103
|
sections.push({
|
|
104
104
|
key: 'agents',
|
|
105
|
-
|
|
105
|
+
// Naming phase 1 (naming-revamp.md): the CLI coding agents are HARNESSES — a runtime one
|
|
106
|
+
// of the user's own agents can be given as its engine. The key and kind are code.
|
|
107
|
+
label: 'Harnesses',
|
|
106
108
|
kind: 'agent',
|
|
107
109
|
items: agents.slice().sort(availableFirst).map(decorate),
|
|
108
110
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.89.0",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
".": "./index.js",
|
|
9
9
|
"./adapters.js": "./adapters.js",
|
|
10
10
|
"./adaptive-tool-policy.js": "./adaptive-tool-policy.js",
|
|
11
|
+
"./agent.js": "./agent.js",
|
|
11
12
|
"./attribution.js": "./attribution.js",
|
|
12
13
|
"./backup-envelope.js": "./backup-envelope.js",
|
|
13
14
|
"./budget.js": "./budget.js",
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
"./curate.js": "./curate.js",
|
|
22
23
|
"./distance.js": "./distance.js",
|
|
23
24
|
"./entitlement.js": "./entitlement.js",
|
|
25
|
+
"./engine.js": "./engine.js",
|
|
24
26
|
"./entity.js": "./entity.js",
|
|
25
27
|
"./event.js": "./event.js",
|
|
26
28
|
"./extraction.js": "./extraction.js",
|
|
@@ -47,6 +49,7 @@
|
|
|
47
49
|
"./meeting-text.js": "./meeting-text.js",
|
|
48
50
|
"./memory.js": "./memory.js",
|
|
49
51
|
"./model-candidates.js": "./model-candidates.js",
|
|
52
|
+
"./model-ledger.js": "./model-ledger.js",
|
|
50
53
|
"./model-picker.js": "./model-picker.js",
|
|
51
54
|
"./note-actions.js": "./note-actions.js",
|
|
52
55
|
"./note-graph.js": "./note-graph.js",
|
|
@@ -63,6 +66,7 @@
|
|
|
63
66
|
"./reach.js": "./reach.js",
|
|
64
67
|
"./recipe-tool.js": "./recipe-tool.js",
|
|
65
68
|
"./recipe.js": "./recipe.js",
|
|
69
|
+
"./recruit.js": "./recruit.js",
|
|
66
70
|
"./record-list.js": "./record-list.js",
|
|
67
71
|
"./redaction-tokens.js": "./redaction-tokens.js",
|
|
68
72
|
"./ref.js": "./ref.js",
|
|
@@ -73,6 +77,7 @@
|
|
|
73
77
|
"./rrf.js": "./rrf.js",
|
|
74
78
|
"./rules.js": "./rules.js",
|
|
75
79
|
"./schedule.js": "./schedule.js",
|
|
80
|
+
"./scm-connection.js": "./scm-connection.js",
|
|
76
81
|
"./scopes.js": "./scopes.js",
|
|
77
82
|
"./search-engines.js": "./search-engines.js",
|
|
78
83
|
"./skill-manifest.js": "./skill-manifest.js",
|
|
@@ -95,6 +100,9 @@
|
|
|
95
100
|
"./team-task.js": "./team-task.js",
|
|
96
101
|
"./team-record.js": "./team-record.js",
|
|
97
102
|
"./scorecard.js": "./scorecard.js",
|
|
103
|
+
"./project.js": "./project.js",
|
|
104
|
+
"./job.js": "./job.js",
|
|
105
|
+
"./gate.js": "./gate.js",
|
|
98
106
|
"./team-plan.js": "./team-plan.js",
|
|
99
107
|
"./team-run.js": "./team-run.js",
|
|
100
108
|
"./team-tool.js": "./team-tool.js",
|
|
@@ -130,6 +138,8 @@
|
|
|
130
138
|
"README.md",
|
|
131
139
|
"adapters.js",
|
|
132
140
|
"adaptive-tool-policy.js",
|
|
141
|
+
"agent.js",
|
|
142
|
+
"attribution.js",
|
|
133
143
|
"backup-envelope.js",
|
|
134
144
|
"budget.js",
|
|
135
145
|
"capability.js",
|
|
@@ -142,6 +152,7 @@
|
|
|
142
152
|
"curate.js",
|
|
143
153
|
"distance.js",
|
|
144
154
|
"entitlement.js",
|
|
155
|
+
"engine.js",
|
|
145
156
|
"entity.js",
|
|
146
157
|
"event.js",
|
|
147
158
|
"extraction.js",
|
|
@@ -168,6 +179,8 @@
|
|
|
168
179
|
"meeting-shape.js",
|
|
169
180
|
"meeting-text.js",
|
|
170
181
|
"memory.js",
|
|
182
|
+
"model-candidates.js",
|
|
183
|
+
"model-ledger.js",
|
|
171
184
|
"model-picker.js",
|
|
172
185
|
"note-actions.js",
|
|
173
186
|
"note-graph.js",
|
|
@@ -184,15 +197,18 @@
|
|
|
184
197
|
"reach.js",
|
|
185
198
|
"recipe-tool.js",
|
|
186
199
|
"recipe.js",
|
|
200
|
+
"recruit.js",
|
|
187
201
|
"record-list.js",
|
|
188
202
|
"redaction-tokens.js",
|
|
189
203
|
"ref.js",
|
|
190
204
|
"registry.js",
|
|
191
205
|
"route-graph.js",
|
|
206
|
+
"route-strategies.js",
|
|
192
207
|
"router.js",
|
|
193
208
|
"rrf.js",
|
|
194
209
|
"rules.js",
|
|
195
210
|
"schedule.js",
|
|
211
|
+
"scm-connection.js",
|
|
196
212
|
"scopes.js",
|
|
197
213
|
"search-engines.js",
|
|
198
214
|
"skill-manifest.js",
|
|
@@ -215,6 +231,9 @@
|
|
|
215
231
|
"team-task.js",
|
|
216
232
|
"team-record.js",
|
|
217
233
|
"scorecard.js",
|
|
234
|
+
"project.js",
|
|
235
|
+
"job.js",
|
|
236
|
+
"gate.js",
|
|
218
237
|
"team-plan.js",
|
|
219
238
|
"team-run.js",
|
|
220
239
|
"team-tool.js",
|
|
@@ -238,6 +257,7 @@
|
|
|
238
257
|
"vault.js",
|
|
239
258
|
"view.js",
|
|
240
259
|
"voice-intents.js",
|
|
260
|
+
"voice-speaker.js",
|
|
241
261
|
"weather-tool.js",
|
|
242
262
|
"weather.js",
|
|
243
263
|
"web-search-tool.js",
|
package/project.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// A project — the page a goal starts on, and the record that folds everything done for it.
|
|
2
|
+
//
|
|
3
|
+
// Every job or goal starts here (F8 §12): the goal, defined by its stakeholder — the *chief
|
|
4
|
+
// executive*: a person by default, an agent from the pool when the person delegates it —
|
|
5
|
+
// with a done-when a run can be checked against, a budget the jobs are carved from, the
|
|
6
|
+
// repos the work happens in, and the gate that says how far a team may go without a person.
|
|
7
|
+
// A project is data (a `projects` prefs section, both clients) and a gateway record that
|
|
8
|
+
// folds its jobs, runs and spend from events, the way a run folds (team-record.js).
|
|
9
|
+
//
|
|
10
|
+
// A project's status is a small machine: draft (a goal being written) → open (jobs may be
|
|
11
|
+
// posted) → active (a job was recruited) → done (done-when held, a person closed it) or
|
|
12
|
+
// closed (abandoned). Nothing here runs anything: project-run.js is the executive loop.
|
|
13
|
+
|
|
14
|
+
import { validateBudget, normalizeBudget } from './budget.js';
|
|
15
|
+
import { normalizeGate, validateGate } from './gate.js';
|
|
16
|
+
|
|
17
|
+
export const PROJECT_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
18
|
+
export const PROJECT_STATUSES = Object.freeze(['draft', 'open', 'active', 'done', 'closed']);
|
|
19
|
+
const NEXT = Object.freeze({ draft: ['open', 'closed'], open: ['active', 'closed', 'draft'], active: ['done', 'closed', 'open'], done: ['closed', 'open'], closed: ['open'] });
|
|
20
|
+
export const MAX_REPOS = 16;
|
|
21
|
+
|
|
22
|
+
export class ProjectError extends Error {
|
|
23
|
+
constructor(code, message) { super(message); this.name = 'ProjectError'; this.code = code; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
27
|
+
const clip = (s, n) => String(s || '').trim().slice(0, n);
|
|
28
|
+
|
|
29
|
+
export function validateProject(p) {
|
|
30
|
+
const errors = [];
|
|
31
|
+
if (!isRecord(p)) return { ok: false, errors: ['project must be an object'] };
|
|
32
|
+
if (!PROJECT_ID_RE.test(String(p.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
|
|
33
|
+
if (!clip(p.title, 200)) errors.push('title: what the project is called');
|
|
34
|
+
if (!clip(p.goal, 4000)) errors.push('goal: what done looks like, in the stakeholder\'s words');
|
|
35
|
+
if (p.doneWhen !== undefined && !clip(p.doneWhen, 2000)) errors.push('doneWhen: a check a run can be held to, or leave it out');
|
|
36
|
+
if (p.stakeholder !== undefined && p.stakeholder !== 'person' && !/^[a-z][a-z0-9_-]{0,63}$/i.test(String(p.stakeholder))) errors.push('stakeholder: "person" or an agent id');
|
|
37
|
+
if (p.status !== undefined && !PROJECT_STATUSES.includes(p.status)) errors.push(`status: one of ${PROJECT_STATUSES.join(', ')}`);
|
|
38
|
+
const b = validateBudget(p.budget);
|
|
39
|
+
if (!b.ok) errors.push(...b.errors.map((e) => `budget: ${e}`));
|
|
40
|
+
if (p.repos !== undefined) {
|
|
41
|
+
if (!Array.isArray(p.repos)) errors.push('repos: a list of repo ids');
|
|
42
|
+
else if (p.repos.length > MAX_REPOS) errors.push(`repos: at most ${MAX_REPOS}`);
|
|
43
|
+
}
|
|
44
|
+
if (p.gate !== undefined && p.gate !== null) errors.push(...validateGate(p.gate, { partial: true }).errors.map((e) => `gate: ${e}`));
|
|
45
|
+
return { ok: errors.length === 0, errors };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The stored form: defaults filled, the budget normalised, the gate (if any) normalised. */
|
|
49
|
+
export function normalizeProject(p) {
|
|
50
|
+
const v = validateProject(p);
|
|
51
|
+
if (!v.ok) throw new ProjectError('INVALID', v.errors.join('; '));
|
|
52
|
+
return {
|
|
53
|
+
id: String(p.id),
|
|
54
|
+
title: clip(p.title, 200),
|
|
55
|
+
goal: clip(p.goal, 4000),
|
|
56
|
+
doneWhen: clip(p.doneWhen, 2000),
|
|
57
|
+
stakeholder: p.stakeholder ? String(p.stakeholder) : 'person',
|
|
58
|
+
budget: normalizeBudget(p.budget),
|
|
59
|
+
status: PROJECT_STATUSES.includes(p.status) ? p.status : 'draft',
|
|
60
|
+
repos: Array.isArray(p.repos) ? [...new Set(p.repos.map((r) => clip(r, 120)).filter(Boolean))].slice(0, MAX_REPOS) : [],
|
|
61
|
+
...(p.gate ? { gate: normalizeGate(p.gate, { partial: true }) } : {}),
|
|
62
|
+
tags: Array.isArray(p.tags) ? [...new Set(p.tags.map((t) => clip(t, 40)).filter(Boolean))].slice(0, 12) : [],
|
|
63
|
+
createdBy: clip(p.createdBy, 80) || 'person',
|
|
64
|
+
createdAt: Number(p.createdAt) || Date.now(),
|
|
65
|
+
...(p.updatedAt ? { updatedAt: Number(p.updatedAt) } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function defineProject(p) { return Object.freeze(normalizeProject(p)); }
|
|
70
|
+
|
|
71
|
+
/** May the project move from `from` to `to`? The machine above; a person's close is always allowed. */
|
|
72
|
+
export function canTransition(from, to) {
|
|
73
|
+
return (NEXT[from] || []).includes(to);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A blank page for the form. */
|
|
77
|
+
export function blankProject() {
|
|
78
|
+
return { id: '', title: '', goal: '', doneWhen: '', stakeholder: 'person', budget: { tokens: 200000, ms: 3600000 }, status: 'draft', repos: [], tags: [] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The form → a project, or the errors (the same shaping both clients use). */
|
|
82
|
+
export function projectFromForm(form) {
|
|
83
|
+
const budget = {};
|
|
84
|
+
for (const k of ['tokens', 'calls', 'ms', 'usd']) {
|
|
85
|
+
const v = Number(form?.budget?.[k]);
|
|
86
|
+
if (form?.budget?.[k] !== '' && form?.budget?.[k] != null && Number.isFinite(v) && v > 0) budget[k] = v;
|
|
87
|
+
}
|
|
88
|
+
const p = {
|
|
89
|
+
id: String(form?.id || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 64),
|
|
90
|
+
title: form?.title, goal: form?.goal, doneWhen: form?.doneWhen, stakeholder: form?.stakeholder || 'person',
|
|
91
|
+
budget, status: form?.status || 'draft',
|
|
92
|
+
repos: String(Array.isArray(form?.repos) ? form.repos.join(',') : form?.repos || '').split(/[,\s]+/).filter(Boolean),
|
|
93
|
+
tags: String(Array.isArray(form?.tags) ? form.tags.join(',') : form?.tags || '').split(/[,\s]+/).filter(Boolean),
|
|
94
|
+
...(form?.gate ? { gate: form.gate } : {}), ...(form?.createdAt ? { createdAt: form.createdAt } : {}),
|
|
95
|
+
};
|
|
96
|
+
const v = validateProject(p);
|
|
97
|
+
return v.ok ? { ok: true, project: normalizeProject(p) } : { ok: false, errors: v.errors };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── the record: a project folded from its events ────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/** The empty record — what the gateway holds per project and both clients read. */
|
|
103
|
+
export function emptyProjectRecord({ id, now = Date.now() } = {}) {
|
|
104
|
+
return { id, page: null, status: 'draft', jobs: [], runs: [], spend: { tokens: 0, calls: 0, usd: 0, ms: 0 }, decisions: [], report: null, createdAt: now, lastEventAt: now };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fold one event into the record. Events: `project.created` `{ project }` · `project.updated`
|
|
109
|
+
* `{ project }` · `project.status` `{ status, by, note }` · `job.posted` `{ job }` ·
|
|
110
|
+
* `job.updated` `{ job }` (any field: applications, recruited, status) · `run.linked`
|
|
111
|
+
* `{ runId, jobId }` · `run.spent` `{ runId, spent }` · `project.decision` `{ by, kind, text,
|
|
112
|
+
* refs }` · `project.report` `{ text, by }`. Idempotent by job id and run id.
|
|
113
|
+
*/
|
|
114
|
+
export function foldProject(rec, ev) {
|
|
115
|
+
const type = String(ev?.type || '');
|
|
116
|
+
const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : (ev || {});
|
|
117
|
+
const at = Number(ev?.at) || Date.now();
|
|
118
|
+
rec.lastEventAt = at;
|
|
119
|
+
switch (type) {
|
|
120
|
+
case 'project.created':
|
|
121
|
+
case 'project.updated':
|
|
122
|
+
if (p.project && typeof p.project === 'object') { rec.page = { ...p.project, updatedAt: at }; rec.status = p.project.status || rec.status; }
|
|
123
|
+
break;
|
|
124
|
+
case 'project.status':
|
|
125
|
+
if (PROJECT_STATUSES.includes(p.status)) { rec.status = p.status; if (rec.page) rec.page.status = p.status; rec.decisions.push({ at, by: p.by || 'person', kind: 'status', text: `${p.status}${p.note ? ` — ${p.note}` : ''}` }); }
|
|
126
|
+
break;
|
|
127
|
+
case 'job.posted':
|
|
128
|
+
if (p.job?.id && !rec.jobs.some((j) => j.id === p.job.id)) rec.jobs.push({ ...p.job, postedAt: at });
|
|
129
|
+
if (rec.status === 'draft') rec.status = 'open';
|
|
130
|
+
break;
|
|
131
|
+
case 'job.updated': {
|
|
132
|
+
const i = rec.jobs.findIndex((j) => j.id === p.job?.id);
|
|
133
|
+
if (i >= 0) rec.jobs[i] = { ...rec.jobs[i], ...p.job, updatedAt: at };
|
|
134
|
+
if (p.job?.status === 'recruited' || p.job?.status === 'in-progress') { if (rec.status === 'open' || rec.status === 'draft') rec.status = 'active'; }
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'run.linked':
|
|
138
|
+
if (p.runId && !rec.runs.some((r) => r.runId === p.runId)) rec.runs.push({ runId: p.runId, jobId: p.jobId || null, at });
|
|
139
|
+
break;
|
|
140
|
+
case 'run.spent': {
|
|
141
|
+
const r = rec.runs.find((x) => x.runId === p.runId);
|
|
142
|
+
const prev = r?.spent || { tokens: 0, calls: 0, usd: 0, ms: 0 };
|
|
143
|
+
const next = { tokens: Number(p.spent?.tokens) || 0, calls: Number(p.spent?.calls) || 0, usd: Number(p.spent?.usd) || 0, ms: Number(p.spent?.ms) || 0 };
|
|
144
|
+
// A run reports its running total; the project's spend is the sum of every run's latest.
|
|
145
|
+
for (const k of Object.keys(next)) rec.spend[k] = Math.max(0, (rec.spend[k] || 0) - (prev[k] || 0) + next[k]);
|
|
146
|
+
if (r) r.spent = next; else rec.runs.push({ runId: p.runId, jobId: null, at, spent: next });
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case 'project.decision':
|
|
150
|
+
rec.decisions.push({ at, by: p.by || 'person', kind: p.kind || 'note', text: String(p.text || '').slice(0, 2000), refs: Array.isArray(p.refs) ? p.refs.slice(0, 8) : [] });
|
|
151
|
+
break;
|
|
152
|
+
case 'project.report':
|
|
153
|
+
rec.report = { text: String(p.text || ''), by: p.by || 'runner', at };
|
|
154
|
+
break;
|
|
155
|
+
default: break;
|
|
156
|
+
}
|
|
157
|
+
return rec;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** How far along: jobs by status, spend against the budget, whether done-when is claimed. */
|
|
161
|
+
export function projectProgress(rec) {
|
|
162
|
+
const by = {};
|
|
163
|
+
for (const j of rec?.jobs || []) by[j.status] = (by[j.status] || 0) + 1;
|
|
164
|
+
const cap = rec?.page?.budget || {};
|
|
165
|
+
const spend = rec?.spend || {};
|
|
166
|
+
const pct = cap.tokens ? Math.min(1, (spend.tokens || 0) / cap.tokens) : cap.usd ? Math.min(1, (spend.usd || 0) / cap.usd) : cap.ms ? Math.min(1, (spend.ms || 0) / cap.ms) : null;
|
|
167
|
+
const total = (rec?.jobs || []).length;
|
|
168
|
+
const done = by.done || 0;
|
|
169
|
+
return { jobs: { total, by }, done, open: (by.open || 0) + (by.evaluating || 0) + (by.recruited || 0) + (by['in-progress'] || 0), spend, cap, budgetUsed: pct, hasReport: !!rec?.report, status: rec?.status || 'draft' };
|
|
170
|
+
}
|