@grinev/opencode-telegram-bot 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.env.example +11 -0
  2. package/README.md +26 -15
  3. package/dist/bot/commands/definitions.js +7 -4
  4. package/dist/bot/commands/help.js +7 -1
  5. package/dist/bot/commands/new.js +4 -0
  6. package/dist/bot/commands/projects.js +18 -9
  7. package/dist/bot/commands/rename.js +49 -1
  8. package/dist/bot/commands/sessions.js +15 -2
  9. package/dist/bot/commands/stop.js +3 -5
  10. package/dist/bot/handlers/agent.js +12 -1
  11. package/dist/bot/handlers/context.js +15 -25
  12. package/dist/bot/handlers/inline-menu.js +119 -0
  13. package/dist/bot/handlers/model.js +15 -2
  14. package/dist/bot/handlers/permission.js +81 -12
  15. package/dist/bot/handlers/question.js +97 -9
  16. package/dist/bot/handlers/variant.js +12 -1
  17. package/dist/bot/index.js +92 -16
  18. package/dist/bot/middleware/interaction-guard.js +80 -0
  19. package/dist/bot/middleware/unknown-command.js +22 -0
  20. package/dist/bot/utils/commands.js +21 -0
  21. package/dist/config.js +31 -0
  22. package/dist/i18n/en.js +23 -4
  23. package/dist/i18n/ru.js +23 -4
  24. package/dist/interaction/cleanup.js +24 -0
  25. package/dist/interaction/guard.js +87 -0
  26. package/dist/interaction/manager.js +106 -0
  27. package/dist/interaction/types.js +1 -0
  28. package/dist/permission/manager.js +60 -38
  29. package/dist/pinned/manager.js +7 -5
  30. package/dist/question/manager.js +33 -0
  31. package/dist/rename/manager.js +3 -0
  32. package/dist/settings/manager.js +6 -1
  33. package/dist/summary/aggregator.js +87 -6
  34. package/dist/summary/formatter.js +91 -15
  35. package/dist/summary/tool-message-batcher.js +182 -0
  36. package/package.json +1 -1
