grok-telegram-bot 2.3.0 → 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 +55 -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 +71 -2
- 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 +55 -5
- 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/reauth-controller.ts +2 -2
- package/src/bot/session-fork.ts +11 -0
- package/src/bot/session-runtime.ts +831 -64
- package/src/bot/suggestions.ts +429 -0
- package/src/config.ts +41 -0
- package/src/grok/client.ts +106 -20
- 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
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-turn follow-up suggestions + gated self-recheck helpers.
|
|
3
|
+
*
|
|
4
|
+
* Self-recheck (once per real user turn, when enabled):
|
|
5
|
+
* 1. Hard-skip if no files were modified.
|
|
6
|
+
* 2. Quiet meta ask: AI refuses (simple / pure build / nothing to re-verify)
|
|
7
|
+
* or writes a focused recheck prompt.
|
|
8
|
+
* 3. That prompt is queued as the one-shot SELF-RECHECK turn.
|
|
9
|
+
* 4. Then Done + suggestions.
|
|
10
|
+
*
|
|
11
|
+
* After Done, the bot quietly asks for 1–3 short next steps as JSON with a
|
|
12
|
+
* "need" score (0–100). Suggestions appear as inline buttons; those at/above
|
|
13
|
+
* SUGGESTIONS_AUTO_APPROVE_PCT are merged into **one** auto-queued prompt
|
|
14
|
+
* (`1) …\n2) …`) so they run as a single turn.
|
|
15
|
+
*/
|
|
16
|
+
import { InlineKeyboard } from "grammy";
|
|
17
|
+
|
|
18
|
+
/** Max suggestions shown / accepted. */
|
|
19
|
+
export const SUGGESTION_MAX = 3;
|
|
20
|
+
/** Button label budget (Telegram ~64 chars). */
|
|
21
|
+
const BTN_MAX = 56;
|
|
22
|
+
|
|
23
|
+
/** Marker for the automatic post-turn self-recheck prompt (once per user turn). */
|
|
24
|
+
export const SELF_RECHECK_MARKER = "SELF-RECHECK (automatic quality pass";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default self-recheck body (user can override via SELF_RECHECK_PROMPT).
|
|
28
|
+
* Placeholders: {{USER}} = user's request, {{DONE}} = first-turn summary.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_SELF_RECHECK_PROMPT = [
|
|
31
|
+
`${SELF_RECHECK_MARKER} — once only).`,
|
|
32
|
+
"Do a rigorous self-review of the work just completed for the user request below.",
|
|
33
|
+
"",
|
|
34
|
+
"You are both a bugs/logic finder AND a completeness checker for related follow-ups.",
|
|
35
|
+
"Look for:",
|
|
36
|
+
"1) Bugs, regressions, incomplete steps, wrong assumptions, missing verification, edge cases.",
|
|
37
|
+
"2) Logical gaps the user would still need — e.g. if they asked for password reset and you",
|
|
38
|
+
" shipped the happy path only, also cover brute-force protection, rate limits, token expiry,",
|
|
39
|
+
" email enumeration, CSRF, audit logging, and similar security/ops follow-through that a",
|
|
40
|
+
" solid implementation of THAT feature should include (not a random new product idea).",
|
|
41
|
+
"",
|
|
42
|
+
"Rules:",
|
|
43
|
+
"- Prefer fixing real problems with tools when needed; do not invent unrelated features.",
|
|
44
|
+
"- If something is unfinished relative to the user request (or a tightly related follow-up",
|
|
45
|
+
" that leaves the feature incomplete/insecure), finish or fix it now.",
|
|
46
|
+
"- If everything checks out, briefly confirm what you verified (no long essay).",
|
|
47
|
+
"- Do NOT ask the user questions. Do NOT call enter_plan_mode unless truly necessary.",
|
|
48
|
+
"- End with an honest {progress: N%} marker for this recheck pass.",
|
|
49
|
+
"",
|
|
50
|
+
"USER'S REQUEST:",
|
|
51
|
+
"{{USER}}",
|
|
52
|
+
"",
|
|
53
|
+
"WHAT WAS JUST DONE (summary):",
|
|
54
|
+
"{{DONE}}",
|
|
55
|
+
].join("\n");
|
|
56
|
+
|
|
57
|
+
export interface Suggestion {
|
|
58
|
+
/** Plain follow-up the user would type / the bot will submit. */
|
|
59
|
+
text: string;
|
|
60
|
+
/** How needed/critical relative to the last user prompt (0–100). */
|
|
61
|
+
need: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Quiet meta-prompt after a successful turn. Must produce JSON only.
|
|
66
|
+
* Percentage rules enforce honesty: unrelated ideas cannot score above 60.
|
|
67
|
+
*/
|
|
68
|
+
export function buildSuggestionsPrompt(userText: string, assistantSnippet: string): string {
|
|
69
|
+
const user = clamp(userText.replace(/\s+/g, " ").trim(), 500) || "(empty)";
|
|
70
|
+
const did = clamp(assistantSnippet.replace(/\s+/g, " ").trim(), 600) || "(no assistant text)";
|
|
71
|
+
return [
|
|
72
|
+
"FOLLOW-UP SUGGESTIONS (meta only). Do NOT use tools. Do NOT write code. Do NOT continue the task.",
|
|
73
|
+
"Based ONLY on the user's last request and what was just done, propose 1 to 3 short next steps.",
|
|
74
|
+
"",
|
|
75
|
+
"USER'S LAST PROMPT:",
|
|
76
|
+
user,
|
|
77
|
+
"",
|
|
78
|
+
"WHAT WAS JUST DONE (summary):",
|
|
79
|
+
did,
|
|
80
|
+
"",
|
|
81
|
+
"For each suggestion set need = integer 0–100 = how needed/critical it is for completing or properly finishing THAT user prompt:",
|
|
82
|
+
"- High need (70–100): tightly related, critical next step for the same request (fix a gap, verify, finish unfinished part).",
|
|
83
|
+
"- Medium (40–69): related polish or natural continuation of the same task.",
|
|
84
|
+
"- Low (1–39): optional or weakly related.",
|
|
85
|
+
"- HARD RULE: if a suggestion is NOT clearly related to the user's last prompt, need MUST be ≤ 60 (never higher).",
|
|
86
|
+
"- Do not invent unrelated new features just to fill slots; prefer fewer high-quality items.",
|
|
87
|
+
"- Prefer the highest need for the single most critical related follow-up.",
|
|
88
|
+
"",
|
|
89
|
+
"Reply with ONLY a JSON array (no markdown fences, no keys other than text/need, no commentary):",
|
|
90
|
+
'[{"text":"short imperative follow-up the user would send","need":85}]',
|
|
91
|
+
"Constraints: 1–3 items; text ≤ 120 chars; plain language; no quotes wrapping the whole array.",
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Parse model output into 0–3 validated suggestions. */
|
|
96
|
+
export function parseSuggestions(raw: string): Suggestion[] {
|
|
97
|
+
if (!raw?.trim()) return [];
|
|
98
|
+
let t = raw.trim();
|
|
99
|
+
// Strip accidental code fences / progress markers.
|
|
100
|
+
t = t.replace(/\{[\s]*progress[\s]*:[\s]*\d{1,3}\s*%?[\s]*\}/gi, "").trim();
|
|
101
|
+
const fence = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
|
|
102
|
+
if (fence) t = fence[1]!.trim();
|
|
103
|
+
// Extract first JSON array if prose sneaks in.
|
|
104
|
+
const arrMatch = /\[[\s\S]*\]/.exec(t);
|
|
105
|
+
if (arrMatch) t = arrMatch[0]!;
|
|
106
|
+
|
|
107
|
+
let parsed: unknown;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(t);
|
|
110
|
+
} catch {
|
|
111
|
+
// Try line-wise objects.
|
|
112
|
+
const objs = [...t.matchAll(/\{[^{}]*"text"[^{}]*\}/g)].map((m) => {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(m[0]!) as unknown;
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
parsed = objs.filter(Boolean);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const list = Array.isArray(parsed) ? parsed : [];
|
|
123
|
+
const out: Suggestion[] = [];
|
|
124
|
+
for (const item of list) {
|
|
125
|
+
if (!item || typeof item !== "object") continue;
|
|
126
|
+
const rec = item as Record<string, unknown>;
|
|
127
|
+
const text = String(rec.text ?? rec.suggestion ?? rec.prompt ?? "").replace(/\s+/g, " ").trim();
|
|
128
|
+
if (!text || text.length < 2) continue;
|
|
129
|
+
let need = Number(rec.need ?? rec.pct ?? rec.percent ?? rec.score ?? 0);
|
|
130
|
+
if (!Number.isFinite(need)) need = 0;
|
|
131
|
+
need = Math.max(0, Math.min(100, Math.round(need)));
|
|
132
|
+
out.push({ text: text.slice(0, 120), need });
|
|
133
|
+
if (out.length >= SUGGESTION_MAX) break;
|
|
134
|
+
}
|
|
135
|
+
// Highest need first for auto-approve + button order.
|
|
136
|
+
out.sort((a, b) => b.need - a.need);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Inline keyboard for Done: one row per suggestion + optional extra rows. */
|
|
141
|
+
export function suggestionsKeyboard(
|
|
142
|
+
batchId: number,
|
|
143
|
+
suggestions: Suggestion[],
|
|
144
|
+
extra?: InlineKeyboard,
|
|
145
|
+
): InlineKeyboard {
|
|
146
|
+
const kb = new InlineKeyboard();
|
|
147
|
+
suggestions.forEach((s, i) => {
|
|
148
|
+
const label = clamp(`${s.need}% · ${s.text}`, BTN_MAX);
|
|
149
|
+
kb.text(label, `sug:${batchId}:${i}`).row();
|
|
150
|
+
});
|
|
151
|
+
if (extra) {
|
|
152
|
+
// Append extra keyboard rows (e.g. Switch session).
|
|
153
|
+
const rows = (extra as unknown as { inline_keyboard?: Array<Array<{ text: string; callback_data: string }>> })
|
|
154
|
+
.inline_keyboard;
|
|
155
|
+
if (rows) {
|
|
156
|
+
for (const row of rows) {
|
|
157
|
+
for (let i = 0; i < row.length; i++) {
|
|
158
|
+
const b = row[i]!;
|
|
159
|
+
if (i === row.length - 1) kb.text(b.text, b.callback_data).row();
|
|
160
|
+
else kb.text(b.text, b.callback_data);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return kb;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Suggestions at/above the auto-approve threshold (need >= pct). */
|
|
169
|
+
export function autoApproveSuggestions(suggestions: Suggestion[], thresholdPct: number): Suggestion[] {
|
|
170
|
+
if (thresholdPct <= 0) return [];
|
|
171
|
+
const thr = Math.max(0, Math.min(100, Math.round(thresholdPct)));
|
|
172
|
+
return suggestions.filter((s) => s.need >= thr);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Merge auto-approved suggestions into a single multi-step prompt so they run
|
|
177
|
+
* as one agent turn: `1) …\n2) …\n3) …` (already sorted highest need first).
|
|
178
|
+
*/
|
|
179
|
+
export function formatBatchedSuggestionsPrompt(suggestions: Suggestion[]): string {
|
|
180
|
+
if (suggestions.length === 0) return "";
|
|
181
|
+
if (suggestions.length === 1) return suggestions[0]!.text;
|
|
182
|
+
return suggestions.map((s, i) => `${i + 1}) ${s.text}`).join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Detect the quiet suggestions meta-prompt (history strip / title guard). */
|
|
186
|
+
export function isSuggestionsMetaPrompt(text: string): boolean {
|
|
187
|
+
return /^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(text.trim());
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Marker for the quiet "should we recheck?" meta-prompt. */
|
|
191
|
+
export const SELF_RECHECK_DECISION_MARKER = "SELF-RECHECK DECISION (meta only)";
|
|
192
|
+
|
|
193
|
+
/** Detect the quiet self-recheck decision meta-prompt. */
|
|
194
|
+
export function isSelfRecheckDecisionPrompt(text: string): boolean {
|
|
195
|
+
return text.trim().startsWith(SELF_RECHECK_DECISION_MARKER);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Detect the automatic self-recheck pass (not a normal user message). */
|
|
199
|
+
export function isSelfRecheckPrompt(text: string): boolean {
|
|
200
|
+
return text.trim().startsWith(SELF_RECHECK_MARKER);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Outcome of the quiet recheck-decision turn. */
|
|
204
|
+
export type SelfRecheckDecision =
|
|
205
|
+
| { needed: false; reason?: string }
|
|
206
|
+
| { needed: true; prompt: string };
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Quiet meta-prompt: AI either refuses recheck (simple / no value) or writes
|
|
210
|
+
* the focused recheck instructions that will be submitted as the next turn.
|
|
211
|
+
*/
|
|
212
|
+
export function buildSelfRecheckDecisionPrompt(
|
|
213
|
+
userText: string,
|
|
214
|
+
assistantSnippet: string,
|
|
215
|
+
filesSummary: string,
|
|
216
|
+
): string {
|
|
217
|
+
const user = clamp(userText.replace(/\s+/g, " ").trim(), 700) || "(empty)";
|
|
218
|
+
const did = clamp(assistantSnippet.replace(/\s+/g, " ").trim(), 900) || "(no assistant text)";
|
|
219
|
+
const files = clamp(filesSummary.replace(/\s+/g, " ").trim(), 400) || "(none)";
|
|
220
|
+
return [
|
|
221
|
+
`${SELF_RECHECK_DECISION_MARKER}. Do NOT use tools. Do NOT write code. Do NOT continue the task.`,
|
|
222
|
+
"Decide whether a second automatic quality pass is worth running for the work just completed.",
|
|
223
|
+
"",
|
|
224
|
+
"Set needed=false (skip recheck) when ANY of these apply:",
|
|
225
|
+
"- Simple task: Q&A, explanation, status, one-liner, or trivial change.",
|
|
226
|
+
"- Pure build / install / run / package with no non-trivial logic to re-audit.",
|
|
227
|
+
"- Work is clearly complete and low-risk; a re-verify would add little value.",
|
|
228
|
+
"- No plausible bugs, incomplete steps, or tightly related security/ops gaps.",
|
|
229
|
+
"",
|
|
230
|
+
"Set needed=true when:",
|
|
231
|
+
"- Non-trivial code/config changed and edge cases, regressions, or incomplete",
|
|
232
|
+
" follow-through are plausible (auth, multi-file logic, concurrency, data paths).",
|
|
233
|
+
"- A focused second pass with tools could catch real bugs or finish related gaps.",
|
|
234
|
+
"",
|
|
235
|
+
"If needed=true, write a focused recheck prompt: imperative instructions for a one-shot",
|
|
236
|
+
"second pass that may use tools to fix REAL issues. Do not invent unrelated features.",
|
|
237
|
+
"If needed=false, give a short reason.",
|
|
238
|
+
"",
|
|
239
|
+
"USER'S REQUEST:",
|
|
240
|
+
user,
|
|
241
|
+
"",
|
|
242
|
+
"WHAT WAS JUST DONE (summary):",
|
|
243
|
+
did,
|
|
244
|
+
"",
|
|
245
|
+
"FILES MODIFIED THIS TURN:",
|
|
246
|
+
files,
|
|
247
|
+
"",
|
|
248
|
+
"Reply with ONLY one JSON object (no markdown fences, no commentary):",
|
|
249
|
+
'{"needed":false,"reason":"short reason"}',
|
|
250
|
+
"or",
|
|
251
|
+
'{"needed":true,"prompt":"focused recheck / fix instructions for the agent"}',
|
|
252
|
+
].join("\n");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Parse quiet recheck-decision JSON. Defaults to skip on empty/invalid output
|
|
257
|
+
* so a bad meta reply never blocks Done — except when the model clearly wrote a
|
|
258
|
+
* recheck body without wrapping JSON (treated as needed=true).
|
|
259
|
+
*/
|
|
260
|
+
export function parseSelfRecheckDecision(raw: string): SelfRecheckDecision {
|
|
261
|
+
if (!raw?.trim()) return { needed: false, reason: "empty decision" };
|
|
262
|
+
let t = raw.trim();
|
|
263
|
+
// Strip trailing progress markers only (avoid eating prompt text mid-JSON).
|
|
264
|
+
t = t.replace(/\n?\s*\{[\s]*progress[\s]*:[\s]*\d{1,3}\s*%?[\s]*\}\s*$/gi, "").trim();
|
|
265
|
+
t = t.replace(/\{[\s]*progress[\s]*:[\s]*\d{1,3}\s*%?[\s]*\}/gi, "").trim();
|
|
266
|
+
const fence = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
|
|
267
|
+
if (fence) t = fence[1]!.trim();
|
|
268
|
+
|
|
269
|
+
// Soft refuse phrases (JSON or prose).
|
|
270
|
+
if (/\b(not needed|no recheck|skip recheck|unnecessary|not necessary|no need to recheck)\b/i.test(t)
|
|
271
|
+
&& !/"needed"\s*:\s*true/i.test(t)) {
|
|
272
|
+
// Only force-skip when JSON does not explicitly set needed:true.
|
|
273
|
+
if (!/"needed"\s*:\s*true/i.test(raw) && !/"recheck"\s*:\s*true/i.test(raw)) {
|
|
274
|
+
const asJson = tryParseDecisionObject(t);
|
|
275
|
+
if (!asJson || asJson.needed !== true) {
|
|
276
|
+
return { needed: false, reason: "refused in prose" };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const asJson = tryParseDecisionObject(t);
|
|
282
|
+
if (asJson) return asJson;
|
|
283
|
+
|
|
284
|
+
// Plain imperative body (model forgot JSON) → treat as recheck prompt.
|
|
285
|
+
const prose = t.replace(/\s+/g, " ").trim();
|
|
286
|
+
if (prose.length >= 24 && !/^(ok|done|none|n\/a|skip)\b/i.test(prose)) {
|
|
287
|
+
return { needed: true, prompt: prose.slice(0, 4000) };
|
|
288
|
+
}
|
|
289
|
+
return { needed: false, reason: "unparseable decision" };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Best-effort extract/parse of a decision object from model text. */
|
|
293
|
+
function tryParseDecisionObject(t: string): SelfRecheckDecision | undefined {
|
|
294
|
+
// Prefer balanced-ish first object; fall back to greedy match.
|
|
295
|
+
let candidate = t;
|
|
296
|
+
const objMatch = /\{[\s\S]*\}/.exec(t);
|
|
297
|
+
if (objMatch) candidate = objMatch[0]!;
|
|
298
|
+
|
|
299
|
+
let parsed: unknown;
|
|
300
|
+
try {
|
|
301
|
+
parsed = JSON.parse(candidate);
|
|
302
|
+
} catch {
|
|
303
|
+
// Try smaller object if trailing junk broke parse.
|
|
304
|
+
const m = /\{[^{}]*"needed"[^{}]*\}/i.exec(t)
|
|
305
|
+
|| /\{[^{}]*"recheck"[^{}]*\}/i.exec(t)
|
|
306
|
+
|| /\{[^{}]*"prompt"[^{}]*\}/i.exec(t);
|
|
307
|
+
if (!m) return undefined;
|
|
308
|
+
try {
|
|
309
|
+
parsed = JSON.parse(m[0]!);
|
|
310
|
+
} catch {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
315
|
+
const rec = parsed as Record<string, unknown>;
|
|
316
|
+
|
|
317
|
+
// Do not treat suggestion-style "need" (0–100 score) as the needed flag.
|
|
318
|
+
// Only needed / recheck / required (boolean-ish) control the gate.
|
|
319
|
+
const neededRaw = rec.needed ?? rec.recheck ?? rec.required;
|
|
320
|
+
const prompt = String(rec.prompt ?? rec.text ?? rec.instructions ?? rec.recheck_prompt ?? "")
|
|
321
|
+
.replace(/\r\n/g, "\n")
|
|
322
|
+
.trim();
|
|
323
|
+
|
|
324
|
+
let needed: boolean | undefined;
|
|
325
|
+
if (typeof neededRaw === "boolean") needed = neededRaw;
|
|
326
|
+
else if (typeof neededRaw === "string") {
|
|
327
|
+
const s = neededRaw.trim().toLowerCase();
|
|
328
|
+
if (/^(1|true|yes|y|needed|recheck)$/i.test(s)) needed = true;
|
|
329
|
+
else if (/^(0|false|no|n|skip|none)$/i.test(s)) needed = false;
|
|
330
|
+
} else if (typeof neededRaw === "number") {
|
|
331
|
+
needed = neededRaw > 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Explicit skip / refuse keys always win.
|
|
335
|
+
if (rec.skip === true || rec.refuse === true) needed = false;
|
|
336
|
+
|
|
337
|
+
// Prompt-only object: model wrote instructions without a needed flag → run.
|
|
338
|
+
if (needed === undefined && prompt.length >= 3) needed = true;
|
|
339
|
+
if (needed === undefined) needed = false;
|
|
340
|
+
|
|
341
|
+
if (!needed) {
|
|
342
|
+
const reason = String(rec.reason ?? rec.why ?? rec.message ?? "").replace(/\s+/g, " ").trim();
|
|
343
|
+
return { needed: false, reason: reason || undefined };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// needed=true but no usable prompt → empty body; caller fills default template.
|
|
347
|
+
if (!prompt || prompt.length < 3) {
|
|
348
|
+
return { needed: true, prompt: "" };
|
|
349
|
+
}
|
|
350
|
+
return { needed: true, prompt: prompt.slice(0, 4000) };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Build the one-shot self-recheck turn text from an optional env template.
|
|
355
|
+
* Placeholders: {{USER}}, {{DONE}} (also {USER}/{DONE} for convenience).
|
|
356
|
+
*/
|
|
357
|
+
export function buildSelfRecheckPrompt(
|
|
358
|
+
userText: string,
|
|
359
|
+
assistantSnippet: string,
|
|
360
|
+
template?: string,
|
|
361
|
+
): string {
|
|
362
|
+
const user = clamp(userText.replace(/\s+/g, " ").trim(), 700) || "(empty)";
|
|
363
|
+
const did = clamp(assistantSnippet.replace(/\s+/g, " ").trim(), 900) || "(no assistant text)";
|
|
364
|
+
const tpl = (template?.trim() || DEFAULT_SELF_RECHECK_PROMPT).trim();
|
|
365
|
+
let out = tpl
|
|
366
|
+
.replace(/\{\{\s*USER\s*\}\}/gi, user)
|
|
367
|
+
.replace(/\{\{\s*DONE\s*\}\}/gi, did)
|
|
368
|
+
.replace(/\{USER\}/gi, user)
|
|
369
|
+
.replace(/\{DONE\}/gi, did);
|
|
370
|
+
// Ensure the marker is present so isSelfRecheckPrompt / one-shot guard work
|
|
371
|
+
// even if the user customized SELF_RECHECK_PROMPT and dropped it.
|
|
372
|
+
return ensureSelfRecheckMarker(out);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Turn an AI-written recheck body into a full one-shot turn (marker + context).
|
|
377
|
+
*/
|
|
378
|
+
export function composeSelfRecheckTurn(
|
|
379
|
+
agentPrompt: string,
|
|
380
|
+
userText: string,
|
|
381
|
+
assistantSnippet: string,
|
|
382
|
+
): string {
|
|
383
|
+
const user = clamp(userText.replace(/\s+/g, " ").trim(), 700) || "(empty)";
|
|
384
|
+
const did = clamp(assistantSnippet.replace(/\s+/g, " ").trim(), 900) || "(no assistant text)";
|
|
385
|
+
const body =
|
|
386
|
+
agentPrompt.trim() ||
|
|
387
|
+
DEFAULT_SELF_RECHECK_PROMPT
|
|
388
|
+
.replace(/\{\{\s*USER\s*\}\}/gi, user)
|
|
389
|
+
.replace(/\{\{\s*DONE\s*\}\}/gi, did)
|
|
390
|
+
.replace(/\{USER\}/gi, user)
|
|
391
|
+
.replace(/\{DONE\}/gi, did);
|
|
392
|
+
// Full template already has context — don't double-append USER/DONE sections.
|
|
393
|
+
// Only skip wrapping when the body looks like a complete recheck brief (marker
|
|
394
|
+
// or both a request header and a done header), not a casual mention of the words.
|
|
395
|
+
const looksComplete =
|
|
396
|
+
isSelfRecheckPrompt(body) ||
|
|
397
|
+
(/USER'S REQUEST:/i.test(body) && /WHAT WAS JUST DONE/i.test(body));
|
|
398
|
+
if (looksComplete) {
|
|
399
|
+
return ensureSelfRecheckMarker(body);
|
|
400
|
+
}
|
|
401
|
+
return ensureSelfRecheckMarker(
|
|
402
|
+
[
|
|
403
|
+
body,
|
|
404
|
+
"",
|
|
405
|
+
"Rules:",
|
|
406
|
+
"- Prefer fixing real problems with tools when needed; do not invent unrelated features.",
|
|
407
|
+
"- Do NOT ask the user questions. Do NOT call enter_plan_mode unless truly necessary.",
|
|
408
|
+
"- End with an honest {progress: N%} marker for this recheck pass.",
|
|
409
|
+
"",
|
|
410
|
+
"USER'S REQUEST:",
|
|
411
|
+
user,
|
|
412
|
+
"",
|
|
413
|
+
"WHAT WAS JUST DONE (summary):",
|
|
414
|
+
did,
|
|
415
|
+
].join("\n"),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Prepend the self-recheck marker when missing. */
|
|
420
|
+
export function ensureSelfRecheckMarker(text: string): string {
|
|
421
|
+
const t = text.trim();
|
|
422
|
+
if (t.startsWith(SELF_RECHECK_MARKER)) return t;
|
|
423
|
+
return `${SELF_RECHECK_MARKER} — once only).\n\n${t}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function clamp(s: string, max: number): string {
|
|
427
|
+
if (s.length <= max) return s;
|
|
428
|
+
return s.slice(0, max - 1) + "\u2026";
|
|
429
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -144,6 +144,29 @@ export interface AppConfig {
|
|
|
144
144
|
autoUpdate: boolean;
|
|
145
145
|
updateCheckMs: number;
|
|
146
146
|
singleInstance: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* After a successful Done, ask Grok for 1–3 follow-up suggestions (JSON) and
|
|
149
|
+
* attach them as inline buttons on the Done message. Default true.
|
|
150
|
+
*/
|
|
151
|
+
suggestionsEnabled: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* Auto-queue any suggestion whose need score is ≥ this percent (0–100).
|
|
154
|
+
* 0 disables auto-approve (buttons only). Default 95.
|
|
155
|
+
* Multiple hits are merged into one numbered multi-step prompt.
|
|
156
|
+
*/
|
|
157
|
+
suggestionsAutoApprovePct: number;
|
|
158
|
+
/**
|
|
159
|
+
* After a successful user turn (queue empty), optionally run one self-recheck
|
|
160
|
+
* pass before Done + suggestions. Skipped when no files changed or when a
|
|
161
|
+
* quiet AI decision refuses. Default true. Typo alias: SLEF_RECHECK.
|
|
162
|
+
*/
|
|
163
|
+
selfRecheckEnabled: boolean;
|
|
164
|
+
/**
|
|
165
|
+
* Optional override for the recheck turn body when the AI decides recheck is
|
|
166
|
+
* needed (SELF_RECHECK_PROMPT). Supports {{USER}} and {{DONE}}. Empty → use
|
|
167
|
+
* the AI-written recheck prompt (or built-in default if the AI left it blank).
|
|
168
|
+
*/
|
|
169
|
+
selfRecheckPrompt: string;
|
|
147
170
|
}
|
|
148
171
|
|
|
149
172
|
export function loadConfig(): AppConfig {
|
|
@@ -231,11 +254,29 @@ export function loadConfig(): AppConfig {
|
|
|
231
254
|
autoUpdate: bool(process.env.AUTO_UPDATE, true),
|
|
232
255
|
updateCheckMs: num(process.env.UPDATE_CHECK_MS, 3_600_000),
|
|
233
256
|
singleInstance: bool(process.env.GROK_TG_SINGLE_INSTANCE, true),
|
|
257
|
+
// Post-turn follow-ups: default on; auto-run suggestions scoring ≥ 95%.
|
|
258
|
+
suggestionsEnabled: bool(process.env.SUGGESTIONS_ENABLED, true),
|
|
259
|
+
suggestionsAutoApprovePct: clampPct(process.env.SUGGESTIONS_AUTO_APPROVE_PCT, 95),
|
|
260
|
+
// One-shot post-turn self-recheck before Done/suggestions (default on).
|
|
261
|
+
// Accept typo SLEF_RECHECK as alias.
|
|
262
|
+
selfRecheckEnabled: bool(
|
|
263
|
+
process.env.SELF_RECHECK ?? process.env.SLEF_RECHECK,
|
|
264
|
+
true,
|
|
265
|
+
),
|
|
266
|
+
selfRecheckPrompt: (process.env.SELF_RECHECK_PROMPT ?? "").trim(),
|
|
234
267
|
};
|
|
235
268
|
|
|
236
269
|
return cfg;
|
|
237
270
|
}
|
|
238
271
|
|
|
272
|
+
/** Parse 0–100 percentage; blank → default. */
|
|
273
|
+
function clampPct(v: string | undefined, def: number): number {
|
|
274
|
+
if (v === undefined || v === "") return def;
|
|
275
|
+
const n = Number(v);
|
|
276
|
+
if (!Number.isFinite(n)) return def;
|
|
277
|
+
return Math.max(0, Math.min(100, Math.round(n)));
|
|
278
|
+
}
|
|
279
|
+
|
|
239
280
|
/** Resolve the `grok` binary path. The official installer puts it in
|
|
240
281
|
* ~/.grok/bin; also try common PATH locations before a bare `grok`. */
|
|
241
282
|
function resolveGrokPath(explicit?: string): string {
|
package/src/grok/client.ts
CHANGED
|
@@ -19,19 +19,26 @@ import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
|
|
|
19
19
|
import { PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
20
20
|
import { SessionLog } from "./session-log.js";
|
|
21
21
|
import { JsonRpcTransport } from "./transport.js";
|
|
22
|
-
import
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
import {
|
|
23
|
+
contentText,
|
|
24
|
+
type ContentBlock,
|
|
25
|
+
type InitializeResult,
|
|
26
|
+
type JsonRpcMessage,
|
|
27
|
+
type PendingStage,
|
|
28
|
+
type PermissionOutcome,
|
|
29
|
+
type PromptResult,
|
|
30
|
+
type RequestPermissionParams,
|
|
31
|
+
type SessionNotificationParams,
|
|
32
|
+
type SessionUpdate,
|
|
33
|
+
type SubagentInfo,
|
|
34
|
+
type SubagentListUpdate,
|
|
34
35
|
} from "./types.js";
|
|
36
|
+
import {
|
|
37
|
+
autoApproveExitPlanMode,
|
|
38
|
+
autoSkipAskUserQuestion,
|
|
39
|
+
isAskUserQuestionMethod,
|
|
40
|
+
isPlanExitMethod,
|
|
41
|
+
} from "./plan-approval.js";
|
|
35
42
|
|
|
36
43
|
const log = createLogger("grok:client");
|
|
37
44
|
|
|
@@ -59,6 +66,11 @@ const ACCOUNT_EXHAUSTED_RE =
|
|
|
59
66
|
* saved login may be permitted, while same-account retries cannot help. */
|
|
60
67
|
const ACCOUNT_ACCESS_DENIED_RE =
|
|
61
68
|
/\b403\b|forbidden|access denied/i;
|
|
69
|
+
/** Process/session lifecycle failures are not evidence that the active login
|
|
70
|
+
* is bad. They require a session re-bind on the current process generation,
|
|
71
|
+
* never account rotation. */
|
|
72
|
+
const SESSION_LIFECYCLE_RE =
|
|
73
|
+
/unknown session id|grok agent is restarting|grok agent stdio exited|grok agent (?:is )?not running|agent connection (?:is )?closed|authentication required.{0,80}no auth method id provided/i;
|
|
62
74
|
|
|
63
75
|
export class GrokError extends Error {
|
|
64
76
|
constructor(
|
|
@@ -123,7 +135,14 @@ export function isAccountRotationError(err: Error): boolean {
|
|
|
123
135
|
return false;
|
|
124
136
|
}
|
|
125
137
|
|
|
138
|
+
export function isSessionLifecycleError(err: Error): boolean {
|
|
139
|
+
return SESSION_LIFECYCLE_RE.test(err.message);
|
|
140
|
+
}
|
|
141
|
+
|
|
126
142
|
export function isTransientError(err: Error): boolean {
|
|
143
|
+
// Retrying the same stale session cannot recover a process-generation
|
|
144
|
+
// mismatch. SessionRuntime owns the immediate re-bind + one safe retry.
|
|
145
|
+
if (isSessionLifecycleError(err)) return false;
|
|
127
146
|
// Quota exhaustion and access denial are permanent for this login — rotate,
|
|
128
147
|
// never back off and retry the same credentials.
|
|
129
148
|
if (isAccountRotationError(err)) return false;
|
|
@@ -265,9 +284,10 @@ export class GrokClient extends EventEmitter {
|
|
|
265
284
|
this.availableModels = KNOWN_MODELS.map((m) => ({ modelId: m.modelId, name: m.name, description: m.description }));
|
|
266
285
|
}
|
|
267
286
|
|
|
268
|
-
async start(): Promise<void> {
|
|
287
|
+
async start(notifyRestarted = false): Promise<void> {
|
|
269
288
|
this.stopped = false;
|
|
270
289
|
await this.connect();
|
|
290
|
+
if (notifyRestarted) this.emit("restarted");
|
|
271
291
|
}
|
|
272
292
|
|
|
273
293
|
private async connect(): Promise<void> {
|
|
@@ -499,6 +519,16 @@ export class GrokClient extends EventEmitter {
|
|
|
499
519
|
this.currentModeId = modeId;
|
|
500
520
|
}
|
|
501
521
|
|
|
522
|
+
/** Persisted Running/Sessions card comment (current step or chat summary). */
|
|
523
|
+
sessionComment(sessionId: string | undefined): string | undefined {
|
|
524
|
+
if (!sessionId) return undefined;
|
|
525
|
+
return this.slog.commentFor(sessionId);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
setSessionComment(sessionId: string, comment: string): void {
|
|
529
|
+
this.slog.setComment(sessionId, comment);
|
|
530
|
+
}
|
|
531
|
+
|
|
502
532
|
async executeCommand(sessionId: string, command: string): Promise<unknown> {
|
|
503
533
|
return this.request("_grok.dev/commands/execute", { sessionId, command });
|
|
504
534
|
}
|
|
@@ -529,9 +559,7 @@ export class GrokClient extends EventEmitter {
|
|
|
529
559
|
this.stopped = true;
|
|
530
560
|
this.restartAttempts = 0;
|
|
531
561
|
await this.killCurrent();
|
|
532
|
-
this.
|
|
533
|
-
await this.connect();
|
|
534
|
-
this.emit("restarted");
|
|
562
|
+
await this.start(true);
|
|
535
563
|
}
|
|
536
564
|
|
|
537
565
|
private killCurrent(): Promise<void> {
|
|
@@ -643,8 +671,34 @@ export class GrokClient extends EventEmitter {
|
|
|
643
671
|
// No handler: auto-approve, preferring session-scope / always options.
|
|
644
672
|
const opts = (params.options as Array<{ optionId: string; name?: string; kind?: string }>) ?? [];
|
|
645
673
|
result = pickAllowOption(opts);
|
|
674
|
+
} else if (isPlanExitMethod(method)) {
|
|
675
|
+
// Live method name is `_x.ai/exit_plan_mode` (leading underscore).
|
|
676
|
+
// Grok intercepts exit_plan_mode and reverse-requests the client to
|
|
677
|
+
// show a plan-approval UI. Method-not-found is reported as
|
|
678
|
+
// "client disconnected" and plan mode stays Active forever.
|
|
679
|
+
const planSnippet =
|
|
680
|
+
(typeof params.planContent === "string" && params.planContent) ||
|
|
681
|
+
(typeof params.plan_content === "string" && params.plan_content) ||
|
|
682
|
+
(typeof params.plan_file_path === "string" && params.plan_file_path) ||
|
|
683
|
+
"";
|
|
684
|
+
const keys = Object.keys(params || {}).slice(0, 20).join(",");
|
|
685
|
+
log.info(
|
|
686
|
+
`auto-approving plan exit via ${method}` +
|
|
687
|
+
(params.sessionId ? ` session=${String(params.sessionId).slice(0, 8)}` : "") +
|
|
688
|
+
(params.toolCallId ? ` tool=${String(params.toolCallId).slice(0, 24)}` : "") +
|
|
689
|
+
(planSnippet ? ` plan=${planSnippet.replace(/\s+/g, " ").slice(0, 80)}` : "") +
|
|
690
|
+
(keys ? ` keys=[${keys}]` : ""),
|
|
691
|
+
);
|
|
692
|
+
result = autoApproveExitPlanMode(params);
|
|
693
|
+
} else if (isAskUserQuestionMethod(method)) {
|
|
694
|
+
// No TUI question form: skip so the agent continues (prefer later Telegram UI).
|
|
695
|
+
log.info(`auto-skipping ${method} (no interactive question UI in Telegram bridge)`);
|
|
696
|
+
result = autoSkipAskUserQuestion(params);
|
|
646
697
|
} else {
|
|
647
698
|
// We advertise no fs/terminal capabilities, so the agent shouldn't ask.
|
|
699
|
+
// Log at warn — unknown reverse methods used to silently break plan exit
|
|
700
|
+
// when we only matched `x.ai/…` and Grok sent `_x.ai/…`.
|
|
701
|
+
log.warn(`unsupported client reverse-request: ${method} keys=[${Object.keys(params || {}).join(",")}]`);
|
|
648
702
|
throw new GrokError(`unsupported client method: ${method}`, -32601);
|
|
649
703
|
}
|
|
650
704
|
this.transport?.send({ jsonrpc: "2.0", id, result });
|
|
@@ -690,10 +744,28 @@ export class GrokClient extends EventEmitter {
|
|
|
690
744
|
|
|
691
745
|
/** Accumulate assistant text and log tool calls to the session's jsonl. */
|
|
692
746
|
private recordUpdate(sessionId: string, u: SessionUpdate): void {
|
|
693
|
-
if (u.sessionUpdate === "agent_message_chunk"
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
747
|
+
if (u.sessionUpdate === "agent_message_chunk") {
|
|
748
|
+
const t = contentText(u.content);
|
|
749
|
+
if (t) this.assistantBuf.set(sessionId, (this.assistantBuf.get(sessionId) ?? "") + t);
|
|
750
|
+
} else if (u.sessionUpdate === "tool_call" || u.sessionUpdate === "tool_call_update") {
|
|
751
|
+
// Prefer stable name over generic title ("Tool call").
|
|
752
|
+
const name =
|
|
753
|
+
(typeof u.name === "string" && u.name) ||
|
|
754
|
+
(typeof u.toolName === "string" && u.toolName) ||
|
|
755
|
+
(typeof u.title === "string" && u.title && !/^tool[_ ]?call$/i.test(u.title) ? u.title : "") ||
|
|
756
|
+
u.kind ||
|
|
757
|
+
"tool";
|
|
758
|
+
const raw = (u.rawInput || {}) as Record<string, unknown>;
|
|
759
|
+
const detail =
|
|
760
|
+
(typeof raw.path === "string" && raw.path) ||
|
|
761
|
+
(typeof raw.target_file === "string" && raw.target_file) ||
|
|
762
|
+
(typeof raw.command === "string" && raw.command) ||
|
|
763
|
+
(typeof raw.pattern === "string" && raw.pattern) ||
|
|
764
|
+
(Array.isArray(u.locations) && u.locations[0]?.path) ||
|
|
765
|
+
"";
|
|
766
|
+
if (u.sessionUpdate === "tool_call" || detail) {
|
|
767
|
+
this.slog.logTool(sessionId, String(name), detail ? String(detail).slice(0, 200) : "");
|
|
768
|
+
}
|
|
697
769
|
}
|
|
698
770
|
// Derive a context-usage %/token count if the update carries usage info.
|
|
699
771
|
const usage = (u as { usage?: { totalTokens?: number } }).usage;
|
|
@@ -738,6 +810,20 @@ export class GrokClient extends EventEmitter {
|
|
|
738
810
|
const marker = "User's new message:\n";
|
|
739
811
|
const mi = t.lastIndexOf(marker);
|
|
740
812
|
if (mi !== -1) t = t.slice(mi + marker.length);
|
|
813
|
+
// Strip auto-complexity steering (and legacy forced-complex wrapper).
|
|
814
|
+
const taskMarker = "User task:";
|
|
815
|
+
const ti = t.lastIndexOf(taskMarker);
|
|
816
|
+
if (
|
|
817
|
+
ti !== -1 &&
|
|
818
|
+
(/^COMPLEXITY \(decide yourself/i.test(t) || /^TASK COMPLEXITY:/i.test(t))
|
|
819
|
+
) {
|
|
820
|
+
t = t.slice(ti + taskMarker.length);
|
|
821
|
+
}
|
|
822
|
+
// Never persist quiet meta-prompts as a user message title.
|
|
823
|
+
if (/^Session status update \(meta only\)/i.test(t.trim())) t = "";
|
|
824
|
+
if (/^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(t.trim())) t = "";
|
|
825
|
+
if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t.trim())) t = "";
|
|
826
|
+
if (/^SELF-RECHECK \(automatic quality pass/i.test(t.trim())) t = "";
|
|
741
827
|
t = t.replace(/^\([^\n)]*\)\s*\n+/, "");
|
|
742
828
|
return t.trim();
|
|
743
829
|
}
|