@chatpanel/events 0.91.1 → 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 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 , spendOf, describeSpend } 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.91.1",
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
@@ -132,9 +132,61 @@ export function spendOf(run, { now = Date.now() } = {}) {
132
132
  const cap = run?.usage?.cap || run?.budget || null;
133
133
  if (!cap || !Object.keys(cap).length) return null;
134
134
  const spent = { tokens: 0, calls: 0, usd: 0, ms: 0, ...(run?.usage?.spent || {}) };
135
- if (LIVE_RUN_STATUSES.includes(run?.status) && run?.startedAt) spent.ms = Math.max(spent.ms || 0, now - run.startedAt);
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);
136
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;
137
- return { cap, spent, pct, exhausted: run?.usage?.exhausted || 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);
138
190
  }
139
191
 
140
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`; };
@@ -144,10 +196,11 @@ const num = (n) => (Number(n) || 0).toLocaleString('en-US');
144
196
  export function describeSpend(spend) {
145
197
  if (!spend?.cap) return '';
146
198
  const { cap, spent } = spend;
199
+ const over = (k) => ((spend.over || []).includes(k) ? ' (over)' : '');
147
200
  return [
148
- cap.tokens ? `${num(spent.tokens)} / ${num(cap.tokens)} tokens` : '',
149
- cap.calls ? `${num(spent.calls)} / ${num(cap.calls)} calls` : '',
150
- cap.usd ? `$${(Number(spent.usd) || 0).toFixed(2)} / $${Number(cap.usd).toFixed(2)}` : '',
151
- cap.ms ? `${secs(spent.ms)} / ${secs(cap.ms)}` : '',
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')}` : '',
152
205
  ].filter(Boolean).join(' · ');
153
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)) {