@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.
Files changed (139) 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 +4 -5
  62. package/pdf-layout.js +7 -253
  63. package/policy.js +1 -0
  64. package/project-run.js +20 -444
  65. package/project.js +1 -170
  66. package/promotion.js +1 -171
  67. package/queue.js +1 -98
  68. package/reach.js +1 -23
  69. package/recipe-tool.js +2 -161
  70. package/recipe.js +1 -264
  71. package/record-list.js +2 -110
  72. package/recruit.js +3 -456
  73. package/redaction-tokens.js +1 -61
  74. package/ref.js +1 -55
  75. package/registry.js +1 -240
  76. package/route-graph.js +1 -115
  77. package/route-strategies.js +2 -232
  78. package/router.js +2 -880
  79. package/rrf.js +1 -89
  80. package/rules.js +1 -142
  81. package/schedule.js +1 -663
  82. package/scm-connection.js +1 -180
  83. package/scopes.js +1 -12
  84. package/scorecard.js +1 -335
  85. package/search-engines.js +1 -81
  86. package/skill-manifest.js +1 -330
  87. package/skill-scan.js +3 -209
  88. package/skill-sources.js +1 -162
  89. package/skill-vars.js +2 -252
  90. package/slash-commands.js +1 -158
  91. package/source-gate.js +1 -92
  92. package/sources-retrieval.js +5 -186
  93. package/sources.js +1 -273
  94. package/store.js +1 -171
  95. package/structured.js +4 -901
  96. package/subject-kinds.js +1 -5
  97. package/subject-name.js +1 -96
  98. package/sync-plan.js +1 -213
  99. package/synthesis.js +3 -123
  100. package/tags.js +1 -203
  101. package/team-board.js +6 -337
  102. package/team-cache.js +2 -63
  103. package/team-observe.js +1 -118
  104. package/team-org.js +1 -442
  105. package/team-plan.js +4 -131
  106. package/team-record.js +1 -225
  107. package/team-run.js +9 -652
  108. package/team-subtask.js +2 -191
  109. package/team-task.js +2 -124
  110. package/team-tool.js +2 -196
  111. package/team-trail.js +2 -73
  112. package/team-worklog.js +2 -149
  113. package/team.js +1 -302
  114. package/text-search.js +1 -155
  115. package/theme.js +17 -154
  116. package/titles.js +4 -285
  117. package/tool-discovery.js +1 -87
  118. package/tool-dispatch.js +5 -230
  119. package/tool-groups.js +1 -81
  120. package/tool-hints.js +10 -185
  121. package/tool-loop-guard.js +4 -182
  122. package/tool-need.js +2 -124
  123. package/tool-result.js +2 -438
  124. package/tool-round.js +0 -0
  125. package/tool-schema.js +1 -155
  126. package/tool-traits.js +1 -182
  127. package/toolset.js +2 -88
  128. package/trajectory.js +1 -530
  129. package/turn-loop.js +4 -440
  130. package/upcast.js +1 -37
  131. package/vault.js +2 -250
  132. package/view.js +1 -88
  133. package/voice-intents.js +2 -1488
  134. package/voice-speaker.js +1 -98
  135. package/weather-tool.js +1 -65
  136. package/weather.js +2 -211
  137. package/web-search-tool.js +14 -102
  138. package/web-search.js +1 -160
  139. 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};