@forwardimpact/libharness 0.1.20 → 1.0.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 (86) hide show
  1. package/LICENSE +21 -201
  2. package/README.md +196 -80
  3. package/bin/fit-benchmark.js +44 -0
  4. package/bin/fit-harness.js +358 -0
  5. package/bin/fit-selfedit.js +165 -0
  6. package/bin/fit-trace.js +510 -0
  7. package/package.json +42 -12
  8. package/src/agent-runner.js +256 -0
  9. package/src/benchmark/apm-installer.js +207 -0
  10. package/src/benchmark/env-loader.js +158 -0
  11. package/src/benchmark/hook-env.js +40 -0
  12. package/src/benchmark/invariants.js +141 -0
  13. package/src/benchmark/judge.js +187 -0
  14. package/src/benchmark/npm-installer.js +87 -0
  15. package/src/benchmark/report.js +522 -0
  16. package/src/benchmark/result.js +127 -0
  17. package/src/benchmark/runner.js +583 -0
  18. package/src/benchmark/task-family.js +260 -0
  19. package/src/benchmark/workdir.js +298 -0
  20. package/src/commands/assert.js +153 -0
  21. package/src/commands/benchmark-definition.js +165 -0
  22. package/src/commands/benchmark-invariants.js +73 -0
  23. package/src/commands/benchmark-report.js +51 -0
  24. package/src/commands/benchmark-run.js +111 -0
  25. package/src/commands/by-discussion.js +94 -0
  26. package/src/commands/callback.js +119 -0
  27. package/src/commands/discuss.js +132 -0
  28. package/src/commands/facilitate.js +123 -0
  29. package/src/commands/output.js +36 -0
  30. package/src/commands/run.js +152 -0
  31. package/src/commands/supervise.js +136 -0
  32. package/src/commands/task-input.js +54 -0
  33. package/src/commands/tee.js +53 -0
  34. package/src/commands/trace.js +630 -0
  35. package/src/commands/work-tracker.js +35 -0
  36. package/src/cost.js +79 -0
  37. package/src/discuss-tools.js +173 -0
  38. package/src/discusser.js +394 -0
  39. package/src/events/github.js +161 -0
  40. package/src/facilitator.js +205 -0
  41. package/src/inbox-poller.js +81 -0
  42. package/src/index.js +72 -2
  43. package/src/judge.js +210 -0
  44. package/src/message-bus.js +118 -0
  45. package/src/orchestration-loop.js +330 -0
  46. package/src/orchestration-toolkit.js +441 -0
  47. package/src/orchestrator-helpers.js +23 -0
  48. package/src/profile-prompt.js +266 -0
  49. package/src/redaction.js +253 -0
  50. package/src/render/line-renderer.js +54 -0
  51. package/src/render/orchestrator-filter.js +19 -0
  52. package/src/render/palette.js +63 -0
  53. package/src/render/tool-hints.js +154 -0
  54. package/src/render/turn-renderer.js +96 -0
  55. package/src/reply-emitter.js +47 -0
  56. package/src/sequence-counter.js +21 -0
  57. package/src/signature-filter.js +27 -0
  58. package/src/supervisor.js +236 -0
  59. package/src/tee-writer.js +150 -0
  60. package/src/trace-collector.js +444 -0
  61. package/src/trace-github.js +473 -0
  62. package/src/trace-multi.js +101 -0
  63. package/src/trace-query.js +748 -0
  64. package/src/trace-render.js +211 -0
  65. package/src/trace-usage.js +249 -0
  66. package/src/fixture/assertions.js +0 -42
  67. package/src/fixture/cache.js +0 -50
  68. package/src/fixture/eval.js +0 -146
  69. package/src/fixture/index.js +0 -9
  70. package/src/fixture/pathway.js +0 -451
  71. package/src/fixture/services.js +0 -56
  72. package/src/mock/clients.js +0 -135
  73. package/src/mock/config.js +0 -45
  74. package/src/mock/data.js +0 -46
  75. package/src/mock/fs.js +0 -111
  76. package/src/mock/grpc.js +0 -94
  77. package/src/mock/http.js +0 -60
  78. package/src/mock/index.js +0 -36
  79. package/src/mock/infra.js +0 -219
  80. package/src/mock/logger.js +0 -42
  81. package/src/mock/observer.js +0 -74
  82. package/src/mock/resource-index.js +0 -95
  83. package/src/mock/service-callbacks.js +0 -39
  84. package/src/mock/services.js +0 -79
  85. package/src/mock/spy.js +0 -44
  86. package/src/mock/storage.js +0 -118