@@ -0,0 +1,87 @@
1
+ import { interactionManager } from "./manager.js";
2
+ function normalizeIncomingCommand(text) {
3
+ const trimmed = text.trim();
4
+ if (!trimmed.startsWith("/")) {
5
+ return null;
6
+ }
7
+ const token = trimmed.split(/\s+/)[0];
8
+ const withoutMention = token.split("@")[0].toLowerCase();
9
+ if (withoutMention.length <= 1) {
10
+ return null;
11
+ }
12
+ return withoutMention;
13
+ }
14
+ function classifyIncomingInput(ctx) {
15
+ if (ctx.callbackQuery?.data) {
16
+ return { inputType: "callback" };
17
+ }
18
+ const text = ctx.message?.text;
19
+ if (typeof text === "string") {
20
+ const command = normalizeIncomingCommand(text);
21
+ if (command) {
22
+ return { inputType: "command", command };
23
+ }
24
+ return { inputType: "text" };
25
+ }
26
+ return { inputType: "other" };
27
+ }
28
+ function getExpectedInputBlockReason(expectedInput) {
29
+ switch (expectedInput) {
30
+ case "callback":
31
+ return "expected_callback";
32
+ case "command":
33
+ return "expected_command";
34
+ case "text":
35
+ case "mixed":
36
+ return "expected_text";
37
+ }
38
+ }
39
+ function createAllowDecision(inputType, state, command) {
40
+ return {
41
+ allow: true,
42
+ inputType,
43
+ state,
44
+ command,
45
+ };
46
+ }
47
+ function createBlockDecision(inputType, state, reason, command) {
48
+ return {
49
+ allow: false,
50
+ inputType,
51
+ state,
52
+ reason,
53
+ command,
54
+ };
55
+ }
56
+ function isAllowedRenameCancelCallback(ctx, state) {
57
+ return (state.kind === "rename" &&
58
+ state.expectedInput === "text" &&
59
+ ctx.callbackQuery?.data === "rename:cancel");
60
+ }
61
+ export function resolveInteractionGuardDecision(ctx) {
62
+ const state = interactionManager.getSnapshot();
63
+ const { inputType, command } = classifyIncomingInput(ctx);
64
+ if (!state) {
65
+ return createAllowDecision(inputType, null, command);
66
+ }
67
+ if (interactionManager.isExpired()) {
68
+ interactionManager.clear("expired");
69
+ return createBlockDecision(inputType, state, "expired", command);
70
+ }
71
+ if (inputType === "command") {
72
+ if (command && state.allowedCommands.includes(command)) {
73
+ return createAllowDecision(inputType, state, command);
74
+ }
75
+ return createBlockDecision(inputType, state, "command_not_allowed", command);
76
+ }
77
+ if (state.expectedInput === "mixed") {
78
+ return createAllowDecision(inputType, state, command);
79
+ }
80
+ if (inputType === "callback" && isAllowedRenameCancelCallback(ctx, state)) {
81
+ return createAllowDecision(inputType, state, command);
82
+ }
83
+ if (state.expectedInput === inputType) {
84
+ return createAllowDecision(inputType, state, command);
85
+ }
86
+ return createBlockDecision(inputType, state, getExpectedInputBlockReason(state.expectedInput), command);
87
+ }
@@ -0,0 +1,106 @@
1
+ import { logger } from "../utils/logger.js";
2
+ export const DEFAULT_ALLOWED_INTERACTION_COMMANDS = ["/help", "/status", "/stop"];
3
+ function normalizeCommand(command) {
4
+ const trimmed = command.trim().toLowerCase();
5
+ if (!trimmed) {
6
+ return null;
7
+ }
8
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
9
+ const withoutMention = withSlash.split("@")[0];
10
+ if (withoutMention.length <= 1) {
11
+ return null;
12
+ }
13
+ return withoutMention;
14
+ }
15
+ function normalizeAllowedCommands(commands) {
16
+ if (commands === undefined) {
17
+ return [...DEFAULT_ALLOWED_INTERACTION_COMMANDS];
18
+ }
19
+ const normalized = new Set();
20
+ for (const command of commands) {
21
+ const value = normalizeCommand(command);
22
+ if (value) {
23
+ normalized.add(value);
24
+ }
25
+ }
26
+ return Array.from(normalized);
27
+ }
28
+ function cloneState(state) {
29
+ return {
30
+ ...state,
31
+ allowedCommands: [...state.allowedCommands],
32
+ metadata: { ...state.metadata },
33
+ };
34
+ }
35
+ class InteractionManager {
36
+ state = null;
37
+ start(options) {
38
+ const now = Date.now();
39
+ let expiresAt = null;
40
+ if (this.state) {
41
+ this.clear("state_replaced");
42
+ }
43
+ if (typeof options.expiresInMs === "number") {
44
+ expiresAt = now + options.expiresInMs;
45
+ }
46
+ const nextState = {
47
+ kind: options.kind,
48
+ expectedInput: options.expectedInput,
49
+ allowedCommands: normalizeAllowedCommands(options.allowedCommands),
50
+ metadata: options.metadata ? { ...options.metadata } : {},
51
+ createdAt: now,
52
+ expiresAt,
53
+ };
54
+ this.state = nextState;
55
+ logger.info(`[InteractionManager] Started interaction: kind=${nextState.kind}, expectedInput=${nextState.expectedInput}, allowedCommands=${nextState.allowedCommands.join(",") || "none"}`);
56
+ return cloneState(nextState);
57
+ }
58
+ get() {
59
+ if (!this.state) {
60
+ return null;
61
+ }
62
+ return cloneState(this.state);
63
+ }
64
+ getSnapshot() {
65
+ return this.get();
66
+ }
67
+ isActive() {
68
+ return this.state !== null;
69
+ }
70
+ isExpired(referenceTimeMs = Date.now()) {
71
+ if (!this.state || this.state.expiresAt === null) {
72
+ return false;
73
+ }
74
+ return referenceTimeMs >= this.state.expiresAt;
75
+ }
76
+ transition(options) {
77
+ if (!this.state) {
78
+ return null;
79
+ }
80
+ const now = Date.now();
81
+ this.state = {
82
+ ...this.state,
83
+ kind: options.kind ?? this.state.kind,
84
+ expectedInput: options.expectedInput ?? this.state.expectedInput,
85
+ allowedCommands: options.allowedCommands !== undefined
86
+ ? normalizeAllowedCommands(options.allowedCommands)
87
+ : [...this.state.allowedCommands],
88
+ metadata: options.metadata ? { ...options.metadata } : { ...this.state.metadata },
89
+ expiresAt: options.expiresInMs === undefined
90
+ ? this.state.expiresAt
91
+ : options.expiresInMs === null
92
+ ? null
93
+ : now + options.expiresInMs,
94
+ };
95
+ logger.debug(`[InteractionManager] Transitioned interaction: kind=${this.state.kind}, expectedInput=${this.state.expectedInput}, allowedCommands=${this.state.allowedCommands.join(",") || "none"}`);
96
+ return cloneState(this.state);
97
+ }
98
+ clear(reason = "manual") {
99
+ if (!this.state) {
100
+ return;
101
+ }
102
+ logger.info(`[InteractionManager] Cleared interaction: reason=${reason}, kind=${this.state.kind}, expectedInput=${this.state.expectedInput}`);
103
+ this.state = null;
104
+ }
105
+ }
106
+ export const interactionManager = new InteractionManager();
@@ -0,0 +1 @@
1
+ export {};
@@ -1,77 +1,99 @@
1
1
  import { logger } from "../utils/logger.js";
