@chatpanel/events 0.102.0 → 0.103.1

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.
Files changed (138) hide show
  1. package/adapters.js +1 -83
  2. package/adaptive-tool-policy.js +1 -45
  3. package/agent.js +2 -255
  4. package/attribution.js +1 -132
  5. package/backup-envelope.js +1 -221
  6. package/board-tool.js +2 -165
  7. package/budget.js +1 -116
  8. package/capability.js +1 -126
  9. package/citations.js +5 -127
  10. package/client-prefs.js +1 -148
  11. package/context-attachments.js +8 -144
  12. package/cowriter-router.js +1 -83
  13. package/cowriter-writer.js +19 -193
  14. package/cowriter.js +1 -190
  15. package/curate.js +3 -509
  16. package/distance.js +1 -124
  17. package/engine.js +1 -131
  18. package/entitlement.js +1 -332
  19. package/entity.js +1 -354
  20. package/event.js +1 -175
  21. package/extraction.js +4 -293
  22. package/failover.js +1 -93
  23. package/find-tool.js +1 -66
  24. package/flowchart.js +2 -644
  25. package/gate.js +1 -74
  26. package/harness.js +4 -101
  27. package/index.js +1 -345
  28. package/invariants.js +1 -174
  29. package/job.js +3 -147
  30. package/kernel.js +1 -255
  31. package/knowledge-derive.js +1 -267
  32. package/knowledge.js +3 -230
  33. package/library.js +11 -280
  34. package/loop.js +1 -132
  35. package/manifest.js +1 -107
  36. package/markdown-authoring.js +11 -288
  37. package/markdown-render.js +0 -0
  38. package/mcp-client.js +8 -358
  39. package/mcp-dispatch.js +1 -52
  40. package/mcp-errors.js +1 -106
  41. package/mcp-manager.js +1 -127
  42. package/media-transcript.js +8 -531
  43. package/meeting-analyzers.js +1 -83
  44. package/meeting-insights.js +7 -164
  45. package/meeting-shape.js +1 -282
  46. package/meeting-text.js +5 -94
  47. package/memory.js +2 -638
  48. package/model-candidates.js +1 -358
  49. package/model-health.js +1 -182
  50. package/model-ledger.js +1 -228
  51. package/model-picker.js +1 -188
  52. package/note-actions.js +15 -246
  53. package/note-graph.js +1 -158
  54. package/note-links.js +1 -68
  55. package/note-mentions.js +8 -114
  56. package/note-plan.js +9 -107
  57. package/note-research.js +1 -135
  58. package/observability.js +1 -119
  59. package/omni.js +1 -125
  60. package/order.js +1 -78
  61. package/package.json +1 -4
  62. package/pdf-layout.js +7 -253
  63. package/project-run.js +20 -444
  64. package/project.js +1 -170
  65. package/promotion.js +1 -171
  66. package/queue.js +1 -98
  67. package/reach.js +1 -23
  68. package/recipe-tool.js +2 -161
  69. package/recipe.js +1 -264
  70. package/record-list.js +2 -110
  71. package/recruit.js +3 -456
  72. package/redaction-tokens.js +1 -61
  73. package/ref.js +1 -55
  74. package/registry.js +1 -240
  75. package/route-graph.js +1 -115
  76. package/route-strategies.js +2 -232
  77. package/router.js +2 -880
  78. package/rrf.js +1 -89
  79. package/rules.js +1 -142
  80. package/schedule.js +1 -663
  81. package/scm-connection.js +1 -180
  82. package/scopes.js +1 -12
  83. package/scorecard.js +1 -335
  84. package/search-engines.js +1 -81
  85. package/skill-manifest.js +1 -330
  86. package/skill-scan.js +3 -209
  87. package/skill-sources.js +1 -162
  88. package/skill-vars.js +2 -252
  89. package/slash-commands.js +1 -158
  90. package/source-gate.js +1 -92
  91. package/sources-retrieval.js +5 -186
  92. package/sources.js +1 -273
  93. package/store.js +1 -171
  94. package/structured.js +4 -901
  95. package/subject-kinds.js +1 -5
  96. package/subject-name.js +1 -96
  97. package/sync-plan.js +1 -213
  98. package/synthesis.js +3 -123
  99. package/tags.js +1 -203
  100. package/team-board.js +6 -337
  101. package/team-cache.js +2 -63
  102. package/team-observe.js +1 -118
  103. package/team-org.js +1 -442
  104. package/team-plan.js +4 -131
  105. package/team-record.js +1 -225
  106. package/team-run.js +9 -652
  107. package/team-subtask.js +2 -191
  108. package/team-task.js +2 -124
  109. package/team-tool.js +2 -196
  110. package/team-trail.js +2 -73
  111. package/team-worklog.js +2 -149
  112. package/team.js +1 -302
  113. package/text-search.js +1 -155
  114. package/theme.js +17 -154
  115. package/titles.js +4 -285
  116. package/tool-discovery.js +1 -87
  117. package/tool-dispatch.js +5 -230
  118. package/tool-groups.js +1 -81
  119. package/tool-hints.js +10 -185
  120. package/tool-loop-guard.js +4 -182
  121. package/tool-need.js +2 -124
  122. package/tool-result.js +2 -438
  123. package/tool-round.js +0 -0
  124. package/tool-schema.js +1 -155
  125. package/tool-traits.js +1 -182
  126. package/toolset.js +2 -88
  127. package/trajectory.js +1 -530
  128. package/turn-loop.js +4 -440
  129. package/upcast.js +1 -37
  130. package/vault.js +2 -250
  131. package/view.js +1 -88
  132. package/voice-intents.js +2 -1488
  133. package/voice-speaker.js +1 -98
  134. package/weather-tool.js +1 -65
  135. package/weather.js +2 -211
  136. package/web-search-tool.js +14 -102
  137. package/web-search.js +1 -160
  138. package/widget.js +1 -153
