switchroom 0.14.26 → 0.14.28

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 (27) hide show
  1. package/dist/cli/switchroom.js +20 -4
  2. package/dist/host-control/main.js +2 -2
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +15 -0
  5. package/telegram-plugin/card-format.ts +65 -0
  6. package/telegram-plugin/dist/bridge/bridge.js +14 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +2201 -1748
  8. package/telegram-plugin/dist/server.js +14 -0
  9. package/telegram-plugin/gateway/gateway.ts +458 -13
  10. package/telegram-plugin/gateway/worker-feed-dispatch.ts +1 -1
  11. package/telegram-plugin/history.ts +16 -4
  12. package/telegram-plugin/permission-title.ts +48 -0
  13. package/telegram-plugin/secret-detect/patterns.ts +8 -0
  14. package/telegram-plugin/secret-detect/redact.ts +76 -0
  15. package/telegram-plugin/tests/card-format.test.ts +96 -0
  16. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +80 -0
  17. package/telegram-plugin/tests/gateway-request-secret.test.ts +78 -0
  18. package/telegram-plugin/tests/history.test.ts +59 -0
  19. package/telegram-plugin/tests/permission-title.test.ts +68 -0
  20. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +35 -0
  21. package/telegram-plugin/tests/secret-detect-sanctum.test.ts +115 -0
  22. package/telegram-plugin/tests/worker-activity-feed.test.ts +110 -51
  23. package/telegram-plugin/uat/assertions.ts +8 -6
  24. package/telegram-plugin/uat/feed-matcher.test.ts +14 -8
  25. package/telegram-plugin/uat/scenarios/jtbd-request-secret-dm.test.ts +101 -0
  26. package/telegram-plugin/uat/scenarios/jtbd-worker-activity-feed-dm.test.ts +17 -6
  27. package/telegram-plugin/worker-activity-feed.ts +84 -46
@@ -32,7 +32,13 @@
32
32
  * the feed is fed from watcher callbacks rather than the bridge event stream.
33
33
  */
34
34
 
35
- import { escapeHtml, formatDuration, truncate } from './card-format.js'
35
+ import {
36
+ cleanWorkerResultParagraph,
37
+ escapeHtml,
38
+ formatDuration,
39
+ stripMarkdown,
40
+ truncate,
41
+ } from './card-format.js'
36
42
 
37
43
  /** Worker-activity feed is ON by default; an operator opts out with
38
44
  * SWITCHROOM_WORKER_ACTIVITY_FEED=0. */
