@chatpanel/events 0.91.0 → 0.92.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/index.js +1 -1
- package/package.json +1 -1
- package/team-board.js +1 -1
- package/team-record.js +82 -0
- package/team-run.js +22 -0
package/index.js
CHANGED
|
@@ -197,7 +197,7 @@ export { fixedPlan, parsePlan, plannerPrompt, waves, breakCycles, TEAM_PLAN_SCHE
|
|
|
197
197
|
export { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, FINDINGS_SCHEMA, FINDING_KINDS, THREAD_KINDS, THREAD_STATUSES, POST_KINDS, POST_STATUSES, ASK_TYPES, emptyBoardState, foldBoard, findingsOf } from './team-board.js';
|
|
198
198
|
export { boardToolProvider, boardToolSpec, createAnswerBox, withBoardTool, BOARD_TOOL_NAME, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
|
|
199
199
|
export { createRunCache, withRunCache } from './team-cache.js';
|
|
200
|
-
export { emptyRun, foldRun, runFromEvents, checkpointFrom, isResumable, LIVE_RUN_STATUSES, RESUMABLE_RUN_STATUSES } from './team-record.js';
|
|
200
|
+
export { emptyRun, foldRun, runFromEvents, checkpointFrom, isResumable, LIVE_RUN_STATUSES, RESUMABLE_RUN_STATUSES , spendOf, describeSpend, runState, priorWorkFor, STALLED_AFTER_MS } from './team-record.js';
|
|
201
201
|
export { validateProject, normalizeProject, defineProject, canTransition as canProjectTransition, blankProject, projectFromForm, emptyProjectRecord, foldProject, projectProgress, ProjectError, PROJECT_STATUSES, PROJECT_ID_RE } from './project.js';
|
|
202
202
|
// Job POSTINGS (F8 §12) — `jobs.js` is the scheduler and keeps `defineJob`; a posting is a JobPost here.
|
|
203
203
|
export { validateJob as validateJobPost, normalizeJob as normalizeJobPost, defineJob as defineJobPost, canTransition as canJobPostTransition, applyAll, jobToRole, readyJobs, blankJob as blankJobPost, jobFromForm as jobPostFromForm, JobError as JobPostError, JOB_STATUSES as JOB_POST_STATUSES, JOB_ID_RE as JOB_POST_ID_RE } from './job.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.92.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",
|
package/team-board.js
CHANGED
|
@@ -234,7 +234,7 @@ export function createBoard({ now = () => Date.now(), newId = null, state = null
|
|
|
234
234
|
function asFinding(st) {
|
|
235
235
|
return (p) => {
|
|
236
236
|
const t = st.threads.find((x) => x.id === p.threadId);
|
|
237
|
-
return { id: p.id, kind: p.finding?.kind || 'claim', text: p.text, refs: p.refs || [], confidence: p.finding?.confidence ?? null, role: p.by, taskId: t?.taskId || null, at: p.at, status: p.status };
|
|
237
|
+
return { id: p.id, kind: p.finding?.kind || 'claim', text: p.text, refs: p.refs || [], confidence: p.finding?.confidence ?? null, role: p.by, taskId: t?.taskId || null, at: p.at, status: p.status, ...(p.finding?.prior ? { prior: true } : {}) };
|
|
238
238
|
};
|
|
239
239
|
}
|
|
240
240
|
|
package/team-record.js
CHANGED
|
@@ -122,3 +122,85 @@ export function isResumable(run) {
|
|
|
122
122
|
if (RESUMABLE_RUN_STATUSES.includes(run.status)) return true;
|
|
123
123
|
return !!run.stale && LIVE_RUN_STATUSES.includes(run.status); // its client went away mid-run
|
|
124
124
|
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The run's spend against its cap, as a board shows it: the record's last `run.usage` (or
|
|
128
|
+
* nothing spent yet) with `ms` measured LIVE for a run still going — the stored figure is as
|
|
129
|
+
* of the last task's end, and a board read "0 s" through a ten-minute research task.
|
|
130
|
+
*/
|
|
131
|
+
export function spendOf(run, { now = Date.now() } = {}) {
|
|
132
|
+
const cap = run?.usage?.cap || run?.budget || null;
|
|
133
|
+
if (!cap || !Object.keys(cap).length) return null;
|
|
134
|
+
const spent = { tokens: 0, calls: 0, usd: 0, ms: 0, ...(run?.usage?.spent || {}) };
|
|
135
|
+
// The clock runs while the client is writing, and STOPS where it stopped: a run whose
|
|
136
|
+
// record says `running` because its client died (or its end never landed) is not still
|
|
137
|
+
// spending — it read "18m13s / 5m00s" and counting for a member that had finished.
|
|
138
|
+
const st = runState(run, { now });
|
|
139
|
+
if (LIVE_RUN_STATUSES.includes(run?.status) && run?.startedAt) spent.ms = Math.max(spent.ms || 0, (st.key === 'stalled' ? Number(run.lastEventAt) || now : now) - run.startedAt);
|
|
140
|
+
const pct = cap.tokens ? Math.min(100, Math.round(((spent.tokens || 0) / cap.tokens) * 100)) : cap.ms ? Math.min(100, Math.round(((spent.ms || 0) / cap.ms) * 100)) : null;
|
|
141
|
+
const over = BUDGET_KEYS.filter((k) => cap[k] && (spent[k] || 0) >= cap[k]);
|
|
142
|
+
return { cap, spent, pct, exhausted: run?.usage?.exhausted || over[0] || null, over };
|
|
143
|
+
}
|
|
144
|
+
const BUDGET_KEYS = ['tokens', 'calls', 'usd', 'ms'];
|
|
145
|
+
|
|
146
|
+
export const STALLED_AFTER_MS = 2 * 60_000;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* What a run IS right now, for a person: the record's status read against the clock. A
|
|
150
|
+
* record that says `running` with no event for minutes is STALLED — its client stopped
|
|
151
|
+
* writing (died, or its end never landed) — not running. `{ key, label, tone, detail }`;
|
|
152
|
+
* `tone` is the chip: on · warn · ok · err · muted.
|
|
153
|
+
*/
|
|
154
|
+
export function runState(run, { now = Date.now(), stalledAfterMs = STALLED_AFTER_MS } = {}) {
|
|
155
|
+
const s = String(run?.status || 'planning');
|
|
156
|
+
const quiet = Number.isFinite(run?.quietMs) ? run.quietMs : Math.max(0, now - (Number(run?.lastEventAt) || now));
|
|
157
|
+
const agoText = (ms) => { const sec = Math.round(ms / 1000); return sec < 60 ? `${sec} s` : sec < 3600 ? `${Math.round(sec / 60)} min` : `${Math.round(sec / 3600)} h`; };
|
|
158
|
+
if (s === 'waiting') return { key: 'waiting', label: 'waiting on you', tone: 'warn', detail: '' };
|
|
159
|
+
if (LIVE_RUN_STATUSES.includes(s)) {
|
|
160
|
+
if (run?.stale === true || quiet > stalledAfterMs) return { key: 'stalled', label: 'stalled', tone: 'err', detail: `no events for ${agoText(quiet)} — its client stopped writing; Resume here picks it up from the record` };
|
|
161
|
+
return { key: 'running', label: s === 'merging' ? 'merging' : s === 'planning' ? 'planning' : 'running', tone: 'on', detail: `last event ${agoText(quiet)} ago` };
|
|
162
|
+
}
|
|
163
|
+
if (s === 'completed') return { key: 'done', label: 'done', tone: 'ok', detail: '' };
|
|
164
|
+
if (s === 'partial') return { key: 'partial', label: 'done with failures', tone: 'warn', detail: 'a member failed; the rest merged' };
|
|
165
|
+
if (s === 'answered') return { key: 'answered', label: 'answered — resume to continue', tone: 'warn', detail: '' };
|
|
166
|
+
if (s === 'over-budget') return { key: 'over-budget', label: 'over budget', tone: 'err', detail: 'stopped with what it had' };
|
|
167
|
+
if (s === 'stopped') return { key: 'stopped', label: 'stopped', tone: 'muted', detail: '' };
|
|
168
|
+
if (s === 'failed') return { key: 'failed', label: 'failed', tone: 'err', detail: '' };
|
|
169
|
+
return { key: s, label: s, tone: 'muted', detail: '' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Earlier runs whose work a new run should read before repeating it (§12.2.6, the librarian's
|
|
174
|
+
* first step): the same team (or any, when `team` is empty), a request that says the same
|
|
175
|
+
* thing (word overlap ≥ `minSimilarity`), findings on the record, not the run itself, newest
|
|
176
|
+
* first. `runs` is the store's list (`findings` is a count there). Returns
|
|
177
|
+
* `[{ id, at, similarity, findings }]`; the host fetches the record for the findings.
|
|
178
|
+
*/
|
|
179
|
+
export function priorWorkFor(runs, { team = '', request = '', excludeId = null, minSimilarity = 0.6, maxAgeMs = 7 * 24 * 3600_000, now = Date.now(), limit = 3 } = {}) {
|
|
180
|
+
const words = (t) => new Set(String(t || '').toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter((w) => w.length > 2));
|
|
181
|
+
const want = words(request);
|
|
182
|
+
if (!want.size) return [];
|
|
183
|
+
const sim = (t) => { const have = words(t); if (!have.size) return 0; let hit = 0; for (const w of want) if (have.has(w)) hit += 1; return hit / Math.max(want.size, have.size); };
|
|
184
|
+
return (runs || [])
|
|
185
|
+
.filter((r) => r && r.id && r.id !== excludeId && (!team || r.team === team) && !LIVE_RUN_STATUSES.includes(r.status) && (Number(r.findings) || (Array.isArray(r.board) ? r.board.length : 0)) > 0 && now - (Number(r.createdAt) || 0) <= maxAgeMs)
|
|
186
|
+
.map((r) => ({ id: r.id, at: r.createdAt, similarity: Math.round(sim(r.request) * 100) / 100, findings: Number(r.findings) || (Array.isArray(r.board) ? r.board.length : 0), status: r.status }))
|
|
187
|
+
.filter((r) => r.similarity >= minSimilarity)
|
|
188
|
+
.sort((a, b) => b.similarity - a.similarity || b.at - a.at)
|
|
189
|
+
.slice(0, limit);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const secs = (ms) => { const s = Math.max(0, Math.round((Number(ms) || 0) / 1000)); return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`; };
|
|
193
|
+
const num = (n) => (Number(n) || 0).toLocaleString('en-US');
|
|
194
|
+
|
|
195
|
+
/** One line: `1,240 / 40,000 tokens · 3 / 20 calls · 2m10s / 15m00s`. Only the capped dimensions. */
|
|
196
|
+
export function describeSpend(spend) {
|
|
197
|
+
if (!spend?.cap) return '';
|
|
198
|
+
const { cap, spent } = spend;
|
|
199
|
+
const over = (k) => ((spend.over || []).includes(k) ? ' (over)' : '');
|
|
200
|
+
return [
|
|
201
|
+
cap.tokens ? `${num(spent.tokens)} / ${num(cap.tokens)} tokens${over('tokens')}` : '',
|
|
202
|
+
cap.calls ? `${num(spent.calls)} / ${num(cap.calls)} calls${over('calls')}` : '',
|
|
203
|
+
cap.usd ? `$${(Number(spent.usd) || 0).toFixed(2)} / $${Number(cap.usd).toFixed(2)}${over('usd')}` : '',
|
|
204
|
+
cap.ms ? `${secs(spent.ms)} / ${secs(cap.ms)}${over('ms')}` : '',
|
|
205
|
+
].filter(Boolean).join(' · ');
|
|
206
|
+
}
|
package/team-run.js
CHANGED
|
@@ -127,6 +127,10 @@ export async function runTeam({
|
|
|
127
127
|
// model from the board, on either client; the runner continues the task's transcript there.
|
|
128
128
|
control = null,
|
|
129
129
|
recruit = null, projectId = null,
|
|
130
|
+
// Earlier runs' findings for the same request (team-record.js priorWorkFor, fetched by the
|
|
131
|
+
// host): `[{ runId, at, request?, findings: [...] }]`. Posted as a "Prior work" thread every
|
|
132
|
+
// member reads before repeating a lookup — the librarian's first step (§12.2.6).
|
|
133
|
+
prior = null,
|
|
130
134
|
} = {}) {
|
|
131
135
|
if (typeof callModel !== 'function') throw new TeamRunError('BAD_RUN', 'callModel required');
|
|
132
136
|
const t = normalizeTeam(team); // throws on a team without a budget — O1
|
|
@@ -197,6 +201,24 @@ export async function runTeam({
|
|
|
197
201
|
say('plan.ready', { by: planBy, tasks: tasks.map((x) => ({ id: x.id, role: x.role, title: x.title, dependsOn: x.dependsOn, ...(x.parent ? { parent: x.parent, requestedBy: x.requestedBy || null } : {}), ...(x.grants ? { grants: x.grants, why: x.why || '' } : {}) })) });
|
|
198
202
|
// A thread per task, before anything runs: a member's findings and replies have a home.
|
|
199
203
|
for (const task of tasks) board.openThread({ taskId: task.id, kind: 'task', title: task.title || task.id, by: task.requestedBy || RUNNER, ...(task.parent ? { parent: task.parent } : {}), ...(task.role && task.parent ? { holder: task.role } : {}) });
|
|
204
|
+
// PRIOR WORK: what an earlier run already found for this request is on the board before
|
|
205
|
+
// anyone starts — a discussion thread per run, its findings as posts with their refs — so a
|
|
206
|
+
// second run of the same question reads instead of re-searching. Only findings a person did
|
|
207
|
+
// not reject; at most 40 per run.
|
|
208
|
+
if (!resume && Array.isArray(prior)) {
|
|
209
|
+
for (const pr of prior.filter((x) => x && Array.isArray(x.findings) && x.findings.length)) {
|
|
210
|
+
const age = Number.isFinite(pr.at) ? Math.max(0, Math.round((now() - pr.at) / 60000)) : null;
|
|
211
|
+
const th = board.openThread({ kind: 'discussion', title: `Prior work — run ${pr.runId}${age != null ? ` (${age < 60 ? `${age} min` : `${Math.round(age / 60)} h`} ago)` : ''}`, by: RUNNER });
|
|
212
|
+
board.post({ threadId: th.id, by: RUNNER, kind: 'note', text: `An earlier run answered ${pr.request ? `"${String(pr.request).slice(0, 200)}"` : 'the same request'}. Its findings follow — read them, verify what is stale, and do not repeat lookups already made.`, refs: [`run:${pr.runId}`] });
|
|
213
|
+
let n = 0;
|
|
214
|
+
for (const f of pr.findings) {
|
|
215
|
+
if (!f || !f.text || f.status === 'rejected' || n >= 40) continue;
|
|
216
|
+
board.post({ threadId: th.id, by: f.role || RUNNER, kind: 'finding', text: String(f.text).slice(0, 2000), refs: [...(Array.isArray(f.refs) ? f.refs : []), `run:${pr.runId}`].slice(0, 8), finding: { kind: f.kind || 'claim', confidence: f.confidence ?? null, prior: true } });
|
|
217
|
+
n += 1;
|
|
218
|
+
}
|
|
219
|
+
say('run.prior', { from: pr.runId, threadId: th.id, findings: n });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
200
222
|
// The planner's TOOL PROPOSAL (§15.2): which tools each task will need and why, as a
|
|
201
223
|
// proposal thread a person reads — and what the nudge below holds the member to.
|
|
202
224
|
if (!resume && tasks.some((x) => x.grants?.length)) {
|