grok-telegram-bot 2.3.1 → 2.4.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 +26 -0
- package/CHANGELOG.md +37 -0
- package/package.json +1 -1
- package/scripts/analyze-jsonl.ts +33 -0
- package/scripts/delayed-restart.ps1 +29 -0
- package/scripts/probe-exit-response-shape.py +77 -0
- package/scripts/probe-plan-exit.py +60 -0
- package/scripts/probe-plan-exit2.py +48 -0
- package/scripts/probe-plan-fields.py +41 -0
- package/scripts/probe-plan-fields2.py +58 -0
- package/scripts/probe-plan-response-path.py +48 -0
- package/scripts/sample-claude-tooluse.ts +21 -0
- package/scripts/sample-kiro-events.ts +31 -0
- package/scripts/smoke-exit-plan.ts +274 -0
- package/scripts/smoke-exit-shapes.ts +252 -0
- package/scripts/smoke-import.mjs +82 -0
- package/scripts/smoke-import.ts +73 -0
- package/src/app/accounts.ts +84 -0
- package/src/app/instance-lock.ts +6 -0
- package/src/app/types.ts +19 -2
- package/src/app/updater.ts +17 -6
- package/src/app/usage.ts +204 -7
- package/src/bot/account-rotator.ts +10 -0
- package/src/bot/bot.ts +36 -0
- package/src/bot/chat-controller.ts +35 -0
- package/src/bot/commands.ts +2 -0
- package/src/bot/complexity-gate.ts +69 -0
- package/src/bot/deps.ts +19 -0
- package/src/bot/handlers/accounts.ts +51 -1
- package/src/bot/handlers/import-session.ts +290 -0
- package/src/bot/handlers/menu.ts +17 -38
- package/src/bot/handlers/message.ts +1 -0
- package/src/bot/handlers/running.ts +35 -5
- package/src/bot/handlers/session-card.ts +12 -0
- package/src/bot/handlers/sessions.ts +14 -3
- package/src/bot/handlers/usage.ts +118 -16
- package/src/bot/menu/keyboard.ts +5 -4
- package/src/bot/menu/status-panel.ts +19 -6
- package/src/bot/prompt-content.ts +4 -0
- package/src/bot/session-fork.ts +11 -0
- package/src/bot/session-runtime.ts +740 -58
- package/src/bot/suggestions.ts +429 -0
- package/src/config.ts +41 -0
- package/src/grok/client.ts +91 -16
- package/src/grok/plan-approval.ts +72 -0
- package/src/grok/session-log.ts +16 -0
- package/src/grok/types.ts +21 -2
- package/src/import/build-import.ts +132 -0
- package/src/import/history-readers.ts +681 -0
- package/src/import/list-running.ts +100 -0
- package/src/import/sources.ts +78 -0
- package/src/index.ts +179 -24
- package/src/render/diff.ts +11 -2
- package/src/render/file-summary.ts +31 -1
- package/src/render/markdown.ts +293 -35
- package/src/render/plan.ts +127 -0
- package/src/render/session-comment.ts +261 -0
- package/src/render/tool-call-detail.ts +400 -19
- package/src/render/tool-call-merge.ts +115 -0
- package/src/render/tool-call.ts +405 -142
- package/src/render/truncate.ts +85 -0
- package/src/service/windows.ts +14 -2
- package/src/sessions/history.ts +57 -0
- package/src/sessions/store.ts +3 -0
- package/src/sessions/types.ts +5 -0
- package/src/stream/streamer.ts +73 -9
- package/src/tasks/runner.ts +4 -3
package/src/bot/deps.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type { AppConfig } from "../config.js";
|
|
|
10
10
|
import type { SttService } from "../app/stt.js";
|
|
11
11
|
import type { UsageService } from "../app/usage.js";
|
|
12
12
|
import type { ProjectEntry, ProjectManager } from "../projects/manager.js";
|
|
13
|
+
import type { ImportableSession } from "../import/list-running.js";
|
|
14
|
+
import type { ImportSourceId } from "../import/sources.js";
|
|
13
15
|
import type { SessionMeta } from "../sessions/types.js";
|
|
14
16
|
import type { SessionStore } from "../sessions/store.js";
|
|
15
17
|
import type { TaskRunner } from "../tasks/runner.js";
|
|
@@ -42,6 +44,10 @@ export interface BotDeps {
|
|
|
42
44
|
export class MenuCache {
|
|
43
45
|
private readonly projectLists = new Map<number, ProjectEntry[]>();
|
|
44
46
|
private readonly sessionLists = new Map<number, { metas: SessionMeta[]; heading: string }>();
|
|
47
|
+
private readonly importLists = new Map<
|
|
48
|
+
number,
|
|
49
|
+
{ sourceId: ImportSourceId; sessions: ImportableSession[] }
|
|
50
|
+
>();
|
|
45
51
|
|
|
46
52
|
setProjects(chatId: number, list: ProjectEntry[]): void {
|
|
47
53
|
this.projectLists.set(chatId, list);
|
|
@@ -64,4 +70,17 @@ export class MenuCache {
|
|
|
64
70
|
getSessions(chatId: number): { metas: SessionMeta[]; heading: string } | undefined {
|
|
65
71
|
return this.sessionLists.get(chatId);
|
|
66
72
|
}
|
|
73
|
+
|
|
74
|
+
/** Cache foreign sessions shown by Import session (callback index → session). */
|
|
75
|
+
setImportSessions(chatId: number, sourceId: ImportSourceId, sessions: ImportableSession[]): void {
|
|
76
|
+
this.importLists.set(chatId, { sourceId, sessions });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getImportSessions(chatId: number): { sourceId: ImportSourceId; sessions: ImportableSession[] } | undefined {
|
|
80
|
+
return this.importLists.get(chatId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getImportSession(chatId: number, index: number): ImportableSession | undefined {
|
|
84
|
+
return this.importLists.get(chatId)?.sessions[index];
|
|
85
|
+
}
|
|
67
86
|
}
|
|
@@ -11,6 +11,7 @@ import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
|
11
11
|
import { AuthService } from "../../app/auth-service.js";
|
|
12
12
|
import type { StoredAccount } from "../../app/accounts.js";
|
|
13
13
|
import { UNSUPPORTED_LOGIN_HELP } from "../../app/grok-credentials.js";
|
|
14
|
+
import { formatCliBillingLines } from "../../app/usage.js";
|
|
14
15
|
import { createLogger } from "../../logger.js";
|
|
15
16
|
import type { BotDeps } from "../deps.js";
|
|
16
17
|
|
|
@@ -48,12 +49,35 @@ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keybo
|
|
|
48
49
|
} else {
|
|
49
50
|
lines.push("\u{1F7E2} Signed in (identity unknown).", "");
|
|
50
51
|
}
|
|
52
|
+
// Live Grok CLI monthly quota for the host login (same source as OmniRoute-style clients).
|
|
53
|
+
if (loggedIn) {
|
|
54
|
+
const { billing, error } = await deps.usage.cliBilling().catch((e) => ({
|
|
55
|
+
billing: undefined,
|
|
56
|
+
error: (e as Error).message,
|
|
57
|
+
}));
|
|
58
|
+
if (billing) {
|
|
59
|
+
lines.push(...formatCliBillingLines(billing), "");
|
|
60
|
+
} else if (error) {
|
|
61
|
+
lines.push(`\u{1F4B3} Grok CLI quota: unavailable (${error})`, "");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
51
64
|
if (list.length === 0) {
|
|
52
65
|
lines.push("No saved accounts yet.", "", "Save the current login below, or sign in via /reauth.");
|
|
53
66
|
} else {
|
|
54
67
|
for (const a of list) {
|
|
55
68
|
lines.push(accountLine(a, a.id === active));
|
|
56
|
-
|
|
69
|
+
const usage = deps.accounts.formatUsageLine(a);
|
|
70
|
+
if (usage) lines.push(` \u2514 \u{1F4CA} ${usage}`);
|
|
71
|
+
else lines.push(" \u2514 \u{1F4CA} No recorded usage yet");
|
|
72
|
+
if (a.warning) {
|
|
73
|
+
lines.push(
|
|
74
|
+
` \u2514 \u26A0\uFE0F Skipped by auto-rotate: ${a.warning.reason.slice(0, 80)}${a.warning.reason.length > 80 ? "\u2026" : ""}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const totals = summarizeUsage(list);
|
|
79
|
+
if (totals) {
|
|
80
|
+
lines.push("", `\u{1F4CA} All saved accounts: ${totals}`);
|
|
57
81
|
}
|
|
58
82
|
}
|
|
59
83
|
const rotate = deps.accounts.autoRotateEnabled();
|
|
@@ -86,6 +110,32 @@ function trim(s: string, n = 22): string {
|
|
|
86
110
|
return s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
|
|
87
111
|
}
|
|
88
112
|
|
|
113
|
+
/** Aggregate usage across saved accounts for the footer line. */
|
|
114
|
+
function summarizeUsage(list: StoredAccount[]): string {
|
|
115
|
+
let turns = 0;
|
|
116
|
+
let credits = 0;
|
|
117
|
+
let used = 0;
|
|
118
|
+
for (const a of list) {
|
|
119
|
+
const u = a.usage;
|
|
120
|
+
if (!u) continue;
|
|
121
|
+
if (u.turns > 0 || u.credits > 0) used++;
|
|
122
|
+
turns += u.turns || 0;
|
|
123
|
+
credits += u.credits || 0;
|
|
124
|
+
}
|
|
125
|
+
if (used === 0 && turns === 0 && credits === 0) return "";
|
|
126
|
+
const parts: string[] = [];
|
|
127
|
+
if (turns > 0) parts.push(`${turns} turn${turns === 1 ? "" : "s"}`);
|
|
128
|
+
if (credits > 0) parts.push(`${fmtNum(credits)} credits total`);
|
|
129
|
+
parts.push(`${used}/${list.length} accounts used`);
|
|
130
|
+
return parts.join(" \u00B7 ");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function fmtNum(n: number): string {
|
|
134
|
+
if (!Number.isFinite(n)) return String(n);
|
|
135
|
+
if (Number.isInteger(n)) return n.toLocaleString("en-US");
|
|
136
|
+
return n.toFixed(2);
|
|
137
|
+
}
|
|
138
|
+
|
|
89
139
|
export async function showAccounts(ctx: Context, deps: BotDeps): Promise<void> {
|
|
90
140
|
const { text, keyboard } = await view(deps);
|
|
91
141
|
await deps.ephemeral.open(ctx);
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import session — pick a sibling bot (Kiro / OpenCode / Claude / Codex), list
|
|
3
|
+
* its /running (controlled) sessions, and import one into this chat as a new
|
|
4
|
+
* Grok session primed with the full transcript (nothing lost).
|
|
5
|
+
*
|
|
6
|
+
* Flow:
|
|
7
|
+
* Menu → Import session → source → running session cards → Import
|
|
8
|
+
*/
|
|
9
|
+
import { basename, join } from "node:path";
|
|
10
|
+
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
11
|
+
import { textPrompt } from "../../app/types.js";
|
|
12
|
+
import { buildImportPackage } from "../../import/build-import.js";
|
|
13
|
+
import { listRunningFromSource, type ImportableSession } from "../../import/list-running.js";
|
|
14
|
+
import {
|
|
15
|
+
getImportSource,
|
|
16
|
+
IMPORT_SOURCES,
|
|
17
|
+
sourceAvailable,
|
|
18
|
+
type ImportSourceId,
|
|
19
|
+
} from "../../import/sources.js";
|
|
20
|
+
import { createLogger } from "../../logger.js";
|
|
21
|
+
import type { BotDeps } from "../deps.js";
|
|
22
|
+
import { IMPORT_CONFIRM_PROMPT } from "../session-fork.js";
|
|
23
|
+
import { refreshMenu } from "../menu/refresh.js";
|
|
24
|
+
|
|
25
|
+
const log = createLogger("import-session");
|
|
26
|
+
|
|
27
|
+
const CARD_LIMIT = 20;
|
|
28
|
+
|
|
29
|
+
/** Open the source picker (menu entry / /import). */
|
|
30
|
+
export async function showImportSources(ctx: Context, deps: BotDeps): Promise<void> {
|
|
31
|
+
await deps.ephemeral.open(ctx);
|
|
32
|
+
const kb = new InlineKeyboard();
|
|
33
|
+
for (const src of IMPORT_SOURCES) {
|
|
34
|
+
const ok = sourceAvailable(src);
|
|
35
|
+
const mark = ok ? "" : " \u26A0";
|
|
36
|
+
kb.text(`${labelEmoji(src.id)} ${src.label}${mark}`, `imp:src:${src.id}`).row();
|
|
37
|
+
}
|
|
38
|
+
kb.text("\u2716 Cancel", "imp:cancel");
|
|
39
|
+
await deps.ephemeral.reply(
|
|
40
|
+
ctx,
|
|
41
|
+
[
|
|
42
|
+
"\u{1F4E5} Import session",
|
|
43
|
+
"",
|
|
44
|
+
"Choose the source tool. You will then see its /running sessions",
|
|
45
|
+
"and can import one into Grok with full conversation context.",
|
|
46
|
+
].join("\n"),
|
|
47
|
+
{ reply_markup: kb },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** List /running sessions for a source bot. */
|
|
52
|
+
export async function showImportRunning(
|
|
53
|
+
ctx: Context,
|
|
54
|
+
deps: BotDeps,
|
|
55
|
+
sourceId: ImportSourceId,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const src = getImportSource(sourceId);
|
|
58
|
+
if (!src) {
|
|
59
|
+
await ctx.reply("Unknown source.");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await deps.ephemeral.open(ctx);
|
|
63
|
+
|
|
64
|
+
if (!sourceAvailable(src)) {
|
|
65
|
+
await deps.ephemeral.reply(
|
|
66
|
+
ctx,
|
|
67
|
+
`\u26A0\uFE0F ${src.label} bot root not found:\n\`${src.botRoot}\``,
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const chatId = ctx.chat!.id;
|
|
73
|
+
let sessions = listRunningFromSource(src, chatId);
|
|
74
|
+
// If this Telegram chat id has no controlled sessions there, union all chats
|
|
75
|
+
// on that bot (same human often uses one chat id everywhere).
|
|
76
|
+
if (sessions.length === 0) sessions = listRunningFromSource(src);
|
|
77
|
+
|
|
78
|
+
if (sessions.length === 0) {
|
|
79
|
+
const kb = new InlineKeyboard().text("\u25C0 Sources", "imp:back").text("\u2716 Cancel", "imp:cancel");
|
|
80
|
+
await deps.ephemeral.reply(
|
|
81
|
+
ctx,
|
|
82
|
+
[
|
|
83
|
+
`\u{1F4E5} ${src.label} \u2014 no /running sessions`,
|
|
84
|
+
"",
|
|
85
|
+
"That bot has no controlled sessions in its settings right now.",
|
|
86
|
+
"Open a session there first (Project / message), then try Import again.",
|
|
87
|
+
].join("\n"),
|
|
88
|
+
{ reply_markup: kb },
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
deps.menuCache.setImportSessions(chatId, sourceId, sessions);
|
|
94
|
+
const shown = sessions.slice(0, CARD_LIMIT);
|
|
95
|
+
await deps.ephemeral.reply(
|
|
96
|
+
ctx,
|
|
97
|
+
`\u{1F4E5} ${src.label} /running \u2014 ${sessions.length} session(s). Tap Import on a card:`,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
for (let i = 0; i < shown.length; i++) {
|
|
102
|
+
const s = shown[i]!;
|
|
103
|
+
const { text, kb } = buildImportCard(s, i, now);
|
|
104
|
+
await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
|
|
105
|
+
}
|
|
106
|
+
if (sessions.length > shown.length) {
|
|
107
|
+
await deps.ephemeral.reply(ctx, `\u2026and ${sessions.length - shown.length} more (not shown).`);
|
|
108
|
+
}
|
|
109
|
+
const nav = new InlineKeyboard().text("\u25C0 Sources", "imp:back").text("\u2716 Cancel", "imp:cancel");
|
|
110
|
+
await deps.ephemeral.reply(ctx, "Pick a session above, or go back.", { reply_markup: nav });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Perform the import into a new Grok /running session. */
|
|
114
|
+
export async function doImportSession(ctx: Context, deps: BotDeps, index: number): Promise<void> {
|
|
115
|
+
const chatId = ctx.chat!.id;
|
|
116
|
+
const cached = deps.menuCache.getImportSessions(chatId);
|
|
117
|
+
const session = cached?.sessions[index];
|
|
118
|
+
if (!session || !cached) {
|
|
119
|
+
await ctx.reply("That import list expired \u2014 open Import session again.");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const src = getImportSource(cached.sourceId);
|
|
123
|
+
if (!src) {
|
|
124
|
+
await ctx.reply("Unknown source.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await deps.ephemeral.clear(chatId).catch(() => {});
|
|
129
|
+
await ctx.reply(
|
|
130
|
+
`\u23F3 Importing from ${src.label} \u2026\n\`${session.sessionId.slice(0, 12)}\` \u00B7 ${session.projectName ?? (basename(session.cwd || "") || "project")}`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const cwd = resolveImportCwd(session, deps, chatId);
|
|
134
|
+
const projectName = session.projectName || (cwd ? basename(cwd) : "imported");
|
|
135
|
+
const importsDir = join(deps.cfg.dataDir, "imports");
|
|
136
|
+
|
|
137
|
+
let pkg;
|
|
138
|
+
try {
|
|
139
|
+
pkg = buildImportPackage(src, { ...session, cwd }, importsDir);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
log.error("buildImportPackage failed:", (e as Error).message);
|
|
142
|
+
await ctx.reply(`\u274C Could not read source history: ${(e as Error).message}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (pkg.entryCount === 0) {
|
|
147
|
+
await ctx.reply(
|
|
148
|
+
[
|
|
149
|
+
`\u26A0\uFE0F No history entries found on disk for this session.`,
|
|
150
|
+
`Session id: \`${session.sessionId}\``,
|
|
151
|
+
`I will still open a Grok session in the project, but context may be empty.`,
|
|
152
|
+
`Transcript archive: \`${pkg.transcriptPath}\``,
|
|
153
|
+
].join("\n"),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const rt = await deps.registry.controller(chatId).addImport(cwd, projectName, pkg.priming);
|
|
159
|
+
// Flush priming into the live Grok session immediately so context is not
|
|
160
|
+
// waiting on the user's next free-form message.
|
|
161
|
+
void rt.submit(textPrompt(IMPORT_CONFIRM_PROMPT));
|
|
162
|
+
|
|
163
|
+
const lines = [
|
|
164
|
+
`\u2705 Imported into Grok /running`,
|
|
165
|
+
`\u{1F4E5} ${src.label} \u2192 Grok`,
|
|
166
|
+
`\u{1F4C1} ${projectName}`,
|
|
167
|
+
cwd ? ` ${cwd}` : "",
|
|
168
|
+
`\u{1F4DC} ${pkg.entryCount} history entries \u00B7 ${humanSize(pkg.transcriptChars)} transcript`,
|
|
169
|
+
pkg.truncatedInline
|
|
170
|
+
? `\u2139\uFE0F Full transcript is on disk (inline slice was capped); Grok will use the file if needed.`
|
|
171
|
+
: `\u2705 Full transcript inlined into the first Grok turn.`,
|
|
172
|
+
`\u{1F4C4} ${pkg.transcriptPath}`,
|
|
173
|
+
rt.sessionId ? `\u{1F194} new Grok session ${rt.sessionId.slice(0, 8)}` : "",
|
|
174
|
+
``,
|
|
175
|
+
`Grok is loading the context now. After the short confirmation, send your next instruction to continue.`,
|
|
176
|
+
].filter(Boolean);
|
|
177
|
+
|
|
178
|
+
await refreshMenu(ctx, deps, `\u{1F4E5} Imported ${src.label} \u00B7 ${projectName}`);
|
|
179
|
+
await ctx.reply(lines.join("\n"));
|
|
180
|
+
} catch (e) {
|
|
181
|
+
log.error("import failed:", (e as Error).message);
|
|
182
|
+
await ctx.reply(
|
|
183
|
+
`\u274C Import failed: ${(e as Error).message}\nTranscript (if written): \`${pkg.transcriptPath}\``,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function registerImportSession(bot: Bot, deps: BotDeps): void {
|
|
189
|
+
bot.command("import", (ctx) => showImportSources(ctx, deps));
|
|
190
|
+
|
|
191
|
+
bot.callbackQuery("imp:cancel", async (ctx) => {
|
|
192
|
+
await ctx.answerCallbackQuery();
|
|
193
|
+
await ctx.deleteMessage().catch(() => {});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
bot.callbackQuery("imp:back", async (ctx) => {
|
|
197
|
+
await ctx.answerCallbackQuery();
|
|
198
|
+
await deps.ephemeral.clear(ctx.chat!.id).catch(() => {});
|
|
199
|
+
await showImportSources(ctx, deps);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
bot.callbackQuery(/^imp:src:(kiro|opencode|claude|codex)$/, async (ctx) => {
|
|
203
|
+
await ctx.answerCallbackQuery();
|
|
204
|
+
await deps.ephemeral.clear(ctx.chat!.id).catch(() => {});
|
|
205
|
+
await showImportRunning(ctx, deps, ctx.match![1] as ImportSourceId);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
bot.callbackQuery(/^imp:go:(\d+)$/, async (ctx) => {
|
|
209
|
+
await ctx.answerCallbackQuery({ text: "Importing\u2026" });
|
|
210
|
+
await doImportSession(ctx, deps, Number(ctx.match![1]));
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── cards / helpers ──────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
function buildImportCard(
|
|
217
|
+
s: ImportableSession,
|
|
218
|
+
index: number,
|
|
219
|
+
now: number,
|
|
220
|
+
): { text: string; kb: InlineKeyboard } {
|
|
221
|
+
const proj = s.projectName || (s.cwd ? basename(s.cwd) : "(no project)");
|
|
222
|
+
const when = s.updatedAt ? timeAgo(now - Date.parse(s.updatedAt)) : "unknown";
|
|
223
|
+
const title = cleanTitle(s.title);
|
|
224
|
+
const hist =
|
|
225
|
+
s.historyBytes > 0 ? humanSize(s.historyBytes) : s.historyPath ? "0 B" : "no history file";
|
|
226
|
+
|
|
227
|
+
const lines = [
|
|
228
|
+
`\u{1F4E5} ${proj}`,
|
|
229
|
+
title ? `\u{1F4AC} \u201C${trunc(title, 120)}\u201D` : "\u{1F4AC} (no title)",
|
|
230
|
+
s.cwd ? `\u{1F4C1} ${s.cwd}` : "\u{1F4C1} (no cwd recorded)",
|
|
231
|
+
`\u{1F552} ${when} \u00B7 \u{1F4DC} ${hist}`,
|
|
232
|
+
`\u{1F194} ${s.sessionId.length > 16 ? s.sessionId.slice(0, 12) + "\u2026" : s.sessionId}`,
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const kb = new InlineKeyboard().text("\u{1F4E5} Import into Grok", `imp:go:${index}`);
|
|
236
|
+
return { text: lines.join("\n"), kb };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function resolveImportCwd(session: ImportableSession, deps: BotDeps, chatId: number): string {
|
|
240
|
+
if (session.cwd) return session.cwd;
|
|
241
|
+
// No cwd on the source session — stay on this chat's current project.
|
|
242
|
+
try {
|
|
243
|
+
return deps.registry.get(chatId).cwd || deps.cfg.workspace;
|
|
244
|
+
} catch {
|
|
245
|
+
return deps.cfg.workspace;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function labelEmoji(id: ImportSourceId): string {
|
|
250
|
+
switch (id) {
|
|
251
|
+
case "kiro":
|
|
252
|
+
return "\u{1F3AF}";
|
|
253
|
+
case "opencode":
|
|
254
|
+
return "\u26A1";
|
|
255
|
+
case "claude":
|
|
256
|
+
return "\u{1F9E0}";
|
|
257
|
+
case "codex":
|
|
258
|
+
return "\u{1F4D6}";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cleanTitle(raw: string): string {
|
|
263
|
+
let t = (raw || "").trim().replace(/^\([^)]*\)\s*/, "");
|
|
264
|
+
const marker = "User's new message:";
|
|
265
|
+
const i = t.lastIndexOf(marker);
|
|
266
|
+
if (i !== -1) t = t.slice(i + marker.length);
|
|
267
|
+
return t.replace(/\s+/g, " ").trim();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function trunc(s: string, n: number): string {
|
|
271
|
+
return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function timeAgo(ms: number): string {
|
|
275
|
+
if (!Number.isFinite(ms) || ms < 0) return "unknown";
|
|
276
|
+
const s = Math.round(ms / 1000);
|
|
277
|
+
if (s < 45) return "just now";
|
|
278
|
+
const m = Math.round(s / 60);
|
|
279
|
+
if (m < 60) return `${m}m ago`;
|
|
280
|
+
const h = Math.round(m / 60);
|
|
281
|
+
if (h < 24) return `${h}h ago`;
|
|
282
|
+
return `${Math.round(h / 24)}d ago`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function humanSize(bytes: number): string {
|
|
286
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
287
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
288
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
289
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
290
|
+
}
|
package/src/bot/handlers/menu.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Menu handler — maps the persistent reply-keyboard buttons (matched by emoji
|
|
3
|
-
* prefix for stateful ones) to actions, and provides inline submenus for
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* prefix for stateful ones) to actions, and provides inline submenus for
|
|
4
|
+
* Reasoning and Model. Changing a value re-renders the keyboard so its labels
|
|
5
|
+
* always reflect the current state.
|
|
6
|
+
*
|
|
7
|
+
* The Agent picker was removed: Grok has no useful headless agent switch, and
|
|
8
|
+
* plan mode is entered automatically when the agent judges a task complex.
|
|
6
9
|
*/
|
|
7
10
|
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
8
11
|
import { reasoningLabel } from "../../app/reasoning.js";
|
|
9
|
-
import { listAgents } from "../../agents/catalog.js";
|
|
10
12
|
import { REASONING_LEVELS, type ReasoningEffort } from "../../app/types.js";
|
|
11
13
|
import type { BotDeps } from "../deps.js";
|
|
12
14
|
import { BAR_LABELS, compactKeyboard, mainMenuInline, MENU_BTN, RUNNING_BTN, STOP_BTN } from "../menu/keyboard.js";
|
|
13
15
|
import { refreshMenu } from "../menu/refresh.js";
|
|
16
|
+
import { showImportSources } from "./import-session.js";
|
|
14
17
|
import { showKillConfirm } from "./kill.js";
|
|
15
18
|
import { showMcp } from "./mcp.js";
|
|
16
19
|
import { showProjects } from "./projects.js";
|
|
@@ -20,13 +23,12 @@ import { showSessions } from "./sessions.js";
|
|
|
20
23
|
import { showTasks } from "./tasks.js";
|
|
21
24
|
import { showUsage } from "./usage.js";
|
|
22
25
|
|
|
23
|
-
/** Open the full inline menu, showing the current
|
|
26
|
+
/** Open the full inline menu, showing the current model/reasoning. */
|
|
24
27
|
export async function openMainMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
25
28
|
await deps.ephemeral.open(ctx);
|
|
26
29
|
const rt = deps.registry.get(ctx.chat!.id);
|
|
27
30
|
await deps.ephemeral.reply(ctx, "\u2699\uFE0F Menu", {
|
|
28
31
|
reply_markup: mainMenuInline({
|
|
29
|
-
agent: rt.agent || "default",
|
|
30
32
|
model: rt.model || "default",
|
|
31
33
|
reasoning: reasoningLabel(rt.reasoning),
|
|
32
34
|
}),
|
|
@@ -52,16 +54,6 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
|
|
|
52
54
|
// Inline menu actions.
|
|
53
55
|
bot.callbackQuery(/^m:(\w+)$/, (ctx) => dispatchMenu(ctx, deps, ctx.match![1]!));
|
|
54
56
|
|
|
55
|
-
// ── Agent (real Grok modes) ─────────────────────────────────────────────
|
|
56
|
-
bot.callbackQuery(/^agent:set:(\d+)$/, async (ctx) => {
|
|
57
|
-
const mode = deps.acp.availableModes[Number(ctx.match![1])];
|
|
58
|
-
if (!mode) return void ctx.answerCallbackQuery({ text: "Expired, tap Agent again." });
|
|
59
|
-
// Answer before ACP so a slow setMode never expires the callback query.
|
|
60
|
-
await ctx.answerCallbackQuery({ text: `\u{1F916} Agent: ${mode.name}` });
|
|
61
|
-
await deps.registry.get(ctx.chat!.id).setAgentPref(mode.id);
|
|
62
|
-
await confirmUi(ctx, deps);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
57
|
// ── Reasoning ──────────────────────────────────────────────────────────────
|
|
66
58
|
bot.callbackQuery(/^reason:(minimal|low|medium|high|max)$/, async (ctx) => {
|
|
67
59
|
const level = ctx.match![1] as ReasoningEffort;
|
|
@@ -113,12 +105,19 @@ async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promis
|
|
|
113
105
|
case "sessions":
|
|
114
106
|
await ctx.answerCallbackQuery();
|
|
115
107
|
return showSessions(ctx, deps);
|
|
108
|
+
case "import":
|
|
109
|
+
await ctx.answerCallbackQuery();
|
|
110
|
+
return showImportSources(ctx, deps);
|
|
116
111
|
case "tasks":
|
|
117
112
|
await ctx.answerCallbackQuery();
|
|
118
113
|
return showTasks(ctx, deps);
|
|
119
114
|
case "agent":
|
|
120
|
-
|
|
121
|
-
|
|
115
|
+
// Legacy callback from old keyboards.
|
|
116
|
+
await ctx.answerCallbackQuery({
|
|
117
|
+
text: "Agent menu removed — Grok picks sub-agents automatically",
|
|
118
|
+
show_alert: true,
|
|
119
|
+
});
|
|
120
|
+
return openMainMenu(ctx, deps);
|
|
122
121
|
case "model":
|
|
123
122
|
await ctx.answerCallbackQuery();
|
|
124
123
|
return showModelMenu(ctx, deps);
|
|
@@ -178,26 +177,6 @@ async function confirmUi(ctx: Context, deps: BotDeps): Promise<void> {
|
|
|
178
177
|
await openMainMenu(ctx, deps); // reopen so the new value is visible
|
|
179
178
|
}
|
|
180
179
|
|
|
181
|
-
async function showAgentMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
182
|
-
const rt = deps.registry.get(ctx.chat!.id);
|
|
183
|
-
await ensureReady(ctx, rt);
|
|
184
|
-
await deps.ephemeral.open(ctx);
|
|
185
|
-
const modes = deps.acp.availableModes.slice(0, 60);
|
|
186
|
-
if (modes.length === 0) {
|
|
187
|
-
// Grok has no headless agent switch; surface the sub-agents it can delegate
|
|
188
|
-
// to (built-ins + any custom ones in ~/.grok/user-settings.json) as info.
|
|
189
|
-
const subs = listAgents(rt.cwd).map((s) => s.name);
|
|
190
|
-
const info = subs.length
|
|
191
|
-
? `Grok sub-agents available: ${subs.join(", ")}.\nGrok delegates to these automatically during a turn (via its task/delegate tools) — there's no headless agent switch. Use \u{1F9E9} Model to change the model.`
|
|
192
|
-
: "Grok delegates to built-in sub-agents automatically in headless mode; there's no agent to select. Use \u{1F9E9} Model to change the model.";
|
|
193
|
-
await deps.ephemeral.reply(ctx, `Current agent: ${rt.agent || "default"}\n${info}`);
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
const kb = new InlineKeyboard();
|
|
197
|
-
modes.forEach((m, i) => kb.text(`${m.id === rt.agent ? "\u2713 " : ""}${m.name}`, `agent:set:${i}`).row());
|
|
198
|
-
await deps.ephemeral.reply(ctx, `Current agent: ${rt.agent || "default"}\nChoose an agent:`, { reply_markup: kb });
|
|
199
|
-
}
|
|
200
|
-
|
|
201
180
|
async function showReasoningMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
202
181
|
const rt = deps.registry.get(ctx.chat!.id);
|
|
203
182
|
await deps.ephemeral.open(ctx);
|
|
@@ -88,6 +88,7 @@ async function flush(deps: BotDeps, batches: Map<number, TextBatch>, chatId: num
|
|
|
88
88
|
`\u{1F4E5} Queued (position ${rt.queueLength})${note} \u2014 I'm still working on the previous task. It'll run next.`,
|
|
89
89
|
);
|
|
90
90
|
}
|
|
91
|
+
// "ran": turn started; complexity is steered silently by the agent.
|
|
91
92
|
} catch (err) {
|
|
92
93
|
log.warn(`submit failed for chat ${chatId}: ${(err as Error).message}`);
|
|
93
94
|
await send(deps, chatId, `\u274C Couldn't start your message: ${(err as Error).message}`);
|
|
@@ -7,7 +7,7 @@ import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
|
7
7
|
import type { RunningSession, SwitchResult } from "../chat-controller.js";
|
|
8
8
|
import type { BotDeps } from "../deps.js";
|
|
9
9
|
import type { HistoryEntry } from "../../sessions/types.js";
|
|
10
|
-
import { jsonlMtimeMs, readFirstPrompt } from "../../sessions/history.js";
|
|
10
|
+
import { jsonlMtimeMs, readFirstPrompt, readLastCardSummary } from "../../sessions/history.js";
|
|
11
11
|
import { progressBar } from "../../render/progress.js";
|
|
12
12
|
import { refreshMenu } from "../menu/refresh.js";
|
|
13
13
|
import { sendMarkdownDoc } from "../telegram-io.js";
|
|
@@ -49,27 +49,47 @@ function cleanPrompt(raw: string): string {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/** Build a rich card (plain text, no MarkdownV2) + buttons for one controlled
|
|
52
|
-
* session: Switch / History / Close.
|
|
52
|
+
* session: Switch / History / Close.
|
|
53
|
+
*
|
|
54
|
+
* The comment line always answers "what is happening / what was solved":
|
|
55
|
+
* busy → live current step (tool / thinking / working on …)
|
|
56
|
+
* idle → last-turn outcome (assistant result + files), never bare import noise
|
|
57
|
+
*/
|
|
53
58
|
function buildRunningCard(s: RunningSession, deps: BotDeps, now: number): { text: string; kb: InlineKeyboard } {
|
|
54
59
|
const dot = s.foreground ? "\u25B6\uFE0F" : s.busy ? "\u{1F7E0}" : "\u26AA";
|
|
55
60
|
const state = s.foreground ? "foreground" : s.busy ? "working" : "idle";
|
|
56
61
|
|
|
57
62
|
let when = "new";
|
|
58
|
-
let
|
|
63
|
+
let historySummary = "";
|
|
64
|
+
let firstPrompt = "";
|
|
59
65
|
if (s.sessionId) {
|
|
60
66
|
const path = deps.store.jsonlPath(s.sessionId);
|
|
61
67
|
const mtime = jsonlMtimeMs(path);
|
|
62
68
|
if (mtime) when = timeAgo(now - mtime);
|
|
63
|
-
prompt
|
|
69
|
+
// Last assistant outcome beats first-prompt import-confirm noise.
|
|
70
|
+
historySummary = readLastCardSummary(path);
|
|
71
|
+
firstPrompt = cleanPrompt(readFirstPrompt(path));
|
|
72
|
+
if (/session import complete/i.test(firstPrompt)) firstPrompt = "";
|
|
64
73
|
}
|
|
65
74
|
|
|
75
|
+
const diskComment = s.sessionId ? deps.store.get(s.sessionId)?.comment?.trim() : undefined;
|
|
76
|
+
// Order: live runtime comment → persisted last-turn summary → history tail → first prompt.
|
|
77
|
+
const comment =
|
|
78
|
+
(s.comment && s.comment.trim()) ||
|
|
79
|
+
diskComment ||
|
|
80
|
+
historySummary ||
|
|
81
|
+
firstPrompt;
|
|
82
|
+
|
|
66
83
|
const meta = [when, state];
|
|
67
84
|
if (s.busy) meta.push("\u23F3");
|
|
68
85
|
if (s.unread > 0) meta.push(`${s.unread} \u{1F4EC} unread`);
|
|
69
86
|
|
|
87
|
+
const commentLabel = s.busy ? "\u23F3" : "\u{1F4AC}";
|
|
70
88
|
const lines = [
|
|
71
89
|
`${dot} ${s.projectName}`,
|
|
72
|
-
|
|
90
|
+
comment
|
|
91
|
+
? `${commentLabel} ${trunc(comment, 200)}`
|
|
92
|
+
: "\u{1F4AC} (no messages yet)",
|
|
73
93
|
`\u{1F552} ${meta.join(" \u00B7 ")}`,
|
|
74
94
|
];
|
|
75
95
|
if (s.progress !== undefined) lines.push(`\u{1F4C8} ${progressBar(s.progress)}`);
|
|
@@ -171,6 +191,16 @@ async function deliverSwitch(ctx: Context, deps: BotDeps, res: SwitchResult): Pr
|
|
|
171
191
|
if (!res.busy && res.rt.lastTurnSummary) {
|
|
172
192
|
await ctx.reply(res.rt.lastTurnSummary);
|
|
173
193
|
}
|
|
194
|
+
|
|
195
|
+
// Re-show post-turn suggestions generated while this session was in the
|
|
196
|
+
// background (or if the user missed the Done notify). Buttons stay wired to
|
|
197
|
+
// the same batch ids so taps still submit the follow-up.
|
|
198
|
+
if (!res.busy) {
|
|
199
|
+
const sug = res.rt.peekPendingSuggestions();
|
|
200
|
+
if (sug) {
|
|
201
|
+
await ctx.reply(sug.text, { reply_markup: sug.markup }).catch(() => {});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
174
204
|
}
|
|
175
205
|
|
|
176
206
|
function fmtEntry(e: HistoryEntry): string {
|
|
@@ -10,6 +10,7 @@ import { InlineKeyboard } from "grammy";
|
|
|
10
10
|
import { basename } from "node:path";
|
|
11
11
|
import { progressBar } from "../../render/progress.js";
|
|
12
12
|
import type { SessionMeta } from "../../sessions/types.js";
|
|
13
|
+
// Note: callers may pass `comment` from runtime or history (last-turn outcome).
|
|
13
14
|
|
|
14
15
|
export interface SessionCardExtras {
|
|
15
16
|
/** Context-usage %, when the session is loaded in the current ACP process. */
|
|
@@ -22,6 +23,11 @@ export interface SessionCardExtras {
|
|
|
22
23
|
selfPid?: number;
|
|
23
24
|
/** Latest task-completion % (0–100) for this session, if this chat runs it. */
|
|
24
25
|
progress?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Live step (while working) or chat summary (when idle). Overrides
|
|
28
|
+
* `m.comment` when provided by the controlling chat runtime.
|
|
29
|
+
*/
|
|
30
|
+
comment?: string;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export interface SessionCard {
|
|
@@ -34,9 +40,15 @@ export function buildSessionCard(m: SessionMeta, extra: SessionCardExtras = {}):
|
|
|
34
40
|
const dot = m.active ? "\u{1F7E2}" : "\u26AA";
|
|
35
41
|
const state = m.active ? `running${m.lockPid ? ` \u00B7 pid ${m.lockPid}` : ""}` : "idle";
|
|
36
42
|
const proj = m.cwd ? basename(m.cwd) : "(no project)";
|
|
43
|
+
const comment = (extra.comment || m.comment || "").trim();
|
|
37
44
|
|
|
38
45
|
const lines = [`${dot} ${m.title}`, `\u{1F4C1} ${proj}`];
|
|
39
46
|
if (m.cwd) lines.push(` ${m.cwd}`);
|
|
47
|
+
// Always surface "what is happening / what was done" when we have it.
|
|
48
|
+
if (comment) {
|
|
49
|
+
const icon = m.active || typeof extra.progress === "number" ? "\u23F3" : "\u{1F4AC}";
|
|
50
|
+
lines.push(`${icon} ${comment.length > 160 ? comment.slice(0, 159) + "\u2026" : comment}`);
|
|
51
|
+
}
|
|
40
52
|
lines.push(`\u{1F552} updated ${relTime(m.updatedAt)} \u00B7 created ${relTime(m.createdAt)}`);
|
|
41
53
|
const ctx = typeof extra.contextPct === "number" ? ` \u00B7 \u{1F9E0} ctx ${Math.round(extra.contextPct)}%` : "";
|
|
42
54
|
lines.push(`\u{1F4CA} ${state} \u00B7 \u{1F4DC} history ${humanSize(m.historyBytes)}${ctx}`);
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
11
11
|
import { basename } from "node:path";
|
|
12
12
|
import type { BotDeps } from "../deps.js";
|
|
13
|
-
import { readHistory } from "../../sessions/history.js";
|
|
13
|
+
import { readHistory, readLastCardSummary } from "../../sessions/history.js";
|
|
14
14
|
import type { SessionMeta } from "../../sessions/types.js";
|
|
15
15
|
import { refreshMenu } from "../menu/refresh.js";
|
|
16
16
|
import { showHistory } from "./history.js";
|
|
@@ -52,8 +52,19 @@ async function renderSessionPage(ctx: Context, deps: BotDeps, page: number): Pro
|
|
|
52
52
|
|
|
53
53
|
for (const m of slice) {
|
|
54
54
|
const contextPct = deps.acp.metadataFor(m.sessionId)?.contextUsagePercentage;
|
|
55
|
-
const
|
|
56
|
-
const
|
|
55
|
+
const ctrl = deps.registry.controller(ctx.chat!.id);
|
|
56
|
+
const progress = ctrl.progressFor(m.sessionId);
|
|
57
|
+
// Live runtime → persisted comment → last assistant outcome from history.
|
|
58
|
+
const comment =
|
|
59
|
+
ctrl.commentFor(m.sessionId) ||
|
|
60
|
+
m.comment ||
|
|
61
|
+
readLastCardSummary(deps.store.jsonlPath(m.sessionId));
|
|
62
|
+
const { text, keyboard } = buildSessionCard(m, {
|
|
63
|
+
contextPct,
|
|
64
|
+
selfPid: deps.acp.pid,
|
|
65
|
+
progress,
|
|
66
|
+
comment,
|
|
67
|
+
});
|
|
57
68
|
await deps.ephemeral.reply(ctx, text, { reply_markup: keyboard });
|
|
58
69
|
}
|
|
59
70
|
|