2
2
  class PermissionManager {
3
3
  state = {
4
- request: null,
5
- messageId: null,
6
- isActive: false,
4
+ requestsByMessageId: new Map(),
7
5
  };
8
6
  /**
9
- * Start a new permission request
7
+ * Register a new permission request message
10
8
  */
11
- startPermission(request) {
12
- logger.debug(`[PermissionManager] startPermission: id=${request.id}, permission=${request.permission}`);
13
- if (this.state.isActive) {
14
- logger.warn("[PermissionManager] Permission already active, replacing");
15
- this.clear();
9
+ startPermission(request, messageId) {
10
+ logger.debug(`[PermissionManager] startPermission: id=${request.id}, permission=${request.permission}, messageId=${messageId}`);
11
+ if (this.state.requestsByMessageId.has(messageId)) {
12
+ logger.warn(`[PermissionManager] Message ID already tracked, replacing: ${messageId}`);
16
13
  }
17
- logger.info(`[PermissionManager] New permission request: type=${request.permission}, patterns=${request.patterns.join(", ")}`);
18
- this.state = {
19
- request,
20
- messageId: null,
21
- isActive: true,
22
- };
14
+ this.state.requestsByMessageId.set(messageId, request);
15
+ logger.info(`[PermissionManager] New permission request: type=${request.permission}, patterns=${request.patterns.join(", ")}, pending=${this.state.requestsByMessageId.size}`);
23
16
  }
24
17
  /**
25
- * Get current permission request
18
+ * Get permission request by Telegram message ID
26
19
  */
27
- getRequest() {
28
- return this.state.request;
20
+ getRequest(messageId) {
21
+ if (messageId === null) {
22
+ return null;
23
+ }
24
+ return this.state.requestsByMessageId.get(messageId) ?? null;
29
25
  }
30
26
  /**
31
- * Get request ID for API reply
27
+ * Get request ID for API reply by Telegram message ID
32
28
  */
33
- getRequestID() {
34
- return this.state.request?.id ?? null;
29
+ getRequestID(messageId) {
30
+ return this.getRequest(messageId)?.id ?? null;
35
31
  }
36
32
  /**
37
- * Get permission type (bash, edit, etc.)
33
+ * Get permission type (bash, edit, etc.) by message ID
38
34
  */
39
- getPermissionType() {
40
- return this.state.request?.permission ?? null;
35
+ getPermissionType(messageId) {
36
+ return this.getRequest(messageId)?.permission ?? null;
41
37
  }
42
38
  /**
43
- * Get patterns (commands/files)
39
+ * Get patterns (commands/files) by message ID
44
40
  */
45
- getPatterns() {
46
- return this.state.request?.patterns ?? [];
41
+ getPatterns(messageId) {
42
+ return this.getRequest(messageId)?.patterns ?? [];
47
43
  }
48
44
  /**
49
- * Set Telegram message ID for later deletion
45
+ * Check if callback message ID belongs to active permission request
50
46
  */
51
- setMessageId(messageId) {
52
- this.state.messageId = messageId;
47
+ isActiveMessage(messageId) {
48
+ return messageId !== null && this.state.requestsByMessageId.has(messageId);
53
49
  }
54
50
  /**
55
- * Get Telegram message ID
51
+ * Get latest Telegram message ID
56
52
  */
57
53
  getMessageId() {
58
- return this.state.messageId;
54
+ const messageIds = this.getMessageIds();
55
+ if (messageIds.length === 0) {
56
+ return null;
57
+ }
58
+ return messageIds[messageIds.length - 1];
59
+ }
60
+ /**
61
+ * Get Telegram message IDs for all active requests
62
+ */
63
+ getMessageIds() {
64
+ return Array.from(this.state.requestsByMessageId.keys());
65
+ }
66
+ /**
67
+ * Remove permission request by Telegram message ID
68
+ */
69
+ removeByMessageId(messageId) {
70
+ const request = this.getRequest(messageId);
71
+ if (!request || messageId === null) {
72
+ return null;
73
+ }
74
+ this.state.requestsByMessageId.delete(messageId);
75
+ logger.debug(`[PermissionManager] Removed permission request: id=${request.id}, messageId=${messageId}, pending=${this.state.requestsByMessageId.size}`);
76
+ return request;
77
+ }
78
+ /**
79
+ * Get number of active permission requests
80
+ */
81
+ getPendingCount() {
82
+ return this.state.requestsByMessageId.size;
59
83
  }
60
84
  /**
61
- * Check if permission request is active
85
+ * Check if there are active permission requests
62
86
  */
63
87
  isActive() {
64
- return this.state.isActive;
88
+ return this.state.requestsByMessageId.size > 0;
65
89
  }
66
90
  /**
67
91
  * Clear state after reply
68
92
  */
69
93
  clear() {
70
- logger.debug("[PermissionManager] Clearing permission state");
94
+ logger.debug(`[PermissionManager] Clearing permission state: pending=${this.state.requestsByMessageId.size}`);
71
95
  this.state = {
72
- request: null,
73
- messageId: null,
74
- isActive: false,
96
+ requestsByMessageId: new Map(),
75
97
  };
76
98
  }
77
99
  }
@@ -266,7 +266,7 @@ class PinnedMessageManager {
266
266
  logger.debug(`[PinnedManager] loadDiffsFromMessages: ${messagesData.length} messages`);
267
267
  const filesMap = new Map();
268
268
  let toolCount = 0;
269
- let editWriteCount = 0;
269
+ let fileToolCount = 0;
270
270
  for (const { parts } of messagesData) {
271
271
  for (const part of parts) {
272
272
  if (part.type !== "tool")
@@ -275,10 +275,12 @@ class PinnedMessageManager {
275
275
  const toolPart = part;
276
276
  if (toolPart.state.status !== "completed")
277
277
  continue;
278
- if (toolPart.tool === "edit" || toolPart.tool === "write") {
279
- editWriteCount++;
278
+ if (toolPart.tool === "edit" ||
279
+ toolPart.tool === "write" ||
280
+ toolPart.tool === "apply_patch") {
281
+ fileToolCount++;
280
282
  }
281
- if (toolPart.tool === "edit" &&
283
+ if ((toolPart.tool === "edit" || toolPart.tool === "apply_patch") &&
282
284
  toolPart.state.metadata &&
283
285
  "filediff" in toolPart.state.metadata) {
284
286
  const filediff = toolPart.state.metadata.filediff;
@@ -318,7 +320,7 @@ class PinnedMessageManager {
318
320
  }
319
321
  }
320
322
  }
321
- logger.debug(`[PinnedManager] loadDiffsFromMessages: found ${toolCount} tool parts, ${editWriteCount} edit/write`);
323
+ logger.debug(`[PinnedManager] loadDiffsFromMessages: found ${toolCount} tool parts, ${fileToolCount} file tools`);
322
324
  if (filesMap.size > 0) {
323
325
  this.state.changedFiles = Array.from(filesMap.values());
324
326
  logger.info(`[PinnedManager] Loaded ${this.state.changedFiles.length} file diffs from messages`);
@@ -5,6 +5,8 @@ class QuestionManager {
5
5
  currentIndex: 0,
6
6
  selectedOptions: new Map(),
7
7
  customAnswers: new Map(),
8
+ customInputQuestionIndex: null,
9
+ activeMessageId: null,
8
10
  messageIds: [],
9
11
  isActive: false,
10
12
  requestID: null,
@@ -22,6 +24,8 @@ class QuestionManager {
22
24
  currentIndex: 0,
23
25
  selectedOptions: new Map(),
24
26
  customAnswers: new Map(),
27
+ customInputQuestionIndex: null,
28
+ activeMessageId: null,
25
29
  messageIds: [],
26
30
  isActive: true,
27
31
  requestID,
@@ -87,6 +91,8 @@ class QuestionManager {
87
91
  }
88
92
  nextQuestion() {
89
93
  this.state.currentIndex++;
94
+ this.state.customInputQuestionIndex = null;
95
+ this.state.activeMessageId = null;
90
96
  logger.debug(`[QuestionManager] Moving to next question: ${this.state.currentIndex}/${this.state.questions.length}`);
91
97
  }
92
98
  hasNextQuestion() {
@@ -101,6 +107,29 @@ class QuestionManager {
101
107
  addMessageId(messageId) {
102
108
  this.state.messageIds.push(messageId);
103
109
  }
110
+ setActiveMessageId(messageId) {
111
+ this.state.activeMessageId = messageId;
112
+ }
113
+ getActiveMessageId() {
114
+ return this.state.activeMessageId;
115
+ }
116
+ isActiveMessage(messageId) {
117
+ return (this.state.isActive &&
118
+ this.state.activeMessageId !== null &&
119
+ messageId === this.state.activeMessageId);
120
+ }
121
+ startCustomInput(questionIndex) {
122
+ if (!this.state.isActive || !this.state.questions[questionIndex]) {
123
+ return;
124
+ }
125
+ this.state.customInputQuestionIndex = questionIndex;
126
+ }
127
+ clearCustomInput() {
128
+ this.state.customInputQuestionIndex = null;
129
+ }
130
+ isWaitingForCustomInput(questionIndex) {
131
+ return this.state.customInputQuestionIndex === questionIndex;
132
+ }
104
133
  getMessageIds() {
105
134
  return [...this.state.messageIds];
106
135
  }
@@ -111,6 +140,8 @@ class QuestionManager {
111
140
  cancel() {
112
141
  logger.info("[QuestionManager] Poll cancelled");
113
142
  this.state.isActive = false;
143
+ this.state.customInputQuestionIndex = null;
144
+ this.state.activeMessageId = null;
114
145
  }
115
146
  clear() {
116
147
  this.state = {
@@ -118,6 +149,8 @@ class QuestionManager {
118
149
  currentIndex: 0,
119
150
  selectedOptions: new Map(),
120
151
  customAnswers: new Map(),
152
+ customInputQuestionIndex: null,
153
+ activeMessageId: null,
121
154
  messageIds: [],
122
155
  isActive: false,
123
156
  requestID: null,
@@ -23,6 +23,9 @@ class RenameManager {
23
23
  getMessageId() {
24
24
  return this.state.messageId;
25
25
  }
26
+ isActiveMessage(messageId) {
27
+ return (this.state.isWaiting && this.state.messageId !== null && this.state.messageId === messageId);
28
+ }
26
29
  isWaitingForName() {
27
30
  return this.state.isWaiting;
28
31
  }
@@ -119,5 +119,10 @@ export function __resetSettingsForTests() {
119
119
  settingsWriteQueue = Promise.resolve();
120
120
  }
121
121
  export async function loadSettings() {
122
- currentSettings = await readSettingsFile();
122
+ const loadedSettings = (await readSettingsFile());
123
+ if ("toolMessagesIntervalSec" in loadedSettings) {
124
+ delete loadedSettings.toolMessagesIntervalSec;
125
+ void writeSettingsFile(loadedSettings);
126
+ }
127
+ currentSettings = loadedSettings;
123
128
  }
@@ -1,6 +1,29 @@
1
- import { prepareCodeFile } from "./formatter.js";
1
+ import { normalizePathForDisplay, prepareCodeFile } from "./formatter.js";
2
2
  import { logger } from "../utils/logger.js";
3
3
  import { getCurrentProject } from "../settings/manager.js";
4
+ function extractFirstUpdatedFileFromTitle(title) {
5
+ for (const rawLine of title.split("\n")) {
6
+ const line = rawLine.trim();
7
+ if (line.length >= 3 && line[1] === " " && /[AMDURC]/.test(line[0])) {
8
+ return line.slice(2).trim();
9
+ }
10
+ }
11
+ return "";
12
+ }
13
+ function countDiffChangesFromText(text) {
14
+ let additions = 0;
15
+ let deletions = 0;
16
+ for (const line of text.split("\n")) {
17
+ if (line.startsWith("+") && !line.startsWith("+++")) {
18
+ additions++;
19
+ continue;
20
+ }
21
+ if (line.startsWith("-") && !line.startsWith("---")) {
22
+ deletions++;
23
+ }
24
+ }
25
+ return { additions, deletions };
26
+ }
4
27
  class SummaryAggregator {
5
28
  currentSessionId = null;
6
29
  currentMessageParts = new Map();
@@ -19,6 +42,7 @@ class SummaryAggregator {
19
42
  onPermissionCallback = null;
20
43
  onSessionDiffCallback = null;
21
44
  onFileChangeCallback = null;
45
+ onClearedCallback = null;
22
46
  processedToolStates = new Set();
23
47
  bot = null;
24
48
  chatId = null;
@@ -61,6 +85,9 @@ class SummaryAggregator {
61
85
  setOnFileChange(callback) {
62
86
  this.onFileChangeCallback = callback;
63
87
  }
88
+ setOnCleared(callback) {
89
+ this.onClearedCallback = callback;
90
+ }
64
91
  startTypingIndicator() {
65
92
  if (this.typingTimer) {
66
93
  return;
@@ -145,6 +172,14 @@ class SummaryAggregator {
145
172
  this.processedToolStates.clear();
146
173
  this.messageCount = 0;
147
174
  this.lastUpdated = 0;
175
+ if (this.onClearedCallback) {
176
+ try {
177
+ this.onClearedCallback();
178
+ }
179
+ catch (err) {
180
+ logger.error("[Aggregator] Error in clear callback:", err);
181
+ }
182
+ }
148
183
  }
149
184
  handleMessageUpdated(event) {
150
185
  const { info } = event.properties;
@@ -160,8 +195,11 @@ class SummaryAggregator {
160
195
  this.startTypingIndicator();
161
196
  // Notify that agent started thinking
162
197
  if (this.onThinkingCallback) {
198
+ const callback = this.onThinkingCallback;
163
199
  setImmediate(() => {
164
- this.onThinkingCallback();
200
+ if (typeof callback === "function") {
201
+ callback(info.sessionID);
202
+ }
165
203
  });
166
204
  }
167
205
  }
@@ -264,6 +302,7 @@ class SummaryAggregator {
264
302
  if (!this.processedToolStates.has(notifiedKey)) {
265
303
  this.processedToolStates.add(notifiedKey);
266
304
  const toolData = {
305
+ sessionId: part.sessionID,
267
306
  messageId: messageID,
268
307
  callId: part.callID,
269
308
  tool: part.tool,
@@ -283,7 +322,8 @@ class SummaryAggregator {
283
322
  if (!this.processedToolStates.has(fileKey)) {
284
323
  this.processedToolStates.add(fileKey);
285
324
  if (part.tool === "write" && input && "content" in input && "filePath" in input) {
286
- const fileData = prepareCodeFile(input.content, input.filePath, "write");
325
+ const filePath = normalizePathForDisplay(input.filePath);
326
+ const fileData = prepareCodeFile(input.content, filePath, "write");
287
327
  if (fileData && this.onToolFileCallback) {
288
328
  logger.debug(`[Aggregator] Sending write file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
289
329
  this.onToolFileCallback(fileData);
@@ -292,7 +332,7 @@ class SummaryAggregator {
292
332
  if (this.onFileChangeCallback) {
293
333
  const lines = input.content.split("\n").length;
294
334
  this.onFileChangeCallback({
295
- file: input.filePath,
335
+ file: filePath,
296
336
  additions: lines,
297
337
  deletions: 0,
298
338
  });
@@ -303,12 +343,12 @@ class SummaryAggregator {
303
343
  "diff" in state.metadata &&
304
344
  "filediff" in state.metadata) {
305
345
  const filediff = state.metadata.filediff;
306
- const filePath = filediff.file;
346
+ const filePath = filediff.file ? normalizePathForDisplay(filediff.file) : undefined;
307
347
  const diff = state.metadata.diff;
308
348
  if (filePath && diff) {
309
349
  const fileData = prepareCodeFile(diff, filePath, "edit");
310
350
  if (fileData && this.onToolFileCallback) {
311
- logger.debug(`[Aggregator] Sending edit file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
351
+ logger.debug(`[Aggregator] Sending ${part.tool} file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
312
352
  this.onToolFileCallback(fileData);
313
353
  }
314
354
  // Notify about file change for pinned message
@@ -321,6 +361,47 @@ class SummaryAggregator {
321
361
  }
322
362
  }
323
363
  }
364
+ else if (part.tool === "apply_patch") {
365
+ const metadata = state.metadata;
366
+ const filePathFromInput = input && typeof input.filePath === "string"
367
+ ? normalizePathForDisplay(input.filePath)
368
+ : input && typeof input.path === "string"
369
+ ? normalizePathForDisplay(input.path)
370
+ : "";
371
+ const filePathFromTitle = title ? extractFirstUpdatedFileFromTitle(title) : "";
372
+ const filePath = (metadata?.filediff?.file && normalizePathForDisplay(metadata.filediff.file)) ||
373
+ filePathFromInput ||
374
+ normalizePathForDisplay(filePathFromTitle);
375
+ const diffText = typeof metadata?.diff === "string"
376
+ ? metadata.diff
377
+ : input && typeof input.patchText === "string"
378
+ ? input.patchText
379
+ : "";
380
+ if (filePath && diffText) {
381
+ const fileData = prepareCodeFile(diffText, filePath, "edit");
382
+ if (fileData && this.onToolFileCallback) {
383
+ logger.debug(`[Aggregator] Sending apply_patch file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
384
+ this.onToolFileCallback(fileData);
385
+ }
386
+ }
387
+ if (filePath && this.onFileChangeCallback) {
388
+ if (metadata?.filediff) {
389
+ this.onFileChangeCallback({
390
+ file: filePath,
391
+ additions: metadata.filediff.additions || 0,
392
+ deletions: metadata.filediff.deletions || 0,
393
+ });
394
+ }
395
+ else if (diffText) {
396
+ const changes = countDiffChangesFromText(diffText);
397
+ this.onFileChangeCallback({
398
+ file: filePath,
399
+ additions: changes.additions,
400
+ deletions: changes.deletions,
401
+ });
402
+ }
403
+ }
404
+ }
324
405
  }
325
406
  }
326
407
  }