@@ -54,10 +60,11 @@ export interface WorkerActivityView {
54
60
  latestSummary: string
55
61
  /**
56
62
  * Accumulated narrative lines, oldest→newest, already deduped + capped by
57
- * the feed manager. When present and non-empty, the render grows a block
58
- * of `↳` lines (mirroring the main agent's live answer) instead of
59
- * collapsing to the single `latestSummary` line. Absent/empty → the
60
- * single-line fallback (back-compat for direct render callers).
63
+ * the feed manager. When present and non-empty, the render grows a `✓`/`→`
64
+ * step feed (prior steps done, newest in-progress mirroring the main
65
+ * agent's activity card) instead of collapsing to the single `latestSummary`
66
+ * line. Absent/empty → the single-line fallback (back-compat for direct
67
+ * render callers).
61
68
  */
62
69
  narrativeLines?: string[]
63
70
  /** Wall-clock since dispatch, ms. */
@@ -80,8 +87,10 @@ export interface BotApiForWorkerFeed {
80
87
  }
81
88
 
82
89
  const DESC_MAX = 80
83
- const TOOL_ARG_MAX = 64
84
- const SUMMARY_MAX = 100
90
+ const STEP_MAX = 100
91
+ const RESULT_MAX = 320
92
+ /** Subtle horizontal rule between the running feed and the finished result. */
93
+ const RULE = '─────'
85
94
  /**
86
95
  * How many trailing narrative lines the live feed keeps visible. The feed
87
96
  * grows like the main agent's answer but can't grow unbounded — Telegram
@@ -91,57 +100,86 @@ const SUMMARY_MAX = 100
91
100
  const NARRATIVE_MAX_LINES = 6
92
101
 
93
102
  /**
94
- * Render the worker-activity message body as Telegram HTML.
103
+ * Append the accumulated step feed to `lines`, mirroring the main agent's
104
+ * activity card (`renderActivityFeed`): prior steps render done (`✓`, italic),
105
+ * the newest renders in-progress (`→`, bold) unless `allDone`, and an overflow
106
+ * header (`✓ +N earlier…`) appears when the feed exceeds NARRATIVE_MAX_LINES.
107
+ * `steps` are already cleaned + escaped HTML.
108
+ */
109
+ function appendStepFeed(lines: string[], steps: string[], allDone: boolean): void {
110
+ if (steps.length === 0) return
111
+ const shown = steps.slice(-NARRATIVE_MAX_LINES)
112
+ const hidden = steps.length - shown.length
113
+ if (hidden > 0) lines.push(`<i>✓ +${hidden} earlier…</i>`)
114
+ const lastIdx = shown.length - 1
115
+ shown.forEach((s, i) => {
116
+ lines.push(!allDone && i === lastIdx ? `<b>→ ${s}</b>` : `<i>✓ ${s}</i>`)
117
+ })
118
+ }
119
+
120
+ /**
121
+ * Render the worker-activity message body as native Telegram HTML, matching
122
+ * the main agent's activity card (`renderActivityFeed` in
123
+ * tool-activity-summary.ts): a `🛠 Worker · <desc>` header, a one-line status,
124
+ * then a `✓`/`→` step feed. Worker narration is authored as Markdown, so every
125
+ * text fragment is run through `stripMarkdown` before escaping — without it the
126
+ * raw `**`/`` ` ``/`---` leak through as literal characters (the "half-done"
127
+ * look we're fixing).
95
128
  *
96
129
  * Layout (running):
97
- * 🔧 <b>Worker</b> · <i>{description}</i>
98
- * <code>{tool}</code> {arg} <i>({n} tools · {elapsed})</i>
99
- *<i>{latest summary}</i>
130
+ * 🛠 <b>Worker</b> · <i>{description}</i>
131
+ * <i>running · {elapsed} · {n} tools</i>
132
+ * <i>✓ {earlier step}</i>
133
+ * <b>→ {newest step}</b>
100
134
  *
101
- * Terminal collapses the activity line to a tool-count + duration recap:
102
- * <b>Worker done</b> · <i>{description}</i>
103
- * <i>{n} tools · {elapsed}</i>
135
+ * Layout (finished): the feed renders all-done, then a rule + cleaned result:
136
+ * 🛠 <b>Worker</b> · <i>{description}</i>
137
+ * <i>finished · completed · {n} tools · {elapsed}</i>
138
+ * <i>✓ {step}</i>
139
+ * ─────
140
+ * ✅ <i>{cleaned result paragraph}</i>
104
141
  */
105
142
  export function renderWorkerActivity(v: WorkerActivityView): string {
106
- const desc = truncate(v.description.trim() || 'background task', DESC_MAX)
143
+ const desc = truncate(stripMarkdown(v.description).trim() || 'background task', DESC_MAX)
107
144
  const elapsed = formatDuration(v.elapsedMs)
108
145
  const toolWord = v.toolCount === 1 ? 'tool' : 'tools'
146
+ const header = `🛠 <b>Worker</b> · <i>${escapeHtml(desc)}</i>`
147
+ const finished = v.state === 'done' || v.state === 'failed'
109
148
 
110
- if (v.state === 'done' || v.state === 'failed') {
111
- const head =
112
- v.state === 'done'
113
- ? `✅ <b>Worker done</b> · <i>${escapeHtml(desc)}</i>`
114
- : `⚠️ <b>Worker failed</b> · <i>${escapeHtml(desc)}</i>`
115
- return `${head}\n<i>${v.toolCount} ${toolWord} · ${elapsed}</i>`
116
- }
117
-
118
- const header = `🔧 <b>Worker</b> · <i>${escapeHtml(desc)}</i>`
149
+ const steps = (v.narrativeLines ?? [])
150
+ // A narrative entry can itself be multi-line (e.g. a worker's final
151
+ // "Done.\n\n## Summary\n…"). Collapse to one visual line so a step
152
+ // slot stays single-line after the per-line markdown strip.
153
+ .map((s) => stripMarkdown(s).replace(/\s+/g, ' ').trim())
154
+ .filter((s) => s.length > 0)
155
+ .map((s) => escapeHtml(truncate(s, STEP_MAX)))
119
156
 
120
- let activity: string
121
- if (v.lastTool != null) {
122
- const arg = v.lastTool.sanitisedArg.trim()
123
- const argPart = arg.length > 0 ? ` ${escapeHtml(truncate(arg, TOOL_ARG_MAX))}` : ''
124
- activity = `⚡ <code>${escapeHtml(v.lastTool.name)}</code>${argPart} <i>(${v.toolCount} ${toolWord} · ${elapsed})</i>`
125
- } else {
126
- activity = `<i>starting… (${elapsed})</i>`
157
+ if (finished) {
158
+ const verb = v.state === 'done' ? 'completed' : 'failed'
159
+ const lines = [header, `<i>finished · ${verb} · ${v.toolCount} ${toolWord} · ${elapsed}</i>`]
160
+ appendStepFeed(lines, steps, true)
161
+ // On terminal, latestSummary carries the worker's final result text
162
+ // (gateway onFinish), distinct from the running narrative steps.
163
+ const result = cleanWorkerResultParagraph(v.latestSummary)
164
+ if (result.length > 0) {
165
+ const emoji = v.state === 'done' ? '✅' : '⚠️'
166
+ lines.push(RULE)
167
+ lines.push(`${emoji} <i>${escapeHtml(truncate(result, RESULT_MAX))}</i>`)
168
+ }
169
+ return lines.join('\n')
127
170
  }
128
171
 
129
- const lines = [header, activity]
130
-
131
- // Growing narrative block when the manager has accumulated lines; the feed
132
- // reads like the main agent's live answer rather than a single replaced
133
- // status line. Fall back to the single latestSummary line otherwise.
134
- const narrative = (v.narrativeLines ?? [])
135
- .map((s) => s.trim())
136
- .filter((s) => s.length > 0)
137
- if (narrative.length > 0) {
138
- for (const line of narrative) {
139
- lines.push(` ↳ <i>${escapeHtml(truncate(line, SUMMARY_MAX))}</i>`)
140
- }
172
+ const lines = [header, `<i>running · ${elapsed} · ${v.toolCount} ${toolWord}</i>`]
173
+ if (steps.length > 0) {
174
+ appendStepFeed(lines, steps, false)
141
175
  } else {
142
- const summary = v.latestSummary.trim()
176
+ // Back-compat for direct render callers that pass only latestSummary;
177
+ // the manager always supplies narrativeLines.
178
+ const summary = stripMarkdown(v.latestSummary).replace(/\s+/g, ' ').trim()
143
179
  if (summary.length > 0) {
144
- lines.push(` ↳ <i>${escapeHtml(truncate(summary, SUMMARY_MAX))}</i>`)
180
+ lines.push(`<b>→ ${escapeHtml(truncate(summary, STEP_MAX))}</b>`)
181
+ } else {
182
+ lines.push('<i>starting…</i>')
145
183
  }
146
184
  }
147
185
  return lines.join('\n')
@@ -307,7 +345,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
307
345
  // message is left at its last running render — stale but harmless.
308
346
  return
309
347
  }
310
- const body = renderWorkerActivity(view)
348
+ const body = renderWorkerActivity({ ...view, narrativeLines: h.narrative })
311
349
  if (body === h.lastBody) return
312
350
  try {
313
351
  await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))