package/team-board.js CHANGED
@@ -1,337 +1,6 @@
1
- // The board where a team's members meet: a message board, not a log.
2
- //
3
- // One board per run. A THREAD per task (and one per ask, discussion or proposal); POSTS in
4
- // threads a member's findings, its notes, a question, a person's answer, a draft, a
5
- // decision — and REPLIES hanging off posts. Members do not read each other's transcripts:
6
- // a task ends with FINDINGS (claims with the refs they came from, I-K1), those become posts
7
- // in its thread, and a later task reads the threads it depends on, threaded and sized like a
8
- // shielded tool result. A member that disagrees replies where it disagrees; a member that
9
- // is stuck ASKS, and its task waits for a person — on either client — to answer. A person
10
- // posting is a member posting, and a person's decision on any post wins.
11
- //
12
- // The board is the run's record: durable (every change is an event the run store folds with
13
- // `foldBoard`), attributable per member, and — after a run — what can become draft briefs
14
- // and be promoted on convergence (W7), rather than evaporating with the run.
15
- //
16
- // Findings are parsed GENEROUSLY from a model's answer through the structured layer, and a
17
- // task whose answer cannot be read as findings is not lost: its whole answer becomes one
18
- // `draft` finding. A member that only wrote prose still contributed.
19
-
20
- import { defineSchema, describeSchema, coerce } from './structured.js';
21
-
22
- export const FINDING_KINDS = Object.freeze(['claim', 'draft', 'link', 'question', 'answer']);
23
- export const MAX_FINDINGS_PER_TASK = 40;
24
- export const BOARD_TEXT_MAX = 12_000;
25
-
26
- export const FINDINGS_SCHEMA = defineSchema({
27
- name: 'findings',
28
- purpose: 'what this task established, each item on its own with where it came from',
29
- fields: {
30
- findings: {
31
- type: 'object[]', required: true, maxItems: MAX_FINDINGS_PER_TASK,
32
- describe: 'one entry per fact, draft, link or open question — never a paragraph of several',
33
- fields: {
34
- kind: { type: 'enum', values: FINDING_KINDS, default: 'claim' },
35
- text: { type: 'string', required: true, max: 2000 },
36
- refs: { type: 'string[]', maxItems: 8, describe: 'record ids or URLs this rests on, when any' },
37
- confidence: { type: 'number', describe: '0–1, how sure' },
38
- },
39
- },
40
- },
41
- nothing: { findings: [] },
42
- });
43
-
44
- /** The instruction appended to every task so the answer can be read as findings. */
45
- export function findingsInstruction() {
46
- return `When you are done, end your answer with your findings in this shape:\n${describeSchema(FINDINGS_SCHEMA)}`;
47
- }
48
-
49
- /**
50
- * Read a task's answer into findings. The JSON block, when present; otherwise the whole
51
- * answer as one draft — a member that only wrote prose still contributed.
52
- */
53
- export function parseFindings(text, { role, taskId } = {}) {
54
- const raw = String(text || '').trim();
55
- if (!raw) return [];
56
- const got = coerce(raw, FINDINGS_SCHEMA);
57
- const list = Array.isArray(got?.value?.findings) ? got.value.findings.filter((f) => f && String(f.text || '').trim()) : [];
58
- const stamp = (f, i) => ({
59
- id: `${taskId || 't'}:${i + 1}`,
60
- kind: FINDING_KINDS.includes(f.kind) ? f.kind : 'claim',
61
- text: String(f.text).trim().slice(0, 2000),
62
- refs: Array.isArray(f.refs) ? f.refs.map(String).filter(Boolean).slice(0, 8) : [],
63
- // Absent or zero reads as "not stated": a model that gives no number is not 0% sure.
64
- confidence: Number.isFinite(Number(f.confidence)) && Number(f.confidence) > 0 ? Math.min(1, Number(f.confidence)) : null,
65
- role: role || null,
66
- taskId: taskId || null,
67
- });
68
- if (list.length) return list.slice(0, MAX_FINDINGS_PER_TASK).map(stamp);
69
- // No JSON block: the prose is the finding. Strip a fenced JSON tail that failed to parse.
70
- const prose = raw.replace(/```json[\s\S]*$/i, '').trim() || raw;
71
- return [stamp({ kind: 'draft', text: prose.slice(0, 2000), refs: [] }, 0)];
72
- }
73
-
74
- // ── threads, posts, asks ────────────────────────────────────────────────────────────────
75
-
76
- export const THREAD_KINDS = Object.freeze(['task', 'ask', 'discussion', 'proposal']);
77
- // `failed`: the task behind the thread ended without an answer (every model on the roster
78
- // tried, or a hard error) — not `resolved`, which read as "done" on the board.
79
- export const THREAD_STATUSES = Object.freeze(['open', 'waiting', 'resolved', 'failed', 'approved', 'rejected']);
80
- // `request`: a member asks for a piece of its task to be done by someone else — the runner
81
- // turns it into a sub-task with its own thread (team-subtask.js).
82
- export const POST_KINDS = Object.freeze(['finding', 'note', 'question', 'answer', 'draft', 'decision', 'request']);
83
- export const POST_STATUSES = Object.freeze(['open', 'proposed', 'approved', 'rejected']);
84
- export const ASK_TYPES = Object.freeze(['info', 'budget', 'permission', 'direction']);
85
- export const RUNNER = 'runner';
86
- export const PERSON = 'person';
87
- export const MAX_POST_TEXT = 4000;
88
-
89
- const clip = (t, n) => String(t || '').trim().slice(0, n);
90
- const refsOf = (r) => (Array.isArray(r) ? r.map(String).filter(Boolean).slice(0, 8) : []);
91
-
92
- /** The empty folded state — what a run record holds, what `foldBoard` grows. */
93
- export function emptyBoardState() { return { threads: [], posts: [] }; }
94
-
95
- /**
96
- * Fold one board event into a state (pure; the gateway's run store and both clients use it).
97
- * Ids dedupe: an answer posted through the gateway and echoed by the running client's runner
98
- * arrives twice with one id and lands once.
99
- */
100
- export function foldBoard(state, ev) {
101
- const s = state && Array.isArray(state.threads) && Array.isArray(state.posts) ? state : emptyBoardState();
102
- const type = String(ev?.type || '');
103
- const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : ev || {};
104
- if (type === 'board.thread' && p.thread?.id) {
105
- if (!s.threads.some((t) => t.id === p.thread.id)) s.threads.push({ ...p.thread });
106
- } else if (type === 'board.post' && p.post?.id) {
107
- if (Array.isArray(s.removed) && s.removed.includes(p.post.threadId)) return s;
108
- if (!s.posts.some((x) => x.id === p.post.id)) s.posts.push({ ...p.post });
109
- const t = s.threads.find((x) => x.id === p.post.threadId);
110
- if (t) { t.lastAt = p.post.at; t.lastBy = p.post.by; t.posts = (t.posts || 0) + 1; }
111
- } else if (type === 'board.decision' && p.postId) {
112
- const x = s.posts.find((q) => q.id === p.postId);
113
- if (x) { x.status = p.status; x.decidedBy = p.by; x.decidedAt = p.at ?? ev?.at; }
114
- } else if (type === 'board.thread-status' && p.threadId) {
115
- const t = s.threads.find((x) => x.id === p.threadId);
116
- if (t) { t.status = p.status; if (p.status !== 'waiting') t.waitingOn = null; if (p.holder) t.holder = p.holder; }
117
- } else if (type === 'board.thread-removed' && p.threadId) {
118
- // A person took the thread off the board: it and its posts go, and its id is kept so a
119
- // post a member still makes in it (its runner did not see the removal) lands nowhere.
120
- s.threads = s.threads.filter((x) => x.id !== p.threadId);
121
- s.posts = s.posts.filter((x) => x.threadId !== p.threadId);
122
- if (!Array.isArray(s.removed)) s.removed = [];
123
- if (!s.removed.includes(p.threadId)) s.removed.push(p.threadId);
124
- }
125
- return s;
126
- }
127
-
128
- /**
129
- * The live board a run works on. `state` seeds it (a resume); `onEvent` receives every
130
- * change as the event the run store folds. The legacy findings API (`add`, `all`, `byTask`)
131
- * stays: a finding is a post of kind `finding` in its task's thread.
132
- */
133
- export function createBoard({ now = () => Date.now(), newId = null, state = null, onEvent = null } = {}) {
134
- const st = state && Array.isArray(state.threads) ? { threads: state.threads.map((t) => ({ ...t })), posts: (state.posts || []).map((x) => ({ ...x })), ...(Array.isArray(state.removed) && state.removed.length ? { removed: [...state.removed] } : {}) } : emptyBoardState();
135
- const listeners = new Set();
136
- let seq = st.posts.length + st.threads.length;
137
- const mk = (prefix) => (newId ? newId(prefix) : `${prefix}_${(++seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`);
138
- const say = (type, payload) => { const ev = { type, at: now(), ...payload }; if (onEvent) onEvent(type, ev); return ev; };
139
- const threadOf = (id) => st.threads.find((t) => t.id === id);
140
- const postOf = (id) => st.posts.find((x) => x.id === id);
141
- const threadForTask = (taskId) => st.threads.find((t) => t.kind === 'task' && t.taskId === taskId);
142
-
143
- const api = {
144
- /** Open a thread. A task's thread is opened once; asking for it again returns it. */
145
- openThread({ id = null, taskId = null, kind = 'discussion', title = '', by = RUNNER, status = 'open', ask = null, parent = null, holder = null } = {}) {
146
- if (kind === 'task' && taskId) { const had = threadForTask(taskId); if (had) return had; }
147
- // `parent`: the task this one was requested from (a sub-task's thread hangs under its
148
- // parent's on the board); `holder`: the member that took it.
149
- const thread = { id: id || mk('th'), kind: THREAD_KINDS.includes(kind) ? kind : 'discussion', taskId, title: clip(title, 200), by, status: THREAD_STATUSES.includes(status) ? status : 'open', at: now(), posts: 0, ...(ask ? { ask } : {}), ...(parent ? { parent } : {}), ...(holder ? { holder } : {}) };
150
- st.threads.push(thread);
151
- say('board.thread', { thread: { ...thread } });
152
- return thread;
153
- },
154
- /** A post in a thread; `replyTo` makes it a reply. */
155
- /** `proposal` rides on a draft a person decides on — `{ kind: 'agent', agent, jobId }` (§15.2): the card the host creates when the post is approved. */
156
- post({ id = null, threadId, by, kind = 'note', text = '', refs = [], replyTo = null, status = 'open', finding = null, ask = null, proposal = null } = {}) {
157
- const t = threadOf(threadId);
158
- if (!t) throw new Error(`no thread ${threadId}`);
159
- if (id && postOf(id)) return postOf(id);
160
- const post = {
161
- id: id || mk('p'), threadId, by: String(by || RUNNER), kind: POST_KINDS.includes(kind) ? kind : 'note',
162
- text: clip(text, MAX_POST_TEXT), refs: refsOf(refs), replyTo, status: POST_STATUSES.includes(status) ? status : 'open', at: now(),
163
- ...(finding ? { finding } : {}), ...(ask ? { ask } : {}), ...(proposal && typeof proposal === 'object' ? { proposal } : {}),
164
- };
165
- st.posts.push(post);
166
- t.lastAt = post.at; t.lastBy = post.by; t.posts = (t.posts || 0) + 1;
167
- say('board.post', { post: { ...post } });
168
- for (const l of listeners) l(post);
169
- return post;
170
- },
171
- /** A reply hangs off a post and lives in that post's thread. */
172
- reply({ postId, ...args }) {
173
- const target = postOf(postId || args.replyTo);
174
- if (!target) throw new Error(`no post ${postId}`);
175
- return api.post({ ...args, threadId: target.threadId, replyTo: target.id });
176
- },
177
- /** A person's (or the judge's) decision on a post. */
178
- decide(postId, status, by = PERSON) {
179
- const x = postOf(postId);
180
- if (!x || !['approved', 'rejected', 'proposed', 'open'].includes(status)) return null;
181
- x.status = status; x.decidedBy = by; x.decidedAt = now();
182
- say('board.decision', { postId, status, by, at: x.decidedAt });
183
- return x;
184
- },
185
- setThreadStatus(threadId, status, extra = {}) {
186
- const t = threadOf(threadId);
187
- if (!t || !THREAD_STATUSES.includes(status)) return null;
188
- t.status = status; if (status !== 'waiting') t.waitingOn = null; Object.assign(t, extra);
189
- say('board.thread-status', { threadId, status, ...extra });
190
- return t;
191
- },
192
- /**
193
- * A person takes a thread off the board (from either client — the store appends the
194
- * event, the running client's tail applies it here). Its posts go with it; a member's
195
- * later post in it lands nowhere. A waiting ask is not removed: answer it or stop the run.
196
- */
197
- removeThread(threadId, { by = PERSON } = {}) {
198
- const t = threadOf(threadId);
199
- if (!t || (t.kind === 'ask' && t.status === 'waiting')) return null;
200
- st.threads = st.threads.filter((x) => x.id !== threadId);
201
- st.posts = st.posts.filter((x) => x.threadId !== threadId);
202
- (st.removed ||= []).push(threadId);
203
- say('board.thread-removed', { threadId, by });
204
- return t;
205
- },
206
- /**
207
- * A member is stuck: open an ask thread (status `waiting`) with the question as its
208
- * first post. The runner waits on it; a person answers from either client.
209
- */
210
- ask({ taskId = null, by, type = 'info', text, options = [], timeoutMs = 0, title = '' } = {}) {
211
- const ask = { type: ASK_TYPES.includes(type) ? type : 'info', options: (Array.isArray(options) ? options : []).map(String).slice(0, 6), timeoutMs };
212
- const thread = api.openThread({ taskId, kind: 'ask', title: title || clip(text, 120), by, status: 'waiting', ask });
213
- thread.waitingOn = PERSON;
214
- const post = api.post({ threadId: thread.id, by, kind: 'question', text, ask });
215
- return { thread, post };
216
- },
217
- /** The answer to an ask — from a person, on any client. Resolves the thread. */
218
- answer(threadId, { id = null, text, by = PERSON } = {}) {
219
- const t = threadOf(threadId);
220
- if (!t) return null;
221
- const post = api.post({ id, threadId, by, kind: 'answer', text });
222
- api.setThreadStatus(threadId, 'resolved', { answeredAt: post.at });
223
- return post;
224
- },
225
- thread: threadOf,
226
- threadForTask,
227
- threads: () => st.threads.map((t) => ({ ...t })),
228
- posts: (threadId = null) => st.posts.filter((x) => !threadId || x.threadId === threadId).map((x) => ({ ...x })),
229
- postById: postOf,
230
- /** Every ask still waiting — what a client pins at the top. */
231
- waiting: () => st.threads.filter((t) => t.kind === 'ask' && t.status === 'waiting').map((t) => ({ ...t })),
232
- /** The whole board, for the run record and for a resume. */
233
- state: () => ({ threads: st.threads.map((t) => ({ ...t })), posts: st.posts.map((x) => ({ ...x })), ...(st.removed?.length ? { removed: [...st.removed] } : {}) }),
234
-
235
- // ── findings, as before: a finding is a post in its task's thread ──
236
- add(list, { by = null } = {}) {
237
- let n = 0;
238
- for (const f of Array.isArray(list) ? list : [list]) {
239
- if (!f || !f.text) continue;
240
- const t = f.taskId ? (threadForTask(f.taskId) || api.openThread({ taskId: f.taskId, kind: 'task', title: f.taskId })) : (st.threads.find((x) => x.kind === 'discussion' && x.title === 'findings') || api.openThread({ kind: 'discussion', title: 'findings' }));
241
- api.post({ id: f.id, threadId: t.id, by: by || f.role || RUNNER, kind: 'finding', text: f.text, refs: f.refs, finding: { kind: f.kind || 'claim', confidence: f.confidence ?? null } });
242
- n += 1;
243
- }
244
- return n;
245
- },
246
- all: () => st.posts.filter((x) => x.kind === 'finding').map(asFinding(st)),
247
- byTask: (taskId) => api.all().filter((f) => f.taskId === taskId),
248
- byRole: (role) => api.all().filter((f) => f.role === role),
249
- onFinding(fn) { const l = (p) => { if (p.kind === 'finding') fn(asFinding(st)(p)); }; listeners.add(l); return () => listeners.delete(l); },
250
- get size() { return st.posts.filter((x) => x.kind === 'finding').length; },
251
- };
252
- return api;
253
- }
254
-
255
- /** A finding post in the legacy finding shape (what merge, briefs and lanes read). */
256
- function asFinding(st) {
257
- return (p) => {
258
- const t = st.threads.find((x) => x.id === p.threadId);
259
- 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 } : {}) };
260
- };
261
- }
262
-
263
- /** Findings from a folded state (a run record read from the store). */
264
- export function findingsOf(state) {
265
- const st = state && Array.isArray(state.posts) ? state : emptyBoardState();
266
- return st.posts.filter((x) => x.kind === 'finding').map(asFinding(st));
267
- }
268
-
269
- /**
270
- * What a later task READS: the findings of the tasks it depends on (or everything so far),
271
- * sized. Newest are kept whole; the oldest are what get cut, and the cut is stated.
272
- */
273
- export function boardText(source, { taskIds = null, max = BOARD_TEXT_MAX, role = null } = {}) {
274
- // A board (threads + posts) reads threaded; a bare findings array reads as before.
275
- const state = source && typeof source.state === 'function' ? source.state() : (source && Array.isArray(source.posts) ? source : null);
276
- const lines = state ? threadedLines(state, { taskIds, role }) : findingLines(source, taskIds);
277
- if (!lines.length) return '';
278
- const head = state ? 'The board so far' : 'Findings so far';
279
- const out = lines.join('\n');
280
- if (out.length <= max) return `${head}:\n${out}`;
281
- const kept = [];
282
- let size = 0;
283
- for (let i = lines.length - 1; i >= 0; i -= 1) {
284
- if (size + lines[i].length + 1 > max) break;
285
- kept.unshift(lines[i]);
286
- size += lines[i].length + 1;
287
- }
288
- return `${head} (${lines.length - kept.length} earlier lines omitted for length):\n${kept.join('\n')}`;
289
- }
290
-
291
- function findingLines(findings, taskIds) {
292
- return (findings || []).filter((f) => !taskIds || taskIds.includes(f.taskId))
293
- .map((f) => `- [${f.kind}${f.role ? ` · ${f.role}` : ''}${f.confidence != null ? ` · ${Math.round(f.confidence * 100)}%` : ''}] ${f.text}${f.refs?.length ? ` (refs: ${f.refs.join(', ')})` : ''}`);
294
- }
295
-
296
- /**
297
- * What a member READS: the task threads it depends on (or every task thread), the answered
298
- * asks and settled discussions — never rejected posts — threaded, a reply under its post.
299
- * A person's decision is marked so a member treats it as settled.
300
- */
301
- function threadedLines(state, { taskIds, role }) {
302
- const out = [];
303
- const posts = state.posts;
304
- const threads = state.threads.filter((t) => {
305
- if (t.kind === 'task') return !taskIds || taskIds.includes(t.taskId);
306
- if (t.kind === 'ask') return t.status === 'resolved' && (!role || t.by === role || t.by === RUNNER);
307
- if (t.kind === 'discussion') return true;
308
- return false; // a proposal is the run's output, not a member's input
309
- });
310
- for (const t of threads) {
311
- const own = posts.filter((x) => x.threadId === t.id && x.status !== 'rejected');
312
- if (!own.length) continue;
313
- out.push(`## ${t.kind}${t.by && t.by !== RUNNER ? ` by ${t.by}` : ''}: ${t.title}${t.holder ? ` (held by ${t.holder})` : ''}${t.status === 'resolved' && t.kind === 'ask' ? ' (answered)' : ''}`);
314
- const line = (x, depth) => {
315
- const tag = [x.kind === 'finding' ? (x.finding?.kind || 'claim') : x.kind, x.by, x.finding?.confidence != null ? `${Math.round(x.finding.confidence * 100)}%` : null, x.status === 'approved' ? 'APPROVED' : x.status === 'proposed' ? 'proposed' : null, x.kind === 'decision' || x.by === PERSON ? 'SETTLED' : null].filter(Boolean).join(' · ');
316
- out.push(`${' '.repeat(depth)}- [${tag}] ${x.text}${x.refs?.length ? ` (refs: ${x.refs.join(', ')})` : ''}`);
317
- for (const r of own.filter((y) => y.replyTo === x.id)) line(r, depth + 1);
318
- };
319
- for (const x of own.filter((y) => !y.replyTo)) line(x, 0);
320
- }
321
- return out;
322
- }
323
-
324
- /** Findings → the claim shape a draft brief takes (W7): text + refs as `{ kind, id }`. */
325
- export function toBriefClaims(findings) {
326
- return (findings || [])
327
- .filter((f) => f.kind === 'claim' && f.text)
328
- .map((f) => ({
329
- text: f.text,
330
- refs: (f.refs || []).map((r) => {
331
- const m = /^([a-z][a-z0-9_-]*):(?!\/\/)(.+)$/.exec(String(r));
332
- return m ? { kind: m[1], id: m[2] } : { kind: 'url', id: String(r) };
333
- }),
334
- by: f.role || 'team',
335
- confidence: f.confidence,
336
- }));
337
- }
1
+ import{defineSchema as R,describeSchema as F,coerce as q}from"./structured.js";const N=Object.freeze(["claim","draft","link","question","answer"]),E=40,M=12e3,B=R({name:"findings",purpose:"what this task established, each item on its own with where it came from",fields:{findings:{type:"object[]",required:!0,maxItems:E,describe:"one entry per fact, draft, link or open question — never a paragraph of several",fields:{kind:{type:"enum",values:N,default:"claim"},text:{type:"string",required:!0,max:2e3},refs:{type:"string[]",maxItems:8,describe:"record ids or URLs this rests on, when any"},confidence:{type:"number",describe:"0–1, how sure"}}}},nothing:{findings:[]}});function W(){return`When you are done, end your answer with your findings in this shape:
2
+ ${F(B)}`}function Y(d,{role:i,taskId:n}={}){const f=String(d||"").trim();if(!f)return[];const t=q(f,B),o=Array.isArray(t?.value?.findings)?t.value.findings.filter(u=>u&&String(u.text||"").trim()):[],a=(u,c)=>({id:`${n||"t"}:${c+1}`,kind:N.includes(u.kind)?u.kind:"claim",text:String(u.text).trim().slice(0,2e3),refs:Array.isArray(u.refs)?u.refs.map(String).filter(Boolean).slice(0,8):[],confidence:Number.isFinite(Number(u.confidence))&&Number(u.confidence)>0?Math.min(1,Number(u.confidence)):null,role:i||null,taskId:n||null});if(o.length)return o.slice(0,E).map(a);const m=f.replace(/```json[\s\S]*$/i,"").trim()||f;return[a({kind:"draft",text:m.slice(0,2e3),refs:[]},0)]}const P=Object.freeze(["task","ask","discussion","proposal"]),D=Object.freeze(["open","waiting","resolved","failed","approved","rejected"]),K=Object.freeze(["finding","note","question","answer","draft","decision","request"]),X=Object.freeze(["open","proposed","approved","rejected"]),L=Object.freeze(["info","budget","permission","direction"]),v="runner",x="person",U=4e3,j=(d,i)=>String(d||"").trim().slice(0,i),G=d=>Array.isArray(d)?d.map(String).filter(Boolean).slice(0,8):[];function O(){return{threads:[],posts:[]}}function J(d,i){const n=d&&Array.isArray(d.threads)&&Array.isArray(d.posts)?d:O(),f=String(i?.type||""),t=i?.payload&&typeof i.payload=="object"?i.payload:i||{};if(f==="board.thread"&&t.thread?.id)n.threads.some(o=>o.id===t.thread.id)||n.threads.push({...t.thread});else if(f==="board.post"&&t.post?.id){if(Array.isArray(n.removed)&&n.removed.includes(t.post.threadId))return n;n.posts.some(a=>a.id===t.post.id)||n.posts.push({...t.post});const o=n.threads.find(a=>a.id===t.post.threadId);o&&(o.lastAt=t.post.at,o.lastBy=t.post.by,o.posts=(o.posts||0)+1)}else if(f==="board.decision"&&t.postId){const o=n.posts.find(a=>a.id===t.postId);o&&(o.status=t.status,o.decidedBy=t.by,o.decidedAt=t.at??i?.at)}else if(f==="board.thread-status"&&t.threadId){const o=n.threads.find(a=>a.id===t.threadId);o&&(o.status=t.status,t.status!=="waiting"&&(o.waitingOn=null),t.holder&&(o.holder=t.holder))}else f==="board.thread-removed"&&t.threadId&&(n.threads=n.threads.filter(o=>o.id!==t.threadId),n.posts=n.posts.filter(o=>o.threadId!==t.threadId),Array.isArray(n.removed)||(n.removed=[]),n.removed.includes(t.threadId)||n.removed.push(t.threadId));return n}function Q({now:d=()=>Date.now(),newId:i=null,state:n=null,onEvent:f=null}={}){const t=n&&Array.isArray(n.threads)?{threads:n.threads.map(e=>({...e})),posts:(n.posts||[]).map(e=>({...e})),...Array.isArray(n.removed)&&n.removed.length?{removed:[...n.removed]}:{}}:O(),o=new Set;let a=t.posts.length+t.threads.length;const m=e=>i?i(e):`${e}_${(++a).toString(36)}${Math.random().toString(36).slice(2,6)}`,u=(e,r)=>{const l={type:e,at:d(),...r};return f&&f(e,l),l},c=e=>t.threads.find(r=>r.id===e),p=e=>t.posts.find(r=>r.id===e),w=e=>t.threads.find(r=>r.kind==="task"&&r.taskId===e),h={openThread({id:e=null,taskId:r=null,kind:l="discussion",title:s="",by:y=v,status:g="open",ask:S=null,parent:b=null,holder:k=null}={}){if(l==="task"&&r){const $=w(r);if($)return $}const A={id:e||m("th"),kind:P.includes(l)?l:"discussion",taskId:r,title:j(s,200),by:y,status:D.includes(g)?g:"open",at:d(),posts:0,...S?{ask:S}:{},...b?{parent:b}:{},...k?{holder:k}:{}};return t.threads.push(A),u("board.thread",{thread:{...A}}),A},post({id:e=null,threadId:r,by:l,kind:s="note",text:y="",refs:g=[],replyTo:S=null,status:b="open",finding:k=null,ask:A=null,proposal:$=null}={}){const I=c(r);if(!I)throw new Error(`no thread ${r}`);if(e&&p(e))return p(e);const T={id:e||m("p"),threadId:r,by:String(l||v),kind:K.includes(s)?s:"note",text:j(y,U),refs:G(g),replyTo:S,status:X.includes(b)?b:"open",at:d(),...k?{finding:k}:{},...A?{ask:A}:{},...$&&typeof $=="object"?{proposal:$}:{}};t.posts.push(T),I.lastAt=T.at,I.lastBy=T.by,I.posts=(I.posts||0)+1,u("board.post",{post:{...T}});for(const z of o)z(T);return T},reply({postId:e,...r}){const l=p(e||r.replyTo);if(!l)throw new Error(`no post ${e}`);return h.post({...r,threadId:l.threadId,replyTo:l.id})},decide(e,r,l=x){const s=p(e);return!s||!["approved","rejected","proposed","open"].includes(r)?null:(s.status=r,s.decidedBy=l,s.decidedAt=d(),u("board.decision",{postId:e,status:r,by:l,at:s.decidedAt}),s)},setThreadStatus(e,r,l={}){const s=c(e);return!s||!D.includes(r)?null:(s.status=r,r!=="waiting"&&(s.waitingOn=null),Object.assign(s,l),u("board.thread-status",{threadId:e,status:r,...l}),s)},removeThread(e,{by:r=x}={}){const l=c(e);return!l||l.kind==="ask"&&l.status==="waiting"?null:(t.threads=t.threads.filter(s=>s.id!==e),t.posts=t.posts.filter(s=>s.threadId!==e),(t.removed||=[]).push(e),u("board.thread-removed",{threadId:e,by:r}),l)},ask({taskId:e=null,by:r,type:l="info",text:s,options:y=[],timeoutMs:g=0,title:S=""}={}){const b={type:L.includes(l)?l:"info",options:(Array.isArray(y)?y:[]).map(String).slice(0,6),timeoutMs:g},k=h.openThread({taskId:e,kind:"ask",title:S||j(s,120),by:r,status:"waiting",ask:b});k.waitingOn=x;const A=h.post({threadId:k.id,by:r,kind:"question",text:s,ask:b});return{thread:k,post:A}},answer(e,{id:r=null,text:l,by:s=x}={}){if(!c(e))return null;const g=h.post({id:r,threadId:e,by:s,kind:"answer",text:l});return h.setThreadStatus(e,"resolved",{answeredAt:g.at}),g},thread:c,threadForTask:w,threads:()=>t.threads.map(e=>({...e})),posts:(e=null)=>t.posts.filter(r=>!e||r.threadId===e).map(r=>({...r})),postById:p,waiting:()=>t.threads.filter(e=>e.kind==="ask"&&e.status==="waiting").map(e=>({...e})),state:()=>({threads:t.threads.map(e=>({...e})),posts:t.posts.map(e=>({...e})),...t.removed?.length?{removed:[...t.removed]}:{}}),add(e,{by:r=null}={}){let l=0;for(const s of Array.isArray(e)?e:[e]){if(!s||!s.text)continue;const y=s.taskId?w(s.taskId)||h.openThread({taskId:s.taskId,kind:"task",title:s.taskId}):t.threads.find(g=>g.kind==="discussion"&&g.title==="findings")||h.openThread({kind:"discussion",title:"findings"});h.post({id:s.id,threadId:y.id,by:r||s.role||v,kind:"finding",text:s.text,refs:s.refs,finding:{kind:s.kind||"claim",confidence:s.confidence??null}}),l+=1}return l},all:()=>t.posts.filter(e=>e.kind==="finding").map(_(t)),byTask:e=>h.all().filter(r=>r.taskId===e),byRole:e=>h.all().filter(r=>r.role===e),onFinding(e){const r=l=>{l.kind==="finding"&&e(_(t)(l))};return o.add(r),()=>o.delete(r)},get size(){return t.posts.filter(e=>e.kind==="finding").length}};return h}function _(d){return i=>{const n=d.threads.find(f=>f.id===i.threadId);return{id:i.id,kind:i.finding?.kind||"claim",text:i.text,refs:i.refs||[],confidence:i.finding?.confidence??null,role:i.by,taskId:n?.taskId||null,at:i.at,status:i.status,...i.finding?.prior?{prior:!0}:{}}}}function Z(d){const i=d&&Array.isArray(d.posts)?d:O();return i.posts.filter(n=>n.kind==="finding").map(_(i))}function ee(d,{taskIds:i=null,max:n=M,role:f=null}={}){const t=d&&typeof d.state=="function"?d.state():d&&Array.isArray(d.posts)?d:null,o=t?C(t,{taskIds:i,role:f}):H(d,i);if(!o.length)return"";const a=t?"The board so far":"Findings so far",m=o.join(`
3
+ `);if(m.length<=n)return`${a}:
4
+ ${m}`;const u=[];let c=0;for(let p=o.length-1;p>=0&&!(c+o[p].length+1>n);p-=1)u.unshift(o[p]),c+=o[p].length+1;return`${a} (${o.length-u.length} earlier lines omitted for length):
5
+ ${u.join(`
6
+ `)}`}function H(d,i){return(d||[]).filter(n=>!i||i.includes(n.taskId)).map(n=>`- [${n.kind}${n.role?` · ${n.role}`:""}${n.confidence!=null?` · ${Math.round(n.confidence*100)}%`:""}] ${n.text}${n.refs?.length?` (refs: ${n.refs.join(", ")})`:""}`)}function C(d,{taskIds:i,role:n}){const f=[],t=d.posts,o=d.threads.filter(a=>a.kind==="task"?!i||i.includes(a.taskId):a.kind==="ask"?a.status==="resolved"&&(!n||a.by===n||a.by===v):a.kind==="discussion");for(const a of o){const m=t.filter(c=>c.threadId===a.id&&c.status!=="rejected");if(!m.length)continue;f.push(`## ${a.kind}${a.by&&a.by!==v?` by ${a.by}`:""}: ${a.title}${a.holder?` (held by ${a.holder})`:""}${a.status==="resolved"&&a.kind==="ask"?" (answered)":""}`);const u=(c,p)=>{const w=[c.kind==="finding"?c.finding?.kind||"claim":c.kind,c.by,c.finding?.confidence!=null?`${Math.round(c.finding.confidence*100)}%`:null,c.status==="approved"?"APPROVED":c.status==="proposed"?"proposed":null,c.kind==="decision"||c.by===x?"SETTLED":null].filter(Boolean).join(" · ");f.push(`${" ".repeat(p)}- [${w}] ${c.text}${c.refs?.length?` (refs: ${c.refs.join(", ")})`:""}`);for(const h of m.filter(e=>e.replyTo===c.id))u(h,p+1)};for(const c of m.filter(p=>!p.replyTo))u(c,0)}return f}function te(d){return(d||[]).filter(i=>i.kind==="claim"&&i.text).map(i=>({text:i.text,refs:(i.refs||[]).map(n=>{const f=/^([a-z][a-z0-9_-]*):(?!\/\/)(.+)$/.exec(String(n));return f?{kind:f[1],id:f[2]}:{kind:"url",id:String(n)}}),by:i.role||"team",confidence:i.confidence}))}export{L as ASK_TYPES,M as BOARD_TEXT_MAX,B as FINDINGS_SCHEMA,N as FINDING_KINDS,E as MAX_FINDINGS_PER_TASK,U as MAX_POST_TEXT,x as PERSON,K as POST_KINDS,X as POST_STATUSES,v as RUNNER,P as THREAD_KINDS,D as THREAD_STATUSES,ee as boardText,Q as createBoard,O as emptyBoardState,W as findingsInstruction,Z as findingsOf,J as foldBoard,Y as parseFindings,te as toBriefClaims};
package/team-cache.js CHANGED
@@ -1,63 +1,2 @@
1
- // One lookup per run a run-scoped cache over read-only tools.
2
- //
3
- // Three members of a travel team searched "Snoqualmie Valley School District calendar" five
4
- // times between them. Each member's own turn already dedupes its own repeats; the RUN did
5
- // not, and members in one wave cannot read each other's findings yet. So: a read-only call
6
- // with the same name and arguments, from any member, runs once per run; the rest get the
7
- // first answer back, marked as shared. Only tools that declare themselves read-only (MCP
8
- // annotations, the shared retrieval tools) are cached; a dispatcher's paging (`get_result`)
9
- // and the board are per-member and never are.
10
-
11
- const NEVER = new Set(['get_result', 'board', 'team', 'recipe']);
12
- const READ_ONLY_NAME_RE = /^(find|web_search|history_search|history_get_source|history_list|meeting_live_transcript|read|fetch|get_|list_|search)/i;
13
-
14
- function stableKey(name, input) {
15
- const walk = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? Object.keys(v).sort().reduce((o, k) => { o[k] = walk(v[k]); return o; }, {}) : Array.isArray(v) ? v.map(walk) : v);
16
- try { return `${name} ${JSON.stringify(walk(input ?? {}))}`; } catch { return `${name} ?`; }
17
- }
18
-
19
- export function createRunCache() {
20
- const hits = new Map(); // key -> { result, by, at }
21
- let shared = 0;
22
- return {
23
- get: (k) => hits.get(k) || null,
24
- set: (k, v) => { hits.set(k, v); },
25
- delete: (k) => { hits.delete(k); },
26
- countShared: () => { shared += 1; },
27
- get size() { return hits.size; },
28
- get shared() { return shared; },
29
- };
30
- }
31
-
32
- function isReadOnly(toolset, name) {
33
- if (NEVER.has(name)) return false;
34
- const spec = (toolset.specs || []).find((s) => s.name === name);
35
- if (spec?.annotations?.readOnlyHint === true) return true;
36
- if (spec?.annotations && spec.annotations.readOnlyHint === false) return false;
37
- const t = toolset.traits instanceof Map ? toolset.traits.get(name) : null;
38
- if (t && typeof t.readOnly === 'boolean') return t.readOnly;
39
- return READ_ONLY_NAME_RE.test(name);
40
- }
41
-
42
- /** The toolset with its read-only calls served from the run's cache. */
43
- export function withRunCache(toolset, cache, { role = '' } = {}) {
44
- if (!toolset || !cache) return toolset;
45
- return {
46
- ...toolset,
47
- execute: async (name, input, ...rest) => {
48
- if (!isReadOnly(toolset, name)) return toolset.execute(name, input, ...rest);
49
- const key = stableKey(name, input);
50
- const hit = cache.get(key);
51
- if (hit) {
52
- // Members in one wave ask at the same moment: the second waits on the first's call.
53
- cache.countShared();
54
- const result = await hit.promise;
55
- const note = `[shared: ${hit.by || 'another member'} already ran this in this run]\n`;
56
- return typeof result === 'string' ? note + result : result;
57
- }
58
- const promise = Promise.resolve().then(() => toolset.execute(name, input, ...rest));
59
- cache.set(key, { promise, by: role, at: Date.now() });
60
- try { return await promise; } catch (e) { cache.delete(key); throw e; }
61
- },
62
- };
63
- }
1
+ const f=new Set(["get_result","board","team","recipe"]),h=/^(find|web_search|history_search|history_get_source|history_list|meeting_live_transcript|read|fetch|get_|list_|search)/i;function d(t,r){const n=e=>e&&typeof e=="object"&&!Array.isArray(e)?Object.keys(e).sort().reduce((s,i)=>(s[i]=n(e[i]),s),{}):Array.isArray(e)?e.map(n):e;try{return`${t} ${JSON.stringify(n(r??{}))}`}catch{return`${t} ?`}}function _(){const t=new Map;let r=0;return{get:n=>t.get(n)||null,set:(n,e)=>{t.set(n,e)},delete:n=>{t.delete(n)},countShared:()=>{r+=1},get size(){return t.size},get shared(){return r}}}function l(t,r){if(f.has(r))return!1;const n=(t.specs||[]).find(s=>s.name===r);if(n?.annotations?.readOnlyHint===!0)return!0;if(n?.annotations&&n.annotations.readOnlyHint===!1)return!1;const e=t.traits instanceof Map?t.traits.get(r):null;return e&&typeof e.readOnly=="boolean"?e.readOnly:h.test(r)}function g(t,r,{role:n=""}={}){return!t||!r?t:{...t,execute:async(e,s,...i)=>{if(!l(t,e))return t.execute(e,s,...i);const u=d(e,s),c=r.get(u);if(c){r.countShared();const a=await c.promise,y=`[shared: ${c.by||"another member"} already ran this in this run]
2
+ `;return typeof a=="string"?y+a:a}const o=Promise.resolve().then(()=>t.execute(e,s,...i));r.set(u,{promise:o,by:n,at:Date.now()});try{return await o}catch(a){throw r.delete(u),a}}}}export{_ as createRunCache,g as withRunCache};
package/team-observe.js CHANGED
@@ -1,118 +1 @@
1
- // Observe (F8 §17.2) what is happening, where the time went, what needs you: derived from
2
- // the run store's rows and records and the engine ledger's cards, once, here, so the desktop's
3
- // Observe tab, the extension's and a phone's read the same inbox, the same lanes and the
4
- // same spend rows. Nothing renders; the honesty rule holds — a number is given only where
5
- // it is known (a relayed agent tool reports no tokens: its row says time, never $0).
6
-
7
- import { runState, spendOf, LIVE_RUN_STATUSES } from './team-record.js';
8
-
9
- const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
10
-
11
- /**
12
- * The inbox: every run that needs a person — an ask waiting (`waiting` or a `waiting` count
13
- * on the row), a live run gone quiet (stalled) — newest first, each with the one action.
14
- * `runs` are the store's list rows (`GET /v1/teams/runs`) or folded records.
15
- */
16
- export function observeInbox(runs = [], { now = Date.now() } = {}) {
17
- const items = [];
18
- for (const r of Array.isArray(runs) ? runs : []) {
19
- if (!r?.id) continue;
20
- const st = runState(r, { now });
21
- const waiting = r.status === 'waiting' || num(r.waiting) > 0 || st.key === 'waiting';
22
- if (waiting) items.push({ kind: 'ask', runId: r.id, team: r.team || '', request: String(r.request || '').slice(0, 120), count: Math.max(1, num(r.waiting)), action: 'answer', at: num(r.lastEventAt) || num(r.createdAt), detail: st.detail || '' });
23
- else if (st.key === 'stalled') items.push({ kind: 'stalled', runId: r.id, team: r.team || '', request: String(r.request || '').slice(0, 120), action: 'resume', at: num(r.lastEventAt) || num(r.createdAt), detail: st.detail || '' });
24
- }
25
- return items.sort((a, b) => (a.kind === b.kind ? b.at - a.at : a.kind === 'ask' ? -1 : 1));
26
- }
27
-
28
- /** The strip's numbers: live runs, stalled, asks waiting, tasks done this window, rotations seen. */
29
- export function observeStrip(runs = [], { now = Date.now(), sinceMs = 7 * 24 * 3600 * 1000 } = {}) {
30
- const list = (Array.isArray(runs) ? runs : []).filter((r) => r?.id && num(r.createdAt) >= now - sinceMs);
31
- const live = list.filter((r) => LIVE_RUN_STATUSES.includes(r.status));
32
- const stalled = live.filter((r) => runState(r, { now }).key === 'stalled').length;
33
- const asks = observeInbox(list, { now }).filter((i) => i.kind === 'ask').reduce((n, i) => n + i.count, 0);
34
- const tasks = list.reduce((a, r) => { for (const t of r.tasks || []) { if (t.status === 'ok') a.done += 1; else if (t.status === 'failed' || t.status === 'unassigned') a.failed += 1; } return a; }, { done: 0, failed: 0 });
35
- const spend = list.reduce((a, r) => { const s = r.usage?.spent || {}; a.usd += num(s.usd); a.ms += num(s.ms); a.tokens += num(s.tokens); return a; }, { usd: 0, ms: 0, tokens: 0 });
36
- return { runs: list.length, live: live.length, stalled, asks, tasks, spend, completed: list.filter((r) => r.status === 'completed').length };
37
- }
38
-
39
- /**
40
- * One run's timeline: a lane per task with segments placed on the run's own clock (0..1 of
41
- * `span`), so a client draws bars without knowing the record. Segments: `working` (started →
42
- * ended or now), `waiting` (an ask), `declined` (an attempt that failed and rotated),
43
- * `earlier` (a task that ended before the last start of a live run, drawn grey). A sub-task
44
- * carries `parent` for nesting.
45
- */
46
- export function runLanes(run, { now = Date.now() } = {}) {
47
- const has = (v) => Number.isFinite(Number(v)) && v !== null && v !== undefined && v !== '';
48
- const start = has(run?.startedAt) ? Number(run.startedAt) : has(run?.createdAt) ? Number(run.createdAt) : now;
49
- const live = LIVE_RUN_STATUSES.includes(run?.status);
50
- const end = live ? now : Math.max(start + 1, num(run?.endedAt) || num(run?.lastEventAt) || now);
51
- const span = Math.max(1, end - start);
52
- const at = (t) => Math.max(0, Math.min(1, (t - start) / span));
53
- const lanes = [];
54
- for (const t of run?.tasks || []) {
55
- const segs = [];
56
- const s0 = has(t.startedAt) ? Number(t.startedAt) : null;
57
- const e0 = has(t.endedAt) ? Number(t.endedAt) : (t.status === 'running' || t.status === 'waiting' ? end : null);
58
- for (const a of t.attempts || []) {
59
- if (!has(a.startedAt) || !has(a.endedAt) || !(a.status === 'failed' || a.rotated)) continue;
60
- const as = Number(a.startedAt), ae = Number(a.endedAt);
61
- segs.push({ kind: 'declined', from: at(as), to: at(Math.max(ae, as + span / 200)), label: a.error || a.model || 'declined' });
62
- }
63
- if (s0 !== null && e0 !== null) segs.push({ kind: t.status === 'waiting' ? 'waiting' : 'working', from: at(s0), to: at(Math.max(e0, s0 + span / 200)), label: t.status });
64
- const ms = s0 !== null ? (e0 ?? end) - s0 : num(t.ms);
65
- lanes.push({ id: t.id, role: t.role || null, title: t.title || t.id, status: t.status, parent: t.parent || null, kind: t.kind || 'task', findings: num(t.findings), ms, segments: segs });
66
- }
67
- return { start, end, span, live, lanes };
68
- }
69
-
70
- /**
71
- * Spend by agent (the role's card id when the role names one, else the role), by team, or
72
- * by engine — from the run rows' `usage.spent` and each task's share where the record has
73
- * it. `usd` is `null`, not 0, when no run priced it; `ms` is always known.
74
- */
75
- export function spendRows(runs = [], { by = 'team', now = Date.now() } = {}) {
76
- const rows = new Map();
77
- const add = (key, label, spent, { priced }) => {
78
- const r = rows.get(key) || { key, label, ms: 0, tokens: 0, usd: null, calls: 0, runs: 0 };
79
- r.ms += num(spent.ms); r.tokens += num(spent.tokens); r.calls += num(spent.calls); r.runs += 1;
80
- if (priced) r.usd = num(r.usd) + num(spent.usd);
81
- rows.set(key, r);
82
- };
83
- for (const run of Array.isArray(runs) ? runs : []) {
84
- if (!run?.id) continue;
85
- const s = spendOf(run, { now })?.spent || run.usage?.spent || {};
86
- const priced = num(s.usd) > 0 || num(s.tokens) > 0;
87
- if (by === 'team') add(run.team || '?', run.team || '?', s, { priced });
88
- else if (by === 'agent') {
89
- const tasks = (run.tasks || []).filter((t) => t.role);
90
- const share = tasks.length ? 1 / tasks.length : 0;
91
- for (const t of tasks) {
92
- const role = (run.roles || []).find((r) => r.id === t.role);
93
- const key = role?.agent || `${run.team || '?'}-${t.role}`;
94
- add(key, role?.agent || t.role, { ms: num(t.ms) || num(s.ms) * share, tokens: num(s.tokens) * share, usd: num(s.usd) * share, calls: num(s.calls) * share }, { priced });
95
- }
96
- } else if (by === 'engine') {
97
- for (const t of run.tasks || []) { const k = t.engine ? `${t.engine.kind}:${t.engine.id}${t.engine.model ? `/${t.engine.model}` : ''}` : (t.model || '?'); add(k, k, { ms: num(t.ms), tokens: 0, usd: 0, calls: 0 }, { priced: false }); }
98
- }
99
- }
100
- const out = [...rows.values()].sort((a, b) => (b.usd ?? 0) - (a.usd ?? 0) || b.ms - a.ms);
101
- const max = Math.max(1, ...out.map((r) => (r.usd ?? 0) || 0), ...out.map((r) => r.ms / 60000));
102
- return out.map((r) => ({ ...r, share: r.usd != null && r.usd > 0 ? r.usd / max : (r.ms / 60000) / max }));
103
- }
104
-
105
- /** An engine card as one strip: 24 hour cells (`ok` · `declined` · `none`), the rate, and whether it is declining now. */
106
- export function engineStrip(card) {
107
- const a = card?.availability || {};
108
- const hours = Array.isArray(a.byHour) ? a.byHour : Array.from({ length: 24 }, () => ({ calls: 0, declines: 0 }));
109
- return {
110
- key: card?.key || '?',
111
- label: card?.engine ? `${card.engine.id}${card.engine.model ? ` · ${card.engine.model}` : ''}` : (card?.key || '?'),
112
- rate: a.rate ?? null,
113
- decliningNow: !!a.decliningNow,
114
- p50: card?.latency?.total?.p50 ?? null,
115
- calls: num(card?.calls),
116
- cells: hours.map((h) => (num(h.declines) > 0 && num(h.declines) >= num(h.calls) ? 'declined' : num(h.calls) > 0 ? 'ok' : 'none')),
117
- };
118
- }
1
+ import{runState as A,spendOf as w,LIVE_RUN_STATUSES as h}from"./team-record.js";const n=a=>Number.isFinite(Number(a))?Number(a):0;function y(a=[],{now:o=Date.now()}={}){const u=[];for(const e of Array.isArray(a)?a:[]){if(!e?.id)continue;const r=A(e,{now:o});e.status==="waiting"||n(e.waiting)>0||r.key==="waiting"?u.push({kind:"ask",runId:e.id,team:e.team||"",request:String(e.request||"").slice(0,120),count:Math.max(1,n(e.waiting)),action:"answer",at:n(e.lastEventAt)||n(e.createdAt),detail:r.detail||""}):r.key==="stalled"&&u.push({kind:"stalled",runId:e.id,team:e.team||"",request:String(e.request||"").slice(0,120),action:"resume",at:n(e.lastEventAt)||n(e.createdAt),detail:r.detail||""})}return u.sort((e,r)=>e.kind===r.kind?r.at-e.at:e.kind==="ask"?-1:1)}function N(a=[],{now:o=Date.now(),sinceMs:u=168*3600*1e3}={}){const e=(Array.isArray(a)?a:[]).filter(t=>t?.id&&n(t.createdAt)>=o-u),r=e.filter(t=>h.includes(t.status)),c=r.filter(t=>A(t,{now:o}).key==="stalled").length,g=y(e,{now:o}).filter(t=>t.kind==="ask").reduce((t,l)=>t+l.count,0),s=e.reduce((t,l)=>{for(const i of l.tasks||[])i.status==="ok"?t.done+=1:(i.status==="failed"||i.status==="unassigned")&&(t.failed+=1);return t},{done:0,failed:0}),d=e.reduce((t,l)=>{const i=l.usage?.spent||{};return t.usd+=n(i.usd),t.ms+=n(i.ms),t.tokens+=n(i.tokens),t},{usd:0,ms:0,tokens:0});return{runs:e.length,live:r.length,stalled:c,asks:g,tasks:s,spend:d,completed:e.filter(t=>t.status==="completed").length}}function M(a,{now:o=Date.now()}={}){const u=t=>Number.isFinite(Number(t))&&t!==null&&t!==void 0&&t!=="",e=u(a?.startedAt)?Number(a.startedAt):u(a?.createdAt)?Number(a.createdAt):o,r=h.includes(a?.status),c=r?o:Math.max(e+1,n(a?.endedAt)||n(a?.lastEventAt)||o),g=Math.max(1,c-e),s=t=>Math.max(0,Math.min(1,(t-e)/g)),d=[];for(const t of a?.tasks||[]){const l=[],i=u(t.startedAt)?Number(t.startedAt):null,m=u(t.endedAt)?Number(t.endedAt):t.status==="running"||t.status==="waiting"?c:null;for(const f of t.attempts||[]){if(!u(f.startedAt)||!u(f.endedAt)||!(f.status==="failed"||f.rotated))continue;const p=Number(f.startedAt),b=Number(f.endedAt);l.push({kind:"declined",from:s(p),to:s(Math.max(b,p+g/200)),label:f.error||f.model||"declined"})}i!==null&&m!==null&&l.push({kind:t.status==="waiting"?"waiting":"working",from:s(i),to:s(Math.max(m,i+g/200)),label:t.status});const k=i!==null?(m??c)-i:n(t.ms);d.push({id:t.id,role:t.role||null,title:t.title||t.id,status:t.status,parent:t.parent||null,kind:t.kind||"task",findings:n(t.findings),ms:k,segments:l})}return{start:e,end:c,span:g,live:r,lanes:d}}function $(a=[],{by:o="team",now:u=Date.now()}={}){const e=new Map,r=(s,d,t,{priced:l})=>{const i=e.get(s)||{key:s,label:d,ms:0,tokens:0,usd:null,calls:0,runs:0};i.ms+=n(t.ms),i.tokens+=n(t.tokens),i.calls+=n(t.calls),i.runs+=1,l&&(i.usd=n(i.usd)+n(t.usd)),e.set(s,i)};for(const s of Array.isArray(a)?a:[]){if(!s?.id)continue;const d=w(s,{now:u})?.spent||s.usage?.spent||{},t=n(d.usd)>0||n(d.tokens)>0;if(o==="team")r(s.team||"?",s.team||"?",d,{priced:t});else if(o==="agent"){const l=(s.tasks||[]).filter(m=>m.role),i=l.length?1/l.length:0;for(const m of l){const k=(s.roles||[]).find(p=>p.id===m.role),f=k?.agent||`${s.team||"?"}-${m.role}`;r(f,k?.agent||m.role,{ms:n(m.ms)||n(d.ms)*i,tokens:n(d.tokens)*i,usd:n(d.usd)*i,calls:n(d.calls)*i},{priced:t})}}else if(o==="engine")for(const l of s.tasks||[]){const i=l.engine?`${l.engine.kind}:${l.engine.id}${l.engine.model?`/${l.engine.model}`:""}`:l.model||"?";r(i,i,{ms:n(l.ms),tokens:0,usd:0,calls:0},{priced:!1})}}const c=[...e.values()].sort((s,d)=>(d.usd??0)-(s.usd??0)||d.ms-s.ms),g=Math.max(1,...c.map(s=>(s.usd??0)||0),...c.map(s=>s.ms/6e4));return c.map(s=>({...s,share:s.usd!=null&&s.usd>0?s.usd/g:s.ms/6e4/g}))}function S(a){const o=a?.availability||{},u=Array.isArray(o.byHour)?o.byHour:Array.from({length:24},()=>({calls:0,declines:0}));return{key:a?.key||"?",label:a?.engine?`${a.engine.id}${a.engine.model?` · ${a.engine.model}`:""}`:a?.key||"?",rate:o.rate??null,decliningNow:!!o.decliningNow,p50:a?.latency?.total?.p50??null,calls:n(a?.calls),cells:u.map(e=>n(e.declines)>0&&n(e.declines)>=n(e.calls)?"declined":n(e.calls)>0?"ok":"none")}}export{S as engineStrip,y as observeInbox,N as observeStrip,M as runLanes,$ as spendRows};