@chatpanel/events 0.102.0 → 0.104.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/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +4 -5
- package/pdf-layout.js +7 -253
- package/policy.js +1 -0
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -442
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/backup-envelope.js
CHANGED
|
@@ -1,221 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// A ChatPanel backup is the only LOSSLESS channel the corpus has between clients: the
|
|
4
|
-
// extension writes one, the gateway reads one, and a desktop client has to do both. Today
|
|
5
|
-
// that format is implemented twice — the extension's `crypto-backup.js` (both directions)
|
|
6
|
-
// and the gateway's `backup-decrypt.js` (decrypt only, re-derived from the format's shape).
|
|
7
|
-
// Two implementations of a CRYPTO format is the worst kind of duplication: a divergence does
|
|
8
|
-
// not show up as a wrong pixel, it shows up as a backup nobody can open.
|
|
9
|
-
//
|
|
10
|
-
// Envelope, unchanged and frozen:
|
|
11
|
-
// { type:'chatpanel-backup-encrypted', version, kdf:{iterations,salt},
|
|
12
|
-
// cipher:'AES-GCM', compression:'brotli'|'gzip'|'none'|absent, iv, ct }
|
|
13
|
-
//
|
|
14
|
-
// COMPRESSION IS INJECTED, crypto is not. WebCrypto is on every runtime we target
|
|
15
|
-
// (browser, Node 19+, Electron), so it is imported as a global. Compression is not: the
|
|
16
|
-
// browser has CompressionStream, Node has zlib, and neither can be reached from the other.
|
|
17
|
-
// So the caller passes a `codec` — which is the same shape as the `page-capability.js`
|
|
18
|
-
// factory and the `loop.js` clock, and for the same reason.
|
|
19
|
-
//
|
|
20
|
-
// COMPRESS THEN ENCRYPT, never the reverse. Ciphertext is indistinguishable from random and
|
|
21
|
-
// does not compress, so the order is load-bearing rather than stylistic. The envelope
|
|
22
|
-
// records which codec was used so a gzip backup written in 2025 still opens forever.
|
|
23
|
-
|
|
24
|
-
export class BackupError extends Error {
|
|
25
|
-
constructor(message) { super(message); this.name = 'BackupError'; }
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const ENCRYPTED_TYPE = 'chatpanel-backup-encrypted';
|
|
29
|
-
|
|
30
|
-
/** PBKDF2-SHA256 rounds. Recorded IN the envelope so this can be raised without breaking old files. */
|
|
31
|
-
export const KDF_ITERATIONS = 250_000;
|
|
32
|
-
|
|
33
|
-
export const COMPRESSIONS = Object.freeze(['brotli', 'gzip', 'none']);
|
|
34
|
-
|
|
35
|
-
// --------------------------------------------------------------------------
|
|
36
|
-
// Bytes ↔ base64, without Buffer or atob assumptions
|
|
37
|
-
// --------------------------------------------------------------------------
|
|
38
|
-
|
|
39
|
-
const CHUNK = 0x8000;
|
|
40
|
-
|
|
41
|
-
export function toB64(bytes) {
|
|
42
|
-
if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64');
|
|
43
|
-
let bin = '';
|
|
44
|
-
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
45
|
-
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
|
|
46
|
-
}
|
|
47
|
-
return btoa(bin);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function fromB64(s) {
|
|
51
|
-
const str = String(s || '');
|
|
52
|
-
if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(str, 'base64'));
|
|
53
|
-
const bin = atob(str);
|
|
54
|
-
const out = new Uint8Array(bin.length);
|
|
55
|
-
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
56
|
-
return out;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// --------------------------------------------------------------------------
|
|
60
|
-
// Codecs
|
|
61
|
-
// --------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* The no-op codec. A backup written with it is still encrypted — it is just larger.
|
|
65
|
-
* Useful in tests and on a runtime that offers no compression at all.
|
|
66
|
-
*/
|
|
67
|
-
export const identityCodec = Object.freeze({
|
|
68
|
-
name: 'none',
|
|
69
|
-
async compress(bytes) { return bytes; },
|
|
70
|
-
async decompress(bytes) { return bytes; },
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* The browser/Electron-renderer codec, built from CompressionStream.
|
|
75
|
-
* Returns `null` when the runtime lacks the format, so a caller can fall back rather than
|
|
76
|
-
* discover the gap at write time.
|
|
77
|
-
*/
|
|
78
|
-
export function streamCodec(format = 'gzip') {
|
|
79
|
-
if (typeof CompressionStream === 'undefined' || typeof DecompressionStream === 'undefined') return null;
|
|
80
|
-
try {
|
|
81
|
-
new CompressionStream(format);
|
|
82
|
-
new DecompressionStream(format);
|
|
83
|
-
} catch {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
const run = async (bytes, Stream) => {
|
|
87
|
-
const stream = new Blob([bytes]).stream().pipeThrough(new Stream(format));
|
|
88
|
-
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
89
|
-
};
|
|
90
|
-
return Object.freeze({
|
|
91
|
-
name: format,
|
|
92
|
-
compress: (b) => run(b, CompressionStream),
|
|
93
|
-
decompress: (b) => run(b, DecompressionStream),
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Build a codec from Node's zlib without importing it here — the caller passes the module,
|
|
99
|
-
* so this file stays dependency-free and loadable in a browser.
|
|
100
|
-
*
|
|
101
|
-
* import zlib from 'node:zlib';
|
|
102
|
-
* const codec = nodeCodec(zlib, 'brotli');
|
|
103
|
-
*/
|
|
104
|
-
export function nodeCodec(zlib, format = 'brotli') {
|
|
105
|
-
if (!zlib) return null;
|
|
106
|
-
const pair = format === 'brotli'
|
|
107
|
-
? [zlib.brotliCompressSync, zlib.brotliDecompressSync]
|
|
108
|
-
: format === 'gzip' ? [zlib.gzipSync, zlib.gunzipSync] : null;
|
|
109
|
-
if (!pair || !pair[0] || !pair[1]) return null;
|
|
110
|
-
const [enc, dec] = pair;
|
|
111
|
-
return Object.freeze({
|
|
112
|
-
name: format,
|
|
113
|
-
async compress(bytes) { return new Uint8Array(enc(Buffer.from(bytes))); },
|
|
114
|
-
async decompress(bytes) { return new Uint8Array(dec(Buffer.from(bytes))); },
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Pick the best codec a runtime offers, preferring the smallest output. */
|
|
119
|
-
export function bestCodec({ zlib = null } = {}) {
|
|
120
|
-
if (zlib) return nodeCodec(zlib, 'brotli') || nodeCodec(zlib, 'gzip') || identityCodec;
|
|
121
|
-
return streamCodec('brotli') || streamCodec('gzip') || identityCodec;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// --------------------------------------------------------------------------
|
|
125
|
-
// Key derivation
|
|
126
|
-
// --------------------------------------------------------------------------
|
|
127
|
-
|
|
128
|
-
async function deriveKey(passphrase, salt, iterations, usages, subtle) {
|
|
129
|
-
const crypto_ = subtle || globalThis.crypto?.subtle;
|
|
130
|
-
if (!crypto_) throw new BackupError('no WebCrypto available on this runtime');
|
|
131
|
-
const base = await crypto_.importKey(
|
|
132
|
-
'raw', new TextEncoder().encode(String(passphrase)), 'PBKDF2', false, ['deriveKey'],
|
|
133
|
-
);
|
|
134
|
-
return crypto_.deriveKey(
|
|
135
|
-
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
|
|
136
|
-
base,
|
|
137
|
-
{ name: 'AES-GCM', length: 256 },
|
|
138
|
-
false,
|
|
139
|
-
usages,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// --------------------------------------------------------------------------
|
|
144
|
-
// The two directions
|
|
145
|
-
// --------------------------------------------------------------------------
|
|
146
|
-
|
|
147
|
-
export function isEncryptedBackup(obj) {
|
|
148
|
-
return !!obj && typeof obj === 'object' && obj.type === ENCRYPTED_TYPE;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* data + passphrase → envelope.
|
|
153
|
-
*
|
|
154
|
-
* `version` is the ENVELOPE's version, not the payload's — the backup body carries its own.
|
|
155
|
-
*/
|
|
156
|
-
export async function encryptBackup(data, passphrase, {
|
|
157
|
-
codec = null, iterations = KDF_ITERATIONS, subtle = null, random = null, version = 3,
|
|
158
|
-
} = {}) {
|
|
159
|
-
if (!passphrase) throw new BackupError('a passphrase is required to encrypt a backup');
|
|
160
|
-
const c = codec || bestCodec();
|
|
161
|
-
const rand = random || ((n) => globalThis.crypto.getRandomValues(new Uint8Array(n)));
|
|
162
|
-
|
|
163
|
-
const plain = new TextEncoder().encode(JSON.stringify(data));
|
|
164
|
-
const packed = await c.compress(plain);
|
|
165
|
-
|
|
166
|
-
const salt = rand(16);
|
|
167
|
-
const iv = rand(12);
|
|
168
|
-
const key = await deriveKey(passphrase, salt, iterations, ['encrypt'], subtle);
|
|
169
|
-
const cryptoSubtle = subtle || globalThis.crypto.subtle;
|
|
170
|
-
const ct = new Uint8Array(await cryptoSubtle.encrypt({ name: 'AES-GCM', iv }, key, packed));
|
|
171
|
-
|
|
172
|
-
return {
|
|
173
|
-
type: ENCRYPTED_TYPE,
|
|
174
|
-
version,
|
|
175
|
-
kdf: { iterations, salt: toB64(salt) },
|
|
176
|
-
cipher: 'AES-GCM',
|
|
177
|
-
compression: c.name,
|
|
178
|
-
iv: toB64(iv),
|
|
179
|
-
ct: toB64(ct),
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* envelope + passphrase → the original data object.
|
|
185
|
-
*
|
|
186
|
-
* A wrong passphrase and a tampered file are the SAME failure here, and deliberately so:
|
|
187
|
-
* AES-GCM's auth tag catches both, and telling them apart would be an oracle.
|
|
188
|
-
*/
|
|
189
|
-
export async function decryptBackup(envelope, passphrase, { codec = null, subtle = null } = {}) {
|
|
190
|
-
if (!isEncryptedBackup(envelope)) throw new BackupError('not an encrypted ChatPanel backup');
|
|
191
|
-
if (!passphrase) throw new BackupError('a passphrase is required to decrypt this backup');
|
|
192
|
-
|
|
193
|
-
const salt = fromB64(envelope.kdf?.salt);
|
|
194
|
-
const iterations = Number(envelope.kdf?.iterations) || KDF_ITERATIONS;
|
|
195
|
-
const key = await deriveKey(passphrase, salt, iterations, ['decrypt'], subtle);
|
|
196
|
-
const cryptoSubtle = subtle || globalThis.crypto?.subtle;
|
|
197
|
-
if (!cryptoSubtle) throw new BackupError('no WebCrypto available on this runtime');
|
|
198
|
-
|
|
199
|
-
let payload;
|
|
200
|
-
try {
|
|
201
|
-
payload = new Uint8Array(await cryptoSubtle.decrypt(
|
|
202
|
-
{ name: 'AES-GCM', iv: fromB64(envelope.iv) }, key, fromB64(envelope.ct),
|
|
203
|
-
));
|
|
204
|
-
} catch {
|
|
205
|
-
throw new BackupError('wrong passphrase, or the backup file is corrupted');
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// v1 wrote plaintext, v2 gzip, v3 may use brotli. The envelope says which; an absent
|
|
209
|
-
// field means the oldest behaviour, which is why `none` and absent must agree.
|
|
210
|
-
const want = envelope.compression || 'none';
|
|
211
|
-
if (want !== 'none') {
|
|
212
|
-
const c = codec || bestCodec();
|
|
213
|
-
if (c.name !== want) {
|
|
214
|
-
// Being explicit beats returning garbage: a gzip envelope handed a brotli-only codec
|
|
215
|
-
// fails here with a readable reason instead of a JSON parse error twenty lines later.
|
|
216
|
-
throw new BackupError(`this backup is ${want}-compressed; supply a matching codec`);
|
|
217
|
-
}
|
|
218
|
-
payload = await c.decompress(payload);
|
|
219
|
-
}
|
|
220
|
-
return JSON.parse(new TextDecoder().decode(payload));
|
|
221
|
-
}
|
|
1
|
+
class a extends Error{constructor(n){super(n),this.name="BackupError"}}const w="chatpanel-backup-encrypted",h=25e4,O=Object.freeze(["brotli","gzip","none"]),b=32768;function f(e){if(typeof Buffer<"u")return Buffer.from(e).toString("base64");let n="";for(let r=0;r<e.length;r+=b)n+=String.fromCharCode.apply(null,e.subarray(r,r+b));return btoa(n)}function l(e){const n=String(e||"");if(typeof Buffer<"u")return new Uint8Array(Buffer.from(n,"base64"));const r=atob(n),o=new Uint8Array(r.length);for(let t=0;t<r.length;t++)o[t]=r.charCodeAt(t);return o}const S=Object.freeze({name:"none",async compress(e){return e},async decompress(e){return e}});function g(e="gzip"){if(typeof CompressionStream>"u"||typeof DecompressionStream>"u")return null;try{new CompressionStream(e),new DecompressionStream(e)}catch{return null}const n=async(r,o)=>{const t=new Blob([r]).stream().pipeThrough(new o(e));return new Uint8Array(await new Response(t).arrayBuffer())};return Object.freeze({name:e,compress:r=>n(r,CompressionStream),decompress:r=>n(r,DecompressionStream)})}function C(e,n="brotli"){if(!e)return null;const r=n==="brotli"?[e.brotliCompressSync,e.brotliDecompressSync]:n==="gzip"?[e.gzipSync,e.gunzipSync]:null;if(!r||!r[0]||!r[1])return null;const[o,t]=r;return Object.freeze({name:n,async compress(c){return new Uint8Array(o(Buffer.from(c)))},async decompress(c){return new Uint8Array(t(Buffer.from(c)))}})}function x({zlib:e=null}={}){return e?C(e,"brotli")||C(e,"gzip")||S:g("brotli")||g("gzip")||S}async function k(e,n,r,o,t){const c=t||globalThis.crypto?.subtle;if(!c)throw new a("no WebCrypto available on this runtime");const i=await c.importKey("raw",new TextEncoder().encode(String(e)),"PBKDF2",!1,["deriveKey"]);return c.deriveKey({name:"PBKDF2",salt:n,iterations:r,hash:"SHA-256"},i,{name:"AES-GCM",length:256},!1,o)}function D(e){return!!e&&typeof e=="object"&&e.type===w}async function U(e,n,{codec:r=null,iterations:o=h,subtle:t=null,random:c=null,version:i=3}={}){if(!n)throw new a("a passphrase is required to encrypt a backup");const p=r||x(),s=c||(T=>globalThis.crypto.getRandomValues(new Uint8Array(T))),u=new TextEncoder().encode(JSON.stringify(e)),y=await p.compress(u),d=s(16),m=s(12),B=await k(n,d,o,["encrypt"],t),A=t||globalThis.crypto.subtle,E=new Uint8Array(await A.encrypt({name:"AES-GCM",iv:m},B,y));return{type:w,version:i,kdf:{iterations:o,salt:f(d)},cipher:"AES-GCM",compression:p.name,iv:f(m),ct:f(E)}}async function K(e,n,{codec:r=null,subtle:o=null}={}){if(!D(e))throw new a("not an encrypted ChatPanel backup");if(!n)throw new a("a passphrase is required to decrypt this backup");const t=l(e.kdf?.salt),c=Number(e.kdf?.iterations)||h,i=await k(n,t,c,["decrypt"],o),p=o||globalThis.crypto?.subtle;if(!p)throw new a("no WebCrypto available on this runtime");let s;try{s=new Uint8Array(await p.decrypt({name:"AES-GCM",iv:l(e.iv)},i,l(e.ct)))}catch{throw new a("wrong passphrase, or the backup file is corrupted")}const u=e.compression||"none";if(u!=="none"){const y=r||x();if(y.name!==u)throw new a(`this backup is ${u}-compressed; supply a matching codec`);s=await y.decompress(s)}return JSON.parse(new TextDecoder().decode(s))}export{a as BackupError,O as COMPRESSIONS,w as ENCRYPTED_TYPE,h as KDF_ITERATIONS,x as bestCodec,K as decryptBackup,U as encryptBackup,l as fromB64,S as identityCodec,D as isEncryptedBackup,C as nodeCodec,g as streamCodec,f as toB64};
|
package/board-tool.js
CHANGED
|
@@ -1,166 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// Every role gets it, whatever its grants: it reaches nothing outside the run. Four actions:
|
|
4
|
-
// `read` the threads this member may see, `post` a note or draft in a thread, `reply` to a
|
|
5
|
-
// post (agree, dispute with a ref, extend), and `ask` — open an ask thread and WAIT for a
|
|
6
|
-
// person to answer it, from either client. Members do not chat freely: a post is typed, a
|
|
7
|
-
// reply hangs off a post, and an ask pauses the member's own task, never the run.
|
|
8
|
-
//
|
|
9
|
-
// A fifth, `request`: a member hands a PIECE of its task to someone else — the runner turns
|
|
10
|
-
// it into a sub-task with its own thread, offers it to the members that fit, posts it on the
|
|
11
|
-
// job board when none does, and (by default) waits for it and returns its findings here.
|
|
12
|
-
//
|
|
13
|
-
// Bound per task: the member's role and task are fixed at bind time, so a member cannot post
|
|
14
|
-
// as someone else, and its ask lands in its own task's context.
|
|
1
|
+
import{boardText as g,POST_KINDS as x,ASK_TYPES as q}from"./team-board.js";import{normalizeRequest as T}from"./team-subtask.js";const b="board",S=10*6e4;function I(){return{name:b,annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1},description:`The team board — where the other members' work is, and where you speak to them and to the user. Actions: {"action":"read"} the threads you may see; {"action":"post","kind":"note|draft|question","text":"…","refs":["…"]} a post in your task's thread; {"action":"reply","postId":"…","kind":"note","text":"…","refs":["…"]} a reply to another member's post — agree, dispute with a ref, extend; {"action":"ask","type":"info|budget|permission|direction","text":"what you need and why","options":["…"]} asks the USER and waits for the answer (minutes). {"action":"request","title":"check the valuation","brief":"what to do and what done looks like","skills":["finance"],"grants":["web"],"wait":true} hands a piece of your task to another member (or, if none fits, to the job board): a sub-task with its own thread; with wait (default) you get its findings back here. Ask only when you are stuck — a fact you could not find, a choice only the user can make. Otherwise decide, say what you assumed, and go on. Request only work you should not do yourself — another skill, a tool you lack, or a second pair of eyes.`,parameters:{type:"object",properties:{action:{type:"string",enum:["read","post","reply","ask","request"]},kind:{type:"string",enum:x.filter(e=>e!=="finding"&&e!=="answer"&&e!=="decision")},text:{type:"string"},refs:{type:"array",items:{type:"string"}},postId:{type:"string",description:"For reply: the post to reply to."},threadId:{type:"string",description:"For post: another thread you may see (default: your task's)."},type:{type:"string",enum:[...q],description:"For ask."},options:{type:"array",items:{type:"string"},description:"For ask: choices to offer the user."},title:{type:"string",description:"For request: the sub-task in a few words."},brief:{type:"string",description:"For request: what to do and what done looks like."},skills:{type:"array",items:{type:"string"},description:"For request: skills it needs."},grants:{type:"array",items:{type:"string"},description:"For request: tools it needs — data, web, history, mcp, shell, fs:write, scm:read, scm:push, scm:pr."},wait:{type:"boolean",description:"For request: wait for it and get its findings back (default true); false queues it after your task."}},required:["action"]}}}const a=e=>JSON.stringify(e);function O({board:e,role:o,taskId:n,taskIds:s=null,waitFor:i=null,onAsk:u=null,onRequest:p=null,askTimeoutMs:l=S,signal:h=null}={}){const f=()=>e.threadForTask(n)||e.openThread({taskId:n,kind:"task",title:n,by:"runner"});return{id:"board",specs:[I()],system:"Other members' work is on the board (the `board` tool): read it before repeating a lookup, reply where you disagree, and ask the user only when you are stuck.",async execute(k,d){if(k!==b)return a({error:`Unknown tool: ${k}`});const c=String(d?.action||"");if(c==="read"){const r=g(e,{taskIds:s,role:o}),y=e.threads().filter(t=>t.kind!=="proposal").map(t=>({id:t.id,kind:t.kind,title:t.title,status:t.status,by:t.by,posts:t.posts}));return a({board:r||"Nothing on the board yet.",threads:y,postIds:e.posts().filter(t=>t.kind!=="answer").map(t=>({id:t.id,threadId:t.threadId,by:t.by,kind:t.kind,head:t.text.slice(0,80)}))})}const w=String(d?.text||"").trim();if(c==="post"||c==="reply"){if(!w)return a({error:`${c} needs text.`});let r=f().id,y=null;if(c==="reply"){const m=e.postById(String(d?.postId||""));if(!m)return a({error:`No post ${d?.postId}. Read the board for post ids.`});r=m.threadId,y=m.id}else d?.threadId&&e.thread(String(d.threadId))&&(r=String(d.threadId));const t=e.post({threadId:r,by:o,kind:d?.kind||"note",text:w,refs:d?.refs,replyTo:y});return a({posted:t.id,threadId:r,replyTo:y})}if(c==="ask"){if(!w)return a({error:"ask needs text — what you need and why."});if(typeof i!="function")return a({error:"This run cannot wait for the user. Decide on your best assumption and say what you assumed."});const{thread:r,post:y}=e.ask({taskId:n,by:o,type:d?.type,text:w,options:d?.options,timeoutMs:l});u?.(r,y);const t=await i(r.id,l,h);return a(t?{answered:!0,threadId:r.id,answer:t.text,by:t.by}:{answered:!1,threadId:r.id,hint:"No answer arrived in time. Proceed on your best assumption, say what you assumed, and note that the user did not answer."})}if(c==="request"){if(typeof p!="function")return a({error:"This run cannot delegate. Do the work yourself with the tools you have, and say what you could not do."});const r=T(d);if(!r.ok)return a({error:r.error});const y=e.post({threadId:f().id,by:o,kind:"request",text:`${r.request.title}${r.request.brief&&r.request.brief!==r.request.title?` — ${r.request.brief}`:""}`,refs:[]}),t=await p({...r.request,postId:y.id});return a(t||{error:"the request was not taken"})}return a({error:`Unknown action "${c}". Use read, post, reply, ask or request.`})}}}function _(){const e=new Map,o=new Map;return{wait(n,s,i=null){if(o.has(n)){const u=o.get(n);return o.delete(n),Promise.resolve(u)}return new Promise(u=>{let p=null;const l=f=>{clearTimeout(p),e.delete(n),i?.removeEventListener?.("abort",h),u(f)},h=()=>l(null);e.set(n,l),s>0&&(p=setTimeout(()=>l(null),s)),i?.addEventListener?.("abort",h,{once:!0})})},answer(n,s){const i={text:String(s?.text??s??""),by:s?.by||"person",id:s?.id||null},u=e.get(n);return u?u(i):o.set(n,i),!!u},get pending(){return[...e.keys()]}}}function E(e,o){if(!e)return{specs:[...o.specs],system:o.system,execute:(s,i)=>o.execute(s,i)};const n=new Set(o.specs.map(s=>s.name));return{...e,specs:[...o.specs,...(e.specs||[]).filter(s=>!n.has(s.name))],system:[o.system,e.system].filter(Boolean).join(`
|
|
15
2
|
|
|
16
|
-
|
|
17
|
-
import { normalizeRequest } from './team-subtask.js';
|
|
18
|
-
|
|
19
|
-
export const BOARD_TOOL_NAME = 'board';
|
|
20
|
-
export const DEFAULT_ASK_TIMEOUT_MS = 10 * 60_000;
|
|
21
|
-
|
|
22
|
-
export function boardToolSpec() {
|
|
23
|
-
return {
|
|
24
|
-
name: BOARD_TOOL_NAME,
|
|
25
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
26
|
-
description:
|
|
27
|
-
'The team board — where the other members\' work is, and where you speak to them and to the user. '
|
|
28
|
-
+ 'Actions: {"action":"read"} the threads you may see; '
|
|
29
|
-
+ '{"action":"post","kind":"note|draft|question","text":"…","refs":["…"]} a post in your task\'s thread; '
|
|
30
|
-
+ '{"action":"reply","postId":"…","kind":"note","text":"…","refs":["…"]} a reply to another member\'s post — agree, dispute with a ref, extend; '
|
|
31
|
-
+ '{"action":"ask","type":"info|budget|permission|direction","text":"what you need and why","options":["…"]} asks the USER and waits for the answer (minutes). '
|
|
32
|
-
+ '{"action":"request","title":"check the valuation","brief":"what to do and what done looks like","skills":["finance"],"grants":["web"],"wait":true} hands a piece of your task to another member (or, if none fits, to the job board): a sub-task with its own thread; with wait (default) you get its findings back here. '
|
|
33
|
-
+ 'Ask only when you are stuck — a fact you could not find, a choice only the user can make. Otherwise decide, say what you assumed, and go on. Request only work you should not do yourself — another skill, a tool you lack, or a second pair of eyes.',
|
|
34
|
-
parameters: {
|
|
35
|
-
type: 'object',
|
|
36
|
-
properties: {
|
|
37
|
-
action: { type: 'string', enum: ['read', 'post', 'reply', 'ask', 'request'] },
|
|
38
|
-
kind: { type: 'string', enum: POST_KINDS.filter((k) => k !== 'finding' && k !== 'answer' && k !== 'decision') },
|
|
39
|
-
text: { type: 'string' },
|
|
40
|
-
refs: { type: 'array', items: { type: 'string' } },
|
|
41
|
-
postId: { type: 'string', description: 'For reply: the post to reply to.' },
|
|
42
|
-
threadId: { type: 'string', description: 'For post: another thread you may see (default: your task\'s).' },
|
|
43
|
-
type: { type: 'string', enum: [...ASK_TYPES], description: 'For ask.' },
|
|
44
|
-
options: { type: 'array', items: { type: 'string' }, description: 'For ask: choices to offer the user.' },
|
|
45
|
-
title: { type: 'string', description: 'For request: the sub-task in a few words.' },
|
|
46
|
-
brief: { type: 'string', description: 'For request: what to do and what done looks like.' },
|
|
47
|
-
skills: { type: 'array', items: { type: 'string' }, description: 'For request: skills it needs.' },
|
|
48
|
-
grants: { type: 'array', items: { type: 'string' }, description: 'For request: tools it needs — data, web, history, mcp, shell, fs:write, scm:read, scm:push, scm:pr.' },
|
|
49
|
-
wait: { type: 'boolean', description: 'For request: wait for it and get its findings back (default true); false queues it after your task.' },
|
|
50
|
-
},
|
|
51
|
-
required: ['action'],
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const json = (v) => JSON.stringify(v);
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* @param board the run's live board (team-board.js createBoard)
|
|
60
|
-
* @param role the member's role id
|
|
61
|
-
* @param taskId the member's task
|
|
62
|
-
* @param taskIds the tasks this member may read (its dependencies, or null for all)
|
|
63
|
-
* @param waitFor `async (threadId, timeoutMs, signal) => { text, by } | null` — the runner's answer box
|
|
64
|
-
* @param onAsk `(thread, post) => void` — the runner marks the task waiting
|
|
65
|
-
* @param onRequest `async ({ title, brief, needs, wait }) => result` — the runner turns a request
|
|
66
|
-
* into a sub-task (team-subtask.js); absent, a member is told to do it itself
|
|
67
|
-
*/
|
|
68
|
-
export function boardToolProvider({ board, role, taskId, taskIds = null, waitFor = null, onAsk = null, onRequest = null, askTimeoutMs = DEFAULT_ASK_TIMEOUT_MS, signal = null } = {}) {
|
|
69
|
-
const ownThread = () => board.threadForTask(taskId) || board.openThread({ taskId, kind: 'task', title: taskId, by: 'runner' });
|
|
70
|
-
return {
|
|
71
|
-
id: 'board',
|
|
72
|
-
specs: [boardToolSpec()],
|
|
73
|
-
system: 'Other members\' work is on the board (the `board` tool): read it before repeating a lookup, reply where you disagree, and ask the user only when you are stuck.',
|
|
74
|
-
async execute(name, input) {
|
|
75
|
-
if (name !== BOARD_TOOL_NAME) return json({ error: `Unknown tool: ${name}` });
|
|
76
|
-
const action = String(input?.action || '');
|
|
77
|
-
if (action === 'read') {
|
|
78
|
-
const text = boardText(board, { taskIds, role });
|
|
79
|
-
const threads = board.threads().filter((t) => t.kind !== 'proposal').map((t) => ({ id: t.id, kind: t.kind, title: t.title, status: t.status, by: t.by, posts: t.posts }));
|
|
80
|
-
return json({ board: text || 'Nothing on the board yet.', threads, postIds: board.posts().filter((p) => p.kind !== 'answer').map((p) => ({ id: p.id, threadId: p.threadId, by: p.by, kind: p.kind, head: p.text.slice(0, 80) })) });
|
|
81
|
-
}
|
|
82
|
-
const text = String(input?.text || '').trim();
|
|
83
|
-
if (action === 'post' || action === 'reply') {
|
|
84
|
-
if (!text) return json({ error: `${action} needs text.` });
|
|
85
|
-
let threadId = ownThread().id;
|
|
86
|
-
let replyTo = null;
|
|
87
|
-
if (action === 'reply') {
|
|
88
|
-
const target = board.postById(String(input?.postId || ''));
|
|
89
|
-
if (!target) return json({ error: `No post ${input?.postId}. Read the board for post ids.` });
|
|
90
|
-
threadId = target.threadId; replyTo = target.id;
|
|
91
|
-
} else if (input?.threadId && board.thread(String(input.threadId))) {
|
|
92
|
-
threadId = String(input.threadId);
|
|
93
|
-
}
|
|
94
|
-
const post = board.post({ threadId, by: role, kind: input?.kind || 'note', text, refs: input?.refs, replyTo });
|
|
95
|
-
return json({ posted: post.id, threadId, replyTo });
|
|
96
|
-
}
|
|
97
|
-
if (action === 'ask') {
|
|
98
|
-
if (!text) return json({ error: 'ask needs text — what you need and why.' });
|
|
99
|
-
if (typeof waitFor !== 'function') return json({ error: 'This run cannot wait for the user. Decide on your best assumption and say what you assumed.' });
|
|
100
|
-
const { thread, post } = board.ask({ taskId, by: role, type: input?.type, text, options: input?.options, timeoutMs: askTimeoutMs });
|
|
101
|
-
onAsk?.(thread, post);
|
|
102
|
-
const answer = await waitFor(thread.id, askTimeoutMs, signal);
|
|
103
|
-
if (!answer) return json({ answered: false, threadId: thread.id, hint: 'No answer arrived in time. Proceed on your best assumption, say what you assumed, and note that the user did not answer.' });
|
|
104
|
-
return json({ answered: true, threadId: thread.id, answer: answer.text, by: answer.by });
|
|
105
|
-
}
|
|
106
|
-
if (action === 'request') {
|
|
107
|
-
if (typeof onRequest !== 'function') return json({ error: 'This run cannot delegate. Do the work yourself with the tools you have, and say what you could not do.' });
|
|
108
|
-
const r = normalizeRequest(input);
|
|
109
|
-
if (!r.ok) return json({ error: r.error });
|
|
110
|
-
// The request is a post in the member's own thread — the record of who asked for what.
|
|
111
|
-
const post = board.post({ threadId: ownThread().id, by: role, kind: 'request', text: `${r.request.title}${r.request.brief && r.request.brief !== r.request.title ? ` — ${r.request.brief}` : ''}`, refs: [] });
|
|
112
|
-
const result = await onRequest({ ...r.request, postId: post.id });
|
|
113
|
-
return json(result || { error: 'the request was not taken' });
|
|
114
|
-
}
|
|
115
|
-
return json({ error: `Unknown action "${action}". Use read, post, reply, ask or request.` });
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* The runner's answer box: `wait(threadId, ms, signal)` resolves when `answer(threadId, …)`
|
|
122
|
-
* is called — by the host, from its own UI or from the run store's tail when the OTHER
|
|
123
|
-
* client answered. An answer that arrives before anyone waits is kept.
|
|
124
|
-
*/
|
|
125
|
-
export function createAnswerBox() {
|
|
126
|
-
const waiting = new Map(); // threadId -> resolve
|
|
127
|
-
const early = new Map(); // threadId -> answer
|
|
128
|
-
return {
|
|
129
|
-
wait(threadId, timeoutMs, signal = null) {
|
|
130
|
-
if (early.has(threadId)) { const a = early.get(threadId); early.delete(threadId); return Promise.resolve(a); }
|
|
131
|
-
return new Promise((resolve) => {
|
|
132
|
-
let timer = null;
|
|
133
|
-
const done = (v) => { clearTimeout(timer); waiting.delete(threadId); signal?.removeEventListener?.('abort', onAbort); resolve(v); };
|
|
134
|
-
const onAbort = () => done(null);
|
|
135
|
-
waiting.set(threadId, done);
|
|
136
|
-
if (timeoutMs > 0) timer = setTimeout(() => done(null), timeoutMs);
|
|
137
|
-
if (typeof timer?.unref === 'function') timer.unref();
|
|
138
|
-
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
139
|
-
});
|
|
140
|
-
},
|
|
141
|
-
answer(threadId, answer) {
|
|
142
|
-
const a = { text: String(answer?.text ?? answer ?? ''), by: answer?.by || 'person', id: answer?.id || null };
|
|
143
|
-
const w = waiting.get(threadId);
|
|
144
|
-
if (w) w(a); else early.set(threadId, a);
|
|
145
|
-
return !!w;
|
|
146
|
-
},
|
|
147
|
-
get pending() { return [...waiting.keys()]; },
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** The host's toolset with the board tool added — first, so it is read first. */
|
|
152
|
-
export function withBoardTool(toolset, provider) {
|
|
153
|
-
if (!toolset) {
|
|
154
|
-
return {
|
|
155
|
-
specs: [...provider.specs], system: provider.system,
|
|
156
|
-
execute: (name, input) => provider.execute(name, input),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
const own = new Set(provider.specs.map((s) => s.name));
|
|
160
|
-
return {
|
|
161
|
-
...toolset,
|
|
162
|
-
specs: [...provider.specs, ...(toolset.specs || []).filter((s) => !own.has(s.name))],
|
|
163
|
-
system: [provider.system, toolset.system].filter(Boolean).join('\n\n'),
|
|
164
|
-
execute: (name, input, ...rest) => (own.has(name) ? provider.execute(name, input) : toolset.execute(name, input, ...rest)),
|
|
165
|
-
};
|
|
166
|
-
}
|
|
3
|
+
`),execute:(s,i,...u)=>n.has(s)?o.execute(s,i):e.execute(s,i,...u)}}export{b as BOARD_TOOL_NAME,S as DEFAULT_ASK_TIMEOUT_MS,O as boardToolProvider,I as boardToolSpec,_ as createAnswerBox,E as withBoardTool};
|
package/budget.js
CHANGED
|
@@ -1,116 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// There was no spend cap anywhere. A live monitor is declared class C and starts model turns
|
|
4
|
-
// for the length of a meeting; a spoken "keep an eye on X" arms that with nothing bounding
|
|
5
|
-
// it; `jobs.js` caps a per-day job COUNT, not spend. The class declarations on every intent
|
|
6
|
-
// and rule were made for exactly this, and nothing read them.
|
|
7
|
-
//
|
|
8
|
-
// A budget is a VALUE: declared on the thing that spends (a team, a schedule, a monitor),
|
|
9
|
-
// charged by whatever runs it, and carried on the run record so the ledger can say what a
|
|
10
|
-
// run cost in the same units it was capped in. Four dimensions, because the expensive thing
|
|
11
|
-
// differs by executor: tokens and calls for a model, wall time for an agent that thinks for
|
|
12
|
-
// minutes, cost when the gateway can report it. Any dimension may be absent; an absent one is
|
|
13
|
-
// not enforced. A budget with NO dimension is not a budget — `validateBudget` refuses it, and
|
|
14
|
-
// a team without one does not run (F8, O1).
|
|
15
|
-
//
|
|
16
|
-
// Pure. `now` is injected so a wall-time cap is testable.
|
|
17
|
-
|
|
18
|
-
export const BUDGET_DIMENSIONS = Object.freeze(['tokens', 'calls', 'ms', 'usd']);
|
|
19
|
-
|
|
20
|
-
export class BudgetError extends Error {
|
|
21
|
-
constructor(code, message) { super(message); this.name = 'BudgetError'; this.code = code; }
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** `{ ok, errors }` — a budget must cap at least one thing, and every cap must be a positive number. */
|
|
25
|
-
export function validateBudget(b) {
|
|
26
|
-
const errors = [];
|
|
27
|
-
if (!b || typeof b !== 'object') return { ok: false, errors: ['budget must be an object'] };
|
|
28
|
-
let any = false;
|
|
29
|
-
for (const k of BUDGET_DIMENSIONS) {
|
|
30
|
-
if (b[k] === undefined || b[k] === null) continue;
|
|
31
|
-
const n = Number(b[k]);
|
|
32
|
-
if (!Number.isFinite(n) || n <= 0) errors.push(`${k}: a positive number`);
|
|
33
|
-
else any = true;
|
|
34
|
-
}
|
|
35
|
-
for (const k of Object.keys(b)) if (!BUDGET_DIMENSIONS.includes(k)) errors.push(`${k}: not a budget dimension (${BUDGET_DIMENSIONS.join(', ')})`);
|
|
36
|
-
if (!any) errors.push('a budget must cap at least one of tokens, calls, ms, usd');
|
|
37
|
-
return { ok: errors.length === 0, errors };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Only the declared dimensions, as numbers. */
|
|
41
|
-
export function normalizeBudget(b) {
|
|
42
|
-
const out = {};
|
|
43
|
-
for (const k of BUDGET_DIMENSIONS) {
|
|
44
|
-
const n = Number(b?.[k]);
|
|
45
|
-
if (Number.isFinite(n) && n > 0) out[k] = n;
|
|
46
|
-
}
|
|
47
|
-
return out;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The usage a model call reports, in the shapes the providers use, as one record:
|
|
52
|
-
* `{ tokens, calls, usd }`. `ms` is measured by the budget itself.
|
|
53
|
-
*/
|
|
54
|
-
export function usageOf(u = {}) {
|
|
55
|
-
const tokens = Number(u.tokens ?? u.total_tokens ?? ((Number(u.input_tokens ?? u.prompt_tokens) || 0) + (Number(u.output_tokens ?? u.completion_tokens) || 0)));
|
|
56
|
-
return { tokens: Number.isFinite(tokens) ? tokens : 0, calls: Number(u.calls ?? 1) || 0, usd: Number(u.usd ?? u.cost) || 0 };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* A live budget for one run.
|
|
61
|
-
*
|
|
62
|
-
* charge(usage) add a model call's usage; returns what is left
|
|
63
|
-
* canAfford(estimate) false when an estimated call would cross a cap — ask BEFORE calling
|
|
64
|
-
* exhausted() the dimension that ran out, or null
|
|
65
|
-
* snapshot() { cap, spent, remaining, exhausted } for the run record and the meter
|
|
66
|
-
*/
|
|
67
|
-
export function createBudget(declared, { now = () => Date.now() } = {}) {
|
|
68
|
-
const v = validateBudget(declared);
|
|
69
|
-
if (!v.ok) throw new BudgetError('INVALID', v.errors.join('; '));
|
|
70
|
-
const cap = { ...normalizeBudget(declared) };
|
|
71
|
-
const startedAt = now();
|
|
72
|
-
const spent = { tokens: 0, calls: 0, usd: 0 };
|
|
73
|
-
const elapsed = () => now() - startedAt;
|
|
74
|
-
const remaining = () => {
|
|
75
|
-
const out = {};
|
|
76
|
-
for (const k of BUDGET_DIMENSIONS) {
|
|
77
|
-
if (cap[k] === undefined) continue;
|
|
78
|
-
out[k] = Math.max(0, cap[k] - (k === 'ms' ? elapsed() : spent[k]));
|
|
79
|
-
}
|
|
80
|
-
return out;
|
|
81
|
-
};
|
|
82
|
-
const exhausted = () => {
|
|
83
|
-
for (const k of BUDGET_DIMENSIONS) {
|
|
84
|
-
if (cap[k] === undefined) continue;
|
|
85
|
-
if ((k === 'ms' ? elapsed() : spent[k]) >= cap[k]) return k;
|
|
86
|
-
}
|
|
87
|
-
return null;
|
|
88
|
-
};
|
|
89
|
-
return {
|
|
90
|
-
cap,
|
|
91
|
-
charge(usage) {
|
|
92
|
-
const u = usageOf(usage);
|
|
93
|
-
spent.tokens += u.tokens; spent.calls += u.calls; spent.usd += u.usd;
|
|
94
|
-
return remaining();
|
|
95
|
-
},
|
|
96
|
-
canAfford(estimate = {}) {
|
|
97
|
-
if (exhausted()) return false;
|
|
98
|
-
const e = usageOf({ calls: 1, ...estimate });
|
|
99
|
-
for (const k of ['tokens', 'calls', 'usd']) {
|
|
100
|
-
if (cap[k] !== undefined && spent[k] + e[k] > cap[k]) return false;
|
|
101
|
-
}
|
|
102
|
-
return true;
|
|
103
|
-
},
|
|
104
|
-
remaining,
|
|
105
|
-
exhausted,
|
|
106
|
-
/** A person raised the cap mid-run (a budget ask answered "allow"): by a factor, once. */
|
|
107
|
-
raise(factor = 1.5) {
|
|
108
|
-
const f = Math.max(1, Number(factor) || 1);
|
|
109
|
-
for (const k of Object.keys(cap)) if (cap[k] !== undefined) cap[k] = Math.ceil(cap[k] * f);
|
|
110
|
-
return { ...cap };
|
|
111
|
-
},
|
|
112
|
-
snapshot() {
|
|
113
|
-
return { cap, spent: { ...spent, ms: elapsed() }, remaining: remaining(), exhausted: exhausted() };
|
|
114
|
-
},
|
|
115
|
-
};
|
|
116
|
-
}
|
|
1
|
+
const i=Object.freeze(["tokens","calls","ms","usd"]);class k extends Error{constructor(n,r){super(r),this.name="BudgetError",this.code=n}}function p(t){const n=[];if(!t||typeof t!="object")return{ok:!1,errors:["budget must be an object"]};let r=!1;for(const e of i){if(t[e]===void 0||t[e]===null)continue;const f=Number(t[e]);!Number.isFinite(f)||f<=0?n.push(`${e}: a positive number`):r=!0}for(const e of Object.keys(t))i.includes(e)||n.push(`${e}: not a budget dimension (${i.join(", ")})`);return r||n.push("a budget must cap at least one of tokens, calls, ms, usd"),{ok:n.length===0,errors:n}}function b(t){const n={};for(const r of i){const e=Number(t?.[r]);Number.isFinite(e)&&e>0&&(n[r]=e)}return n}function m(t={}){const n=Number(t.tokens??t.total_tokens??(Number(t.input_tokens??t.prompt_tokens)||0)+(Number(t.output_tokens??t.completion_tokens)||0));return{tokens:Number.isFinite(n)?n:0,calls:Number(t.calls??1)||0,usd:Number(t.usd??t.cost)||0}}function h(t,{now:n=()=>Date.now()}={}){const r=p(t);if(!r.ok)throw new k("INVALID",r.errors.join("; "));const e={...b(t)},f=n(),c={tokens:0,calls:0,usd:0},a=()=>n()-f,l=()=>{const s={};for(const o of i)e[o]!==void 0&&(s[o]=Math.max(0,e[o]-(o==="ms"?a():c[o])));return s},d=()=>{for(const s of i)if(e[s]!==void 0&&(s==="ms"?a():c[s])>=e[s])return s;return null};return{cap:e,charge(s){const o=m(s);return c.tokens+=o.tokens,c.calls+=o.calls,c.usd+=o.usd,l()},canAfford(s={}){if(d())return!1;const o=m({calls:1,...s});for(const u of["tokens","calls","usd"])if(e[u]!==void 0&&c[u]+o[u]>e[u])return!1;return!0},remaining:l,exhausted:d,raise(s=1.5){const o=Math.max(1,Number(s)||1);for(const u of Object.keys(e))e[u]!==void 0&&(e[u]=Math.ceil(e[u]*o));return{...e}},snapshot(){return{cap:e,spent:{...c,ms:a()},remaining:l(),exhausted:d()}}}}export{i as BUDGET_DIMENSIONS,k as BudgetError,h as createBudget,b as normalizeBudget,m as usageOf,p as validateBudget};
|