grok-telegram-bot 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +38 -2
- package/CHANGELOG.md +190 -1
- package/README.md +60 -15
- package/docs/GROUP.md +260 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +4 -4
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +38 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +100 -15
- package/src/bot/bot.ts +193 -17
- package/src/bot/chat-controller.ts +181 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +339 -0
- package/src/bot/handlers/accounts.ts +7 -0
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +217 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +247 -27
- package/src/bot/handlers/photo.ts +126 -16
- package/src/bot/handlers/running.ts +150 -24
- package/src/bot/handlers/session-card.ts +13 -5
- package/src/bot/handlers/sessions.ts +68 -18
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +11 -5
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +16 -3
- package/src/bot/menu/keyboard.ts +53 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +12 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +299 -0
- package/src/bot/prompt-content.ts +8 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +95 -0
- package/src/bot/session-runtime.ts +1280 -183
- package/src/bot/suggestions.ts +91 -31
- package/src/bot/telegram-actions.ts +1130 -0
- package/src/bot/telegram-bots.ts +496 -0
- package/src/bot/telegram-io.ts +97 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +201 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +652 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +49 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +130 -28
- package/src/index.ts +205 -75
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/hashtags.ts +5 -1
- package/src/render/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +74 -7
- package/src/render/telegram-bridge.ts +464 -0
- package/src/render/tool-call.ts +56 -37
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +16 -4
- package/src/sessions/history.ts +68 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +62 -15
- package/scripts/analyze-jsonl.ts +0 -33
- package/scripts/delayed-restart.ps1 +0 -29
- package/scripts/probe-exit-response-shape.py +0 -77
- package/scripts/probe-plan-exit.py +0 -60
- package/scripts/probe-plan-exit2.py +0 -48
- package/scripts/probe-plan-fields.py +0 -41
- package/scripts/probe-plan-fields2.py +0 -58
- package/scripts/probe-plan-response-path.py +0 -48
- package/scripts/sample-claude-tooluse.ts +0 -21
- package/scripts/sample-kiro-events.ts +0 -31
- package/scripts/smoke-exit-plan.ts +0 -274
- package/scripts/smoke-exit-shapes.ts +0 -252
- package/scripts/smoke-import.mjs +0 -82
- package/scripts/smoke-import.ts +0 -73
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-injected context for General manager turns: topic catalog, memory hits,
|
|
3
|
+
* recent General chat history, and active dispatch jobs.
|
|
4
|
+
*/
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { ForumManager } from "../forum/manager.js";
|
|
7
|
+
import { readHistory } from "../sessions/history.js";
|
|
8
|
+
import type { SessionStore } from "../sessions/store.js";
|
|
9
|
+
import { searchGroupMemory } from "./group-memory.js";
|
|
10
|
+
import { listActiveManagerJobs, listRecentManagerJobs, type ManagerJob } from "./manager-jobs.js";
|
|
11
|
+
|
|
12
|
+
export const MANAGER_CONTEXT_MARKER = "MANAGER CONTEXT (auto — use before dispatching work):";
|
|
13
|
+
|
|
14
|
+
const CONTEXT_MAX = 6500;
|
|
15
|
+
|
|
16
|
+
export interface ManagerContextOpts {
|
|
17
|
+
userText: string;
|
|
18
|
+
sessionsDir: string;
|
|
19
|
+
store: SessionStore;
|
|
20
|
+
forum?: ForumManager;
|
|
21
|
+
/** Override jobs list (tests). */
|
|
22
|
+
jobs?: ManagerJob[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build a capped context block for manager prompts. */
|
|
26
|
+
export function buildManagerContextBlock(opts: ManagerContextOpts): string {
|
|
27
|
+
const lines: string[] = [MANAGER_CONTEXT_MARKER, ""];
|
|
28
|
+
|
|
29
|
+
const topics = opts.forum?.isReady ? opts.forum.store.all() : [];
|
|
30
|
+
lines.push("## Topics");
|
|
31
|
+
if (topics.length === 0) {
|
|
32
|
+
lines.push("(no forum topics mapped)");
|
|
33
|
+
} else {
|
|
34
|
+
const topicCap = 40;
|
|
35
|
+
for (const t of topics.slice(0, topicCap)) {
|
|
36
|
+
const path = t.projectPath ?? "(unbound)";
|
|
37
|
+
lines.push(`- **${t.name}** #${t.threadId} [${t.kind}] \`${path}\``);
|
|
38
|
+
}
|
|
39
|
+
if (topics.length > topicCap) {
|
|
40
|
+
lines.push(`… +${topics.length - topicCap} more (use list_topics)`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const workspace =
|
|
45
|
+
topics.find((t) => t.kind === "general")?.projectPath ||
|
|
46
|
+
topics.find((t) => t.kind === "ai_chat")?.projectPath ||
|
|
47
|
+
undefined;
|
|
48
|
+
const preferPaths = [
|
|
49
|
+
workspace,
|
|
50
|
+
...topics
|
|
51
|
+
.filter((t) => t.kind === "project" && t.projectPath)
|
|
52
|
+
.map((t) => t.projectPath!)
|
|
53
|
+
.slice(0, 12),
|
|
54
|
+
].filter(Boolean) as string[];
|
|
55
|
+
|
|
56
|
+
// Always surface recent General chat so the manager "remembers" this room.
|
|
57
|
+
lines.push("", "## Recent General chat (always available)");
|
|
58
|
+
const generalSnips = recentGeneralHistory(opts, workspace, 10);
|
|
59
|
+
if (generalSnips.length === 0) {
|
|
60
|
+
lines.push("(no prior General history on disk yet)");
|
|
61
|
+
} else {
|
|
62
|
+
for (const s of generalSnips) lines.push(`- ${s}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
lines.push(
|
|
66
|
+
"",
|
|
67
|
+
"## Memory hits (ranked: relevance + recency — newest work first, not git)",
|
|
68
|
+
);
|
|
69
|
+
const hits = searchGroupMemory({
|
|
70
|
+
query: opts.userText,
|
|
71
|
+
limit: 14,
|
|
72
|
+
sessionsDir: opts.sessionsDir,
|
|
73
|
+
store: opts.store,
|
|
74
|
+
topics: topics.length ? topics : undefined,
|
|
75
|
+
preferPaths,
|
|
76
|
+
preferGeneral: true,
|
|
77
|
+
maxSessions: 32,
|
|
78
|
+
});
|
|
79
|
+
if (hits.length === 0) {
|
|
80
|
+
lines.push(
|
|
81
|
+
"(no hits — call search_memory; do NOT run git until memory is exhausted)",
|
|
82
|
+
);
|
|
83
|
+
} else {
|
|
84
|
+
for (const h of hits) {
|
|
85
|
+
const where =
|
|
86
|
+
h.threadId !== undefined
|
|
87
|
+
? ` #${h.threadId}`
|
|
88
|
+
: h.sessionId
|
|
89
|
+
? ` session=${h.sessionId.slice(0, 8)}`
|
|
90
|
+
: "";
|
|
91
|
+
lines.push(`- [${h.kind}] ${h.title}${where}: ${h.snippet}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
lines.push(
|
|
96
|
+
"",
|
|
97
|
+
"Rules: for \"last modifications / last work\" trust hits with [Xm/h/d ago] stamps — prefer the newest session for that project path.",
|
|
98
|
+
"Ignore older OmniRoute-style notes if a newer session for the same path exists.",
|
|
99
|
+
"Do not use git log/status as the first step unless the user asked for git.",
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const jobs = opts.jobs ?? [...listActiveManagerJobs(8), ...listRecentManagerJobs(4)];
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
const uniq: ManagerJob[] = [];
|
|
105
|
+
for (const j of jobs) {
|
|
106
|
+
if (seen.has(j.id)) continue;
|
|
107
|
+
seen.add(j.id);
|
|
108
|
+
uniq.push(j);
|
|
109
|
+
if (uniq.length >= 10) break;
|
|
110
|
+
}
|
|
111
|
+
lines.push("", "## Manager jobs (this process)");
|
|
112
|
+
if (uniq.length === 0) {
|
|
113
|
+
lines.push("(none)");
|
|
114
|
+
} else {
|
|
115
|
+
for (const j of uniq) {
|
|
116
|
+
const ageMin = Math.max(0, Math.round((Date.now() - j.createdAt) / 60_000));
|
|
117
|
+
lines.push(
|
|
118
|
+
`- ${j.status} **${j.targetName}** #${j.targetThreadId} (${ageMin}m ago) job=${j.id}` +
|
|
119
|
+
(j.childSessionId ? ` session=${j.childSessionId.slice(0, 8)}` : ""),
|
|
120
|
+
);
|
|
121
|
+
if (j.userAskPreview) lines.push(` ask: ${clamp(j.userAskPreview, 160)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
lines.push(
|
|
126
|
+
"",
|
|
127
|
+
"Use this context to pick the right topic and write a strong send_prompt.",
|
|
128
|
+
"When a hit shows session=XXXXXXXX and the user wants follow-up there, pass that exact",
|
|
129
|
+
"prefix as session_id on send_prompt (do NOT rely on the topic's currently open session).",
|
|
130
|
+
"Prefer search_memory again if you need deeper history before dispatching.",
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
let block = lines.join("\n");
|
|
134
|
+
if (block.length > CONTEXT_MAX) {
|
|
135
|
+
block = block.slice(0, CONTEXT_MAX - 1) + "\u2026";
|
|
136
|
+
}
|
|
137
|
+
return block;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Last user/assistant lines from General-named or workspace sessions. */
|
|
141
|
+
function recentGeneralHistory(
|
|
142
|
+
opts: ManagerContextOpts,
|
|
143
|
+
workspace: string | undefined,
|
|
144
|
+
limit: number,
|
|
145
|
+
): string[] {
|
|
146
|
+
const out: string[] = [];
|
|
147
|
+
let metas;
|
|
148
|
+
try {
|
|
149
|
+
metas = opts.store.list(40);
|
|
150
|
+
} catch {
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
const ws = workspace?.replace(/\\/g, "/").toLowerCase();
|
|
154
|
+
const ranked = metas
|
|
155
|
+
.map((m) => {
|
|
156
|
+
const title = (m.title || "").toLowerCase();
|
|
157
|
+
const cwd = (m.cwd || "").replace(/\\/g, "/").toLowerCase();
|
|
158
|
+
let rank = 0;
|
|
159
|
+
// Prefer sessions explicitly labeled General — never treat every project
|
|
160
|
+
// under the workspace root as "General chat" (path prefix trap).
|
|
161
|
+
if (title === "general") rank += 12;
|
|
162
|
+
else if (/\bgeneral\b/.test(title)) rank += 6;
|
|
163
|
+
// Exact workspace cwd only (General/AI Chat bind), not child project paths.
|
|
164
|
+
if (ws && cwd === ws) rank += 4;
|
|
165
|
+
return { m, rank };
|
|
166
|
+
})
|
|
167
|
+
.filter((x) => x.rank > 0)
|
|
168
|
+
.sort(
|
|
169
|
+
(a, b) =>
|
|
170
|
+
b.rank - a.rank ||
|
|
171
|
+
String(b.m.updatedAt || "").localeCompare(String(a.m.updatedAt || "")),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
for (const { m } of ranked.slice(0, 4)) {
|
|
175
|
+
try {
|
|
176
|
+
const path = join(opts.sessionsDir, `${m.sessionId}.jsonl`);
|
|
177
|
+
const entries = readHistory(path, 8);
|
|
178
|
+
for (const e of entries) {
|
|
179
|
+
if (!e.text?.trim()) continue;
|
|
180
|
+
// Skip huge manager context dumps in history.
|
|
181
|
+
if (e.text.includes(MANAGER_CONTEXT_MARKER)) continue;
|
|
182
|
+
if (e.text.startsWith("MANAGER MODE")) continue;
|
|
183
|
+
const role = e.role === "user" ? "user" : e.role === "assistant" ? "bot" : e.role;
|
|
184
|
+
out.push(
|
|
185
|
+
`${role} · ${m.sessionId.slice(0, 8)}: ${clamp(e.text.replace(/\s+/g, " "), 180)}`,
|
|
186
|
+
);
|
|
187
|
+
if (out.length >= limit) return out;
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
/* skip */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Prepend context block to a user/manager prompt body (idempotent). */
|
|
197
|
+
export function injectManagerContext(text: string, contextBlock: string): string {
|
|
198
|
+
const body = text.trim();
|
|
199
|
+
if (!contextBlock.trim()) return body;
|
|
200
|
+
if (body.includes(MANAGER_CONTEXT_MARKER)) return body;
|
|
201
|
+
return `${contextBlock}\n\n---\n\n${body}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function clamp(s: string, max: number): string {
|
|
205
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
206
|
+
if (t.length <= max) return t;
|
|
207
|
+
return t.slice(0, max - 1) + "\u2026";
|
|
208
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory tracking of work dispatched from General (manager) into project topics.
|
|
3
|
+
* Used for status, context inject, and report-back when a child turn finishes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type ManagerJobStatus = "dispatched" | "running" | "done" | "failed" | "cancelled";
|
|
7
|
+
|
|
8
|
+
export interface ManagerJob {
|
|
9
|
+
id: string;
|
|
10
|
+
originChatId: number;
|
|
11
|
+
originThreadId: number;
|
|
12
|
+
targetThreadId: number;
|
|
13
|
+
targetName: string;
|
|
14
|
+
targetPath: string;
|
|
15
|
+
childSessionId?: string;
|
|
16
|
+
dispatchPrompt: string;
|
|
17
|
+
userAskPreview: string;
|
|
18
|
+
createdAt: number;
|
|
19
|
+
updatedAt: number;
|
|
20
|
+
status: ManagerJobStatus;
|
|
21
|
+
resultSummary?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReportBackMeta {
|
|
25
|
+
jobId: string;
|
|
26
|
+
originChatId: number;
|
|
27
|
+
originThreadId: number;
|
|
28
|
+
userAskPreview: string;
|
|
29
|
+
targetName: string;
|
|
30
|
+
targetPath: string;
|
|
31
|
+
dispatchPrompt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_JOBS = 100;
|
|
35
|
+
|
|
36
|
+
const jobsById = new Map<string, ManagerJob>();
|
|
37
|
+
const jobIdBySession = new Map<string, string>();
|
|
38
|
+
|
|
39
|
+
let seq = 0;
|
|
40
|
+
|
|
41
|
+
export function newManagerJobId(): string {
|
|
42
|
+
seq = (seq + 1) % 1_000_000;
|
|
43
|
+
return `mj_${Date.now().toString(36)}_${seq.toString(36)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerManagerJob(input: {
|
|
47
|
+
originChatId: number;
|
|
48
|
+
originThreadId: number;
|
|
49
|
+
targetThreadId: number;
|
|
50
|
+
targetName: string;
|
|
51
|
+
targetPath: string;
|
|
52
|
+
dispatchPrompt: string;
|
|
53
|
+
userAskPreview: string;
|
|
54
|
+
childSessionId?: string;
|
|
55
|
+
}): ManagerJob {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const job: ManagerJob = {
|
|
58
|
+
id: newManagerJobId(),
|
|
59
|
+
originChatId: input.originChatId,
|
|
60
|
+
originThreadId: input.originThreadId,
|
|
61
|
+
targetThreadId: input.targetThreadId,
|
|
62
|
+
targetName: input.targetName,
|
|
63
|
+
targetPath: input.targetPath,
|
|
64
|
+
childSessionId: input.childSessionId,
|
|
65
|
+
dispatchPrompt: input.dispatchPrompt,
|
|
66
|
+
userAskPreview: input.userAskPreview,
|
|
67
|
+
createdAt: now,
|
|
68
|
+
updatedAt: now,
|
|
69
|
+
status: "dispatched",
|
|
70
|
+
};
|
|
71
|
+
jobsById.set(job.id, job);
|
|
72
|
+
if (job.childSessionId) jobIdBySession.set(job.childSessionId, job.id);
|
|
73
|
+
trimJobs();
|
|
74
|
+
return job;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function bindJobSession(jobId: string, sessionId: string): void {
|
|
78
|
+
const job = jobsById.get(jobId);
|
|
79
|
+
if (!job) return;
|
|
80
|
+
job.childSessionId = sessionId;
|
|
81
|
+
job.updatedAt = Date.now();
|
|
82
|
+
if (job.status === "dispatched") job.status = "running";
|
|
83
|
+
jobIdBySession.set(sessionId, jobId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function updateManagerJob(
|
|
87
|
+
jobId: string,
|
|
88
|
+
patch: Partial<Pick<ManagerJob, "status" | "resultSummary" | "childSessionId">>,
|
|
89
|
+
): ManagerJob | undefined {
|
|
90
|
+
const job = jobsById.get(jobId);
|
|
91
|
+
if (!job) return undefined;
|
|
92
|
+
if (patch.status !== undefined) job.status = patch.status;
|
|
93
|
+
if (patch.resultSummary !== undefined) job.resultSummary = patch.resultSummary;
|
|
94
|
+
if (patch.childSessionId !== undefined) {
|
|
95
|
+
job.childSessionId = patch.childSessionId;
|
|
96
|
+
jobIdBySession.set(patch.childSessionId, jobId);
|
|
97
|
+
}
|
|
98
|
+
job.updatedAt = Date.now();
|
|
99
|
+
return job;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getManagerJob(jobId: string): ManagerJob | undefined {
|
|
103
|
+
return jobsById.get(jobId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getJobBySession(sessionId: string): ManagerJob | undefined {
|
|
107
|
+
const id = jobIdBySession.get(sessionId);
|
|
108
|
+
return id ? jobsById.get(id) : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Active (not terminal) jobs, newest first. */
|
|
112
|
+
export function listActiveManagerJobs(limit = 12): ManagerJob[] {
|
|
113
|
+
return [...jobsById.values()]
|
|
114
|
+
.filter((j) => j.status === "dispatched" || j.status === "running")
|
|
115
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
116
|
+
.slice(0, limit);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Recent jobs including finished, newest first. */
|
|
120
|
+
export function listRecentManagerJobs(limit = 12): ManagerJob[] {
|
|
121
|
+
return [...jobsById.values()]
|
|
122
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
123
|
+
.slice(0, Math.max(1, Math.min(50, limit)));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function trimJobs(): void {
|
|
127
|
+
if (jobsById.size <= MAX_JOBS) return;
|
|
128
|
+
const ordered = [...jobsById.values()].sort((a, b) => a.updatedAt - b.updatedAt);
|
|
129
|
+
const drop = ordered.length - MAX_JOBS;
|
|
130
|
+
for (let i = 0; i < drop; i++) {
|
|
131
|
+
const j = ordered[i]!;
|
|
132
|
+
jobsById.delete(j.id);
|
|
133
|
+
if (j.childSessionId) jobIdBySession.delete(j.childSessionId);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Test helper: wipe job state. */
|
|
138
|
+
export function clearManagerJobsForTests(): void {
|
|
139
|
+
jobsById.clear();
|
|
140
|
+
jobIdBySession.clear();
|
|
141
|
+
seq = 0;
|
|
142
|
+
}
|
|
@@ -95,11 +95,24 @@ export class Ephemeral {
|
|
|
95
95
|
async reply(ctx: Context, text: string, extra: Record<string, unknown> = {}): Promise<number | undefined> {
|
|
96
96
|
const chatId = ctx.chat?.id;
|
|
97
97
|
if (chatId === undefined) return undefined;
|
|
98
|
+
// Stay in the forum topic when the trigger message was in a thread.
|
|
99
|
+
const msg = ctx.message ?? ctx.callbackQuery?.message;
|
|
100
|
+
const threadId =
|
|
101
|
+
msg && "message_thread_id" in msg
|
|
102
|
+
? (msg as { message_thread_id?: number }).message_thread_id
|
|
103
|
+
: undefined;
|
|
104
|
+
// Omit General (1) — Bot API rejects message_thread_id=1.
|
|
105
|
+
const merged =
|
|
106
|
+
threadId !== undefined &&
|
|
107
|
+
threadId !== 1 &&
|
|
108
|
+
extra.message_thread_id === undefined
|
|
109
|
+
? { ...extra, message_thread_id: threadId }
|
|
110
|
+
: extra;
|
|
98
111
|
return this.serialize(chatId, async () => {
|
|
99
112
|
try {
|
|
100
|
-
const
|
|
101
|
-
this.remember(chatId,
|
|
102
|
-
return
|
|
113
|
+
const m = await ctx.reply(text, merged);
|
|
114
|
+
this.remember(chatId, m.message_id);
|
|
115
|
+
return m.message_id;
|
|
103
116
|
} catch {
|
|
104
117
|
return undefined;
|
|
105
118
|
}
|
package/src/bot/menu/keyboard.ts
CHANGED
|
@@ -1,32 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Menu surfaces:
|
|
3
|
-
* -
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* - PERSISTENT bar (reply keyboard): ☰ Menu · 🆕 New session / 🧭 Running · ⏹ Stop
|
|
4
|
+
* — primary place for New session in private chats (not the inline Menu message).
|
|
5
|
+
* - INLINE menu message (opened via ☰ Menu or /menu) — settings & navigation.
|
|
6
|
+
* Forum topics: reply keyboards are unreliable, so New stays on the topic inline menu.
|
|
7
|
+
* Live state lives in the pinned status panel so the bar stays clean.
|
|
8
8
|
*/
|
|
9
9
|
import { InlineKeyboard, Keyboard } from "grammy";
|
|
10
10
|
|
|
11
11
|
export const MENU_BTN = "\u2630 Menu"; // ☰
|
|
12
|
+
/** Persistent bar + forum topic control — brand-new session (same as /new). */
|
|
13
|
+
export const NEW_BTN = "\u{1F195} New session";
|
|
12
14
|
export const RUNNING_BTN = "\u{1F9ED} Running";
|
|
13
15
|
export const STOP_BTN = "\u23F9 Stop";
|
|
14
|
-
export const BAR_LABELS = [MENU_BTN, RUNNING_BTN, STOP_BTN];
|
|
16
|
+
export const BAR_LABELS = [MENU_BTN, NEW_BTN, RUNNING_BTN, STOP_BTN];
|
|
15
17
|
|
|
16
|
-
/** The always-visible compact bar. */
|
|
18
|
+
/** The always-visible compact bar (private chats; best-effort in groups). */
|
|
17
19
|
export function compactKeyboard(): Keyboard {
|
|
18
|
-
return new Keyboard()
|
|
20
|
+
return new Keyboard()
|
|
21
|
+
.text(MENU_BTN)
|
|
22
|
+
.text(NEW_BTN)
|
|
23
|
+
.row()
|
|
24
|
+
.text(RUNNING_BTN)
|
|
25
|
+
.text(STOP_BTN)
|
|
26
|
+
.resized()
|
|
27
|
+
.persistent();
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/** The full, grouped inline menu (opened via ☰ Menu or /menu). */
|
|
22
|
-
export function mainMenuInline(state: {
|
|
31
|
+
export function mainMenuInline(state: {
|
|
32
|
+
model: string;
|
|
33
|
+
reasoning: string;
|
|
34
|
+
/** Forum topic scope — hide project switch; label topic sessions. */
|
|
35
|
+
forumTopic?: { name: string; account?: string };
|
|
36
|
+
}): InlineKeyboard {
|
|
23
37
|
const t = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + "\u2026" : s);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
const kb = new InlineKeyboard();
|
|
39
|
+
|
|
40
|
+
if (state.forumTopic) {
|
|
41
|
+
// Groups/topics: control first (no reliable reply-keyboard bar). New session here.
|
|
42
|
+
kb.text("\u23F9 Stop", "m:stop")
|
|
43
|
+
.text("\u{1F9ED} Running", "m:running")
|
|
44
|
+
.row()
|
|
45
|
+
.text(NEW_BTN, "m:new")
|
|
46
|
+
.text("\u{1F5C2} Sessions", "m:sessions")
|
|
47
|
+
.row()
|
|
48
|
+
.text(`\u{1F4C1} ${t(state.forumTopic.name, 28)}`, "m:topicinfo")
|
|
49
|
+
.row()
|
|
50
|
+
.text("\u{1F4E5} Import session", "m:import")
|
|
51
|
+
.row()
|
|
52
|
+
.text(`\u{1F9E9} Model \u00B7 ${t(state.model, 24)}`, "m:model")
|
|
53
|
+
.row()
|
|
54
|
+
.text(`\u{1F9E0} Reasoning \u00B7 ${t(state.reasoning, 24)}`, "m:reasoning")
|
|
55
|
+
.row();
|
|
56
|
+
if (state.forumTopic.account) {
|
|
57
|
+
kb.text(`\u{1F465} Account \u00B7 ${t(state.forumTopic.account, 20)}`, "m:accounts").row();
|
|
58
|
+
}
|
|
59
|
+
kb.text("\u{1F4CA} Status", "m:status").text("\u2716 Close", "m:close");
|
|
60
|
+
return kb;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Private chat: New session is on the persistent bar + /new — not on this message.
|
|
64
|
+
kb.text("\u{1F4C1} Project", "m:project")
|
|
65
|
+
.text("\u{1F5C2} Sessions", "m:sessions")
|
|
27
66
|
.row()
|
|
67
|
+
.text("\u23F9 Stop", "m:stop")
|
|
28
68
|
.text("\u{1F9ED} Running", "m:running")
|
|
29
|
-
.text("\u{1F5C2} Sessions", "m:sessions")
|
|
30
69
|
.row()
|
|
31
70
|
.text("\u{1F4E5} Import session", "m:import")
|
|
32
71
|
.row()
|
|
@@ -41,10 +80,10 @@ export function mainMenuInline(state: { model: string; reasoning: string }): Inl
|
|
|
41
80
|
.text("\u{1F465} Accounts", "m:accounts")
|
|
42
81
|
.row()
|
|
43
82
|
.text("\u{1F9E9} MCP", "m:mcp")
|
|
44
|
-
.text("\u23F9 Stop", "m:stop")
|
|
45
83
|
.text("\u{1F6D1} Kill all", "m:killall")
|
|
46
84
|
.row()
|
|
47
85
|
.text("\u2328\uFE0F Show bar", "m:showbar")
|
|
48
86
|
.text("\u{1F648} Hide bar", "m:hidebar")
|
|
49
87
|
.text("\u2716 Close", "m:close");
|
|
88
|
+
return kb;
|
|
50
89
|
}
|
package/src/bot/menu/refresh.ts
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { Context } from "grammy";
|
|
6
6
|
import type { BotDeps } from "../deps.js";
|
|
7
|
+
import { resolveScope } from "../scope.js";
|
|
7
8
|
import { compactKeyboard } from "./keyboard.js";
|
|
8
9
|
|
|
9
10
|
export async function refreshMenu(ctx: Context, deps: BotDeps, text: string): Promise<void> {
|
|
10
11
|
const chatId = ctx.chat!.id;
|
|
11
|
-
|
|
12
|
+
const scope = resolveScope(ctx, deps);
|
|
13
|
+
await ctx.reply(text, { reply_markup: compactKeyboard(), ...scope.threadExtra });
|
|
12
14
|
await deps.statusPanel.refresh(chatId);
|
|
13
15
|
}
|
|
@@ -65,13 +65,19 @@ export class StatusPanel {
|
|
|
65
65
|
if (subagents) activity.push(`\u{1F465} ${subagents}`);
|
|
66
66
|
lines.push(activity.join(SEP));
|
|
67
67
|
|
|
68
|
-
// 3b)
|
|
69
|
-
//
|
|
70
|
-
|
|
68
|
+
// 3b) Last user prompt (+ thinking while busy) — same source as Running cards.
|
|
69
|
+
// When a plan board is already shown above, still surface the user prompt;
|
|
70
|
+
// skip only a pure thinking second line if plan is active (less noise).
|
|
71
|
+
const comment = rt.cardComment;
|
|
71
72
|
if (comment) {
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
73
|
+
const parts = comment.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
74
|
+
const hideThinking = !!(rt.planSummary && rt.isBusy);
|
|
75
|
+
parts.forEach((part, i) => {
|
|
76
|
+
if (hideThinking && i > 0) return;
|
|
77
|
+
const icon = i === 0 ? (rt.isBusy ? "\u23F3" : "\u{1F4AC}") : "\u{1F9E0}";
|
|
78
|
+
const shown = part.length > 250 ? `${part.slice(0, 249)}\u2026` : part;
|
|
79
|
+
lines.push(`${icon} ${shown}`);
|
|
80
|
+
});
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
// 4) Where: project | session | context usage.
|
|
@@ -161,6 +161,25 @@ export class PermissionService {
|
|
|
161
161
|
return this.pending.get(reqId)?.sessionId;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* ACP: when a session is cancelled, all pending permission requests for that
|
|
166
|
+
* session must complete with `cancelled`. Returns how many were cancelled.
|
|
167
|
+
* Does not touch other sessions.
|
|
168
|
+
*/
|
|
169
|
+
cancelForSession(sessionId: string): number {
|
|
170
|
+
let n = 0;
|
|
171
|
+
for (const [reqId, p] of [...this.pending.entries()]) {
|
|
172
|
+
if (p.sessionId !== sessionId) continue;
|
|
173
|
+
clearTimeout(p.timer);
|
|
174
|
+
this.pending.delete(reqId);
|
|
175
|
+
void this.finishPrompt(p, "\u{1F510} (cancelled — turn stopped)");
|
|
176
|
+
p.resolve({ outcome: { outcome: "cancelled" } });
|
|
177
|
+
n++;
|
|
178
|
+
}
|
|
179
|
+
if (n > 0) log.info(`cancelled ${n} pending permission(s) for session ${sessionId.slice(0, 8)}`);
|
|
180
|
+
return n;
|
|
181
|
+
}
|
|
182
|
+
|
|
164
183
|
/** Unpin (if pinned) and optionally rewrite the prompt message. */
|
|
165
184
|
private async finishPrompt(p: Pending, text?: string): Promise<void> {
|
|
166
185
|
if (p.messageId !== undefined && text) {
|