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.
- package/dist/cli/switchroom.js +260 -9
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/answer-stream.ts +37 -3
- package/telegram-plugin/dist/gateway/gateway.js +265 -72
- package/telegram-plugin/gateway/feed-open-gate.ts +54 -1
- package/telegram-plugin/gateway/gateway.ts +176 -86
- package/telegram-plugin/gateway/model-command.ts +22 -1
- package/telegram-plugin/gateway/turn-typing-loop.ts +102 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/tests/answer-stream.test.ts +118 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +21 -6
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +46 -40
- package/telegram-plugin/tests/feed-open-gate.test.ts +75 -1
- package/telegram-plugin/tests/model-command.test.ts +7 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +20 -0
- package/telegram-plugin/tests/turn-typing-loop.test.ts +124 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -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.
|
|
51678
|
-
var COMMIT_SHA = "
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
22590
|
+
var VERSION = "0.16.17";
|
|
22591
22591
|
|
|
22592
22592
|
// src/cli/resolve-version.ts
|
|
22593
22593
|
function readPackageVersion() {
|
package/package.json
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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
|