grok-telegram-bot 2.0.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.
- package/.env.example +135 -0
- package/CHANGELOG.md +598 -0
- package/LICENSE +21 -0
- package/README.md +644 -0
- package/bin/grok-tg.mjs +21 -0
- package/docs/INSTALL.md +153 -0
- package/docs/UPGRADE.md +253 -0
- package/docs/ops/RELEASE_CHECKLIST.md +39 -0
- package/package.json +74 -0
- package/scripts/setup.mjs +116 -0
- package/src/agents/catalog.ts +58 -0
- package/src/app/accounts.ts +162 -0
- package/src/app/auth-service.ts +136 -0
- package/src/app/grok-credentials.ts +103 -0
- package/src/app/instance-lock.ts +139 -0
- package/src/app/json-store.ts +54 -0
- package/src/app/reasoning.ts +30 -0
- package/src/app/settings-store.ts +38 -0
- package/src/app/stt.ts +53 -0
- package/src/app/types.ts +56 -0
- package/src/app/updater.ts +234 -0
- package/src/app/usage.ts +38 -0
- package/src/app/version.ts +41 -0
- package/src/bot/account-rotator.ts +52 -0
- package/src/bot/auth.ts +38 -0
- package/src/bot/bot.ts +225 -0
- package/src/bot/chat-controller.ts +317 -0
- package/src/bot/commands.ts +52 -0
- package/src/bot/deps.ts +67 -0
- package/src/bot/file-ingest.ts +190 -0
- package/src/bot/handlers/accounts.ts +220 -0
- package/src/bot/handlers/auth.ts +64 -0
- package/src/bot/handlers/control.ts +103 -0
- package/src/bot/handlers/document.ts +112 -0
- package/src/bot/handlers/history.ts +63 -0
- package/src/bot/handlers/kill.ts +54 -0
- package/src/bot/handlers/mcp.ts +206 -0
- package/src/bot/handlers/menu.ts +220 -0
- package/src/bot/handlers/message.ts +103 -0
- package/src/bot/handlers/photo.ts +123 -0
- package/src/bot/handlers/projects.ts +183 -0
- package/src/bot/handlers/running.ts +181 -0
- package/src/bot/handlers/session-card.ts +81 -0
- package/src/bot/handlers/session-kill.ts +95 -0
- package/src/bot/handlers/sessions.ts +148 -0
- package/src/bot/handlers/system.ts +51 -0
- package/src/bot/handlers/tasks.ts +224 -0
- package/src/bot/handlers/usage.ts +38 -0
- package/src/bot/handlers/voice.ts +55 -0
- package/src/bot/image-return.ts +69 -0
- package/src/bot/menu/ephemeral.ts +117 -0
- package/src/bot/menu/keyboard.ts +49 -0
- package/src/bot/menu/refresh.ts +13 -0
- package/src/bot/menu/status-panel.ts +173 -0
- package/src/bot/permission-service.ts +149 -0
- package/src/bot/prompt-content.ts +64 -0
- package/src/bot/prompt-retry.ts +70 -0
- package/src/bot/reauth-controller.ts +297 -0
- package/src/bot/registry.ts +186 -0
- package/src/bot/reply-context.ts +77 -0
- package/src/bot/session-fork.ts +35 -0
- package/src/bot/session-runtime.ts +1048 -0
- package/src/bot/telegram-io.ts +109 -0
- package/src/bot/typing.ts +35 -0
- package/src/bot/wizard/task-wizard.ts +214 -0
- package/src/cli.ts +126 -0
- package/src/config.ts +248 -0
- package/src/grok/client.ts +617 -0
- package/src/grok/models.ts +50 -0
- package/src/grok/session-log.ts +148 -0
- package/src/grok/transport.ts +51 -0
- package/src/grok/types.ts +136 -0
- package/src/index.ts +84 -0
- package/src/logger.ts +78 -0
- package/src/mcp/config.ts +120 -0
- package/src/mcp/probe.ts +218 -0
- package/src/mcp/types.ts +68 -0
- package/src/projects/manager.ts +99 -0
- package/src/render/chunk.ts +57 -0
- package/src/render/diff.ts +48 -0
- package/src/render/escape.ts +22 -0
- package/src/render/file-summary.ts +111 -0
- package/src/render/hashtags.ts +34 -0
- package/src/render/markdown.ts +130 -0
- package/src/render/progress-estimate.ts +63 -0
- package/src/render/progress.ts +80 -0
- package/src/render/subagent.ts +75 -0
- package/src/render/tool-call.ts +196 -0
- package/src/service/index.ts +24 -0
- package/src/service/linux.ts +85 -0
- package/src/service/macos.ts +101 -0
- package/src/service/platform.ts +64 -0
- package/src/service/types.ts +36 -0
- package/src/service/windows.ts +198 -0
- package/src/sessions/history.ts +225 -0
- package/src/sessions/process.ts +30 -0
- package/src/sessions/store.ts +133 -0
- package/src/sessions/tail.ts +86 -0
- package/src/sessions/types.ts +26 -0
- package/src/stream/streamer.ts +261 -0
- package/src/tasks/runner.ts +82 -0
- package/src/tasks/schedule.ts +142 -0
- package/src/tasks/scheduler.ts +53 -0
- package/src/tasks/store.ts +80 -0
- package/src/tasks/types.ts +33 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain text messages -> Grok prompts.
|
|
3
|
+
*
|
|
4
|
+
* Telegram caps a single message at 4096 characters, so a long paste is
|
|
5
|
+
* delivered to the bot as several back-to-back messages. Naively, each part
|
|
6
|
+
* became its own queued turn ("Queued position 1…4") and a part that happened
|
|
7
|
+
* to start with "/" was misread as an "Unknown command". We therefore COALESCE
|
|
8
|
+
* rapid consecutive text messages per chat within a short debounce window
|
|
9
|
+
* (`MESSAGE_BATCH_MS`) into a single prompt — one submission, one confirmation.
|
|
10
|
+
*
|
|
11
|
+
* While a turn is running, the combined message is queued and runs
|
|
12
|
+
* automatically when the current turn finishes.
|
|
13
|
+
* (Wizard input and menu-button text are intercepted by earlier handlers.)
|
|
14
|
+
*/
|
|
15
|
+
import type { Bot } from "grammy";
|
|
16
|
+
import { textPrompt } from "../../app/types.js";
|
|
17
|
+
import { createLogger } from "../../logger.js";
|
|
18
|
+
import type { BotDeps } from "../deps.js";
|
|
19
|
+
import { extractReplyContext } from "../reply-context.js";
|
|
20
|
+
|
|
21
|
+
const log = createLogger("message");
|
|
22
|
+
|
|
23
|
+
/** A pending burst of text messages from one chat, awaiting coalescing. */
|
|
24
|
+
interface TextBatch {
|
|
25
|
+
parts: string[];
|
|
26
|
+
ids: number[];
|
|
27
|
+
/** Reference content if the burst began as a reply to another message. */
|
|
28
|
+
quoted?: string;
|
|
29
|
+
timer: NodeJS.Timeout;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function registerMessages(bot: Bot, deps: BotDeps): void {
|
|
33
|
+
const batches = new Map<number, TextBatch>();
|
|
34
|
+
const windowMs = deps.cfg.messageBatchMs;
|
|
35
|
+
|
|
36
|
+
const arm = (chatId: number): NodeJS.Timeout =>
|
|
37
|
+
setTimeout(() => void flush(deps, batches, chatId), windowMs);
|
|
38
|
+
|
|
39
|
+
bot.on("message:text", async (ctx) => {
|
|
40
|
+
const text = ctx.message.text;
|
|
41
|
+
if (!text.trim()) return;
|
|
42
|
+
const chatId = ctx.chat.id;
|
|
43
|
+
const id = ctx.message.message_id;
|
|
44
|
+
const quoted = extractReplyContext(ctx);
|
|
45
|
+
|
|
46
|
+
const batch = batches.get(chatId);
|
|
47
|
+
if (batch) {
|
|
48
|
+
clearTimeout(batch.timer);
|
|
49
|
+
batch.parts.push(text);
|
|
50
|
+
batch.ids.push(id);
|
|
51
|
+
if (quoted && !batch.quoted) batch.quoted = quoted;
|
|
52
|
+
batch.timer = arm(chatId);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
batches.set(chatId, { parts: [text], ids: [id], quoted, timer: arm(chatId) });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Coalesce a chat's buffered parts into one prompt and submit it once. */
|
|
60
|
+
async function flush(deps: BotDeps, batches: Map<number, TextBatch>, chatId: number): Promise<void> {
|
|
61
|
+
const batch = batches.get(chatId);
|
|
62
|
+
if (!batch) return;
|
|
63
|
+
batches.delete(chatId);
|
|
64
|
+
|
|
65
|
+
// Telegram splits at 4096 chars, almost always on a line boundary, so
|
|
66
|
+
// rejoining with a newline reconstructs the original text faithfully.
|
|
67
|
+
const combined = batch.parts.join("\n").trim();
|
|
68
|
+
if (!combined) return;
|
|
69
|
+
|
|
70
|
+
// A lone, single-line "/something" is an unknown-command typo — guide the
|
|
71
|
+
// user instead of forwarding it to the agent. Split content never trips
|
|
72
|
+
// this: it arrives as multiple parts, and multi-line text is never a command.
|
|
73
|
+
if (batch.parts.length === 1 && !combined.includes("\n") && combined.startsWith("/")) {
|
|
74
|
+
await send(deps, chatId, "Unknown command. Type /help to see what I can do.");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const rt = deps.registry.get(chatId);
|
|
79
|
+
const note = batch.parts.length > 1 ? ` (combined ${batch.parts.length} messages)` : "";
|
|
80
|
+
try {
|
|
81
|
+
// Thread the reply to the prompt message (the user's message is left intact;
|
|
82
|
+
// the agent's response + Done reply to it, and carry searchable hashtags).
|
|
83
|
+
const outcome = await rt.submit(textPrompt(combined, batch.ids[0], batch.quoted));
|
|
84
|
+
if (outcome === "queued") {
|
|
85
|
+
await send(
|
|
86
|
+
deps,
|
|
87
|
+
chatId,
|
|
88
|
+
`\u{1F4E5} Queued (position ${rt.queueLength})${note} \u2014 I'm still working on the previous task. It'll run next.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log.warn(`submit failed for chat ${chatId}: ${(err as Error).message}`);
|
|
93
|
+
await send(deps, chatId, `\u274C Couldn't start your message: ${(err as Error).message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function send(deps: BotDeps, chatId: number, text: string): Promise<void> {
|
|
98
|
+
try {
|
|
99
|
+
await deps.api.sendMessage(chatId, text);
|
|
100
|
+
} catch {
|
|
101
|
+
/* non-fatal */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Photo & image-document handler. Downloads images (including multi-image
|
|
3
|
+
* albums / media groups) and submits them to Grok as ACP image content blocks
|
|
4
|
+
* alongside the caption text.
|
|
5
|
+
*/
|
|
6
|
+
import type { Bot, Context } from "grammy";
|
|
7
|
+
import type { PromptImage } from "../../app/types.js";
|
|
8
|
+
import { createLogger } from "../../logger.js";
|
|
9
|
+
import type { BotDeps } from "../deps.js";
|
|
10
|
+
import { extractReplyContext } from "../reply-context.js";
|
|
11
|
+
|
|
12
|
+
const log = createLogger("photo");
|
|
13
|
+
const GROUP_DEBOUNCE_MS = 900;
|
|
14
|
+
|
|
15
|
+
interface GroupBuffer {
|
|
16
|
+
chatId: number;
|
|
17
|
+
caption: string;
|
|
18
|
+
images: PromptImage[];
|
|
19
|
+
replyTo?: number;
|
|
20
|
+
quoted?: string;
|
|
21
|
+
timer: NodeJS.Timeout;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function registerPhotos(bot: Bot, deps: BotDeps): void {
|
|
25
|
+
const groups = new Map<string, GroupBuffer>();
|
|
26
|
+
|
|
27
|
+
const onMedia = async (ctx: Context, image: PromptImage | undefined, caption: string): Promise<void> => {
|
|
28
|
+
if (!image) return;
|
|
29
|
+
const chatId = ctx.chat!.id;
|
|
30
|
+
const replyTo = ctx.message?.message_id;
|
|
31
|
+
const quoted = extractReplyContext(ctx);
|
|
32
|
+
|
|
33
|
+
// Don't hijack the task wizard.
|
|
34
|
+
if (deps.wizard.isActive(chatId)) {
|
|
35
|
+
await ctx.reply("Finish or /cancel the current task wizard before sending images.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const groupId = ctx.message?.media_group_id;
|
|
40
|
+
if (!groupId) {
|
|
41
|
+
await submit(deps, chatId, caption, [image], replyTo, quoted);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Buffer album items and submit once the group settles.
|
|
46
|
+
const existing = groups.get(groupId);
|
|
47
|
+
if (existing) {
|
|
48
|
+
clearTimeout(existing.timer);
|
|
49
|
+
existing.images.push(image);
|
|
50
|
+
if (caption) existing.caption = caption;
|
|
51
|
+
if (quoted && !existing.quoted) existing.quoted = quoted;
|
|
52
|
+
existing.timer = setTimeout(() => flush(groups, groupId, deps), GROUP_DEBOUNCE_MS);
|
|
53
|
+
} else {
|
|
54
|
+
groups.set(groupId, {
|
|
55
|
+
chatId,
|
|
56
|
+
caption,
|
|
57
|
+
images: [image],
|
|
58
|
+
replyTo,
|
|
59
|
+
quoted,
|
|
60
|
+
timer: setTimeout(() => flush(groups, groupId, deps), GROUP_DEBOUNCE_MS),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
bot.on("message:photo", async (ctx) => {
|
|
66
|
+
const photos = ctx.message.photo;
|
|
67
|
+
const largest = photos[photos.length - 1];
|
|
68
|
+
const image = largest ? await download(ctx, largest.file_id, "image/jpeg", deps.cfg.token) : undefined;
|
|
69
|
+
await onMedia(ctx, image, ctx.message.caption ?? "");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
bot.on("message:document", async (ctx, next) => {
|
|
73
|
+
const doc = ctx.message.document;
|
|
74
|
+
if (!doc.mime_type?.startsWith("image/")) return next(); // let document-handler logic pass
|
|
75
|
+
const image = await download(ctx, doc.file_id, doc.mime_type, deps.cfg.token);
|
|
76
|
+
await onMedia(ctx, image, ctx.message.caption ?? "");
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function flush(groups: Map<string, GroupBuffer>, groupId: string, deps: BotDeps): Promise<void> {
|
|
81
|
+
const buf = groups.get(groupId);
|
|
82
|
+
if (!buf) return;
|
|
83
|
+
groups.delete(groupId);
|
|
84
|
+
await submit(deps, buf.chatId, buf.caption, buf.images, buf.replyTo, buf.quoted);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function submit(
|
|
88
|
+
deps: BotDeps,
|
|
89
|
+
chatId: number,
|
|
90
|
+
caption: string,
|
|
91
|
+
images: PromptImage[],
|
|
92
|
+
replyTo?: number,
|
|
93
|
+
quoted?: string,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const rt = deps.registry.get(chatId);
|
|
96
|
+
const outcome = await rt.submit({ text: caption, images, replyTo, quotedText: quoted });
|
|
97
|
+
if (outcome === "queued") {
|
|
98
|
+
await deps.api.sendMessage(
|
|
99
|
+
chatId,
|
|
100
|
+
`\u{1F4E5} Queued ${images.length} image${images.length > 1 ? "s" : ""} \u2014 will run after the current task.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function download(
|
|
106
|
+
ctx: Context,
|
|
107
|
+
fileId: string,
|
|
108
|
+
mimeType: string,
|
|
109
|
+
token: string,
|
|
110
|
+
): Promise<PromptImage | undefined> {
|
|
111
|
+
try {
|
|
112
|
+
const file = await ctx.api.getFile(fileId);
|
|
113
|
+
if (!file.file_path) return undefined;
|
|
114
|
+
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
|
115
|
+
const res = await fetch(url);
|
|
116
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
117
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
118
|
+
return { data: buf.toString("base64"), mimeType };
|
|
119
|
+
} catch (e) {
|
|
120
|
+
log.warn("image download failed:", (e as Error).message);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /projects (alias /project) — browse, search, open any folder, or create.
|
|
3
|
+
* /projects list/pick projects (freshest first)
|
|
4
|
+
* /projects <query> filter projects by name
|
|
5
|
+
* /projects <path> open any existing folder (e.g. C:\x, /home/x, ~/x);
|
|
6
|
+
* errors if the path doesn't exist (never created)
|
|
7
|
+
* /projects new <name> create a folder under the first root + open it;
|
|
8
|
+
* errors if it already exists
|
|
9
|
+
* Exposes a reusable project menu used by the menu button and the task wizard.
|
|
10
|
+
*/
|
|
11
|
+
import { type Context, InlineKeyboard } from "grammy";
|
|
12
|
+
import type { Bot } from "grammy";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { basename, join, resolve } from "node:path";
|
|
15
|
+
import type { ProjectEntry } from "../../projects/manager.js";
|
|
16
|
+
import type { BotDeps } from "../deps.js";
|
|
17
|
+
import { refreshMenu } from "../menu/refresh.js";
|
|
18
|
+
|
|
19
|
+
const PAGE = 10; // projects per page
|
|
20
|
+
const FETCH = 300; // how many projects to load before paging
|
|
21
|
+
|
|
22
|
+
/** Build the inline keyboard for one page of projects + a Prev/Next nav row. */
|
|
23
|
+
function projectPage(list: ProjectEntry[], page: number, itemPrefix: string, kind: "p" | "w"): InlineKeyboard {
|
|
24
|
+
const totalPages = Math.max(1, Math.ceil(list.length / PAGE));
|
|
25
|
+
const p = Math.min(Math.max(0, page), totalPages - 1);
|
|
26
|
+
const start = p * PAGE;
|
|
27
|
+
const kb = new InlineKeyboard();
|
|
28
|
+
list.slice(start, start + PAGE).forEach((entry, i) => {
|
|
29
|
+
kb.text(`\u{1F4C1} ${entry.name}`, `${itemPrefix}${start + i}`).row();
|
|
30
|
+
});
|
|
31
|
+
if (totalPages > 1) {
|
|
32
|
+
if (p > 0) kb.text("\u25C0 Prev", `pp:${kind}:${p - 1}`);
|
|
33
|
+
kb.text(`${p + 1}/${totalPages}`, "noop");
|
|
34
|
+
if (p < totalPages - 1) kb.text("Next \u25B6", `pp:${kind}:${p + 1}`);
|
|
35
|
+
}
|
|
36
|
+
return kb;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Send a project picker. `prefix` is the callback-data prefix (e.g. "proj:"). */
|
|
40
|
+
export async function sendProjectMenu(
|
|
41
|
+
ctx: Context,
|
|
42
|
+
deps: BotDeps,
|
|
43
|
+
prefix: string,
|
|
44
|
+
title: string,
|
|
45
|
+
entries?: ProjectEntry[],
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
const chatId = ctx.chat!.id;
|
|
48
|
+
await deps.ephemeral.open(ctx);
|
|
49
|
+
const list = sortByRecency(entries ?? deps.projects.list(FETCH), deps);
|
|
50
|
+
deps.menuCache.setProjects(chatId, list);
|
|
51
|
+
if (list.length === 0) {
|
|
52
|
+
await deps.ephemeral.reply(ctx, "No matching projects. Try `/projects new <name>` to create one.");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const kind = prefix === "wiz:proj:" ? "w" : "p";
|
|
56
|
+
await deps.ephemeral.reply(ctx, title, { reply_markup: projectPage(list, 0, prefix, kind) });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Refine project order with Grok session recency: a project's effective
|
|
60
|
+
* "last used" is the latest of its directory mtime and the newest session
|
|
61
|
+
* opened in it, so the project you worked in most recently floats to the top. */
|
|
62
|
+
function sortByRecency(entries: ProjectEntry[], deps: BotDeps): ProjectEntry[] {
|
|
63
|
+
const recencyByCwd = new Map<string, number>();
|
|
64
|
+
for (const s of deps.store.list(300)) {
|
|
65
|
+
const key = normCwd(s.cwd);
|
|
66
|
+
if (!key) continue;
|
|
67
|
+
const ms = Date.parse(s.updatedAt);
|
|
68
|
+
if (!Number.isFinite(ms)) continue;
|
|
69
|
+
const prev = recencyByCwd.get(key) ?? 0;
|
|
70
|
+
if (ms > prev) recencyByCwd.set(key, ms);
|
|
71
|
+
}
|
|
72
|
+
return entries
|
|
73
|
+
.map((p) => ({ ...p, lastUsed: Math.max(p.lastUsed, recencyByCwd.get(normCwd(p.path)) ?? 0) }))
|
|
74
|
+
.sort((a, b) => b.lastUsed - a.lastUsed || a.name.localeCompare(b.name));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Normalise a path for cwd ↔ project matching (case/separator/trailing slash). */
|
|
78
|
+
function normCwd(p: string): string {
|
|
79
|
+
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function showProjects(ctx: Context, deps: BotDeps, query?: string): Promise<void> {
|
|
83
|
+
const arg = (query ?? "").trim();
|
|
84
|
+
|
|
85
|
+
// Create: /projects new <name>
|
|
86
|
+
const create = /^new\s+(.+)$/i.exec(arg);
|
|
87
|
+
if (create) {
|
|
88
|
+
try {
|
|
89
|
+
const entry = deps.projects.create(create[1]!);
|
|
90
|
+
await deps.registry.controller(ctx.chat!.id).addNew(entry.path, entry.name);
|
|
91
|
+
await refreshMenu(ctx, deps, `\u2705 Created and opened ${entry.name}\n${entry.path} \u2014 send a message.`);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
await deps.ephemeral.open(ctx);
|
|
94
|
+
await deps.ephemeral.reply(ctx, `\u274C Could not create project: ${(e as Error).message}`);
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Switch to an explicit path: /projects C:\path · /projects /home/x · ~/x
|
|
100
|
+
if (arg && looksLikePath(arg)) {
|
|
101
|
+
await openProjectPath(ctx, deps, arg);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Search: /projects <query>
|
|
106
|
+
if (arg) {
|
|
107
|
+
const found = deps.projects.search(arg, FETCH);
|
|
108
|
+
await sendProjectMenu(ctx, deps, "proj:", `Projects matching "${arg}":`, found);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await sendProjectMenu(ctx, deps, "proj:", "Choose a project:");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** True when the argument looks like a filesystem path rather than a name. */
|
|
116
|
+
function looksLikePath(s: string): boolean {
|
|
117
|
+
return /[\\/]/.test(s) || /^[a-zA-Z]:/.test(s) || s.startsWith("~");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Open a session in an explicit folder (any path, even outside PROJECT_ROOTS).
|
|
121
|
+
* The folder must already exist — we never create it here. */
|
|
122
|
+
async function openProjectPath(ctx: Context, deps: BotDeps, raw: string): Promise<void> {
|
|
123
|
+
const dir = resolvePath(raw);
|
|
124
|
+
if (!deps.projects.isDirectory(dir)) {
|
|
125
|
+
await deps.ephemeral.open(ctx);
|
|
126
|
+
await deps.ephemeral.reply(
|
|
127
|
+
ctx,
|
|
128
|
+
`\u274C Path not found: ${dir}\nI won't create it \u2014 use \`/projects new <name>\` to make a new project.`,
|
|
129
|
+
);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
await deps.ephemeral.open(ctx);
|
|
133
|
+
const name = basename(dir) || dir;
|
|
134
|
+
try {
|
|
135
|
+
await deps.registry.controller(ctx.chat!.id).addNew(dir, name);
|
|
136
|
+
await refreshMenu(ctx, deps, `\u{1F4C1} Now working in ${name}\n${dir} \u2014 send a message.`);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
await deps.ephemeral.reply(ctx, `\u274C Could not open ${dir}: ${(e as Error).message}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Resolve `~` and normalise a user-supplied path (e.g. `c://lucru` → `C:\lucru`). */
|
|
143
|
+
function resolvePath(p: string): string {
|
|
144
|
+
let s = p.trim();
|
|
145
|
+
if (s === "~") s = homedir();
|
|
146
|
+
else if (s.startsWith("~/") || s.startsWith("~\\")) s = join(homedir(), s.slice(2));
|
|
147
|
+
return resolve(s);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function registerProjects(bot: Bot, deps: BotDeps): void {
|
|
151
|
+
bot.command(["projects", "project"], (ctx) => showProjects(ctx, deps, ctx.match?.toString()));
|
|
152
|
+
|
|
153
|
+
// Page-indicator buttons do nothing but acknowledge the tap.
|
|
154
|
+
bot.callbackQuery("noop", (ctx) => ctx.answerCallbackQuery());
|
|
155
|
+
|
|
156
|
+
// Project picker pagination: pp:<p|w>:<page> edits the keyboard in place.
|
|
157
|
+
bot.callbackQuery(/^pp:(p|w):(\d+)$/, async (ctx) => {
|
|
158
|
+
await ctx.answerCallbackQuery();
|
|
159
|
+
const list = deps.menuCache.getProjects(ctx.chat!.id);
|
|
160
|
+
if (!list) return;
|
|
161
|
+
const kind = ctx.match![1] as "p" | "w";
|
|
162
|
+
const itemPrefix = kind === "w" ? "wiz:proj:" : "proj:";
|
|
163
|
+
const kb = projectPage(list, Number(ctx.match![2]), itemPrefix, kind);
|
|
164
|
+
await ctx.editMessageReplyMarkup({ reply_markup: kb }).catch(() => {});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
bot.callbackQuery(/^proj:(\d+)$/, async (ctx) => {
|
|
168
|
+
const index = Number(ctx.match![1]);
|
|
169
|
+
const entry = deps.menuCache.getProject(ctx.chat!.id, index);
|
|
170
|
+
if (!entry) {
|
|
171
|
+
await ctx.answerCallbackQuery({ text: "Selection expired, run /projects again." });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
await ctx.answerCallbackQuery();
|
|
175
|
+
await deps.ephemeral.clear(ctx.chat!.id); // remove the project picker
|
|
176
|
+
try {
|
|
177
|
+
await deps.registry.controller(ctx.chat!.id).addNew(entry.path, entry.name);
|
|
178
|
+
await refreshMenu(ctx, deps, `\u{1F4C1} Now working in ${entry.name} \u2014 send a message.`);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
await ctx.reply(`\u274C Could not open ${entry.name}: ${(err as Error).message}`);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /running — the sessions this chat controls. Tap one to switch to it; on
|
|
3
|
+
* switch you see a header + the target's unread messages (what happened while
|
|
4
|
+
* you were away) or its recent history the first time.
|
|
5
|
+
*/
|
|
6
|
+
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
7
|
+
import type { RunningSession, SwitchResult } from "../chat-controller.js";
|
|
8
|
+
import type { BotDeps } from "../deps.js";
|
|
9
|
+
import type { HistoryEntry } from "../../sessions/types.js";
|
|
10
|
+
import { jsonlMtimeMs, readFirstPrompt } from "../../sessions/history.js";
|
|
11
|
+
import { progressBar } from "../../render/progress.js";
|
|
12
|
+
import { refreshMenu } from "../menu/refresh.js";
|
|
13
|
+
import { sendMarkdownDoc } from "../telegram-io.js";
|
|
14
|
+
|
|
15
|
+
const UUID = "([0-9a-fA-F-]{36})";
|
|
16
|
+
const ROLE_ICON: Record<string, string> = {
|
|
17
|
+
user: "\u{1F464}",
|
|
18
|
+
assistant: "\u{1F916}",
|
|
19
|
+
tool: "\u{1F527}",
|
|
20
|
+
system: "\u2139\uFE0F",
|
|
21
|
+
};
|
|
22
|
+
const ENTRY_MAX = 700;
|
|
23
|
+
/** Max session cards to send for one /running (avoids flooding the chat). */
|
|
24
|
+
const CARD_LIMIT = 12;
|
|
25
|
+
|
|
26
|
+
function trunc(s: string, n: number): string {
|
|
27
|
+
return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Compact "time ago" label from an elapsed-milliseconds value. */
|
|
31
|
+
function timeAgo(ms: number): string {
|
|
32
|
+
const s = Math.round(ms / 1000);
|
|
33
|
+
if (s < 45) return "just now";
|
|
34
|
+
const m = Math.round(s / 60);
|
|
35
|
+
if (m < 60) return `${m}m ago`;
|
|
36
|
+
const h = Math.round(m / 60);
|
|
37
|
+
if (h < 24) return `${h}h ago`;
|
|
38
|
+
return `${Math.round(h / 24)}d ago`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Reduce a stored first prompt to a clean one-liner: drop the leading reasoning
|
|
42
|
+
* directive and any fork-priming preamble, then collapse whitespace. */
|
|
43
|
+
function cleanPrompt(raw: string): string {
|
|
44
|
+
let t = raw.trim().replace(/^\([^)]*\)\s*/, "");
|
|
45
|
+
const marker = "User's new message:";
|
|
46
|
+
const i = t.lastIndexOf(marker);
|
|
47
|
+
if (i !== -1) t = t.slice(i + marker.length);
|
|
48
|
+
return t.replace(/\s+/g, " ").trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build a rich card (plain text, no MarkdownV2) + buttons for one controlled
|
|
52
|
+
* session: Switch / History / Close. */
|
|
53
|
+
function buildRunningCard(s: RunningSession, deps: BotDeps, now: number): { text: string; kb: InlineKeyboard } {
|
|
54
|
+
const dot = s.foreground ? "\u25B6\uFE0F" : s.busy ? "\u{1F7E0}" : "\u26AA";
|
|
55
|
+
const state = s.foreground ? "foreground" : s.busy ? "working" : "idle";
|
|
56
|
+
|
|
57
|
+
let when = "new";
|
|
58
|
+
let prompt = "";
|
|
59
|
+
if (s.sessionId) {
|
|
60
|
+
const path = deps.store.jsonlPath(s.sessionId);
|
|
61
|
+
const mtime = jsonlMtimeMs(path);
|
|
62
|
+
if (mtime) when = timeAgo(now - mtime);
|
|
63
|
+
prompt = cleanPrompt(readFirstPrompt(path));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const meta = [when, state];
|
|
67
|
+
if (s.busy) meta.push("\u23F3");
|
|
68
|
+
if (s.unread > 0) meta.push(`${s.unread} \u{1F4EC} unread`);
|
|
69
|
+
|
|
70
|
+
const lines = [
|
|
71
|
+
`${dot} ${s.projectName}`,
|
|
72
|
+
prompt ? `\u{1F4AC} \u201C${trunc(prompt, 120)}\u201D` : "\u{1F4AC} (no messages yet)",
|
|
73
|
+
`\u{1F552} ${meta.join(" \u00B7 ")}`,
|
|
74
|
+
];
|
|
75
|
+
if (s.progress !== undefined) lines.push(`\u{1F4C8} ${progressBar(s.progress)}`);
|
|
76
|
+
if (s.sessionId) lines.push(`\u{1F194} ${s.sessionId.slice(0, 8)}`);
|
|
77
|
+
|
|
78
|
+
const kb = new InlineKeyboard();
|
|
79
|
+
if (!s.sessionId) {
|
|
80
|
+
kb.text("\u23F3 starting\u2026", "run:noop");
|
|
81
|
+
return { text: lines.join("\n"), kb };
|
|
82
|
+
}
|
|
83
|
+
if (s.foreground) kb.text("\u25B6\uFE0F Current", "run:noop");
|
|
84
|
+
else kb.text("\u{1F500} Switch", `run:switch:${s.sessionId}`);
|
|
85
|
+
kb.text("\u{1F4DC} History", `hist:${s.sessionId}`).text("\u2716 Close", `run:close:${s.sessionId}`);
|
|
86
|
+
return { text: lines.join("\n"), kb };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function showRunning(ctx: Context, deps: BotDeps): Promise<void> {
|
|
90
|
+
await deps.ephemeral.open(ctx);
|
|
91
|
+
const list = dedupeBySession(deps.registry.controller(ctx.chat!.id).list());
|
|
92
|
+
if (list.length === 0) {
|
|
93
|
+
await deps.ephemeral.reply(ctx, "No sessions controlled yet. Use \u{1F4C1} Project or /new to start one.");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const shown = list.slice(0, CARD_LIMIT);
|
|
98
|
+
await deps.ephemeral.reply(ctx, `\u{1F9ED} Sessions controlled by this chat (${list.length}) \u2014 tap \u{1F500} Switch on a card:`);
|
|
99
|
+
for (const s of shown) {
|
|
100
|
+
const { text, kb } = buildRunningCard(s, deps, now);
|
|
101
|
+
await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
|
|
102
|
+
}
|
|
103
|
+
if (list.length > shown.length) {
|
|
104
|
+
await deps.ephemeral.reply(ctx, `\u2026and ${list.length - shown.length} more.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Collapse any cards that share a session id (defensive — the controller
|
|
109
|
+
* already prunes duplicate runtimes, but never show the same session twice). */
|
|
110
|
+
function dedupeBySession(list: RunningSession[]): RunningSession[] {
|
|
111
|
+
const seen = new Set<string>();
|
|
112
|
+
return list.filter((s) => {
|
|
113
|
+
if (!s.sessionId) return true;
|
|
114
|
+
if (seen.has(s.sessionId)) return false;
|
|
115
|
+
seen.add(s.sessionId);
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Switch the chat to a session and show its summary + unread. */
|
|
121
|
+
export async function switchAndShow(ctx: Context, deps: BotDeps, sessionId: string): Promise<void> {
|
|
122
|
+
const res = await deps.registry.controller(ctx.chat!.id).switchTo(sessionId);
|
|
123
|
+
if (!res) {
|
|
124
|
+
await ctx.reply("Session not found (it may have been closed).");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await deliverSwitch(ctx, deps, res);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function registerRunning(bot: Bot, deps: BotDeps): void {
|
|
131
|
+
bot.command("running", (ctx) => showRunning(ctx, deps));
|
|
132
|
+
|
|
133
|
+
bot.callbackQuery("run:noop", (ctx) => ctx.answerCallbackQuery({ text: "Already in foreground" }));
|
|
134
|
+
|
|
135
|
+
bot.callbackQuery(new RegExp(`^run:switch:${UUID}$`), async (ctx) => {
|
|
136
|
+
await ctx.answerCallbackQuery();
|
|
137
|
+
await deps.ephemeral.clear(ctx.chat!.id); // remove the /running cards; 🔀 Switched stays
|
|
138
|
+
await switchAndShow(ctx, deps, ctx.match![1]!);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
bot.callbackQuery(new RegExp(`^run:close:${UUID}$`), async (ctx) => {
|
|
142
|
+
const id = ctx.match![1]!;
|
|
143
|
+
await deps.registry.controller(ctx.chat!.id).close(id);
|
|
144
|
+
await ctx.answerCallbackQuery({ text: "Closed" });
|
|
145
|
+
await ctx.deleteMessage().catch(() => {}); // remove just this card
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function deliverSwitch(ctx: Context, deps: BotDeps, res: SwitchResult): Promise<void> {
|
|
150
|
+
const proj = res.projectName ?? "session";
|
|
151
|
+
const sid = res.sessionId ? res.sessionId.slice(0, 8) : "?";
|
|
152
|
+
if (res.alreadyForeground) {
|
|
153
|
+
await ctx.reply(`You're already on ${proj} (${sid}).`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const working = res.busy ? " \u00B7 \u23F3 still working (live updates follow)" : "";
|
|
157
|
+
await refreshMenu(ctx, deps, `\u{1F500} Switched to ${proj} (${sid})${working}`);
|
|
158
|
+
|
|
159
|
+
if (res.unread.length === 0) {
|
|
160
|
+
if (!res.busy) await ctx.reply(res.firstView ? "No earlier messages here." : "\u2705 Nothing new while you were away.");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const header = res.firstView
|
|
164
|
+
? `\u{1F4DC} **Recent history** \u2014 ${proj}`
|
|
165
|
+
: `\u{1F4EC} **${res.unread.length} message(s) while away** \u2014 ${proj}`;
|
|
166
|
+
const body = res.unread.map(fmtEntry).join("\n\n");
|
|
167
|
+
await sendMarkdownDoc(deps.api, ctx.chat!.id, `${header}\n\n${body}\n\n${res.rt.tags}`);
|
|
168
|
+
|
|
169
|
+
// Replay how the session's last turn ended (Done + file summary) — this isn't
|
|
170
|
+
// in the .jsonl, so it's the footer you'd have seen had you been watching.
|
|
171
|
+
if (!res.busy && res.rt.lastTurnSummary) {
|
|
172
|
+
await ctx.reply(res.rt.lastTurnSummary);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function fmtEntry(e: HistoryEntry): string {
|
|
177
|
+
const icon = ROLE_ICON[e.role] ?? "\u2022";
|
|
178
|
+
if (e.role === "tool") return `${icon} ${e.tool ? `\`${e.tool}\`` : "tool"}`;
|
|
179
|
+
const text = e.text.length > ENTRY_MAX ? e.text.slice(0, ENTRY_MAX) + " \u2026" : e.text;
|
|
180
|
+
return `${icon} ${text}`;
|
|
181
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a rich, readable "card" for a single Grok session: a plain-text body
|
|
3
|
+
* (no MarkdownV2 so Windows paths and titles never need escaping) plus an
|
|
4
|
+
* inline keyboard with Connect / History / Watch actions.
|
|
5
|
+
*
|
|
6
|
+
* Callback data is unchanged (`sess:` / `hist:` / `watch:` + UUID) so the
|
|
7
|
+
* existing handlers in sessions.ts keep working.
|
|
8
|
+
*/
|
|
9
|
+
import { InlineKeyboard } from "grammy";
|
|
10
|
+
import { basename } from "node:path";
|
|
11
|
+
import { progressBar } from "../../render/progress.js";
|
|
12
|
+
import type { SessionMeta } from "../../sessions/types.js";
|
|
13
|
+
|
|
14
|
+
export interface SessionCardExtras {
|
|
15
|
+
/** Context-usage %, when the session is loaded in the current ACP process. */
|
|
16
|
+
contextPct?: number;
|
|
17
|
+
/**
|
|
18
|
+
* PID of the bot's own `grok acp` process. A session locked by this PID
|
|
19
|
+
* powers the bot itself, so its card omits the Kill button (killing it would
|
|
20
|
+
* take the bot down). Other live sessions get a 🛑 Kill button.
|
|
21
|
+
*/
|
|
22
|
+
selfPid?: number;
|
|
23
|
+
/** Latest task-completion % (0–100) for this session, if this chat runs it. */
|
|
24
|
+
progress?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SessionCard {
|
|
28
|
+
text: string;
|
|
29
|
+
keyboard: InlineKeyboard;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Build the card body + buttons for one session. */
|
|
33
|
+
export function buildSessionCard(m: SessionMeta, extra: SessionCardExtras = {}): SessionCard {
|
|
34
|
+
const dot = m.active ? "\u{1F7E2}" : "\u26AA";
|
|
35
|
+
const state = m.active ? `running${m.lockPid ? ` \u00B7 pid ${m.lockPid}` : ""}` : "idle";
|
|
36
|
+
const proj = m.cwd ? basename(m.cwd) : "(no project)";
|
|
37
|
+
|
|
38
|
+
const lines = [`${dot} ${m.title}`, `\u{1F4C1} ${proj}`];
|
|
39
|
+
if (m.cwd) lines.push(` ${m.cwd}`);
|
|
40
|
+
lines.push(`\u{1F552} updated ${relTime(m.updatedAt)} \u00B7 created ${relTime(m.createdAt)}`);
|
|
41
|
+
const ctx = typeof extra.contextPct === "number" ? ` \u00B7 \u{1F9E0} ctx ${Math.round(extra.contextPct)}%` : "";
|
|
42
|
+
lines.push(`\u{1F4CA} ${state} \u00B7 \u{1F4DC} history ${humanSize(m.historyBytes)}${ctx}`);
|
|
43
|
+
if (typeof extra.progress === "number") lines.push(`\u{1F4C8} ${progressBar(extra.progress)}`);
|
|
44
|
+
lines.push(`\u{1F194} ${m.sessionId.slice(0, 8)}`);
|
|
45
|
+
|
|
46
|
+
const connect = m.active ? "\u{1F374} Continue (fork)" : "\u{1F517} Resume";
|
|
47
|
+
const keyboard = new InlineKeyboard()
|
|
48
|
+
.text(connect, `sess:${m.sessionId}`)
|
|
49
|
+
.text("\u{1F4DC} History", `hist:${m.sessionId}`)
|
|
50
|
+
.text("\u{1F4E1} Watch", `watch:${m.sessionId}`);
|
|
51
|
+
|
|
52
|
+
// A live session running in another process can be terminated by PID. The
|
|
53
|
+
// bot's own agent (selfPid) is never offered — killing it would stop the bot.
|
|
54
|
+
if (m.active && typeof m.lockPid === "number" && m.lockPid !== extra.selfPid) {
|
|
55
|
+
keyboard.row().text(`\u{1F6D1} Kill \u00B7 pid ${m.lockPid}`, `killsess:${m.sessionId}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { text: lines.join("\n"), keyboard };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Compact relative time, e.g. "42s ago", "5m ago", "3h ago", "2d ago". */
|
|
62
|
+
export function relTime(iso: string): string {
|
|
63
|
+
const t = Date.parse(iso);
|
|
64
|
+
if (!Number.isFinite(t)) return "unknown";
|
|
65
|
+
const s = Math.max(0, Math.round((Date.now() - t) / 1000));
|
|
66
|
+
if (s < 60) return `${s}s ago`;
|
|
67
|
+
const m = Math.floor(s / 60);
|
|
68
|
+
if (m < 60) return `${m}m ago`;
|
|
69
|
+
const h = Math.floor(m / 60);
|
|
70
|
+
if (h < 24) return `${h}h ago`;
|
|
71
|
+
const d = Math.floor(h / 24);
|
|
72
|
+
return `${d}d ago`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Human-readable byte size, e.g. "812 B", "42.3 KB", "1.2 MB". */
|
|
76
|
+
export function humanSize(bytes: number): string {
|
|
77
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
78
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
79
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
80
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
81
|
+
}
|