@@ -0,0 +1,441 @@
1
+ /**
2
+ * OrchestrationToolkit — tool schemas, per-role tool sets, and handler
3
+ * factories for orchestration between leads (facilitator, supervisor,
4
+ * discuss-lead) and their participating agents.
5
+ *
6
+ * **Tool surface, by role:**
7
+ *
8
+ * | | Ask | Answer | Announce | RollCall | Conclude | …extras |
9
+ * |-------------|-----|--------|----------|----------|----------|-----------------------|
10
+ * | Facilitator | ✓ | ✓ | ✓ | ✓ | ✓ | |
11
+ * | Fac. agent | ✓ | ✓ | ✓ | ✓ | | RFC |
12
+ * | Supervisor | ✓ | ✓ | ✓ | ✓ | ✓ | |
13
+ * | Sup. agent | ✓ | ✓ | ✓ | ✓ | | |
14
+ * | Discuss lead| ✓ | ✓ | ✓ | ✓ | | Recess / Adjourn |
15
+ * | Discuss agt | ✓ | ✓ | ✓ | ✓ | | RFC |
16
+ * | Judge | | | | | ✓ | |
17
+ *
18
+ * **Ask is async.** Ask returns `{askIds:[…]}` immediately and posts the
19
+ * question to the addressee's bus queue. The reply arrives on the asker's
20
+ * next turn as `[answer#N] <participant>: <text>`. Pending state keys by
21
+ * `askId` (visible in `[ask#N]` tags), so duplicate Asks to the same
22
+ * addressee coexist without overwriting.
23
+ *
24
+ * **Answer's `askId` is optional.** With a matching askId, the reply
25
+ * routes to that specific asker. Without, the handler auto-picks if
26
+ * exactly one ask is owed to the caller, otherwise routes the message
27
+ * as an Announce so it still reaches everyone.
28
+ */
29
+
30
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
31
+ import { z } from "zod";
32
+
33
+ /** Create a fresh orchestration context object. */
34
+ export function createOrchestrationContext() {
35
+ return {
36
+ concluded: false,
37
+ verdict: null,
38
+ summary: null,
39
+ participants: [],
40
+ messageBus: null,
41
+ // Map<askId, {askId, askerName, addresseeName, reminded}>.
42
+ pendingAsks: new Map(),
43
+ askIdCounter: 0,
44
+ };
45
+ }
46
+
47
+ // --- Handler factories ---
48
+
49
+ /**
50
+ * Guard for terminal tools (`Conclude`, `Adjourn`, `Recess`). Returns an
51
+ * error result when the caller still has Asks in flight, telling them to
52
+ * end the turn and wait for the auto-resume. Returns `null` when no Asks
53
+ * are pending and the terminal tool is free to run.
54
+ */
55
+ export function requireNoPendingAsks(ctx) {
56
+ if (ctx.pendingAsks.size === 0) return null;
57
+ return errorResult(
58
+ "Asks are still pending. End your turn. You will be resumed when answers arrive.",
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Guard for terminal tools in discuss mode (`Adjourn`, `Recess`). Returns
64
+ * an error result when the lead's inbox has unprocessed messages from the
65
+ * human, telling them to end the turn and wait for the auto-resume.
66
+ * Returns `null` when no inbox messages are pending and the terminal tool
67
+ * is free to run.
68
+ */
69
+ export function requireNoUnprocessedInbox(ctx) {
70
+ if (!ctx.messageBus?.hasPending?.("lead")) return null;
71
+ return errorResult(
72
+ "New messages from the human are waiting. End your turn. You will be resumed to process them.",
73
+ );
74
+ }
75
+
76
+ /** Mark the session as concluded; cancel any open Asks so askers see the synthetic null on their next turn. */
77
+ export function createConcludeHandler(ctx) {
78
+ return async ({ verdict, summary }) => {
79
+ const guard = requireNoPendingAsks(ctx);
80
+ if (guard) return guard;
81
+ concludeSession(ctx, { verdict, summary, reason: "session concluded" });
82
+ return { content: [{ type: "text", text: "Session concluded." }] };
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Shared terminal-tool helper. Conclude / Adjourn / Recess all set the
88
+ * same three context fields (`concluded`, `verdict`, `summary`) and
89
+ * cancel any in-flight Asks for the same reason: nobody will ever
90
+ * answer them now. Mode-specific handlers (Adjourn, Recess) layer
91
+ * extra state on top before calling this.
92
+ */
93
+ export function concludeSession(ctx, { verdict, summary, reason }) {
94
+ ctx.concluded = true;
95
+ ctx.verdict = verdict;
96
+ ctx.summary = summary;
97
+ cancelPendingAsks(ctx, reason);
98
+ }
99
+
100
+ /** Return the list of participants and their roles. */
101
+ export function createRollCallHandler(ctx) {
102
+ return async () => ({
103
+ content: [{ type: "text", text: JSON.stringify(ctx.participants) }],
104
+ });
105
+ }
106
+
107
+ function resolveAddressees(ctx, { from, to, defaultTo }) {
108
+ const explicitTo = typeof to === "string" && to.length > 0 ? to : null;
109
+ const effectiveTo = explicitTo ?? defaultTo ?? null;
110
+ if (effectiveTo) return [effectiveTo];
111
+ return ctx.participants.map((p) => p.name).filter((n) => n !== from);
112
+ }
113
+
114
+ function registerPendingAsk(ctx, { from, addressee, question }) {
115
+ const askId = ++ctx.askIdCounter;
116
+ ctx.pendingAsks.set(askId, {
117
+ askId,
118
+ askerName: from,
119
+ addresseeName: addressee,
120
+ reminded: false,
121
+ });
122
+ ctx.messageBus.ask(from, addressee, question, askId);
123
+ return askId;
124
+ }
125
+
126
+ /**
127
+ * Create an Ask handler. Registers a pending entry per addressee, posts
128
+ * the ask on the bus, returns `{askIds:[…]}` immediately. The LLM uses
129
+ * those ids to match the `[answer#N]` it sees on a later turn.
130
+ *
131
+ * @param {object} ctx
132
+ * @param {object} opts
133
+ * @param {string} opts.from
134
+ * @param {string|undefined} opts.defaultTo - `undefined` means "broadcast
135
+ * to everyone else"; a participant name means "target that one when
136
+ * `to` is omitted."
137
+ */
138
+ export function createAskHandler(ctx, { from, defaultTo }) {
139
+ return async ({ question, to }) => {
140
+ if (ctx.concluded) {
141
+ return errorResult("Session is concluded; Ask was not delivered.");
142
+ }
143
+ const addressees = resolveAddressees(ctx, { from, to, defaultTo });
144
+ if (addressees.length === 0) {
145
+ return errorResult("No addressee for Ask.");
146
+ }
147
+ const askIds = addressees.map((addressee) =>
148
+ registerPendingAsk(ctx, { from, addressee, question }),
149
+ );
150
+ return jsonResult({ askIds });
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Create an Answer handler with optional askId.
156
+ *
157
+ * - askId provided + matches a pending entry whose addressee is the caller →
158
+ * route the reply to the asker's queue and clear the pending entry.
159
+ * - askId provided but unknown or wrong addressee → `isError`. The caller
160
+ * tried to specify; we tell them why it didn't match.
161
+ * - askId omitted + exactly one ask owed by the caller → auto-pick it.
162
+ * - askId omitted + 0 or many pending → broadcast as Announce so the
163
+ * message still reaches every other participant.
164
+ */
165
+ export function createAnswerHandler(ctx, { from }) {
166
+ return async ({ askId, message }) => {
167
+ if (typeof askId === "number") {
168
+ return routeAnswerByAskId(ctx, { from, askId, message });
169
+ }
170
+ const owed = [...ctx.pendingAsks.values()].filter(
171
+ (e) => e.addresseeName === from,
172
+ );
173
+ if (owed.length === 1) {
174
+ return routeAnswerByAskId(ctx, {
175
+ from,
176
+ askId: owed[0].askId,
177
+ message,
178
+ });
179
+ }
180
+ ctx.messageBus.announce(from, message);
181
+ const reason =
182
+ owed.length === 0
183
+ ? "no pending ask for you"
184
+ : `${owed.length} pending asks (askId omitted is ambiguous)`;
185
+ return textResult(`Answer routed as Announce — ${reason}.`);
186
+ };
187
+ }
188
+
189
+ function routeAnswerByAskId(ctx, { from, askId, message }) {
190
+ const entry = ctx.pendingAsks.get(askId);
191
+ if (!entry) return errorResult(`No pending ask with askId=${askId}.`);
192
+ if (entry.addresseeName !== from) {
193
+ return errorResult(
194
+ `Ask #${askId} is addressed to ${entry.addresseeName}, not ${from}.`,
195
+ );
196
+ }
197
+ ctx.pendingAsks.delete(askId);
198
+ ctx.messageBus.answer(from, entry.askerName, message, askId);
199
+ return textResult("Answer delivered.");
200
+ }
201
+
202
+ /** Broadcast a message to every participant except the sender. */
203
+ export function createAnnounceHandler(ctx, { from }) {
204
+ return async ({ message }) => {
205
+ ctx.messageBus.announce(from, message);
206
+ return textResult("Announcement delivered.");
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Cancel pending Asks and route a synthetic `[no answer: <reason>]` to
212
+ * each asker's queue so callers never deadlock on a participant ignoring
213
+ * its inbox.
214
+ *
215
+ * @param {object} ctx
216
+ * @param {string} reason - Surfaced inside `[no answer: <reason>]`.
217
+ * @param {string} [addressee] - When set, only cancel asks owed by this
218
+ * addressee. Omit to cancel every pending ask.
219
+ */
220
+ export function cancelPendingAsks(ctx, reason, addressee) {
221
+ const text = `[no answer: ${reason}]`;
222
+ for (const [askId, entry] of [...ctx.pendingAsks]) {
223
+ if (addressee && entry.addresseeName !== addressee) continue;
224
+ ctx.pendingAsks.delete(askId);
225
+ ctx.messageBus.answer("@orchestrator", entry.askerName, text, askId);
226
+ }
227
+ }
228
+
229
+ /** Return the list of pending Asks the named participant owes an Answer to. */
230
+ export function pendingAsksOwedBy(ctx, addressee) {
231
+ return [...ctx.pendingAsks.values()].filter(
232
+ (e) => e.addresseeName === addressee,
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Inject a synthetic reminder onto the addressee's bus queue and mark
238
+ * each owed ask as reminded. Returns true when a reminder fired.
239
+ */
240
+ export function remindOwedAsks(ctx, addressee) {
241
+ const owed = pendingAsksOwedBy(ctx, addressee).filter((e) => !e.reminded);
242
+ if (owed.length === 0) return false;
243
+ for (const entry of owed) entry.reminded = true;
244
+ const lines = owed.map(
245
+ (e) =>
246
+ `You have an unanswered ask from ${e.askerName} (askId=${e.askId}). Reply with Answer(message=…, askId=${e.askId}).`,
247
+ );
248
+ ctx.messageBus.synthetic(addressee, lines.join("\n"));
249
+ return true;
250
+ }
251
+
252
+ // --- Tool descriptions (shared across roles) ---
253
+
254
+ const ASK_DESC_BROADCAST =
255
+ "Send a question to one named participant, or omit 'to' to broadcast to every other participant. Returns {askIds:[…]} immediately; the reply arrives on a later turn as `[answer#N] <from>: <text>` in your inbox.";
256
+
257
+ const ASK_DESC_TARGETED = (target) =>
258
+ `Send a question to ${target}. Returns {askIds:[N]} immediately; the reply arrives on a later turn as \`[answer#N] ${target}: <text>\` in your inbox.`;
259
+
260
+ const ANSWER_DESC =
261
+ "Reply to an ask addressed to you. Quote askId from the [ask#N] tag on the question; omit it and the handler auto-picks the only pending ask, or routes your message as an Announce when 0 or many are pending.";
262
+
263
+ const ANNOUNCE_DESC = "Broadcast a message with no reply expected.";
264
+
265
+ const ROLLCALL_DESC = "List all participants in the session.";
266
+
267
+ // Terminal-tool descriptions. Each one ends the run. Group them so the
268
+ // contrast is visible: Conclude (success/failure), Adjourn (settled in
269
+ // thread), Recess (paused for out-of-session input). Each description
270
+ // leads with the cost.
271
+ const CONCLUDE_DESC =
272
+ "End the session. Provide a verdict ('success' or 'failure') and a summary.";
273
+
274
+ const ADJOURN_DESC =
275
+ "End the discussion. Provide a verdict ('adjourned' or 'failed') and a summary. Cancels any unanswered Asks.";
276
+
277
+ const RECESS_DESC =
278
+ "End the run and schedule an out-of-session re-dispatch. Cancels any unanswered Asks. Use only when waiting on an external reply or duration. Do not use to wait on in-flight Asks.";
279
+
280
+ // --- Tool builders ---
281
+
282
+ /** Helper utilities for handler return values. */
283
+ function textResult(text) {
284
+ return { content: [{ type: "text", text }] };
285
+ }
286
+ /** Build an MCP tool error result wrapping a single text message. */
287
+ function errorResult(text) {
288
+ return { content: [{ type: "text", text }], isError: true };
289
+ }
290
+ function jsonResult(obj) {
291
+ return { content: [{ type: "text", text: JSON.stringify(obj) }] };
292
+ }
293
+
294
+ /**
295
+ * Build the four-tool base for any role (lead or participant). Differences
296
+ * across roles live in `from` / `defaultTo` / whether broadcast is allowed.
297
+ *
298
+ * @param {object} ctx
299
+ * @param {object} opts
300
+ * @param {string} opts.from - Caller's canonical name.
301
+ * @param {string|undefined} opts.defaultTo - Default Ask target; `undefined`
302
+ * means "broadcast across everyone else when `to` is omitted."
303
+ * @param {boolean} opts.broadcast - Whether Ask accepts a `to` field at all.
304
+ * Leads with multiple participants set this true; supervise's
305
+ * single-participant roles set it false.
306
+ */
307
+ function baseTools(ctx, { from, defaultTo, broadcast }) {
308
+ const askSchema = broadcast
309
+ ? { question: z.string(), to: z.string().optional() }
310
+ : { question: z.string() };
311
+ const askDesc = broadcast ? ASK_DESC_BROADCAST : ASK_DESC_TARGETED(defaultTo);
312
+ return [
313
+ tool("Ask", askDesc, askSchema, createAskHandler(ctx, { from, defaultTo })),
314
+ tool(
315
+ "Answer",
316
+ ANSWER_DESC,
317
+ { message: z.string(), askId: z.number().optional() },
318
+ createAnswerHandler(ctx, { from }),
319
+ ),
320
+ tool(
321
+ "Announce",
322
+ ANNOUNCE_DESC,
323
+ { message: z.string() },
324
+ createAnnounceHandler(ctx, { from }),
325
+ ),
326
+ tool("RollCall", ROLLCALL_DESC, {}, createRollCallHandler(ctx)),
327
+ ];
328
+ }
329
+
330
+ /** Conclude tool — shared by facilitator + supervisor. */
331
+ function concludeTool(ctx) {
332
+ return tool(
333
+ "Conclude",
334
+ CONCLUDE_DESC,
335
+ { verdict: z.enum(["success", "failure"]), summary: z.string() },
336
+ createConcludeHandler(ctx),
337
+ );
338
+ }
339
+
340
+ const orchestrationServer = (tools) =>
341
+ createSdkMcpServer({ name: "orchestration", tools });
342
+
343
+ // --- Per-role MCP server factories ---
344
+
345
+ /** Supervisor tools: Ask + Answer + Announce + RollCall + Conclude. */
346
+ export function createSupervisorToolServer(ctx) {
347
+ return orchestrationServer([
348
+ ...baseTools(ctx, {
349
+ from: "supervisor",
350
+ defaultTo: "agent",
351
+ broadcast: false,
352
+ }),
353
+ concludeTool(ctx),
354
+ ]);
355
+ }
356
+
357
+ /** Supervised agent tools: Ask + Answer + Announce + RollCall. */
358
+ export function createSupervisedAgentToolServer(ctx) {
359
+ return orchestrationServer(
360
+ baseTools(ctx, {
361
+ from: "agent",
362
+ defaultTo: "supervisor",
363
+ broadcast: false,
364
+ }),
365
+ );
366
+ }
367
+
368
+ /** Facilitator tools: Ask + Answer + Announce + RollCall + Conclude. */
369
+ export function createFacilitatorToolServer(ctx) {
370
+ return orchestrationServer([
371
+ ...baseTools(ctx, {
372
+ from: "facilitator",
373
+ defaultTo: undefined,
374
+ broadcast: true,
375
+ }),
376
+ concludeTool(ctx),
377
+ ]);
378
+ }
379
+
380
+ /** Facilitated agent tools: Ask + Answer + Announce + RollCall + RequestForComment. */
381
+ export function createFacilitatedAgentToolServer(ctx, { from }) {
382
+ return orchestrationServer([
383
+ ...baseTools(ctx, { from, defaultTo: "facilitator", broadcast: true }),
384
+ requestForCommentTool(ctx),
385
+ ]);
386
+ }
387
+
388
+ /**
389
+ * Judge tools: Conclude only. The judge runs a single post-hoc session
390
+ * with no peer participants.
391
+ */
392
+ export function createJudgeToolServer(ctx) {
393
+ return orchestrationServer([concludeTool(ctx)]);
394
+ }
395
+
396
+ // --- RequestForComment (agent-level coordination tool) ---
397
+
398
+ /** RequestForComment handler — queues RFC intent on `ctx.rfcs[]`. */
399
+ export function createRequestForCommentHandler(ctx) {
400
+ return async ({ channel, body, addressees }) => {
401
+ if (!ctx.rfcs) ctx.rfcs = [];
402
+ if (typeof ctx.rfcCounter !== "number") ctx.rfcCounter = 0;
403
+ const correlationId = `rfc_${++ctx.rfcCounter}`;
404
+ const addresseeList = addressees?.length ? addressees : [null];
405
+ for (const addressee of addresseeList) {
406
+ ctx.rfcs.push({
407
+ ...(addressee && { addressee }),
408
+ body,
409
+ channel,
410
+ ...(ctx.discussionId && { thread_id: ctx.discussionId }),
411
+ correlation_id: correlationId,
412
+ });
413
+ }
414
+ return jsonResult({ correlation_id: correlationId, channel });
415
+ };
416
+ }
417
+
418
+ /** Build the RequestForComment tool definition. */
419
+ function requestForCommentTool(ctx) {
420
+ return tool(
421
+ "RequestForComment",
422
+ "Open a new Discussion thread for long-horizon coordination on an open question. The bridge creates the thread; replies arrive asynchronously on future runs.",
423
+ {
424
+ channel: z.string(),
425
+ body: z.string(),
426
+ addressees: z.array(z.string()).optional(),
427
+ },
428
+ createRequestForCommentHandler(ctx),
429
+ );
430
+ }
431
+
432
+ // Re-export the building blocks discuss-tools.js needs to assemble its
433
+ // own lead tool surface (it has two extra terminal tools).
434
+ export {
435
+ ADJOURN_DESC,
436
+ baseTools,
437
+ errorResult,
438
+ orchestrationServer,
439
+ RECESS_DESC,
440
+ requestForCommentTool,
441
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Render a drained batch of bus messages as tagged text lines so the
3
+ * LLM can read its inbox at a glance. Asks and answers include the
4
+ * `askId` in the tag (`[ask#42] facilitator: …`, `[answer#42] agent: …`)
5
+ * so the addressee can quote it back via Answer's `askId` field.
6
+ *
7
+ * @param {Array<{from: string, text: string, kind?: string, askId?: number}>} messages
8
+ * @returns {string}
9
+ */
10
+ export function formatMessages(messages) {
11
+ return messages.map(formatMessage).join("\n");
12
+ }
13
+
14
+ function formatMessage(m) {
15
+ return `${tagFor(m)} ${m.from}: ${m.text}`;
16
+ }
17
+
18
+ function tagFor(m) {
19
+ if (m.kind === "ask") return `[ask#${m.askId}]`;
20
+ if (m.kind === "answer") return `[answer#${m.askId}]`;
21
+ if (m.kind === "synthetic") return "[system]";
22
+ return "[shared]";
23
+ }