switchroom 0.13.56 → 0.13.58
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
CHANGED
|
@@ -48930,8 +48930,8 @@ var {
|
|
|
48930
48930
|
} = import__.default;
|
|
48931
48931
|
|
|
48932
48932
|
// src/build-info.ts
|
|
48933
|
-
var VERSION = "0.13.
|
|
48934
|
-
var COMMIT_SHA = "
|
|
48933
|
+
var VERSION = "0.13.58";
|
|
48934
|
+
var COMMIT_SHA = "20818078";
|
|
48935
48935
|
|
|
48936
48936
|
// src/cli/agent.ts
|
|
48937
48937
|
init_source();
|
package/package.json
CHANGED
|
@@ -31559,6 +31559,109 @@ function isTelegramSurfaceTool(toolName) {
|
|
|
31559
31559
|
return suffix === "reply" || suffix === "stream_reply" || suffix === "edit_message" || suffix === "react";
|
|
31560
31560
|
}
|
|
31561
31561
|
|
|
31562
|
+
// draft-transport.ts
|
|
31563
|
+
var DRAFT_STREAM_STATE_KEY = Symbol.for("switchroom.draftStreamState");
|
|
31564
|
+
function getDraftStreamState() {
|
|
31565
|
+
const g = globalThis;
|
|
31566
|
+
let state = g[DRAFT_STREAM_STATE_KEY];
|
|
31567
|
+
if (!state) {
|
|
31568
|
+
state = { nextDraftId: 0 };
|
|
31569
|
+
g[DRAFT_STREAM_STATE_KEY] = state;
|
|
31570
|
+
}
|
|
31571
|
+
return state;
|
|
31572
|
+
}
|
|
31573
|
+
function allocateDraftId() {
|
|
31574
|
+
const state = getDraftStreamState();
|
|
31575
|
+
state.nextDraftId = state.nextDraftId >= 2147483647 ? 1 : state.nextDraftId + 1;
|
|
31576
|
+
return state.nextDraftId;
|
|
31577
|
+
}
|
|
31578
|
+
|
|
31579
|
+
// tool-activity-summary.ts
|
|
31580
|
+
var READ_VERBS = new Set(["read"]);
|
|
31581
|
+
var WRITE_VERBS = new Set(["wrote", "created", "edited"]);
|
|
31582
|
+
function makeEmptyActivityState() {
|
|
31583
|
+
return { counts: {}, order: [], firstToolName: null };
|
|
31584
|
+
}
|
|
31585
|
+
function verbForTool(toolName) {
|
|
31586
|
+
if (!toolName)
|
|
31587
|
+
return null;
|
|
31588
|
+
const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(toolName);
|
|
31589
|
+
if (mcpMatch && mcpMatch[1] === "switchroom-telegram")
|
|
31590
|
+
return null;
|
|
31591
|
+
const suffix = (mcpMatch ? mcpMatch[2] : toolName).toLowerCase();
|
|
31592
|
+
switch (suffix) {
|
|
31593
|
+
case "read":
|
|
31594
|
+
return "read";
|
|
31595
|
+
case "write":
|
|
31596
|
+
return "created";
|
|
31597
|
+
case "edit":
|
|
31598
|
+
case "multiedit":
|
|
31599
|
+
case "notebookedit":
|
|
31600
|
+
return "edited";
|
|
31601
|
+
case "bash":
|
|
31602
|
+
case "bashoutput":
|
|
31603
|
+
case "killshell":
|
|
31604
|
+
return "ran";
|
|
31605
|
+
case "websearch":
|
|
31606
|
+
case "grep":
|
|
31607
|
+
case "glob":
|
|
31608
|
+
return "searched";
|
|
31609
|
+
case "webfetch":
|
|
31610
|
+
return "fetched";
|
|
31611
|
+
case "task":
|
|
31612
|
+
case "agent":
|
|
31613
|
+
return "dispatched";
|
|
31614
|
+
case "todowrite":
|
|
31615
|
+
case "todoread":
|
|
31616
|
+
return "noted";
|
|
31617
|
+
default:
|
|
31618
|
+
return "used";
|
|
31619
|
+
}
|
|
31620
|
+
}
|
|
31621
|
+
function register(state, toolName) {
|
|
31622
|
+
const verb = verbForTool(toolName);
|
|
31623
|
+
if (!verb)
|
|
31624
|
+
return false;
|
|
31625
|
+
if (state.firstToolName == null)
|
|
31626
|
+
state.firstToolName = toolName;
|
|
31627
|
+
const prior = state.counts[verb] ?? 0;
|
|
31628
|
+
if (prior === 0)
|
|
31629
|
+
state.order.push(verb);
|
|
31630
|
+
state.counts[verb] = prior + 1;
|
|
31631
|
+
return true;
|
|
31632
|
+
}
|
|
31633
|
+
var VERB_PHRASE = {
|
|
31634
|
+
read: { singular: "read a file", plural: "read $N files" },
|
|
31635
|
+
edited: { singular: "edited a file", plural: "edited $N files" },
|
|
31636
|
+
created: { singular: "created a file", plural: "created $N files" },
|
|
31637
|
+
ran: { singular: "ran a command", plural: "ran $N commands" },
|
|
31638
|
+
searched: { singular: "ran a search", plural: "ran $N searches" },
|
|
31639
|
+
fetched: { singular: "fetched a URL", plural: "fetched $N URLs" },
|
|
31640
|
+
dispatched: { singular: "dispatched a sub-agent", plural: "dispatched $N sub-agents" },
|
|
31641
|
+
noted: { singular: "updated the todo list", plural: "updated the todo list ($N edits)" },
|
|
31642
|
+
used: { singular: "used a tool", plural: "used $N tools" }
|
|
31643
|
+
};
|
|
31644
|
+
function formatSummary(state) {
|
|
31645
|
+
const phrases = [];
|
|
31646
|
+
for (const verb of state.order) {
|
|
31647
|
+
const n = state.counts[verb] ?? 0;
|
|
31648
|
+
if (n <= 0)
|
|
31649
|
+
continue;
|
|
31650
|
+
const p = VERB_PHRASE[verb];
|
|
31651
|
+
phrases.push(n === 1 ? p.singular : p.plural.replace("$N", String(n)));
|
|
31652
|
+
}
|
|
31653
|
+
if (phrases.length === 0)
|
|
31654
|
+
return null;
|
|
31655
|
+
const sentence = phrases.join(", ");
|
|
31656
|
+
return sentence.charAt(0).toUpperCase() + sentence.slice(1);
|
|
31657
|
+
}
|
|
31658
|
+
function registerAndRender(state, toolName) {
|
|
31659
|
+
const changed = register(state, toolName);
|
|
31660
|
+
if (!changed)
|
|
31661
|
+
return null;
|
|
31662
|
+
return formatSummary(state);
|
|
31663
|
+
}
|
|
31664
|
+
|
|
31562
31665
|
// tool-labels.ts
|
|
31563
31666
|
var MAX_LABEL_CHARS = 60;
|
|
31564
31667
|
var MAX_BASH_CHARS = 40;
|
|
@@ -31863,18 +31966,18 @@ function isDraft429(err) {
|
|
|
31863
31966
|
const text = typeof err === "string" ? err : err instanceof Error ? err.message : typeof err === "object" && err != null && ("description" in err) ? typeof err.description === "string" ? err.description : "" : "";
|
|
31864
31967
|
return /sendMessageDraft/i.test(text);
|
|
31865
31968
|
}
|
|
31866
|
-
var
|
|
31867
|
-
function
|
|
31969
|
+
var DRAFT_STREAM_STATE_KEY2 = Symbol.for("switchroom.draftStreamState");
|
|
31970
|
+
function getDraftStreamState2() {
|
|
31868
31971
|
const g = globalThis;
|
|
31869
|
-
let state = g[
|
|
31972
|
+
let state = g[DRAFT_STREAM_STATE_KEY2];
|
|
31870
31973
|
if (!state) {
|
|
31871
31974
|
state = { nextDraftId: 0 };
|
|
31872
|
-
g[
|
|
31975
|
+
g[DRAFT_STREAM_STATE_KEY2] = state;
|
|
31873
31976
|
}
|
|
31874
31977
|
return state;
|
|
31875
31978
|
}
|
|
31876
|
-
function
|
|
31877
|
-
const state =
|
|
31979
|
+
function allocateDraftId2() {
|
|
31980
|
+
const state = getDraftStreamState2();
|
|
31878
31981
|
state.nextDraftId = state.nextDraftId >= 2147483647 ? 1 : state.nextDraftId + 1;
|
|
31879
31982
|
return state.nextDraftId;
|
|
31880
31983
|
}
|
|
@@ -31904,7 +32007,7 @@ function createDraftStream(send, edit, config = {}) {
|
|
|
31904
32007
|
warn?.('draft-stream: previewTransport="auto" with sendMessageDraft but isPrivateChat undefined \u2014 defaulting to message transport');
|
|
31905
32008
|
}
|
|
31906
32009
|
let usesDraftTransport = prefersDraft && draftApi != null;
|
|
31907
|
-
let draftId = usesDraftTransport ?
|
|
32010
|
+
let draftId = usesDraftTransport ? allocateDraftId2() : undefined;
|
|
31908
32011
|
if (prefersDraft && !usesDraftTransport) {
|
|
31909
32012
|
warn?.("draft-stream: sendMessageDraft unavailable; falling back to sendMessage/editMessageText");
|
|
31910
32013
|
}
|
|
@@ -32012,7 +32115,7 @@ function createDraftStream(send, edit, config = {}) {
|
|
|
32012
32115
|
const newMsgId = await send(chunk);
|
|
32013
32116
|
messageId = newMsgId;
|
|
32014
32117
|
persistedTextLen = textToSend.length;
|
|
32015
|
-
draftId =
|
|
32118
|
+
draftId = allocateDraftId2();
|
|
32016
32119
|
currentChunkStartedAt = null;
|
|
32017
32120
|
persistChainFires++;
|
|
32018
32121
|
sendFires++;
|
|
@@ -38328,7 +38431,7 @@ function isSilentFlushMarker(text) {
|
|
|
38328
38431
|
var MIN_INITIAL_CHARS = 50;
|
|
38329
38432
|
var DEFAULT_THROTTLE_MS = 1000;
|
|
38330
38433
|
var TELEGRAM_MAX_CHARS2 = 4096;
|
|
38331
|
-
var
|
|
38434
|
+
var allocateDraftId3 = allocateDraftId2;
|
|
38332
38435
|
function createAnswerStream(config) {
|
|
38333
38436
|
const {
|
|
38334
38437
|
chatId,
|
|
@@ -38351,7 +38454,7 @@ function createAnswerStream(config) {
|
|
|
38351
38454
|
const effectiveThrottle = Math.max(250, throttleMs);
|
|
38352
38455
|
const preferDraft = isPrivateChat && draftApi != null;
|
|
38353
38456
|
let usesDraftTransport = preferDraft;
|
|
38354
|
-
let draftId = preferDraft ?
|
|
38457
|
+
let draftId = preferDraft ? allocateDraftId3() : undefined;
|
|
38355
38458
|
let streamMsgId;
|
|
38356
38459
|
let pendingText = null;
|
|
38357
38460
|
let lastSentText = "";
|
|
@@ -38600,7 +38703,7 @@ function createAnswerStream(config) {
|
|
|
38600
38703
|
if (staleDraftId != null) {
|
|
38601
38704
|
clearDraftBestEffort(staleDraftId);
|
|
38602
38705
|
}
|
|
38603
|
-
draftId =
|
|
38706
|
+
draftId = allocateDraftId3();
|
|
38604
38707
|
}
|
|
38605
38708
|
log?.(`answer-stream: forceNewMessage (gen=${generation})`);
|
|
38606
38709
|
},
|
|
@@ -49624,10 +49727,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
49624
49727
|
}
|
|
49625
49728
|
|
|
49626
49729
|
// ../src/build-info.ts
|
|
49627
|
-
var VERSION = "0.13.
|
|
49628
|
-
var COMMIT_SHA = "
|
|
49629
|
-
var COMMIT_DATE = "2026-05-
|
|
49630
|
-
var LATEST_PR =
|
|
49730
|
+
var VERSION = "0.13.58";
|
|
49731
|
+
var COMMIT_SHA = "20818078";
|
|
49732
|
+
var COMMIT_DATE = "2026-05-27T22:11:23Z";
|
|
49733
|
+
var LATEST_PR = 1928;
|
|
49631
49734
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
49632
49735
|
|
|
49633
49736
|
// gateway/boot-version.ts
|
|
@@ -53601,6 +53704,74 @@ function closeProgressLane(chatId, threadId) {
|
|
|
53601
53704
|
}
|
|
53602
53705
|
}
|
|
53603
53706
|
}
|
|
53707
|
+
async function drainActivitySummary(turn) {
|
|
53708
|
+
try {
|
|
53709
|
+
while (turn.activityPendingRender !== turn.activityLastSentRender) {
|
|
53710
|
+
const target = turn.activityPendingRender;
|
|
53711
|
+
if (target == null)
|
|
53712
|
+
break;
|
|
53713
|
+
const html = `<i>${target}</i>`;
|
|
53714
|
+
const chat = turn.sessionChatId;
|
|
53715
|
+
const thread = turn.sessionThreadId;
|
|
53716
|
+
const useDraft = turn.isDm && thread == null && sendMessageDraftFn != null;
|
|
53717
|
+
try {
|
|
53718
|
+
if (useDraft) {
|
|
53719
|
+
if (turn.activityDraftId == null) {
|
|
53720
|
+
turn.activityDraftId = allocateDraftId();
|
|
53721
|
+
}
|
|
53722
|
+
const draftId = turn.activityDraftId;
|
|
53723
|
+
await sendMessageDraftFn(chat, draftId, html, undefined);
|
|
53724
|
+
} else if (turn.activityMessageId == null) {
|
|
53725
|
+
const sent = await robustApiCall(() => bot.api.sendMessage(chat, html, {
|
|
53726
|
+
...thread != null ? { message_thread_id: thread } : {},
|
|
53727
|
+
parse_mode: "HTML",
|
|
53728
|
+
disable_notification: true
|
|
53729
|
+
}), { chat_id: chat, ...thread != null ? { threadId: thread } : {}, verb: "activity-summary.send" });
|
|
53730
|
+
turn.activityMessageId = sent.message_id;
|
|
53731
|
+
} else {
|
|
53732
|
+
const id = turn.activityMessageId;
|
|
53733
|
+
await robustApiCall(() => bot.api.editMessageText(chat, id, html, { parse_mode: "HTML" }), { chat_id: chat, ...thread != null ? { threadId: thread } : {}, verb: "activity-summary.edit" });
|
|
53734
|
+
}
|
|
53735
|
+
turn.activityLastSentRender = target;
|
|
53736
|
+
} catch (err) {
|
|
53737
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
53738
|
+
if (!msg.includes("message is not modified")) {
|
|
53739
|
+
process.stderr.write(`telegram gateway: activity-summary drain failed: ${msg}
|
|
53740
|
+
`);
|
|
53741
|
+
}
|
|
53742
|
+
turn.activityLastSentRender = target;
|
|
53743
|
+
}
|
|
53744
|
+
}
|
|
53745
|
+
} finally {
|
|
53746
|
+
turn.activityInFlight = null;
|
|
53747
|
+
}
|
|
53748
|
+
}
|
|
53749
|
+
function clearActivitySummary(turn) {
|
|
53750
|
+
const chat = turn.sessionChatId;
|
|
53751
|
+
const thread = turn.sessionThreadId;
|
|
53752
|
+
const inFlight = turn.activityInFlight ?? Promise.resolve();
|
|
53753
|
+
inFlight.then(async () => {
|
|
53754
|
+
if (turn.activityDraftId != null && sendMessageDraftFn != null) {
|
|
53755
|
+
const draftId = turn.activityDraftId;
|
|
53756
|
+
turn.activityDraftId = null;
|
|
53757
|
+
try {
|
|
53758
|
+
await sendMessageDraftFn(chat, draftId, "", undefined);
|
|
53759
|
+
} catch (err) {
|
|
53760
|
+
process.stderr.write(`telegram gateway: activity-summary draft-clear failed: ${err}
|
|
53761
|
+
`);
|
|
53762
|
+
}
|
|
53763
|
+
} else if (turn.activityMessageId != null) {
|
|
53764
|
+
const id = turn.activityMessageId;
|
|
53765
|
+
turn.activityMessageId = null;
|
|
53766
|
+
try {
|
|
53767
|
+
await robustApiCall(() => bot.api.deleteMessage(chat, id), { chat_id: chat, ...thread != null ? { threadId: thread } : {}, verb: "activity-summary.delete" });
|
|
53768
|
+
} catch (err) {
|
|
53769
|
+
process.stderr.write(`telegram gateway: activity-summary delete failed: ${err}
|
|
53770
|
+
`);
|
|
53771
|
+
}
|
|
53772
|
+
}
|
|
53773
|
+
});
|
|
53774
|
+
}
|
|
53604
53775
|
function handleSessionEvent(ev) {
|
|
53605
53776
|
switch (ev.kind) {
|
|
53606
53777
|
case "enqueue": {
|
|
@@ -53634,6 +53805,12 @@ function handleSessionEvent(ev) {
|
|
|
53634
53805
|
lastAssistantMsgId: null,
|
|
53635
53806
|
lastAssistantDone: false,
|
|
53636
53807
|
toolCallCount: 0,
|
|
53808
|
+
toolActivity: makeEmptyActivityState(),
|
|
53809
|
+
activityMessageId: null,
|
|
53810
|
+
activityDraftId: null,
|
|
53811
|
+
activityInFlight: null,
|
|
53812
|
+
activityPendingRender: null,
|
|
53813
|
+
activityLastSentRender: null,
|
|
53637
53814
|
answerStream: null,
|
|
53638
53815
|
isDm: isDmChatId(ev.chatId)
|
|
53639
53816
|
};
|
|
@@ -53693,11 +53870,30 @@ function handleSessionEvent(ev) {
|
|
|
53693
53870
|
const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId));
|
|
53694
53871
|
const name = ev.toolName;
|
|
53695
53872
|
if (isTelegramReplyTool(name)) {
|
|
53873
|
+
const wasFirstReply = !turn.replyCalled;
|
|
53696
53874
|
turn.replyCalled = true;
|
|
53697
53875
|
if (turn.orphanedReplyTimeoutId != null) {
|
|
53698
53876
|
clearTimeout(turn.orphanedReplyTimeoutId);
|
|
53699
53877
|
turn.orphanedReplyTimeoutId = null;
|
|
53700
53878
|
}
|
|
53879
|
+
if (wasFirstReply) {
|
|
53880
|
+
clearActivitySummary(turn);
|
|
53881
|
+
}
|
|
53882
|
+
}
|
|
53883
|
+
if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
|
|
53884
|
+
const rendered = registerAndRender(turn.toolActivity, name);
|
|
53885
|
+
if (rendered != null) {
|
|
53886
|
+
try {
|
|
53887
|
+
markAckSent();
|
|
53888
|
+
} catch (err) {
|
|
53889
|
+
process.stderr.write(`telegram gateway: activity-summary markAckSent failed: ${err}
|
|
53890
|
+
`);
|
|
53891
|
+
}
|
|
53892
|
+
turn.activityPendingRender = rendered;
|
|
53893
|
+
if (turn.activityInFlight == null) {
|
|
53894
|
+
turn.activityInFlight = drainActivitySummary(turn);
|
|
53895
|
+
}
|
|
53896
|
+
}
|
|
53701
53897
|
}
|
|
53702
53898
|
if (!ctrl)
|
|
53703
53899
|
return;
|
|
@@ -53,6 +53,12 @@ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
|
53
53
|
import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
|
|
54
54
|
import { StatusReactionController } from '../status-reactions.js'
|
|
55
55
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
56
|
+
import { allocateDraftId } from '../draft-transport.js'
|
|
57
|
+
import {
|
|
58
|
+
makeEmptyActivityState,
|
|
59
|
+
registerAndRender,
|
|
60
|
+
type ActivityState,
|
|
61
|
+
} from '../tool-activity-summary.js'
|
|
56
62
|
import { toolLabel } from '../tool-labels.js'
|
|
57
63
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
58
64
|
import { type DraftStreamHandle } from '../draft-stream.js'
|
|
@@ -1291,6 +1297,39 @@ type CurrentTurn = {
|
|
|
1291
1297
|
// Phase 1 of #332: count of tool_use events in the current turn, for
|
|
1292
1298
|
// the tool_call_count column in the turns registry.
|
|
1293
1299
|
toolCallCount: number
|
|
1300
|
+
// Tool-activity summary — mirrors Claude Code's native chat-UI
|
|
1301
|
+
// rendering ("Ran 5 commands, read a file"). Counters are
|
|
1302
|
+
// incremented in `case 'tool_use'`; `activityMessageId` holds the
|
|
1303
|
+
// Telegram message id we send/edit so a single message accumulates
|
|
1304
|
+
// the summary in place. Stops updating once `replyCalled` flips —
|
|
1305
|
+
// the model's own reply lands below the summary as the actual
|
|
1306
|
+
// content.
|
|
1307
|
+
//
|
|
1308
|
+
// Parallel-tool-use coalescing (PR #1926 review): modern Claude
|
|
1309
|
+
// emits multiple tool_uses in a tight synchronous loop (e.g. 3
|
|
1310
|
+
// parallel Reads). Without coalescing, each would see
|
|
1311
|
+
// `activityMessageId == null` and fire its own sendMessage,
|
|
1312
|
+
// producing N messages instead of one editable summary. Pattern
|
|
1313
|
+
// mirrors `telegram-plugin/answer-stream.ts`:
|
|
1314
|
+
// - `activityInFlight` — promise that resolves when the current
|
|
1315
|
+
// send/edit settles. While set, NEW tool_uses just update
|
|
1316
|
+
// `activityState` and `activityPendingRender` and return.
|
|
1317
|
+
// - When the in-flight resolves, it picks the latest
|
|
1318
|
+
// `activityPendingRender`, fires the next send/edit, and
|
|
1319
|
+
// repeats until the pending matches the last-sent.
|
|
1320
|
+
// Result: at most one Telegram call in flight at a time; the
|
|
1321
|
+
// final state always lands.
|
|
1322
|
+
toolActivity: ActivityState
|
|
1323
|
+
activityMessageId: number | null
|
|
1324
|
+
// Draft-transport id when the activity summary is streamed via
|
|
1325
|
+
// sendMessageDraft (DM-only, no thread). Each call to
|
|
1326
|
+
// sendMessageDraft(chat, draftId, text) REPLACES the draft text —
|
|
1327
|
+
// simpler than send+edit. Cleared by `clearActivitySummary` (which
|
|
1328
|
+
// sends an empty draft) when the model's reply takes over.
|
|
1329
|
+
activityDraftId: number | null
|
|
1330
|
+
activityInFlight: Promise<void> | null
|
|
1331
|
+
activityPendingRender: string | null
|
|
1332
|
+
activityLastSentRender: string | null
|
|
1294
1333
|
// Issue #195 — answer-lane streaming. Lazily created on the first text
|
|
1295
1334
|
// event of a turn (once enough text has accumulated, the stream itself
|
|
1296
1335
|
// gates on minInitialChars). Materialized and cleared at turn_end.
|
|
@@ -6767,6 +6806,120 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
|
|
|
6767
6806
|
}
|
|
6768
6807
|
}
|
|
6769
6808
|
|
|
6809
|
+
/**
|
|
6810
|
+
* Drain the tool-activity summary's pending render queue. Single-flight
|
|
6811
|
+
* by construction (caller assigns the returned promise to
|
|
6812
|
+
* `turn.activityInFlight`; while set, new tool_uses only update
|
|
6813
|
+
* `turn.activityPendingRender` and return).
|
|
6814
|
+
*
|
|
6815
|
+
* Transport priority (mirrors the existing answer-stream pattern):
|
|
6816
|
+
*
|
|
6817
|
+
* 1. DM with no thread AND sendMessageDraft API available →
|
|
6818
|
+
* DRAFT TRANSPORT. Each call REPLACES the draft text (no
|
|
6819
|
+
* edit-in-place needed); the user sees a live preview in their
|
|
6820
|
+
* Telegram compose area as the agent works. When the model's
|
|
6821
|
+
* reply tool lands, `clearActivitySummary` sends an empty draft
|
|
6822
|
+
* to wipe it — only the real reply persists.
|
|
6823
|
+
*
|
|
6824
|
+
* 2. Anything else (forum topic, draft API absent) → fall through
|
|
6825
|
+
* to sendMessage + editMessageText. The activity message is a
|
|
6826
|
+
* real chat message; `clearActivitySummary` deletes it when the
|
|
6827
|
+
* reply tool takes over.
|
|
6828
|
+
*
|
|
6829
|
+
* The drain holds a reference to `turn`, so a turn-swap mid-drain
|
|
6830
|
+
* doesn't corrupt the next turn's atom — late writes land on the
|
|
6831
|
+
* captured `turn` (already-completed turn, harmless).
|
|
6832
|
+
*/
|
|
6833
|
+
async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
|
|
6834
|
+
try {
|
|
6835
|
+
while (turn.activityPendingRender !== turn.activityLastSentRender) {
|
|
6836
|
+
const target = turn.activityPendingRender
|
|
6837
|
+
if (target == null) break
|
|
6838
|
+
const html = `<i>${target}</i>`
|
|
6839
|
+
const chat = turn.sessionChatId
|
|
6840
|
+
const thread = turn.sessionThreadId
|
|
6841
|
+
// sendMessageDraft doesn't support forum threads.
|
|
6842
|
+
const useDraft = turn.isDm && thread == null && sendMessageDraftFn != null
|
|
6843
|
+
try {
|
|
6844
|
+
if (useDraft) {
|
|
6845
|
+
if (turn.activityDraftId == null) {
|
|
6846
|
+
turn.activityDraftId = allocateDraftId()
|
|
6847
|
+
}
|
|
6848
|
+
const draftId = turn.activityDraftId
|
|
6849
|
+
await sendMessageDraftFn!(chat, draftId, html, undefined)
|
|
6850
|
+
} else if (turn.activityMessageId == null) {
|
|
6851
|
+
const sent = await robustApiCall(
|
|
6852
|
+
() => bot.api.sendMessage(chat, html, {
|
|
6853
|
+
...(thread != null ? { message_thread_id: thread } : {}),
|
|
6854
|
+
parse_mode: 'HTML',
|
|
6855
|
+
disable_notification: true,
|
|
6856
|
+
}),
|
|
6857
|
+
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.send' },
|
|
6858
|
+
)
|
|
6859
|
+
turn.activityMessageId = sent.message_id
|
|
6860
|
+
} else {
|
|
6861
|
+
const id = turn.activityMessageId
|
|
6862
|
+
await robustApiCall(
|
|
6863
|
+
() => bot.api.editMessageText(chat, id, html, { parse_mode: 'HTML' }),
|
|
6864
|
+
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.edit' },
|
|
6865
|
+
)
|
|
6866
|
+
}
|
|
6867
|
+
turn.activityLastSentRender = target
|
|
6868
|
+
} catch (err) {
|
|
6869
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
6870
|
+
if (!msg.includes('message is not modified')) {
|
|
6871
|
+
process.stderr.write(`telegram gateway: activity-summary drain failed: ${msg}\n`)
|
|
6872
|
+
}
|
|
6873
|
+
// Mark as sent so we don't infinite-loop on a stuck render.
|
|
6874
|
+
turn.activityLastSentRender = target
|
|
6875
|
+
}
|
|
6876
|
+
}
|
|
6877
|
+
} finally {
|
|
6878
|
+
turn.activityInFlight = null
|
|
6879
|
+
}
|
|
6880
|
+
}
|
|
6881
|
+
|
|
6882
|
+
/**
|
|
6883
|
+
* Clear the activity summary when the model's reply tool takes over
|
|
6884
|
+
* as the authoritative surface. Awaits any in-flight render so we
|
|
6885
|
+
* don't race a stale write against the clear, then either sends an
|
|
6886
|
+
* empty draft (clears the compose-area preview) or deletes the
|
|
6887
|
+
* persisted message. Idempotent + best-effort — failure stderr-logs
|
|
6888
|
+
* but does not block.
|
|
6889
|
+
*
|
|
6890
|
+
* Called from `case 'tool_use'` the moment we see a Telegram reply
|
|
6891
|
+
* tool fire, so the user sees the real reply land in the same beat
|
|
6892
|
+
* the summary disappears.
|
|
6893
|
+
*/
|
|
6894
|
+
function clearActivitySummary(turn: CurrentTurn): void {
|
|
6895
|
+
const chat = turn.sessionChatId
|
|
6896
|
+
const thread = turn.sessionThreadId
|
|
6897
|
+
const inFlight = turn.activityInFlight ?? Promise.resolve()
|
|
6898
|
+
void inFlight.then(async () => {
|
|
6899
|
+
if (turn.activityDraftId != null && sendMessageDraftFn != null) {
|
|
6900
|
+
const draftId = turn.activityDraftId
|
|
6901
|
+
turn.activityDraftId = null
|
|
6902
|
+
try {
|
|
6903
|
+
// Empty text → Telegram clears the draft.
|
|
6904
|
+
await sendMessageDraftFn(chat, draftId, '', undefined)
|
|
6905
|
+
} catch (err) {
|
|
6906
|
+
process.stderr.write(`telegram gateway: activity-summary draft-clear failed: ${err}\n`)
|
|
6907
|
+
}
|
|
6908
|
+
} else if (turn.activityMessageId != null) {
|
|
6909
|
+
const id = turn.activityMessageId
|
|
6910
|
+
turn.activityMessageId = null
|
|
6911
|
+
try {
|
|
6912
|
+
await robustApiCall(
|
|
6913
|
+
() => bot.api.deleteMessage(chat, id),
|
|
6914
|
+
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.delete' },
|
|
6915
|
+
)
|
|
6916
|
+
} catch (err) {
|
|
6917
|
+
process.stderr.write(`telegram gateway: activity-summary delete failed: ${err}\n`)
|
|
6918
|
+
}
|
|
6919
|
+
}
|
|
6920
|
+
})
|
|
6921
|
+
}
|
|
6922
|
+
|
|
6770
6923
|
function handleSessionEvent(ev: SessionEvent): void {
|
|
6771
6924
|
switch (ev.kind) {
|
|
6772
6925
|
case 'enqueue': {
|
|
@@ -6832,6 +6985,12 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6832
6985
|
lastAssistantMsgId: null,
|
|
6833
6986
|
lastAssistantDone: false,
|
|
6834
6987
|
toolCallCount: 0,
|
|
6988
|
+
toolActivity: makeEmptyActivityState(),
|
|
6989
|
+
activityMessageId: null,
|
|
6990
|
+
activityDraftId: null,
|
|
6991
|
+
activityInFlight: null,
|
|
6992
|
+
activityPendingRender: null,
|
|
6993
|
+
activityLastSentRender: null,
|
|
6835
6994
|
answerStream: null,
|
|
6836
6995
|
isDm: isDmChatId(ev.chatId),
|
|
6837
6996
|
}
|
|
@@ -6943,11 +7102,57 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6943
7102
|
// Phase tracking removed in #553 PR 5 — phases only fed the
|
|
6944
7103
|
// placeholder-heartbeat label, which has been retired.
|
|
6945
7104
|
if (isTelegramReplyTool(name)) {
|
|
7105
|
+
const wasFirstReply = !turn.replyCalled
|
|
6946
7106
|
turn.replyCalled = true
|
|
6947
7107
|
if (turn.orphanedReplyTimeoutId != null) {
|
|
6948
7108
|
clearTimeout(turn.orphanedReplyTimeoutId)
|
|
6949
7109
|
turn.orphanedReplyTimeoutId = null
|
|
6950
7110
|
}
|
|
7111
|
+
// The model's real reply takes over as the authoritative
|
|
7112
|
+
// surface. Clear the activity summary — for drafts, send an
|
|
7113
|
+
// empty draft to wipe the compose-area preview; for persisted
|
|
7114
|
+
// messages, delete. The user sees the real reply land in the
|
|
7115
|
+
// same beat the summary disappears.
|
|
7116
|
+
if (wasFirstReply) {
|
|
7117
|
+
clearActivitySummary(turn)
|
|
7118
|
+
}
|
|
7119
|
+
}
|
|
7120
|
+
// Tool-intent surface — companion to the PreToolUse ack-first gate
|
|
7121
|
+
// (#1921). On the FIRST non-reply tool_use of a turn AND only when
|
|
7122
|
+
// Tool-activity summary — same shape Claude Code natively renders
|
|
7123
|
+
// in its CLI/chat UI ("Ran 5 commands, read a file"). The gateway
|
|
7124
|
+
// accumulates non-reply tool_use events into `turn.toolActivity`
|
|
7125
|
+
// and sends ONE Telegram message that edits in place as more tools
|
|
7126
|
+
// land. Stops editing once the model calls `reply` — the summary
|
|
7127
|
+
// line stays as the final state. No model-side prompting; no per-
|
|
7128
|
+
// tool labels. Just surface what's already in the stream.
|
|
7129
|
+
//
|
|
7130
|
+
// Single-flight coalescing (PR #1926 review): modern Claude emits
|
|
7131
|
+
// multiple tool_uses in a synchronous burst (parallel Reads,
|
|
7132
|
+
// Bashes, etc.). All would otherwise race past the message-id
|
|
7133
|
+
// capture and produce N messages. Pattern mirrors answer-stream:
|
|
7134
|
+
// update `activityPendingRender` synchronously here; a single
|
|
7135
|
+
// worker promise drains the pending state, sending or editing
|
|
7136
|
+
// exactly once at a time and re-running until pending matches
|
|
7137
|
+
// the last-sent. Captures `turn` so a late drain after turn-swap
|
|
7138
|
+
// can't corrupt the next turn's atom.
|
|
7139
|
+
if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
|
|
7140
|
+
const rendered = registerAndRender(turn.toolActivity, name)
|
|
7141
|
+
if (rendered != null) {
|
|
7142
|
+
// Mark the ack-flag synchronously so a PreToolUse hook firing
|
|
7143
|
+
// concurrently for THIS tool call (#1921) sees the flag set
|
|
7144
|
+
// and allows the tool through. The drain runs async; failure
|
|
7145
|
+
// is logged but does not block the model.
|
|
7146
|
+
try {
|
|
7147
|
+
markAckSent()
|
|
7148
|
+
} catch (err) {
|
|
7149
|
+
process.stderr.write(`telegram gateway: activity-summary markAckSent failed: ${err}\n`)
|
|
7150
|
+
}
|
|
7151
|
+
turn.activityPendingRender = rendered
|
|
7152
|
+
if (turn.activityInFlight == null) {
|
|
7153
|
+
turn.activityInFlight = drainActivitySummary(turn)
|
|
7154
|
+
}
|
|
7155
|
+
}
|
|
6951
7156
|
}
|
|
6952
7157
|
if (!ctrl) return
|
|
6953
7158
|
if (isTelegramSurfaceTool(name)) return
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
makeEmptyActivityState,
|
|
4
|
+
register,
|
|
5
|
+
formatSummary,
|
|
6
|
+
registerAndRender,
|
|
7
|
+
verbForTool,
|
|
8
|
+
} from "../tool-activity-summary.js";
|
|
9
|
+
|
|
10
|
+
describe("verbForTool — tool name → past-tense verb", () => {
|
|
11
|
+
it("maps standard CLI tools to readable verbs", () => {
|
|
12
|
+
expect(verbForTool("Read")).toBe("read");
|
|
13
|
+
expect(verbForTool("Write")).toBe("created");
|
|
14
|
+
expect(verbForTool("Edit")).toBe("edited");
|
|
15
|
+
expect(verbForTool("MultiEdit")).toBe("edited");
|
|
16
|
+
expect(verbForTool("NotebookEdit")).toBe("edited");
|
|
17
|
+
expect(verbForTool("Bash")).toBe("ran");
|
|
18
|
+
expect(verbForTool("BashOutput")).toBe("ran");
|
|
19
|
+
expect(verbForTool("WebSearch")).toBe("searched");
|
|
20
|
+
expect(verbForTool("Grep")).toBe("searched");
|
|
21
|
+
expect(verbForTool("Glob")).toBe("searched");
|
|
22
|
+
expect(verbForTool("WebFetch")).toBe("fetched");
|
|
23
|
+
expect(verbForTool("Task")).toBe("dispatched");
|
|
24
|
+
expect(verbForTool("Agent")).toBe("dispatched");
|
|
25
|
+
expect(verbForTool("TodoWrite")).toBe("noted");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("skips user-facing switchroom-telegram tools (those ARE the surface)", () => {
|
|
29
|
+
expect(verbForTool("mcp__switchroom-telegram__reply")).toBeNull();
|
|
30
|
+
expect(verbForTool("mcp__switchroom-telegram__stream_reply")).toBeNull();
|
|
31
|
+
expect(verbForTool("mcp__switchroom-telegram__edit_message")).toBeNull();
|
|
32
|
+
expect(verbForTool("mcp__switchroom-telegram__react")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns 'used' for unknown / non-switchroom MCP tools", () => {
|
|
36
|
+
expect(verbForTool("mcp__google-workspace__list_files")).toBe("used");
|
|
37
|
+
expect(verbForTool("mcp__notion__query_database")).toBe("used");
|
|
38
|
+
expect(verbForTool("SomeFutureUnknownTool")).toBe("used");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns null for empty toolName (defensive)", () => {
|
|
42
|
+
expect(verbForTool("")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("register + formatSummary — Claude Code-style summary", () => {
|
|
47
|
+
it("formats a single Read as 'Read a file'", () => {
|
|
48
|
+
const s = makeEmptyActivityState();
|
|
49
|
+
register(s, "Read");
|
|
50
|
+
expect(formatSummary(s)).toBe("Read a file");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("formats multiple Reads as 'Read N files'", () => {
|
|
54
|
+
const s = makeEmptyActivityState();
|
|
55
|
+
register(s, "Read");
|
|
56
|
+
register(s, "Read");
|
|
57
|
+
register(s, "Read");
|
|
58
|
+
expect(formatSummary(s)).toBe("Read 3 files");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("formats single Bash as 'Ran a command'", () => {
|
|
62
|
+
const s = makeEmptyActivityState();
|
|
63
|
+
register(s, "Bash");
|
|
64
|
+
expect(formatSummary(s)).toBe("Ran a command");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("formats multiple Bash as 'Ran N commands'", () => {
|
|
68
|
+
const s = makeEmptyActivityState();
|
|
69
|
+
for (let i = 0; i < 5; i++) register(s, "Bash");
|
|
70
|
+
expect(formatSummary(s)).toBe("Ran 5 commands");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("joins multiple verb-classes with commas (first-occurrence order)", () => {
|
|
74
|
+
const s = makeEmptyActivityState();
|
|
75
|
+
// Tools fire in this order: Read → Bash → Edit
|
|
76
|
+
register(s, "Read");
|
|
77
|
+
register(s, "Bash");
|
|
78
|
+
register(s, "Edit");
|
|
79
|
+
// The summary renders chronologically: read, ran, edited.
|
|
80
|
+
expect(formatSummary(s)).toBe("Read a file, ran a command, edited a file");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("matches the Claude Code screenshot examples", () => {
|
|
84
|
+
// "Ran 5 commands, read a file"
|
|
85
|
+
const s1 = makeEmptyActivityState();
|
|
86
|
+
for (let i = 0; i < 5; i++) register(s1, "Bash");
|
|
87
|
+
register(s1, "Read");
|
|
88
|
+
expect(formatSummary(s1)).toBe("Ran 5 commands, read a file");
|
|
89
|
+
|
|
90
|
+
// "Edited a file, read a file, ran a command"
|
|
91
|
+
const s2 = makeEmptyActivityState();
|
|
92
|
+
register(s2, "Edit");
|
|
93
|
+
register(s2, "Read");
|
|
94
|
+
register(s2, "Bash");
|
|
95
|
+
expect(formatSummary(s2)).toBe("Edited a file, read a file, ran a command");
|
|
96
|
+
|
|
97
|
+
// "Created a file, ran a command"
|
|
98
|
+
const s3 = makeEmptyActivityState();
|
|
99
|
+
register(s3, "Write");
|
|
100
|
+
register(s3, "Bash");
|
|
101
|
+
expect(formatSummary(s3)).toBe("Created a file, ran a command");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns null when state is empty", () => {
|
|
105
|
+
expect(formatSummary(makeEmptyActivityState())).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("ignores user-facing tools (reply/stream_reply etc.)", () => {
|
|
109
|
+
const s = makeEmptyActivityState();
|
|
110
|
+
register(s, "mcp__switchroom-telegram__reply");
|
|
111
|
+
register(s, "mcp__switchroom-telegram__stream_reply");
|
|
112
|
+
expect(formatSummary(s)).toBeNull(); // nothing tracked
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("includes generic 'used' for unknown MCP tools", () => {
|
|
116
|
+
const s = makeEmptyActivityState();
|
|
117
|
+
register(s, "mcp__google-workspace__list_files");
|
|
118
|
+
expect(formatSummary(s)).toBe("Used a tool");
|
|
119
|
+
register(s, "mcp__google-workspace__create_file");
|
|
120
|
+
expect(formatSummary(s)).toBe("Used 2 tools");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("tracks firstToolName for forensic / telemetry use", () => {
|
|
124
|
+
const s = makeEmptyActivityState();
|
|
125
|
+
register(s, "Read");
|
|
126
|
+
register(s, "Bash");
|
|
127
|
+
expect(s.firstToolName).toBe("Read");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("parallel-tool-use coalescing — render only reflects accumulated state", () => {
|
|
132
|
+
it("synchronous burst of N tool_uses produces the right summary at each step", () => {
|
|
133
|
+
// Modern Claude emits parallel tool_uses in a tight sync loop. The
|
|
134
|
+
// gateway calls register() N times before any async drain runs.
|
|
135
|
+
// After N registers, the rendered string should reflect ALL of them
|
|
136
|
+
// — so when the drain fires once with the latest pendingRender, the
|
|
137
|
+
// sent text is correct and complete.
|
|
138
|
+
const s = makeEmptyActivityState();
|
|
139
|
+
register(s, "Read");
|
|
140
|
+
register(s, "Read");
|
|
141
|
+
register(s, "Read");
|
|
142
|
+
register(s, "Bash");
|
|
143
|
+
register(s, "Bash");
|
|
144
|
+
expect(formatSummary(s)).toBe("Read 3 files, ran 2 commands");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("ordering is preserved across a chronological burst", () => {
|
|
148
|
+
const s = makeEmptyActivityState();
|
|
149
|
+
// Simulates: Bash, then Read, then Bash, then Read, then Edit
|
|
150
|
+
register(s, "Bash");
|
|
151
|
+
register(s, "Read");
|
|
152
|
+
register(s, "Bash");
|
|
153
|
+
register(s, "Read");
|
|
154
|
+
register(s, "Edit");
|
|
155
|
+
// Bash was first, then Read, then Edit. Counts: bash 2, read 2, edit 1.
|
|
156
|
+
expect(formatSummary(s)).toBe(
|
|
157
|
+
"Ran 2 commands, read 2 files, edited a file",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("registerAndRender returns null on user-facing tools (no race contribution)", () => {
|
|
162
|
+
const s = makeEmptyActivityState();
|
|
163
|
+
register(s, "Read");
|
|
164
|
+
// A reply tool fires concurrently — should not enter the activity state.
|
|
165
|
+
expect(
|
|
166
|
+
registerAndRender(s, "mcp__switchroom-telegram__reply"),
|
|
167
|
+
).toBeNull();
|
|
168
|
+
// State still reflects only the Read.
|
|
169
|
+
expect(formatSummary(s)).toBe("Read a file");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("registerAndRender — ergonomic full-pipeline call", () => {
|
|
174
|
+
it("returns the updated rendered text on a real tool (chronological)", () => {
|
|
175
|
+
const s = makeEmptyActivityState();
|
|
176
|
+
expect(registerAndRender(s, "Read")).toBe("Read a file");
|
|
177
|
+
// Bash fires AFTER Read — chronological order shows read first.
|
|
178
|
+
expect(registerAndRender(s, "Bash")).toBe(
|
|
179
|
+
"Read a file, ran a command",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns null on a surface tool (no-op)", () => {
|
|
184
|
+
const s = makeEmptyActivityState();
|
|
185
|
+
expect(
|
|
186
|
+
registerAndRender(s, "mcp__switchroom-telegram__reply"),
|
|
187
|
+
).toBeNull();
|
|
188
|
+
// State unchanged
|
|
189
|
+
expect(s.firstToolName).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-activity summary — Claude Code-style natural-language progress
|
|
3
|
+
* line that batches tool_use events for a turn into a single Telegram
|
|
4
|
+
* message that updates in place.
|
|
5
|
+
*
|
|
6
|
+
* Replaces the per-tool intent surface (#1924). The screenshot from
|
|
7
|
+
* Claude Code's own UI shows lines like:
|
|
8
|
+
*
|
|
9
|
+
* "Ran 5 commands, read a file"
|
|
10
|
+
* "Edited a file, read a file, ran a command"
|
|
11
|
+
*
|
|
12
|
+
* Past tense, comma-joined, singular/plural-aware. One message per
|
|
13
|
+
* "phase" (turn start → first reply), progressively edited as tools
|
|
14
|
+
* accumulate. NOT raw tool calls — descriptions of what the agent has
|
|
15
|
+
* been doing.
|
|
16
|
+
*
|
|
17
|
+
* Why this beats per-tool labels:
|
|
18
|
+
* - One Telegram message per phase (low signal-to-noise vs N
|
|
19
|
+
* messages on a heavy turn)
|
|
20
|
+
* - The user sees ACCUMULATED work in a glanceable form, not a flood
|
|
21
|
+
* - Plays nicely with the existing answer-lane stream that handles
|
|
22
|
+
* the actual reply text
|
|
23
|
+
*
|
|
24
|
+
* Tracking shape: per-turn counters keyed by `verb` (the action class
|
|
25
|
+
* derived from tool name). One counter per verb so the summary line
|
|
26
|
+
* collapses neatly regardless of which specific Read/Bash/WebSearch
|
|
27
|
+
* the model chose. `register()` increments the counter; `formatSummary()`
|
|
28
|
+
* renders the current state.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const READ_VERBS = new Set(["read"]);
|
|
32
|
+
const WRITE_VERBS = new Set(["wrote", "created", "edited"]);
|
|
33
|
+
|
|
34
|
+
export type ActivityVerb =
|
|
35
|
+
| "read"
|
|
36
|
+
| "edited"
|
|
37
|
+
| "created"
|
|
38
|
+
| "ran"
|
|
39
|
+
| "searched"
|
|
40
|
+
| "fetched"
|
|
41
|
+
| "dispatched"
|
|
42
|
+
| "noted"
|
|
43
|
+
| "used"; // generic fallback
|
|
44
|
+
|
|
45
|
+
/** Object form so `register()` can mutate; pure functions inside the
|
|
46
|
+
* module work against this shape (easier to unit-test than a Map). */
|
|
47
|
+
export interface ActivityState {
|
|
48
|
+
counts: Partial<Record<ActivityVerb, number>>;
|
|
49
|
+
/** Order verbs were first observed this turn. The summary renders in
|
|
50
|
+
* this order so the line reads as a chronological natural-language
|
|
51
|
+
* account: "edited a file, read a file, ran a command" matches the
|
|
52
|
+
* agent's actual sequence of actions. Stable — once a verb is added
|
|
53
|
+
* to this list, it never moves. */
|
|
54
|
+
order: ActivityVerb[];
|
|
55
|
+
/** First non-trivial tool name observed this turn (for telemetry / future
|
|
56
|
+
* "what kicked this off" forensic). Not used in the rendered summary. */
|
|
57
|
+
firstToolName: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function makeEmptyActivityState(): ActivityState {
|
|
61
|
+
return { counts: {}, order: [], firstToolName: null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Map a tool name → verb. Mirrors the existing `tool-intent-surface.ts`
|
|
65
|
+
* verb table but in past tense. Tools that don't map (or surface tools
|
|
66
|
+
* like reply/stream_reply) return null — the caller skips them. */
|
|
67
|
+
export function verbForTool(toolName: string): ActivityVerb | null {
|
|
68
|
+
if (!toolName) return null;
|
|
69
|
+
const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(toolName);
|
|
70
|
+
// Skip user-facing Telegram-plugin tools entirely — those ARE the
|
|
71
|
+
// surface, never to be summarised.
|
|
72
|
+
if (mcpMatch && mcpMatch[1] === "switchroom-telegram") return null;
|
|
73
|
+
const suffix = (mcpMatch ? mcpMatch[2] : toolName).toLowerCase();
|
|
74
|
+
switch (suffix) {
|
|
75
|
+
case "read":
|
|
76
|
+
return "read";
|
|
77
|
+
case "write":
|
|
78
|
+
return "created";
|
|
79
|
+
case "edit":
|
|
80
|
+
case "multiedit":
|
|
81
|
+
case "notebookedit":
|
|
82
|
+
return "edited";
|
|
83
|
+
case "bash":
|
|
84
|
+
case "bashoutput":
|
|
85
|
+
case "killshell":
|
|
86
|
+
return "ran";
|
|
87
|
+
case "websearch":
|
|
88
|
+
case "grep":
|
|
89
|
+
case "glob":
|
|
90
|
+
return "searched";
|
|
91
|
+
case "webfetch":
|
|
92
|
+
return "fetched";
|
|
93
|
+
case "task":
|
|
94
|
+
case "agent":
|
|
95
|
+
return "dispatched";
|
|
96
|
+
case "todowrite":
|
|
97
|
+
case "todoread":
|
|
98
|
+
return "noted";
|
|
99
|
+
default:
|
|
100
|
+
return "used";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Mutates `state` to record one tool_use of `toolName`. Returns true
|
|
105
|
+
* iff the activity state changed (so the caller knows to refresh the
|
|
106
|
+
* rendered summary). */
|
|
107
|
+
export function register(state: ActivityState, toolName: string): boolean {
|
|
108
|
+
const verb = verbForTool(toolName);
|
|
109
|
+
if (!verb) return false;
|
|
110
|
+
if (state.firstToolName == null) state.firstToolName = toolName;
|
|
111
|
+
const prior = state.counts[verb] ?? 0;
|
|
112
|
+
if (prior === 0) state.order.push(verb);
|
|
113
|
+
state.counts[verb] = prior + 1;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface VerbPhrase {
|
|
118
|
+
singular: string;
|
|
119
|
+
plural: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const VERB_PHRASE: Record<ActivityVerb, VerbPhrase> = {
|
|
123
|
+
read: { singular: "read a file", plural: "read $N files" },
|
|
124
|
+
edited: { singular: "edited a file", plural: "edited $N files" },
|
|
125
|
+
created: { singular: "created a file", plural: "created $N files" },
|
|
126
|
+
ran: { singular: "ran a command", plural: "ran $N commands" },
|
|
127
|
+
searched: { singular: "ran a search", plural: "ran $N searches" },
|
|
128
|
+
fetched: { singular: "fetched a URL", plural: "fetched $N URLs" },
|
|
129
|
+
dispatched: { singular: "dispatched a sub-agent", plural: "dispatched $N sub-agents" },
|
|
130
|
+
noted: { singular: "updated the todo list", plural: "updated the todo list ($N edits)" },
|
|
131
|
+
used: { singular: "used a tool", plural: "used $N tools" },
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Render the activity state as a single natural-language line.
|
|
135
|
+
* Verbs are rendered in `state.order` — first-occurrence order — so
|
|
136
|
+
* the line reads chronologically ("edited a file, read a file, ran
|
|
137
|
+
* a command" mirrors the agent's actual action sequence). Returns
|
|
138
|
+
* null when the state is empty (nothing to show yet). */
|
|
139
|
+
export function formatSummary(state: ActivityState): string | null {
|
|
140
|
+
const phrases: string[] = [];
|
|
141
|
+
for (const verb of state.order) {
|
|
142
|
+
const n = state.counts[verb] ?? 0;
|
|
143
|
+
if (n <= 0) continue;
|
|
144
|
+
const p = VERB_PHRASE[verb];
|
|
145
|
+
phrases.push(n === 1 ? p.singular : p.plural.replace("$N", String(n)));
|
|
146
|
+
}
|
|
147
|
+
if (phrases.length === 0) return null;
|
|
148
|
+
// Capitalize first letter so the sentence reads as a statement.
|
|
149
|
+
const sentence = phrases.join(", ");
|
|
150
|
+
return sentence.charAt(0).toUpperCase() + sentence.slice(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Convenience: ergonomic full pipeline for callers that just want
|
|
154
|
+
* "given the new tool name and prior state, give me the updated rendered
|
|
155
|
+
* text or null if nothing changed". Returns null when the tool is a
|
|
156
|
+
* surface tool / no-op (so the caller can skip the Telegram edit). */
|
|
157
|
+
export function registerAndRender(
|
|
158
|
+
state: ActivityState,
|
|
159
|
+
toolName: string,
|
|
160
|
+
): string | null {
|
|
161
|
+
const changed = register(state, toolName);
|
|
162
|
+
if (!changed) return null;
|
|
163
|
+
return formatSummary(state);
|
|
164
|
+
}
|