@aaroncql/pim-agent 0.0.1
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/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Context,
|
|
3
|
+
type Filter,
|
|
4
|
+
GrammyError,
|
|
5
|
+
InlineKeyboard,
|
|
6
|
+
type Api,
|
|
7
|
+
} from "grammy";
|
|
8
|
+
|
|
9
|
+
import { Paths } from "../shared/Paths";
|
|
10
|
+
import {
|
|
11
|
+
LOGS_MODES,
|
|
12
|
+
THINKING_LEVELS,
|
|
13
|
+
type LogsMode,
|
|
14
|
+
type TelegramConfig,
|
|
15
|
+
type ThinkingLevelOpt,
|
|
16
|
+
} from "./Config";
|
|
17
|
+
import { Markdown } from "./Markdown";
|
|
18
|
+
import { Session, type SessionCompactResult, type SessionId } from "./Session";
|
|
19
|
+
import { SessionRegistry } from "./SessionRegistry";
|
|
20
|
+
import { Supervisor } from "./Supervisor";
|
|
21
|
+
import { TypingIndicator } from "./TypingIndicator";
|
|
22
|
+
|
|
23
|
+
const CB_CLEAR_CONFIRM = "clear-confirm";
|
|
24
|
+
const CB_CLEAR_CANCEL = "clear-cancel";
|
|
25
|
+
const CB_EFFORT = "effort";
|
|
26
|
+
const CB_LOGS = "logs";
|
|
27
|
+
const CB_MODEL = "model";
|
|
28
|
+
const CB_TEMPORARY = "temporary";
|
|
29
|
+
|
|
30
|
+
type BotCommand = { readonly command: string; readonly description: string };
|
|
31
|
+
|
|
32
|
+
export const BOT_COMMANDS: readonly BotCommand[] = [
|
|
33
|
+
{ command: "chatid", description: "Show this chat's numeric ID" },
|
|
34
|
+
{ command: "cancel", description: "Cancel the current turn" },
|
|
35
|
+
{ command: "clear", description: "Reset chat history and context window" },
|
|
36
|
+
{ command: "compact", description: "Compact the current session context" },
|
|
37
|
+
{ command: "cd", description: "Show or change the working directory" },
|
|
38
|
+
{ command: "model", description: "Show or change the AI model" },
|
|
39
|
+
{ command: "effort", description: "Show or change thinking effort level" },
|
|
40
|
+
{ command: "usage", description: "Show context window and session cost" },
|
|
41
|
+
{ command: "logs", description: "Show or change log verbosity" },
|
|
42
|
+
{
|
|
43
|
+
command: "temporary",
|
|
44
|
+
description: "Toggle temporary chat (no history, fresh each message)",
|
|
45
|
+
},
|
|
46
|
+
{ command: "update", description: "Update the bot to the latest version" },
|
|
47
|
+
{ command: "commands", description: "Register all commands with Telegram" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const LOGS_DESCRIPTIONS: Record<LogsMode, string> = {
|
|
51
|
+
off: "final message only",
|
|
52
|
+
tool: "show tool use",
|
|
53
|
+
text: "show tool use, and intermediate texts",
|
|
54
|
+
verbose: "show tool use, intermediate texts, and thinking",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function splitValueAndKey(
|
|
58
|
+
s: string
|
|
59
|
+
): { readonly value: string; readonly key: string } | undefined {
|
|
60
|
+
const i = s.indexOf(":");
|
|
61
|
+
if (i < 0) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
return { value: s.slice(0, i), key: s.slice(i + 1) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isMember<T extends string>(
|
|
68
|
+
tuple: readonly T[],
|
|
69
|
+
value: string
|
|
70
|
+
): value is T {
|
|
71
|
+
return (tuple as readonly string[]).includes(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class Commands {
|
|
75
|
+
private readonly config: TelegramConfig;
|
|
76
|
+
private readonly api: Api;
|
|
77
|
+
private readonly registry: SessionRegistry;
|
|
78
|
+
|
|
79
|
+
public constructor(
|
|
80
|
+
config: TelegramConfig,
|
|
81
|
+
api: Api,
|
|
82
|
+
registry: SessionRegistry
|
|
83
|
+
) {
|
|
84
|
+
this.config = config;
|
|
85
|
+
this.api = api;
|
|
86
|
+
this.registry = registry;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public async handleCommand(
|
|
90
|
+
ctx: Filter<Context, "message">,
|
|
91
|
+
session: Session,
|
|
92
|
+
raw: string
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const [first, ...rest] = raw.trim().split(/\s+/);
|
|
95
|
+
const name = (first ?? "").split("@")[0];
|
|
96
|
+
const args = rest.join(" ").trim();
|
|
97
|
+
try {
|
|
98
|
+
switch (name) {
|
|
99
|
+
case "/chatid":
|
|
100
|
+
await this.cmdChatId(session);
|
|
101
|
+
return;
|
|
102
|
+
case "/cancel":
|
|
103
|
+
await this.cmdCancel(session);
|
|
104
|
+
return;
|
|
105
|
+
case "/clear":
|
|
106
|
+
await this.runQueued(ctx, session, () => this.cmdClear(session));
|
|
107
|
+
return;
|
|
108
|
+
case "/compact":
|
|
109
|
+
await this.runQueued(ctx, session, () =>
|
|
110
|
+
this.cmdCompact(session, args || undefined)
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
case "/cd":
|
|
114
|
+
if (!args) {
|
|
115
|
+
await this.cmdCdRead(session);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
await this.runQueued(ctx, session, () =>
|
|
119
|
+
this.cmdCdWrite(session, args)
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
case "/model":
|
|
123
|
+
if (!args) {
|
|
124
|
+
await this.cmdModelRead(session);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await this.runQueued(ctx, session, () =>
|
|
128
|
+
this.cmdModelWrite(session, args)
|
|
129
|
+
);
|
|
130
|
+
return;
|
|
131
|
+
case "/effort":
|
|
132
|
+
await this.cmdEffort(session);
|
|
133
|
+
return;
|
|
134
|
+
case "/usage":
|
|
135
|
+
await this.cmdUsage(session);
|
|
136
|
+
return;
|
|
137
|
+
case "/logs":
|
|
138
|
+
await this.cmdLogs(session);
|
|
139
|
+
return;
|
|
140
|
+
case "/temporary":
|
|
141
|
+
await this.cmdTemporary(session);
|
|
142
|
+
return;
|
|
143
|
+
case "/update":
|
|
144
|
+
await this.runQueued(ctx, session, () => this.cmdUpdate(session));
|
|
145
|
+
return;
|
|
146
|
+
case "/commands":
|
|
147
|
+
await this.cmdCommands(session);
|
|
148
|
+
return;
|
|
149
|
+
default:
|
|
150
|
+
await this.sendPlain(session.id, `Unknown command: ${name}`);
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.error(`[bot] command ${name} failed:`, err);
|
|
154
|
+
await this.sendPlain(
|
|
155
|
+
session.id,
|
|
156
|
+
`⚠️ ${name} failed: ${(err as Error).message}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
public async handleCallback(
|
|
162
|
+
ctx: Filter<Context, "callback_query:data">
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
const data = ctx.callbackQuery.data;
|
|
165
|
+
|
|
166
|
+
if (data.startsWith(`${CB_MODEL}|`)) {
|
|
167
|
+
const idx1 = data.indexOf("|");
|
|
168
|
+
const idx2 = data.lastIndexOf("|");
|
|
169
|
+
const modelId = data.slice(idx1 + 1, idx2);
|
|
170
|
+
const keyPart = data.slice(idx2 + 1);
|
|
171
|
+
const session = this.registry.get(Session.decodeId(keyPart));
|
|
172
|
+
await ctx.answerCallbackQuery({ text: `Model: ${modelId}` });
|
|
173
|
+
try {
|
|
174
|
+
const result = await session.setModel(modelId);
|
|
175
|
+
if (result.ok) {
|
|
176
|
+
await Commands.safeEditMessage(
|
|
177
|
+
ctx,
|
|
178
|
+
`<b>Model</b> → <code>${Markdown.escape(result.id)}</code>`
|
|
179
|
+
);
|
|
180
|
+
} else {
|
|
181
|
+
await Commands.safeEditMessage(
|
|
182
|
+
ctx,
|
|
183
|
+
Commands.strikeOriginal(ctx, `⚠️ model set failed: ${modelId}`)
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.error(`[bot] model callback failed for ${modelId}:`, err);
|
|
188
|
+
await Commands.safeEditMessage(
|
|
189
|
+
ctx,
|
|
190
|
+
Commands.strikeOriginal(
|
|
191
|
+
ctx,
|
|
192
|
+
`⚠️ model set failed: ${(err as Error).message}`
|
|
193
|
+
)
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const colon = data.indexOf(":");
|
|
200
|
+
const action = colon >= 0 ? data.slice(0, colon) : data;
|
|
201
|
+
const keyPart = colon >= 0 ? data.slice(colon + 1) : "";
|
|
202
|
+
|
|
203
|
+
if (action === CB_CLEAR_CONFIRM && keyPart) {
|
|
204
|
+
const session = this.registry.get(Session.decodeId(keyPart));
|
|
205
|
+
const wasBusy = session.isStreaming;
|
|
206
|
+
await ctx.answerCallbackQuery({
|
|
207
|
+
text: wasBusy ? "Queued — clearing after current turn" : "Cleared",
|
|
208
|
+
});
|
|
209
|
+
try {
|
|
210
|
+
await session.clear();
|
|
211
|
+
await Commands.safeEditMessage(
|
|
212
|
+
ctx,
|
|
213
|
+
Commands.strikeOriginal(ctx, "Context window cleared.")
|
|
214
|
+
);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.error(`[bot] queued clear failed:`, err);
|
|
217
|
+
await Commands.safeEditMessage(
|
|
218
|
+
ctx,
|
|
219
|
+
Commands.strikeOriginal(
|
|
220
|
+
ctx,
|
|
221
|
+
`⚠️ clear failed: ${(err as Error).message}`
|
|
222
|
+
)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (action === CB_CLEAR_CANCEL) {
|
|
228
|
+
await ctx.answerCallbackQuery({ text: "Cancelled" });
|
|
229
|
+
await Commands.safeEditMessage(
|
|
230
|
+
ctx,
|
|
231
|
+
Commands.strikeOriginal(ctx, "Cancelled.")
|
|
232
|
+
);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (action === CB_EFFORT && keyPart) {
|
|
236
|
+
const parts = splitValueAndKey(keyPart);
|
|
237
|
+
if (!parts || !isMember(THINKING_LEVELS, parts.value)) {
|
|
238
|
+
await ctx.answerCallbackQuery();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const session = this.registry.get(Session.decodeId(parts.key));
|
|
242
|
+
await session.setThinkingLevel(parts.value);
|
|
243
|
+
await ctx.answerCallbackQuery({ text: `Effort: ${parts.value}` });
|
|
244
|
+
const { kb, html } = this.buildEffortPicker(
|
|
245
|
+
session.id,
|
|
246
|
+
parts.value,
|
|
247
|
+
session.supportedThinkingLevels
|
|
248
|
+
);
|
|
249
|
+
await Commands.safeEditMessage(ctx, html, kb);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (action === CB_LOGS && keyPart) {
|
|
253
|
+
const parts = splitValueAndKey(keyPart);
|
|
254
|
+
if (!parts || !isMember(LOGS_MODES, parts.value)) {
|
|
255
|
+
await ctx.answerCallbackQuery();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const session = this.registry.get(Session.decodeId(parts.key));
|
|
259
|
+
await session.setLogsMode(parts.value);
|
|
260
|
+
await ctx.answerCallbackQuery({ text: `Logs: ${parts.value}` });
|
|
261
|
+
const { kb, html } = this.buildLogsPicker(session.id, parts.value);
|
|
262
|
+
await Commands.safeEditMessage(ctx, html, kb);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (action === CB_TEMPORARY && keyPart) {
|
|
266
|
+
const parts = splitValueAndKey(keyPart);
|
|
267
|
+
if (!parts || (parts.value !== "0" && parts.value !== "1")) {
|
|
268
|
+
await ctx.answerCallbackQuery();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const value = parts.value === "1";
|
|
272
|
+
const session = this.registry.get(Session.decodeId(parts.key));
|
|
273
|
+
await session.setTemporary(value);
|
|
274
|
+
await ctx.answerCallbackQuery({
|
|
275
|
+
text: `Temporary: ${value ? "on" : "off"}`,
|
|
276
|
+
});
|
|
277
|
+
const { kb, html } = this.buildTemporaryPicker(session.id, value);
|
|
278
|
+
await Commands.safeEditMessage(ctx, html, kb);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
await ctx.answerCallbackQuery();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private async runQueued(
|
|
285
|
+
ctx: Filter<Context, "message">,
|
|
286
|
+
session: Session,
|
|
287
|
+
work: () => Promise<void>
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const wasBusy = session.isStreaming;
|
|
290
|
+
if (wasBusy) {
|
|
291
|
+
await Commands.reactSafe(ctx, "👀");
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
await work();
|
|
295
|
+
} catch (err) {
|
|
296
|
+
console.error(`[bot] queued command failed:`, err);
|
|
297
|
+
await this.sendPlain(
|
|
298
|
+
session.id,
|
|
299
|
+
`⚠️ ${(err as Error).message ?? String(err)}`
|
|
300
|
+
);
|
|
301
|
+
} finally {
|
|
302
|
+
if (wasBusy) {
|
|
303
|
+
await Commands.reactSafe(ctx, []);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private static async reactSafe(
|
|
309
|
+
ctx: Filter<Context, "message">,
|
|
310
|
+
reaction: "👀" | []
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
await ctx.react(reaction).catch((err: unknown) => {
|
|
313
|
+
console.warn(`[bot] react failed:`, err);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private async cmdChatId(session: Session): Promise<void> {
|
|
318
|
+
const lines = [`Chat ID: <code>${session.id.chatId}</code>`];
|
|
319
|
+
if (session.id.threadId) {
|
|
320
|
+
lines.push(`Thread ID: <code>${session.id.threadId}</code>`);
|
|
321
|
+
}
|
|
322
|
+
await this.sendWithFallback(session.id, lines.join("\n"));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private async cmdCancel(session: Session): Promise<void> {
|
|
326
|
+
const cancelled = await session.cancel();
|
|
327
|
+
await this.sendPlain(
|
|
328
|
+
session.id,
|
|
329
|
+
cancelled ? "❌ Cancelled." : "Nothing to cancel."
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private async cmdClear(session: Session): Promise<void> {
|
|
334
|
+
const key = Session.encodeId(session.id);
|
|
335
|
+
const kb = new InlineKeyboard()
|
|
336
|
+
.text("🚫 Cancel", `${CB_CLEAR_CANCEL}:${key}`)
|
|
337
|
+
.text("👍 Yes", `${CB_CLEAR_CONFIRM}:${key}`);
|
|
338
|
+
await this.sendPlain(
|
|
339
|
+
session.id,
|
|
340
|
+
"⚠️ Are you sure you want to reset this thread's chat history and context window?",
|
|
341
|
+
kb
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private async cmdCompact(
|
|
346
|
+
session: Session,
|
|
347
|
+
customInstructions?: string
|
|
348
|
+
): Promise<void> {
|
|
349
|
+
const sent = await this.api.sendMessage(
|
|
350
|
+
session.id.chatId,
|
|
351
|
+
"⏳ Compacting context...",
|
|
352
|
+
{
|
|
353
|
+
message_thread_id: session.id.threadId,
|
|
354
|
+
link_preview_options: { is_disabled: true },
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
const typing = new TypingIndicator(this.api, session.id);
|
|
358
|
+
typing.start();
|
|
359
|
+
try {
|
|
360
|
+
const result = await session.compact(customInstructions);
|
|
361
|
+
await this.editStatusMessage(
|
|
362
|
+
session.id,
|
|
363
|
+
sent.message_id,
|
|
364
|
+
Commands.renderCompactSuccess(result)
|
|
365
|
+
);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
const msg = (err as Error).message ?? String(err);
|
|
368
|
+
console.error(`[bot] compact failed:`, err);
|
|
369
|
+
await this.editStatusMessage(
|
|
370
|
+
session.id,
|
|
371
|
+
sent.message_id,
|
|
372
|
+
`⚠️ ${Markdown.escape(msg)}`
|
|
373
|
+
);
|
|
374
|
+
} finally {
|
|
375
|
+
typing.stop();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private async cmdCdRead(session: Session): Promise<void> {
|
|
380
|
+
const cwd = Paths.abbreviateHome(session.settings.cwd ?? this.config.cwd);
|
|
381
|
+
const lines = [
|
|
382
|
+
`<b>CWD</b>: <code>${Markdown.escape(cwd)}</code>`,
|
|
383
|
+
`<b>To Change</b>: <code>/cd <path></code>`,
|
|
384
|
+
];
|
|
385
|
+
await this.sendWithFallback(session.id, lines.join("\n"));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private async cmdCdWrite(session: Session, args: string): Promise<void> {
|
|
389
|
+
const resolved = Paths.resolve(
|
|
390
|
+
args,
|
|
391
|
+
session.settings.cwd ?? this.config.cwd
|
|
392
|
+
);
|
|
393
|
+
const result = await session.setCwd(resolved);
|
|
394
|
+
if (!result.ok) {
|
|
395
|
+
await this.sendPlain(session.id, `⚠️ ${result.error}`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const html = `<b>CWD</b> → <code>${Markdown.escape(Paths.abbreviateHome(resolved))}</code>`;
|
|
399
|
+
await this.sendWithFallback(session.id, html);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private async cmdModelRead(session: Session): Promise<void> {
|
|
403
|
+
const current = session.currentModelId ?? "(unset)";
|
|
404
|
+
const html = [
|
|
405
|
+
`<b>Model</b>: <code>${Markdown.escape(current)}</code>`,
|
|
406
|
+
`<b>To Change</b>: <code>/model <model_name></code>`,
|
|
407
|
+
].join("\n");
|
|
408
|
+
await this.sendWithFallback(session.id, html);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private async cmdModelWrite(session: Session, args: string): Promise<void> {
|
|
412
|
+
const result = await session.setModel(args);
|
|
413
|
+
if (!result.ok) {
|
|
414
|
+
const key = Session.encodeId(session.id);
|
|
415
|
+
const kb = new InlineKeyboard();
|
|
416
|
+
for (const c of result.candidates) {
|
|
417
|
+
kb.text(c, `${CB_MODEL}|${c}|${key}`).row();
|
|
418
|
+
}
|
|
419
|
+
const header =
|
|
420
|
+
result.kind === "ambiguous"
|
|
421
|
+
? `⚠️ Multiple matches for "${Markdown.escape(args)}". Please choose one below or use /model with a more specific name.`
|
|
422
|
+
: `⚠️ No model matches "${Markdown.escape(args)}". Available:`;
|
|
423
|
+
await this.sendWithFallback(session.id, header, kb);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
await this.sendWithFallback(
|
|
427
|
+
session.id,
|
|
428
|
+
`<b>Model</b> → <code>${Markdown.escape(result.id)}</code>`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private async cmdEffort(session: Session): Promise<void> {
|
|
433
|
+
const supported = session.supportedThinkingLevels;
|
|
434
|
+
if (supported.length <= 1) {
|
|
435
|
+
await this.sendPlain(
|
|
436
|
+
session.id,
|
|
437
|
+
"Effort level for the current model cannot be configured."
|
|
438
|
+
);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const current = session.currentThinkingLevel;
|
|
442
|
+
const { kb, html } = this.buildEffortPicker(session.id, current, supported);
|
|
443
|
+
await this.sendWithFallback(session.id, html, kb);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private buildEffortPicker(
|
|
447
|
+
sessionId: SessionId,
|
|
448
|
+
currentLevel: ThinkingLevelOpt,
|
|
449
|
+
supported: readonly ThinkingLevelOpt[]
|
|
450
|
+
): { readonly kb: InlineKeyboard; readonly html: string } {
|
|
451
|
+
const key = Session.encodeId(sessionId);
|
|
452
|
+
const kb = new InlineKeyboard();
|
|
453
|
+
for (const [i, lvl] of supported.entries()) {
|
|
454
|
+
const label = lvl === currentLevel ? `✅ ${lvl}` : lvl;
|
|
455
|
+
kb.text(label, `${CB_EFFORT}:${lvl}:${key}`);
|
|
456
|
+
if ((i + 1) % 3 === 0 && i < supported.length - 1) {
|
|
457
|
+
kb.row();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const html = `<b>Effort</b>: <code>${Markdown.escape(currentLevel)}</code>`;
|
|
461
|
+
return { kb, html };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private buildLogsPicker(
|
|
465
|
+
sessionId: SessionId,
|
|
466
|
+
currentMode: LogsMode
|
|
467
|
+
): { readonly kb: InlineKeyboard; readonly html: string } {
|
|
468
|
+
const key = Session.encodeId(sessionId);
|
|
469
|
+
const kb = new InlineKeyboard();
|
|
470
|
+
const descriptions: string[] = [];
|
|
471
|
+
for (const [i, mode] of LOGS_MODES.entries()) {
|
|
472
|
+
const label = mode === currentMode ? `✅ ${mode}` : mode;
|
|
473
|
+
kb.text(label, `${CB_LOGS}:${mode}:${key}`);
|
|
474
|
+
if ((i + 1) % 2 === 0 && i < LOGS_MODES.length - 1) {
|
|
475
|
+
kb.row();
|
|
476
|
+
}
|
|
477
|
+
descriptions.push(
|
|
478
|
+
`• <code>${Markdown.escape(mode)}</code>: ${Markdown.escape(LOGS_DESCRIPTIONS[mode])}`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
const html = [
|
|
482
|
+
`<b>Level</b>: <code>${Markdown.escape(currentMode)}</code>`,
|
|
483
|
+
"",
|
|
484
|
+
`<b>Options</b>:`,
|
|
485
|
+
...descriptions,
|
|
486
|
+
].join("\n");
|
|
487
|
+
return { kb, html };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private async cmdUsage(session: Session): Promise<void> {
|
|
491
|
+
const lines: string[] = [];
|
|
492
|
+
const agent = session.agentSession;
|
|
493
|
+
if (agent) {
|
|
494
|
+
const usage = agent.getContextUsage();
|
|
495
|
+
const stats = agent.getSessionStats();
|
|
496
|
+
if (usage) {
|
|
497
|
+
const pct =
|
|
498
|
+
usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "—";
|
|
499
|
+
const tok =
|
|
500
|
+
usage.tokens !== null ? usage.tokens.toLocaleString("en-US") : "—";
|
|
501
|
+
const ctx = usage.contextWindow.toLocaleString("en-US");
|
|
502
|
+
lines.push(`<b>Context</b>: <code>${tok}/${ctx} (${pct})</code>`);
|
|
503
|
+
}
|
|
504
|
+
lines.push(
|
|
505
|
+
`<b>Session Cost</b>: <code>$${(stats.cost ?? 0).toFixed(2)}</code>`
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
lines.push(
|
|
509
|
+
`<b>Cumulative Cost</b>: <code>$${(session.settings.cumulativeCost ?? 0).toFixed(2)}</code>`
|
|
510
|
+
);
|
|
511
|
+
await this.sendWithFallback(session.id, lines.join("\n"));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async cmdLogs(session: Session): Promise<void> {
|
|
515
|
+
const current = session.settings.logsMode ?? "text";
|
|
516
|
+
const { kb, html } = this.buildLogsPicker(session.id, current);
|
|
517
|
+
await this.sendWithFallback(session.id, html, kb);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private async cmdTemporary(session: Session): Promise<void> {
|
|
521
|
+
const { kb, html } = this.buildTemporaryPicker(
|
|
522
|
+
session.id,
|
|
523
|
+
session.temporary
|
|
524
|
+
);
|
|
525
|
+
await this.sendWithFallback(session.id, html, kb);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private buildTemporaryPicker(
|
|
529
|
+
sessionId: SessionId,
|
|
530
|
+
current: boolean
|
|
531
|
+
): { readonly kb: InlineKeyboard; readonly html: string } {
|
|
532
|
+
const key = Session.encodeId(sessionId);
|
|
533
|
+
const kb = new InlineKeyboard()
|
|
534
|
+
.text(current ? "off" : "✅ off", `${CB_TEMPORARY}:0:${key}`)
|
|
535
|
+
.text(current ? "✅ on" : "on", `${CB_TEMPORARY}:1:${key}`);
|
|
536
|
+
const html = [
|
|
537
|
+
`<b>Temporary</b>: <code>${current ? "on" : "off"}</code>`,
|
|
538
|
+
"",
|
|
539
|
+
"When <b>on</b>, every message is independent and runs in a fresh session without any chat history.",
|
|
540
|
+
].join("\n");
|
|
541
|
+
return { kb, html };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private async cmdUpdate(session: Session): Promise<void> {
|
|
545
|
+
const sent = await this.api.sendMessage(
|
|
546
|
+
session.id.chatId,
|
|
547
|
+
"🔄 Updating...",
|
|
548
|
+
{
|
|
549
|
+
message_thread_id: session.id.threadId,
|
|
550
|
+
link_preview_options: { is_disabled: true },
|
|
551
|
+
}
|
|
552
|
+
);
|
|
553
|
+
const result = await Supervisor.update();
|
|
554
|
+
if (!result.ok) {
|
|
555
|
+
await this.sendPlain(session.id, `⚠️ ${result.error}`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
await Supervisor.appendUpdateConfirm(this.config.configDir, {
|
|
560
|
+
chatId: session.id.chatId,
|
|
561
|
+
threadId: session.id.threadId,
|
|
562
|
+
messageId: sent.message_id,
|
|
563
|
+
});
|
|
564
|
+
Supervisor.restart();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private async cmdCommands(session: Session): Promise<void> {
|
|
568
|
+
const chatId = session.id.chatId;
|
|
569
|
+
try {
|
|
570
|
+
const globalScopes = [
|
|
571
|
+
{ type: "default" as const },
|
|
572
|
+
{ type: "all_private_chats" as const },
|
|
573
|
+
{ type: "all_group_chats" as const },
|
|
574
|
+
];
|
|
575
|
+
await Promise.all(
|
|
576
|
+
globalScopes.map((scope) =>
|
|
577
|
+
this.api.setMyCommands(BOT_COMMANDS, { scope })
|
|
578
|
+
)
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
await this.api.deleteMyCommands({
|
|
582
|
+
scope: { type: "chat", chat_id: chatId },
|
|
583
|
+
});
|
|
584
|
+
try {
|
|
585
|
+
await this.api.deleteMyCommands({
|
|
586
|
+
scope: { type: "chat_administrators", chat_id: chatId },
|
|
587
|
+
});
|
|
588
|
+
} catch {
|
|
589
|
+
// chat_administrators scope is only valid for group chats.
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const chat = await this.api.getChat(chatId);
|
|
593
|
+
const resolvedScope =
|
|
594
|
+
chat.type === "private"
|
|
595
|
+
? ({ type: "all_private_chats" } as const)
|
|
596
|
+
: ({ type: "all_group_chats" } as const);
|
|
597
|
+
const actual = await this.api.getMyCommands({
|
|
598
|
+
scope: resolvedScope,
|
|
599
|
+
});
|
|
600
|
+
const actualMap = new Set(actual.map((c) => c.command));
|
|
601
|
+
const lines = [
|
|
602
|
+
"✅ <b>Commands registered</b> and chat-scoped overrides cleared:",
|
|
603
|
+
"",
|
|
604
|
+
...BOT_COMMANDS.map((c) => {
|
|
605
|
+
const ok = actualMap.has(c.command) ? "✅" : "❌";
|
|
606
|
+
return `${ok} <code>/${c.command}</code> — ${Markdown.escape(c.description)}`;
|
|
607
|
+
}),
|
|
608
|
+
];
|
|
609
|
+
if (actual.length !== BOT_COMMANDS.length) {
|
|
610
|
+
lines.push(
|
|
611
|
+
"",
|
|
612
|
+
`⚠️ <b>${BOT_COMMANDS.length}</b> sent but <b>${actual.length}</b> resolved for this chat. Restart Telegram if commands don't show in autocomplete.`
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
await this.sendWithFallback(session.id, lines.join("\n"));
|
|
616
|
+
} catch (err) {
|
|
617
|
+
const msg = (err as Error).message ?? String(err);
|
|
618
|
+
console.error("[bot] setMyCommands failed:", err);
|
|
619
|
+
await this.sendPlain(
|
|
620
|
+
session.id,
|
|
621
|
+
`⚠️ Failed to register commands: ${Markdown.escape(msg)}`
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private static strikeOriginal(
|
|
627
|
+
ctx: Filter<Context, "callback_query:data">,
|
|
628
|
+
note: string
|
|
629
|
+
): string {
|
|
630
|
+
const original = ctx.callbackQuery.message?.text ?? "";
|
|
631
|
+
return `<s>${Markdown.escape(original)}</s>\n\n<i>${note}</i>`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private static renderCompactSuccess(result: SessionCompactResult): string {
|
|
635
|
+
const before = result.compaction.tokensBefore.toLocaleString("en-US");
|
|
636
|
+
const messages = result.activeMessages.toLocaleString("en-US");
|
|
637
|
+
return [
|
|
638
|
+
"✅ <b>Context compacted.</b>",
|
|
639
|
+
"",
|
|
640
|
+
`<b>Before</b>: ${before} tokens`,
|
|
641
|
+
`<b>Now</b>: ${messages} messages (exact usage will update after next message)`,
|
|
642
|
+
].join("\n");
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private async editStatusMessage(
|
|
646
|
+
sessionId: SessionId,
|
|
647
|
+
messageId: number,
|
|
648
|
+
html: string
|
|
649
|
+
): Promise<void> {
|
|
650
|
+
try {
|
|
651
|
+
await this.api.editMessageText(sessionId.chatId, messageId, html, {
|
|
652
|
+
parse_mode: "HTML",
|
|
653
|
+
link_preview_options: { is_disabled: true },
|
|
654
|
+
});
|
|
655
|
+
} catch (err) {
|
|
656
|
+
console.warn(`[send] status edit failed:`, err);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private static async safeEditMessage(
|
|
661
|
+
ctx: Filter<Context, "callback_query:data">,
|
|
662
|
+
html: string,
|
|
663
|
+
replyMarkup?: InlineKeyboard
|
|
664
|
+
): Promise<void> {
|
|
665
|
+
try {
|
|
666
|
+
await ctx.editMessageText(html, {
|
|
667
|
+
parse_mode: "HTML",
|
|
668
|
+
reply_markup: replyMarkup,
|
|
669
|
+
});
|
|
670
|
+
} catch {
|
|
671
|
+
// Message may have aged out past Telegram's edit window — non-fatal.
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private async sendWithFallback(
|
|
676
|
+
sessionId: SessionId,
|
|
677
|
+
html: string,
|
|
678
|
+
replyMarkup?: InlineKeyboard
|
|
679
|
+
): Promise<void> {
|
|
680
|
+
if (!html) {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
try {
|
|
684
|
+
await this.api.sendMessage(sessionId.chatId, html, {
|
|
685
|
+
parse_mode: "HTML",
|
|
686
|
+
message_thread_id: sessionId.threadId,
|
|
687
|
+
link_preview_options: { is_disabled: true },
|
|
688
|
+
reply_markup: replyMarkup,
|
|
689
|
+
});
|
|
690
|
+
console.log(
|
|
691
|
+
`[send] chatId=${sessionId.chatId} threadId=${sessionId.threadId ?? "main"} html ok (${html.length}b)`
|
|
692
|
+
);
|
|
693
|
+
} catch (err) {
|
|
694
|
+
if (err instanceof GrammyError && err.error_code === 400) {
|
|
695
|
+
console.warn(`[send] HTML 400 (${err.description}) — retry plain`);
|
|
696
|
+
await this.sendPlain(sessionId, html, replyMarkup);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
throw err;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private async sendPlain(
|
|
704
|
+
sessionId: SessionId,
|
|
705
|
+
body: string,
|
|
706
|
+
replyMarkup?: InlineKeyboard
|
|
707
|
+
): Promise<void> {
|
|
708
|
+
try {
|
|
709
|
+
await this.api.sendMessage(sessionId.chatId, body, {
|
|
710
|
+
message_thread_id: sessionId.threadId,
|
|
711
|
+
link_preview_options: { is_disabled: true },
|
|
712
|
+
reply_markup: replyMarkup,
|
|
713
|
+
});
|
|
714
|
+
console.log(
|
|
715
|
+
`[send] chatId=${sessionId.chatId} threadId=${sessionId.threadId ?? "main"} plain ok (${body.length}b)`
|
|
716
|
+
);
|
|
717
|
+
} catch (err) {
|
|
718
|
+
console.error(`[send] plain failed:`, err);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|