switchroom 0.16.14 → 0.16.16

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.
@@ -15589,6 +15589,10 @@ var init_helpers = __esm(() => {
15589
15589
  function isKnownCheapModel(model) {
15590
15590
  return model !== undefined && CHEAP_MODEL_RE.test(model);
15591
15591
  }
15592
+ function isCheapCronEnabled(env2 = process.env) {
15593
+ const v = (env2.SWITCHROOM_CHEAP_CRON ?? "").toLowerCase();
15594
+ return !(v === "0" || v === "false" || v === "off");
15595
+ }
15592
15596
  function resolveCronModel(model) {
15593
15597
  return isKnownCheapModel(model) ? model : DEFAULT_CRON_MODEL;
15594
15598
  }
@@ -50753,6 +50757,10 @@ function dispatchTool(name, args) {
50753
50757
  cliArgs = buildArgs(["cron", "list"], args);
50754
50758
  parseMode = "json";
50755
50759
  break;
50760
+ case "cron_doctor":
50761
+ cliArgs = buildArgs(["cron", "doctor"], args);
50762
+ parseMode = "json";
50763
+ break;
50756
50764
  case "skill_list":
50757
50765
  cliArgs = buildArgs(["skill", "list"], args);
50758
50766
  parseMode = "json";
@@ -50970,7 +50978,17 @@ var init_server3 = __esm(() => {
50970
50978
  },
50971
50979
  {
50972
50980
  name: "cron_list",
50973
- description: "List the agent's scheduled cron entries (schedule array) as JSON.",
50981
+ description: "List the agent's scheduled cron entries (schedule array) as JSON. " + "Each entry is self-describing: `source` (base-config vs overlay), " + "`file` (switchroom.yaml or the schedule.d path), resolved " + "`tier`/`context`, and a `duplicate_of` back-reference when another " + "entry shares the same cron expression. Original entry fields are " + "preserved.",
50982
+ inputSchema: {
50983
+ type: "object",
50984
+ properties: {
50985
+ agent: { type: "string" }
50986
+ }
50987
+ }
50988
+ },
50989
+ {
50990
+ name: "cron_doctor",
50991
+ description: "Read-only cron health report: duplicate cron expressions (the " + "double-fire hazard), base-config-vs-overlay name conflicts, and " + "entries missing a resolved tier/context. Returns " + "{agent, entry_count, healthy, findings[]}. Call this BEFORE adding " + "a cron, or when a schedule misbehaves (firing twice, removing the " + "wrong entry).",
50974
50992
  inputSchema: {
50975
50993
  type: "object",
50976
50994
  properties: {
@@ -51674,8 +51692,8 @@ import { existsSync, readFileSync } from "node:fs";
51674
51692
  import { dirname, join } from "node:path";
51675
51693
 
51676
51694
  // src/build-info.ts
51677
- var VERSION = "0.16.14";
51678
- var COMMIT_SHA = "6daa5e37";
51695
+ var VERSION = "0.16.17";
51696
+ var COMMIT_SHA = "de202ab1";
51679
51697
 
51680
51698
  // src/cli/resolve-version.ts
51681
51699
  function readPackageVersion() {
@@ -51828,6 +51846,138 @@ import {
51828
51846
  appendFileSync,
51829
51847
  readFileSync as readFileSync5
51830
51848
  } from "node:fs";
51849
+
51850
+ // src/scheduler/cron-introspect.ts
51851
+ init_overlay_loader();
51852
+ init_cron_routing();
51853
+ function normalizeCronExpr(expr) {
51854
+ if (typeof expr !== "string")
51855
+ return null;
51856
+ const t = expr.trim();
51857
+ if (t === "")
51858
+ return null;
51859
+ return t.replace(/\s+/g, " ");
51860
+ }
51861
+ function attributeEntry(raw) {
51862
+ const entry = raw ?? {};
51863
+ const isOverlay = entry[OVERLAY_SOURCE] === true;
51864
+ const overlayTitle = entry[OVERLAY_TITLE];
51865
+ const explicitName = typeof entry.name === "string" && entry.name.trim() !== "" ? entry.name : null;
51866
+ if (isOverlay) {
51867
+ const title = typeof overlayTitle === "string" && overlayTitle.trim() !== "" ? overlayTitle : explicitName;
51868
+ const cron = typeof entry.cron === "string" ? entry.cron : "";
51869
+ const prompt = typeof entry.prompt === "string" ? entry.prompt : undefined;
51870
+ const file = `schedule.d/cron-${cronUnitHash(cron, prompt)}.yaml`;
51871
+ return { entry, source: "overlay", file, name: title };
51872
+ }
51873
+ return { entry, source: "base-config", file: "switchroom.yaml", name: explicitName };
51874
+ }
51875
+ function resolveTierContext(entry, cheapCronEnabled) {
51876
+ const routing = resolveCronRouting({ kind: entry.kind, model: entry.model, context: entry.context }, { cheapCronEnabled });
51877
+ const ctx = routing.tier === "poll" || routing.tier === "action" ? null : routing.session === "cron" ? "fresh" : "agent";
51878
+ return { tier: routing.tier, context: ctx };
51879
+ }
51880
+ function buildCronList(schedule, opts) {
51881
+ const raws = (schedule ?? []).map(attributeEntry);
51882
+ const byExpr = new Map;
51883
+ raws.forEach((r, i) => {
51884
+ const expr = normalizeCronExpr(r.entry.cron);
51885
+ if (expr === null)
51886
+ return;
51887
+ const arr = byExpr.get(expr);
51888
+ if (arr)
51889
+ arr.push(i);
51890
+ else
51891
+ byExpr.set(expr, [i]);
51892
+ });
51893
+ return raws.map((r, i) => {
51894
+ const { tier, context } = resolveTierContext(r.entry, opts.cheapCronEnabled);
51895
+ const expr = normalizeCronExpr(r.entry.cron);
51896
+ const out = {
51897
+ ...r.entry,
51898
+ name: r.name,
51899
+ source: r.source,
51900
+ file: r.file,
51901
+ tier,
51902
+ context
51903
+ };
51904
+ if (expr !== null) {
51905
+ const siblings = (byExpr.get(expr) ?? []).filter((j) => j !== i);
51906
+ if (siblings.length > 0) {
51907
+ out.duplicate_of = siblings.map((j) => ({ name: raws[j].name, file: raws[j].file }));
51908
+ }
51909
+ }
51910
+ return out;
51911
+ });
51912
+ }
51913
+ function cronDoctor(agent, schedule, opts) {
51914
+ const raws = (schedule ?? []).map(attributeEntry);
51915
+ const findings = [];
51916
+ const byExpr = new Map;
51917
+ for (const r of raws) {
51918
+ const expr = normalizeCronExpr(r.entry.cron);
51919
+ if (expr === null)
51920
+ continue;
51921
+ const arr = byExpr.get(expr);
51922
+ if (arr)
51923
+ arr.push(r);
51924
+ else
51925
+ byExpr.set(expr, [r]);
51926
+ }
51927
+ for (const [expr, group] of byExpr) {
51928
+ if (group.length > 1) {
51929
+ findings.push({
51930
+ kind: "duplicate_cron",
51931
+ severity: "error",
51932
+ message: `${group.length} entries share cron expression "${expr}" \u2014 they will ` + `all fire together (double-fire). Remove the redundant one(s).`,
51933
+ cron: expr,
51934
+ entries: group.map((r) => ({ name: r.name, file: r.file }))
51935
+ });
51936
+ }
51937
+ }
51938
+ const byName = new Map;
51939
+ for (const r of raws) {
51940
+ if (r.name === null)
51941
+ continue;
51942
+ const arr = byName.get(r.name);
51943
+ if (arr)
51944
+ arr.push(r);
51945
+ else
51946
+ byName.set(r.name, [r]);
51947
+ }
51948
+ for (const [name, group] of byName) {
51949
+ const sources = new Set(group.map((r) => r.source));
51950
+ if (group.length > 1 && sources.size > 1) {
51951
+ findings.push({
51952
+ kind: "name_conflict",
51953
+ severity: "warn",
51954
+ message: `name "${name}" is used by both a base-config and an overlay entry \u2014 ` + `removing by name is ambiguous.`,
51955
+ conflicting_name: name,
51956
+ entries: group.map((r) => ({ name: r.name, file: r.file }))
51957
+ });
51958
+ }
51959
+ }
51960
+ for (const r of raws) {
51961
+ const expr = normalizeCronExpr(r.entry.cron);
51962
+ if (expr === null) {
51963
+ findings.push({
51964
+ kind: "missing_tier",
51965
+ severity: "warn",
51966
+ message: `entry ${r.name ? `"${r.name}"` : "(unnamed)"} has no valid cron ` + `expression, so its tier/context cannot be resolved.`,
51967
+ entries: [{ name: r.name, file: r.file }]
51968
+ });
51969
+ }
51970
+ }
51971
+ return {
51972
+ agent,
51973
+ entry_count: raws.length,
51974
+ findings,
51975
+ healthy: findings.length === 0
51976
+ };
51977
+ }
51978
+
51979
+ // src/cli/agent-config.ts
51980
+ init_cron_routing();
51831
51981
  var AUDIT_ROOT = join3(homedir2(), ".switchroom", "audit");
51832
51982
  function auditPathFor(agent) {
51833
51983
  return join3(AUDIT_ROOT, agent, "agent-config.jsonl");
@@ -52068,7 +52218,10 @@ function registerAgentConfigCommands(program2) {
52068
52218
  const cfg = getConfig(program2);
52069
52219
  try {
52070
52220
  const slice = getAgentSlice(cfg, agent);
52071
- const tasks = stripSecretValues(slice.schedule ?? []);
52221
+ const rows = buildCronList(slice.schedule ?? [], {
52222
+ cheapCronEnabled: isCheapCronEnabled()
52223
+ });
52224
+ const tasks = stripSecretValues(rows);
52072
52225
  process.stdout.write(JSON.stringify(tasks) + `
52073
52226
  `);
52074
52227
  appendAudit(agent, "cron.list", { ...opts }, 0);
@@ -52079,6 +52232,32 @@ function registerAgentConfigCommands(program2) {
52079
52232
  process.exit(1);
52080
52233
  }
52081
52234
  }));
52235
+ cron.command("doctor").description("Report cron health: duplicate cron expressions, base-vs-overlay " + "name conflicts, and entries missing a resolved tier/context. " + "Read-only.").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").action(withConfigError(async (opts) => {
52236
+ let agent;
52237
+ try {
52238
+ agent = resolveTargetAgent(opts.agent);
52239
+ } catch (err) {
52240
+ process.stderr.write(`${err.message}
52241
+ `);
52242
+ appendAudit(opts.agent ?? "<unknown>", "cron.doctor", { ...opts }, 7);
52243
+ process.exit(7);
52244
+ }
52245
+ const cfg = getConfig(program2);
52246
+ try {
52247
+ const slice = getAgentSlice(cfg, agent);
52248
+ const report = cronDoctor(agent, slice.schedule ?? [], {
52249
+ cheapCronEnabled: isCheapCronEnabled()
52250
+ });
52251
+ process.stdout.write(JSON.stringify(report) + `
52252
+ `);
52253
+ appendAudit(agent, "cron.doctor", { ...opts }, 0);
52254
+ } catch (err) {
52255
+ process.stderr.write(`${err.message}
52256
+ `);
52257
+ appendAudit(agent, "cron.doctor", { ...opts }, 1);
52258
+ process.exit(1);
52259
+ }
52260
+ }));
52082
52261
  const skill = program2.commands.find((c) => c.name() === "skill") ?? program2.command("skill").description("Read-only access to an agent's skill list");
52083
52262
  skill.command("list").description("List the agent's configured skills as JSON").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").action(withConfigError(async (opts) => {
52084
52263
  let agent;
@@ -77417,11 +77596,19 @@ async function injectInbound(agentsDir, agentName, chatId, threadId, text, promp
77417
77596
  });
77418
77597
  }
77419
77598
  async function handleHermesRest(method, pathname, config) {
77420
- if (method === "GET" && pathname === "/api/sessions") {
77599
+ if (method === "GET" && (pathname === "/api/sessions" || pathname === "/api/profiles/sessions")) {
77421
77600
  const agents = await handleGetAgents(config);
77422
- const agentsDir = resolveAgentsDir(config);
77423
77601
  const sessions = agents.map((a) => toHermesSession(a, agentLiveness(config, a.name)));
77424
- return { status: 200, body: { sessions } };
77602
+ return {
77603
+ status: 200,
77604
+ body: {
77605
+ sessions,
77606
+ total: sessions.length,
77607
+ limit: sessions.length,
77608
+ offset: 0,
77609
+ profile_totals: { default: sessions.length }
77610
+ }
77611
+ };
77425
77612
  }
77426
77613
  const sessionMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/);
77427
77614
  if (method === "GET" && sessionMatch) {
@@ -77460,15 +77647,38 @@ async function handleHermesRest(method, pathname, config) {
77460
77647
  };
77461
77648
  }
77462
77649
  if (method === "GET" && pathname === "/api/config") {
77463
- const agentNames = Object.keys(config.agents ?? {});
77464
77650
  return {
77465
77651
  status: 200,
77466
77652
  body: {
77467
77653
  provider: "switchroom",
77468
- agents: agentNames
77654
+ model: null,
77655
+ context_length: null,
77656
+ system_prompt: null
77469
77657
  }
77470
77658
  };
77471
77659
  }
77660
+ if (method === "GET" && (pathname === "/api/config/defaults" || pathname === "/api/config/schema")) {
77661
+ return { status: 200, body: {} };
77662
+ }
77663
+ if (method === "GET" && pathname === "/api/model/info") {
77664
+ return {
77665
+ status: 200,
77666
+ body: {
77667
+ model: "claude",
77668
+ provider: "switchroom",
77669
+ capabilities: {}
77670
+ }
77671
+ };
77672
+ }
77673
+ if (method === "GET" && pathname.startsWith("/api/logs")) {
77674
+ return { status: 200, body: { file: "gateway.log", lines: [] } };
77675
+ }
77676
+ if (method === "GET" && (pathname.startsWith("/api/cron") || pathname.startsWith("/api/messaging") || pathname.startsWith("/api/profiles") || pathname === "/api/memory/providers")) {
77677
+ if (pathname.includes("sessions")) {
77678
+ return { status: 200, body: { sessions: [], total: 0, limit: 0, offset: 0 } };
77679
+ }
77680
+ return { status: 200, body: {} };
77681
+ }
77472
77682
  return null;
77473
77683
  }
77474
77684
  function sendEvent(ctx, type, sessionId, payload) {
@@ -87819,6 +88029,7 @@ function exitCodeFor(code) {
87819
88029
  switch (code) {
87820
88030
  case "E_OVERLAY_SECRETS_REQUIRES_APPROVAL":
87821
88031
  case "E_CRON_TOO_FREQUENT":
88032
+ case "E_CRON_DUPLICATE":
87822
88033
  case "E_QUOTA_EXCEEDED":
87823
88034
  case "E_WRITE_REQUIRES_RECREATE":
87824
88035
  case "E_SLUG_COLLISION":
@@ -87834,6 +88045,24 @@ function exitCodeFor(code) {
87834
88045
  return 10;
87835
88046
  }
87836
88047
  }
88048
+ function parseOverlayCronAndName(e) {
88049
+ let cron;
88050
+ let name;
88051
+ const headerMatch = e.raw.match(/^#[^\S\n]*name:[^\S\n]*(\S.*?)[^\S\n]*$/m);
88052
+ if (headerMatch)
88053
+ name = headerMatch[1];
88054
+ try {
88055
+ const doc = import_yaml21.parse(e.raw);
88056
+ const first = doc?.schedule?.[0];
88057
+ if (first) {
88058
+ if (typeof first.cron === "string")
88059
+ cron = first.cron;
88060
+ if (!name && typeof first.name === "string")
88061
+ name = first.name;
88062
+ }
88063
+ } catch {}
88064
+ return { cron, name };
88065
+ }
87837
88066
  function scheduleAdd(opts) {
87838
88067
  let agent;
87839
88068
  try {
@@ -87903,6 +88132,28 @@ function scheduleAdd(opts) {
87903
88132
  meta: { current: existing.length }
87904
88133
  };
87905
88134
  }
88135
+ const wantExpr = normalizeCronExpr(opts.cronExpr);
88136
+ for (const e of existing) {
88137
+ const parsed = parseOverlayCronAndName(e);
88138
+ if (opts.name && parsed.name && parsed.name === opts.name) {
88139
+ return {
88140
+ ok: false,
88141
+ code: "E_CRON_DUPLICATE",
88142
+ message: `an overlay cron named "${opts.name}" already exists ` + `(${e.slug}.yaml) \u2014 remove it first or choose a different name`,
88143
+ exit: 9,
88144
+ meta: { conflicting_slug: e.slug, conflicting_name: parsed.name }
88145
+ };
88146
+ }
88147
+ if (wantExpr && parsed.cron && normalizeCronExpr(parsed.cron) === wantExpr) {
88148
+ return {
88149
+ ok: false,
88150
+ code: "E_CRON_DUPLICATE",
88151
+ message: `an overlay cron with expression "${wantExpr}" already exists ` + `(${e.slug}.yaml${parsed.name ? `, name "${parsed.name}"` : ""}) \u2014 ` + `adding another would double-fire. Remove the existing one first.`,
88152
+ exit: 9,
88153
+ meta: { conflicting_slug: e.slug, conflicting_cron: wantExpr }
88154
+ };
88155
+ }
88156
+ }
87906
88157
  const hash2 = cronUnitHash(opts.cronExpr, opts.prompt);
87907
88158
  const slug = `cron-${hash2}`;
87908
88159
  let priorContent = null;
@@ -22587,7 +22587,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:f
22587
22587
  import { dirname as dirname4, join as join2 } from "node:path";
22588
22588
 
22589
22589
  // src/build-info.ts
22590
- var VERSION = "0.16.14";
22590
+ var VERSION = "0.16.17";
22591
22591
 
22592
22592
  // src/cli/resolve-version.ts
22593
22593
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.16.14",
3
+ "version": "0.16.16",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -93,6 +93,24 @@ export interface AnswerStreamConfig {
93
93
  ) => Promise<unknown>
94
94
  deleteMessage?: (chatId: string, messageId: number) => Promise<unknown>
95
95
 
96
+ /**
97
+ * Render the raw assistant transcript text into the wire format used for
98
+ * `parse_mode: 'HTML'` sends/edits. Every OTHER outbound lane in the gateway
99
+ * (handleStreamReply, the reply handler, the turn-flush backstop, the PTY
100
+ * partial handler) converts markdown → Telegram HTML via
101
+ * `sanitizeTelegramHtml(markdownToHtml(...))` before sending. The
102
+ * answer-stream lane historically shipped the RAW transcript under
103
+ * `parse_mode: 'HTML'`, so `**bold**` reached the user as literal asterisks
104
+ * and agent narration read as unformatted text.
105
+ *
106
+ * Injected as a dependency (mirroring `renderText` on the PTY partial
107
+ * handler) rather than hard-importing `format`/`html-sanitize` here, so this
108
+ * module stays free of a grammy/format dependency and remains fully
109
+ * testable. When absent, text is sent verbatim — preserving the old
110
+ * behaviour for callers (and tests) that don't wire it.
111
+ */
112
+ renderText?: (text: string) => string
113
+
96
114
  /** Called when a late edit/send resolves but this stream has been superseded. */
97
115
  onSuperseded?: OnSupersededCallback
98
116
  log?: (msg: string) => void
@@ -179,6 +197,7 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
179
197
  replyToMessageId,
180
198
  sendMessage,
181
199
  editMessageText,
200
+ renderText,
182
201
  onSuperseded,
183
202
  log,
184
203
  warn,
@@ -188,6 +207,13 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
188
207
  recordOutbound,
189
208
  } = config
190
209
 
210
+ /**
211
+ * Convert raw transcript text to the wire format before any
212
+ * `parse_mode: 'HTML'` send/edit. Falls back to the verbatim text when no
213
+ * renderer is injected (old behaviour / unwired tests).
214
+ */
215
+ const render = (text: string): string => (renderText != null ? renderText(text) : text)
216
+
191
217
  const effectiveThrottle = Math.max(250, throttleMs)
192
218
 
193
219
  // Stream state
@@ -229,6 +255,10 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
229
255
  }
230
256
 
231
257
  async function sendOrEditViaMessage(trimmed: string, gen: number, prevText: string): Promise<void> {
258
+ // Convert raw transcript markdown → Telegram HTML before sending under
259
+ // parse_mode: 'HTML'. Without this, `**bold**` ships as literal asterisks
260
+ // (the answer-stream-raw-markdown bug). Mirrors every other outbound lane.
261
+ const rendered = render(trimmed)
232
262
  if (typeof streamMsgId === 'number') {
233
263
  // Edit existing message
234
264
  const editParams: Parameters<typeof editMessageText>[3] = {
@@ -237,7 +267,7 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
237
267
  }
238
268
  if (threadId != null) editParams.message_thread_id = threadId
239
269
  try {
240
- await editMessageText(chatId, streamMsgId, trimmed, editParams)
270
+ await editMessageText(chatId, streamMsgId, rendered, editParams)
241
271
  onMetric?.({ kind: 'answer_lane_update', chatId, messageId: streamMsgId, charCount: trimmed.length, transport: 'edit' })
242
272
  } catch (err) {
243
273
  const msg = err instanceof Error ? err.message : String(err)
@@ -266,7 +296,7 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
266
296
  }
267
297
  if (threadId != null) sendParams.message_thread_id = threadId
268
298
  if (replyToMessageId != null) sendParams.reply_parameters = { message_id: replyToMessageId }
269
- const sent = await sendMessage(chatId, trimmed, sendParams)
299
+ const sent = await sendMessage(chatId, rendered, sendParams)
270
300
  const sentId = sent?.message_id
271
301
  if (typeof sentId !== 'number' || !Number.isFinite(sentId)) {
272
302
  warn?.('answer-stream: sendMessage returned no message_id')
@@ -424,7 +454,11 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
424
454
  // nested quote that looks wrong.
425
455
 
426
456
  try {
427
- const sent = await sendMessage(chatId, textToSend, sendParams)
457
+ // Render markdown Telegram HTML for the wire send. The dedup /
458
+ // silent-marker / history checks above all run on the raw textToSend
459
+ // (so they match the comparisons the other lanes make on raw text);
460
+ // only the actual outbound payload is converted.
461
+ const sent = await sendMessage(chatId, render(textToSend), sendParams)
428
462
  const sentId = sent?.message_id
429
463
  if (typeof sentId === 'number' && Number.isFinite(sentId)) {
430
464
  streamMsgId = sentId