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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* and per-chat preferences (project, agent, model, reasoning). State persists
|
|
5
5
|
* to the settings store so it survives restarts.
|
|
6
6
|
*/
|
|
7
|
-
import { basename } from "node:path";
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
8
|
import { type Api, InlineKeyboard } from "grammy";
|
|
9
9
|
import {
|
|
10
10
|
type GrokClient,
|
|
@@ -15,26 +15,67 @@ import {
|
|
|
15
15
|
type SessionMetadata,
|
|
16
16
|
} from "../grok/client.js";
|
|
17
17
|
import type { AccountRotator } from "./account-rotator.js";
|
|
18
|
-
import type
|
|
18
|
+
import { contentText, type ContentBlock, type PromptResult, type SessionUpdate } from "../grok/types.js";
|
|
19
19
|
import type { AppConfig } from "../config.js";
|
|
20
20
|
import { reasoningDirective } from "../app/reasoning.js";
|
|
21
21
|
import type { SettingsStore } from "../app/settings-store.js";
|
|
22
22
|
import { type PromptInput, type ReasoningEffort, textPrompt } from "../app/types.js";
|
|
23
23
|
import { createLogger } from "../logger.js";
|
|
24
|
-
import { buildTranscript } from "../sessions/history.js";
|
|
24
|
+
import { buildTranscript, readHistory } from "../sessions/history.js";
|
|
25
25
|
import { sessionHashtags } from "../render/hashtags.js";
|
|
26
26
|
import { PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
27
27
|
import { buildPriming, recentTranscript } from "./session-fork.js";
|
|
28
28
|
import { TailWatcher } from "../sessions/tail.js";
|
|
29
29
|
import type { HistoryEntry } from "../sessions/types.js";
|
|
30
30
|
import { formatToolCall } from "../render/tool-call.js";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
mergeToolSnapshot,
|
|
33
|
+
snapshotHasDetail,
|
|
34
|
+
type ToolSnapshot,
|
|
35
|
+
} from "../render/tool-call-merge.js";
|
|
36
|
+
import {
|
|
37
|
+
type FileOp,
|
|
38
|
+
cloneFileOps,
|
|
39
|
+
fileOpFromUpdate,
|
|
40
|
+
mergeFileOp,
|
|
41
|
+
summarizeFileOps,
|
|
42
|
+
summarizeFileOpsShort,
|
|
43
|
+
summarizeFileOpsSplit,
|
|
44
|
+
} from "../render/file-summary.js";
|
|
32
45
|
import { isActiveStatus, renderSubagentTransition, statusKey } from "../render/subagent.js";
|
|
33
46
|
import type { PendingStage, SubagentInfo } from "../grok/types.js";
|
|
34
47
|
import { ResponseStreamer } from "../stream/streamer.js";
|
|
35
48
|
import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
|
|
36
49
|
import { collectTurnImagePaths, sendImages } from "./image-return.js";
|
|
37
50
|
import { buildContentBlocks, mergeInputs } from "./prompt-content.js";
|
|
51
|
+
import { wrapAutoComplexityPrompt } from "./complexity-gate.js";
|
|
52
|
+
import {
|
|
53
|
+
autoApproveSuggestions,
|
|
54
|
+
buildSelfRecheckDecisionPrompt,
|
|
55
|
+
buildSelfRecheckPrompt,
|
|
56
|
+
buildSuggestionsPrompt,
|
|
57
|
+
composeSelfRecheckTurn,
|
|
58
|
+
formatBatchedSuggestionsPrompt,
|
|
59
|
+
isSelfRecheckPrompt,
|
|
60
|
+
parseSelfRecheckDecision,
|
|
61
|
+
parseSuggestions,
|
|
62
|
+
type Suggestion,
|
|
63
|
+
suggestionsKeyboard,
|
|
64
|
+
} from "./suggestions.js";
|
|
65
|
+
import {
|
|
66
|
+
parsePlanUpdate,
|
|
67
|
+
renderPlanMarkdown,
|
|
68
|
+
renderPlanOneLine,
|
|
69
|
+
type PlanEntry,
|
|
70
|
+
} from "../render/plan.js";
|
|
71
|
+
import {
|
|
72
|
+
buildLastTurnSummary,
|
|
73
|
+
cleanCommentLine,
|
|
74
|
+
cleanUserPreview,
|
|
75
|
+
stepFromThought,
|
|
76
|
+
stepFromToolUpdate,
|
|
77
|
+
stripDirectiveWrappers,
|
|
78
|
+
} from "../render/session-comment.js";
|
|
38
79
|
import {
|
|
39
80
|
backoffSchedule,
|
|
40
81
|
fmtSeconds,
|
|
@@ -85,6 +126,8 @@ export class SessionRuntime {
|
|
|
85
126
|
private streamer: ResponseStreamer | undefined;
|
|
86
127
|
private readonly typing: TypingIndicator;
|
|
87
128
|
private shownToolIds = new Set<string>();
|
|
129
|
+
/** toolCallId → merged snapshot so completed updates keep title/args. */
|
|
130
|
+
private toolCallCache = new Map<string, ToolSnapshot>();
|
|
88
131
|
/** Files touched this turn (path -> operation), tracked even in background so
|
|
89
132
|
* the completion message can summarise what changed. */
|
|
90
133
|
private fileOps = new Map<string, FileOp>();
|
|
@@ -127,6 +170,49 @@ export class SessionRuntime {
|
|
|
127
170
|
/** Optional multi-account rotator: when a turn gives up, cycle through the
|
|
128
171
|
* other saved logins once and retry on each. Injected by the registry. */
|
|
129
172
|
accountRotator: AccountRotator | undefined;
|
|
173
|
+
/** Session ids that already received the first-prompt auto-complexity directive. */
|
|
174
|
+
private complexitySteered = new Set<string>();
|
|
175
|
+
/** Last credits total reported for this session (for per-turn delta accounting). */
|
|
176
|
+
private lastReportedCredits = 0;
|
|
177
|
+
/** Live "what is happening now" line while a turn is in flight. */
|
|
178
|
+
private liveStep: string | undefined;
|
|
179
|
+
/** Idle card comment (AI/local summary of the chat after the last turn). */
|
|
180
|
+
private sessionComment: string | undefined;
|
|
181
|
+
/** User text of the turn currently running (for local card-comment fallback). */
|
|
182
|
+
private turnUserText = "";
|
|
183
|
+
/** Assistant prose streamed this turn — used to build the idle card summary. */
|
|
184
|
+
private turnAssistantText = "";
|
|
185
|
+
/** Quiet meta capture (suggestions) — never stream to Telegram. */
|
|
186
|
+
private capturingQuiet = false;
|
|
187
|
+
private quietCaptureBuf = "";
|
|
188
|
+
/** Batches of post-turn suggestions for inline-button callbacks. */
|
|
189
|
+
private suggestionBatches = new Map<number, Suggestion[]>();
|
|
190
|
+
private suggestionBatchSeq = 0;
|
|
191
|
+
/**
|
|
192
|
+
* Last successful Done's suggestions — kept so a background "Done from other
|
|
193
|
+
* session" can carry buttons, and so switching back to this session re-shows
|
|
194
|
+
* them even if the user missed the notify (or notify was off).
|
|
195
|
+
*/
|
|
196
|
+
private pendingSuggestions:
|
|
197
|
+
| { batchId: number; suggestions: Suggestion[]; banner: string }
|
|
198
|
+
| undefined;
|
|
199
|
+
/** Live ACP plan board for the current turn (done / in-progress / pending). */
|
|
200
|
+
private planEntries: PlanEntry[] | undefined;
|
|
201
|
+
/** True while the active turn is the automatic one-shot self-recheck pass. */
|
|
202
|
+
private isSelfRecheckTurn = false;
|
|
203
|
+
/**
|
|
204
|
+
* Original user prompt (and first-pass assistant text) for suggestions after
|
|
205
|
+
* a self-recheck turn, so follow-ups stay grounded in the real user request.
|
|
206
|
+
*/
|
|
207
|
+
private suggestionUserText = "";
|
|
208
|
+
private preRecheckAssistantText = "";
|
|
209
|
+
/** File ops from the first turn, frozen before the self-recheck pass. */
|
|
210
|
+
private preRecheckFileOps = new Map<string, FileOp>();
|
|
211
|
+
/**
|
|
212
|
+
* When true, this turn must not schedule a self-recheck (meta / auto-queue /
|
|
213
|
+
* already-recheck). Set from PromptInput.skipSelfRecheck or recheck marker.
|
|
214
|
+
*/
|
|
215
|
+
private skipSelfRecheck = false;
|
|
130
216
|
|
|
131
217
|
constructor(
|
|
132
218
|
private readonly api: Api,
|
|
@@ -176,11 +262,53 @@ export class SessionRuntime {
|
|
|
176
262
|
return this.lastCompletion;
|
|
177
263
|
}
|
|
178
264
|
|
|
179
|
-
/** Latest task-completion % (0
|
|
265
|
+
/** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
|
|
180
266
|
get taskProgress(): number | undefined {
|
|
181
267
|
return this.progress;
|
|
182
268
|
}
|
|
183
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Full plan board for the live stream / status panel (above the progress bar).
|
|
272
|
+
* Empty when no plan is active this turn.
|
|
273
|
+
*/
|
|
274
|
+
get planBoard(): string | undefined {
|
|
275
|
+
if (!this.planEntries?.length) return undefined;
|
|
276
|
+
return renderPlanMarkdown(this.planEntries);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** One-line plan summary for compact cards. */
|
|
280
|
+
get planSummary(): string | undefined {
|
|
281
|
+
if (!this.planEntries?.length) return undefined;
|
|
282
|
+
return renderPlanOneLine(this.planEntries);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Pending post-turn suggestions for switch replay / Done markup.
|
|
287
|
+
* Returns text + keyboard without clearing (taps still resolve via batch id).
|
|
288
|
+
*/
|
|
289
|
+
peekPendingSuggestions():
|
|
290
|
+
| { text: string; markup: InlineKeyboard; batchId: number }
|
|
291
|
+
| undefined {
|
|
292
|
+
const p = this.pendingSuggestions;
|
|
293
|
+
if (!p?.suggestions.length) return undefined;
|
|
294
|
+
return {
|
|
295
|
+
text: p.banner,
|
|
296
|
+
markup: suggestionsKeyboard(p.batchId, p.suggestions),
|
|
297
|
+
batchId: p.batchId,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* One-line status for Running/Sessions cards:
|
|
303
|
+
* live step while busy, otherwise the last chat summary / comment.
|
|
304
|
+
*/
|
|
305
|
+
get cardComment(): string | undefined {
|
|
306
|
+
if (this.busy && this.liveStep) return this.liveStep;
|
|
307
|
+
if (this.sessionComment) return this.sessionComment;
|
|
308
|
+
if (this.sessionId) return this.acp.sessionComment(this.sessionId);
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
184
312
|
/** Record a new progress value and refresh the status panel / cards. The bar
|
|
185
313
|
* is monotonic within a turn (it's reset to undefined when a new turn starts),
|
|
186
314
|
* so a streamer recreated mid-turn can't make it jump backwards. */
|
|
@@ -191,6 +319,36 @@ export class SessionRuntime {
|
|
|
191
319
|
this.changed();
|
|
192
320
|
}
|
|
193
321
|
|
|
322
|
+
/** Update the live step shown on session cards (throttled by equality). */
|
|
323
|
+
private setLiveStep(step: string | undefined): void {
|
|
324
|
+
const next = step?.trim() ? cleanCommentLine(step) : undefined;
|
|
325
|
+
if (next === this.liveStep) return;
|
|
326
|
+
this.liveStep = next;
|
|
327
|
+
this.changed();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Persist idle card comment (disk + memory) so /running and /sessions see it. */
|
|
331
|
+
private setSessionComment(comment: string | undefined): void {
|
|
332
|
+
const next = comment?.trim() ? cleanCommentLine(comment) : undefined;
|
|
333
|
+
if (next === this.sessionComment) return;
|
|
334
|
+
this.sessionComment = next;
|
|
335
|
+
if (next && this.sessionId) {
|
|
336
|
+
try {
|
|
337
|
+
this.acp.setSessionComment(this.sessionId, next);
|
|
338
|
+
} catch {
|
|
339
|
+
/* non-fatal */
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
this.changed();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Hydrate comment from disk after bind/resume. */
|
|
346
|
+
private loadPersistedComment(): void {
|
|
347
|
+
if (!this.sessionId) return;
|
|
348
|
+
const c = this.acp.sessionComment(this.sessionId);
|
|
349
|
+
if (c) this.sessionComment = c;
|
|
350
|
+
}
|
|
351
|
+
|
|
194
352
|
/** Searchable hashtag footer for this session (project В· session В· model В·
|
|
195
353
|
* reasoning) — appended to every AI-output surface for this session. */
|
|
196
354
|
get tags(): string {
|
|
@@ -212,6 +370,10 @@ export class SessionRuntime {
|
|
|
212
370
|
// Any transient follow-watch of this session is now superseded.
|
|
213
371
|
if (this.watchIsFollow) this.stopWatch();
|
|
214
372
|
this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, this.turnStartedAt);
|
|
373
|
+
// Restore the live plan board so steps stay visible above the progress bar.
|
|
374
|
+
if (this.planEntries?.length) {
|
|
375
|
+
this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
|
|
376
|
+
}
|
|
215
377
|
this.typing.start();
|
|
216
378
|
}
|
|
217
379
|
} else {
|
|
@@ -274,6 +436,10 @@ export class SessionRuntime {
|
|
|
274
436
|
this.rebindPending = false;
|
|
275
437
|
this.cwd = cwd;
|
|
276
438
|
this.projectName = projectName;
|
|
439
|
+
this.turnCount = 0;
|
|
440
|
+
this.lastReportedCredits = 0;
|
|
441
|
+
this.liveStep = undefined;
|
|
442
|
+
this.sessionComment = undefined;
|
|
277
443
|
await this.applySessionPrefs();
|
|
278
444
|
this.persist();
|
|
279
445
|
this.sessionChanged();
|
|
@@ -298,6 +464,7 @@ export class SessionRuntime {
|
|
|
298
464
|
this.rebindPending = false;
|
|
299
465
|
this.cwd = cwd;
|
|
300
466
|
this.projectName = projectName;
|
|
467
|
+
this.loadPersistedComment();
|
|
301
468
|
this.persist();
|
|
302
469
|
log.info(`chat ${this.chatId} -> resumed session ${sessionId} @ ${cwd}`);
|
|
303
470
|
this.changed();
|
|
@@ -320,6 +487,18 @@ export class SessionRuntime {
|
|
|
320
487
|
}
|
|
321
488
|
}
|
|
322
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Start a brand-new Grok session primed with a full foreign transcript
|
|
492
|
+
* (import from Kiro / OpenCode / Claude / Codex). Priming is applied on the
|
|
493
|
+
* next {@link submit} so the imported context becomes part of Grok's history.
|
|
494
|
+
*/
|
|
495
|
+
async startImportedSession(cwd: string, projectName: string | undefined, priming: string): Promise<void> {
|
|
496
|
+
await this.startNewSession(cwd, projectName);
|
|
497
|
+
if (priming.trim()) this.primingContext = priming;
|
|
498
|
+
// Imported transcripts already have context — skip first-prompt complexity steering.
|
|
499
|
+
this.markComplexitySteered();
|
|
500
|
+
}
|
|
501
|
+
|
|
323
502
|
startWatch(jsonlPath: string, follow = false): void {
|
|
324
503
|
this.stopWatch();
|
|
325
504
|
this.watchIsFollow = follow;
|
|
@@ -399,7 +578,7 @@ export class SessionRuntime {
|
|
|
399
578
|
}
|
|
400
579
|
}
|
|
401
580
|
|
|
402
|
-
//
|
|
581
|
+
// ── prompting ────────────────────────────────────────────────────────────
|
|
403
582
|
|
|
404
583
|
async submit(input: PromptInput): Promise<"ran" | "queued"> {
|
|
405
584
|
await this.ensureSession();
|
|
@@ -408,10 +587,43 @@ export class SessionRuntime {
|
|
|
408
587
|
this.changed();
|
|
409
588
|
return "queued";
|
|
410
589
|
}
|
|
411
|
-
|
|
590
|
+
// First prompt of a fresh session: steer the agent to decide complexity
|
|
591
|
+
// itself (plan if complex, implement if simple) — never ask the user.
|
|
592
|
+
let toRun = input;
|
|
593
|
+
if (this.shouldSteerComplexity()) {
|
|
594
|
+
toRun = wrapAutoComplexityPrompt(input);
|
|
595
|
+
this.markComplexitySteered();
|
|
596
|
+
log.info(`chat ${this.chatId}: first-prompt auto-complexity steering applied`);
|
|
597
|
+
}
|
|
598
|
+
void this.runTurn(toRun);
|
|
412
599
|
return "ran";
|
|
413
600
|
}
|
|
414
601
|
|
|
602
|
+
private markComplexitySteered(): void {
|
|
603
|
+
if (this.sessionId) this.complexitySteered.add(this.sessionId);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Apply auto-complexity directive only on the first prompt of a brand-new
|
|
608
|
+
* conversation (no prior user turns in this process / session jsonl).
|
|
609
|
+
*/
|
|
610
|
+
private shouldSteerComplexity(): boolean {
|
|
611
|
+
if (!this.sessionId) return false;
|
|
612
|
+
if (this.complexitySteered.has(this.sessionId)) return false;
|
|
613
|
+
if (this.turnCount > 0) return false;
|
|
614
|
+
try {
|
|
615
|
+
const path = join(this.cfg.sessionsDir, `${this.sessionId}.jsonl`);
|
|
616
|
+
const hist = readHistory(path, 8);
|
|
617
|
+
if (hist.some((e) => e.role === "user" && e.text.trim().length > 0)) {
|
|
618
|
+
this.complexitySteered.add(this.sessionId);
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
} catch {
|
|
622
|
+
/* treat as fresh */
|
|
623
|
+
}
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
|
|
415
627
|
async cancel(): Promise<boolean> {
|
|
416
628
|
if (!this.busy || !this.sessionId) return false;
|
|
417
629
|
this.cancelled = true;
|
|
@@ -462,10 +674,11 @@ export class SessionRuntime {
|
|
|
462
674
|
/** Reload a persisted session, retrying flaky failures with a short backoff.
|
|
463
675
|
* Returns true once loaded, false after the attempts are exhausted. */
|
|
464
676
|
private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
|
|
465
|
-
const delays = [400, 1200, 3000]; //
|
|
677
|
+
const delays = [400, 1200, 3000]; // ~4.6s total before giving up
|
|
466
678
|
for (let i = 0; i < attempts; i++) {
|
|
467
679
|
try {
|
|
468
680
|
await this.acp.loadSession(sessionId, this.cwd);
|
|
681
|
+
this.loadPersistedComment();
|
|
469
682
|
return true;
|
|
470
683
|
} catch (err) {
|
|
471
684
|
log.warn(
|
|
@@ -501,10 +714,34 @@ export class SessionRuntime {
|
|
|
501
714
|
this.busy = true;
|
|
502
715
|
this.cancelled = false;
|
|
503
716
|
this.turnReplyTo = input.replyTo;
|
|
717
|
+
this.turnUserText = input.text;
|
|
718
|
+
this.turnAssistantText = "";
|
|
719
|
+
this.isSelfRecheckTurn = isSelfRecheckPrompt(input.text);
|
|
720
|
+
// Meta turns (recheck, auto-suggestion batches) never arm another recheck.
|
|
721
|
+
this.skipSelfRecheck = !!input.skipSelfRecheck || this.isSelfRecheckTurn;
|
|
722
|
+
// Fresh user work resets suggestion anchors; recheck keeps the original ask.
|
|
723
|
+
// Strip complexity/reply wrappers so suggestions + recheck see the real ask.
|
|
724
|
+
if (!this.isSelfRecheckTurn) {
|
|
725
|
+
this.suggestionUserText = stripDirectiveWrappers(input.text) || input.text;
|
|
726
|
+
this.preRecheckAssistantText = "";
|
|
727
|
+
this.preRecheckFileOps = new Map();
|
|
728
|
+
}
|
|
504
729
|
this.shownToolIds = new Set();
|
|
730
|
+
this.toolCallCache = new Map();
|
|
505
731
|
this.fileOps = new Map();
|
|
506
732
|
this.subagentShown = new Map();
|
|
507
733
|
this.progress = undefined; // a new turn = a new task; clear the old bar
|
|
734
|
+
this.planEntries = undefined; // plan board is per-turn
|
|
735
|
+
this.pendingSuggestions = undefined; // new work supersedes previous Done suggestions
|
|
736
|
+
this.setLiveStep(
|
|
737
|
+
this.isSelfRecheckTurn
|
|
738
|
+
? "Self-recheck: hunting bugs / incomplete logic\u2026"
|
|
739
|
+
: input.text.trim()
|
|
740
|
+
? `Working: ${cleanUserPreview(input.text, 110)}`
|
|
741
|
+
: input.images.length
|
|
742
|
+
? "Working on attached image(s)\u2026"
|
|
743
|
+
: "Working\u2026",
|
|
744
|
+
);
|
|
508
745
|
// A new streamed turn supersedes any transient "follow" watch of this same
|
|
509
746
|
// session's previous in-flight turn (avoids duplicated output).
|
|
510
747
|
if (this.watchIsFollow) this.stopWatch();
|
|
@@ -545,7 +782,7 @@ export class SessionRuntime {
|
|
|
545
782
|
if (resumed) final = resumed;
|
|
546
783
|
const streamedOutput = this.streamer?.hasOutput ?? false;
|
|
547
784
|
// On a successful, non-cancelled turn, top the fallback bar up to 100 (a
|
|
548
|
-
// no-op when the agent reported its own progress
|
|
785
|
+
// no-op when the agent reported its own progress — its value is kept).
|
|
549
786
|
if (final.result && !this.cancelled) this.streamer?.completeFallback();
|
|
550
787
|
if (this.streamer) await this.streamer.finalize();
|
|
551
788
|
if (this.foreground) await this.sendTurnImages();
|
|
@@ -554,31 +791,219 @@ export class SessionRuntime {
|
|
|
554
791
|
// the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
|
|
555
792
|
const canPing = this.foreground || this.cfg.notifyOtherSessions;
|
|
556
793
|
// A background session about to run a queued follow-up shouldn't ping its
|
|
557
|
-
// interim "Done"
|
|
794
|
+
// interim "Done" — only the final, queue-empty turn announces completion.
|
|
558
795
|
const hasQueued = this.queue.length > 0;
|
|
559
796
|
const switchKb = this.switchKeyboard();
|
|
560
|
-
if (final.result && !this.cancelled)
|
|
797
|
+
if (final.result && !this.cancelled) {
|
|
798
|
+
this.turnCount++;
|
|
799
|
+
// Persist real per-account usage (turns + reported credits) for /accounts and /usage.
|
|
800
|
+
this.recordAccountUsage();
|
|
801
|
+
// Card comment: what this turn solved (assistant result + files) — no extra agent call.
|
|
802
|
+
this.setSessionComment(
|
|
803
|
+
buildLastTurnSummary({
|
|
804
|
+
userText: this.turnUserText,
|
|
805
|
+
assistantText: this.turnAssistantText,
|
|
806
|
+
fileOps: this.fileOps,
|
|
807
|
+
stopReason: final.result.stopReason,
|
|
808
|
+
}),
|
|
809
|
+
);
|
|
810
|
+
this.setLiveStep(undefined);
|
|
811
|
+
} else if (this.cancelled) {
|
|
812
|
+
this.setSessionComment(
|
|
813
|
+
buildLastTurnSummary({
|
|
814
|
+
userText: this.turnUserText,
|
|
815
|
+
assistantText: this.turnAssistantText,
|
|
816
|
+
fileOps: this.fileOps,
|
|
817
|
+
cancelled: true,
|
|
818
|
+
}),
|
|
819
|
+
);
|
|
820
|
+
this.setLiveStep(undefined);
|
|
821
|
+
} else if (final.error) {
|
|
822
|
+
this.setSessionComment(
|
|
823
|
+
buildLastTurnSummary({
|
|
824
|
+
userText: this.turnUserText,
|
|
825
|
+
assistantText: this.turnAssistantText,
|
|
826
|
+
fileOps: this.fileOps,
|
|
827
|
+
error: final.error.message,
|
|
828
|
+
}),
|
|
829
|
+
);
|
|
830
|
+
this.setLiveStep(undefined);
|
|
831
|
+
}
|
|
561
832
|
if (final.result || this.cancelled) {
|
|
562
|
-
const
|
|
833
|
+
const liveMsg = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
|
|
563
834
|
const pingDone = canPing && (this.foreground || !hasQueued);
|
|
564
|
-
|
|
835
|
+
|
|
836
|
+
// One-shot self-recheck: only after a real *user* turn (not meta/auto),
|
|
837
|
+
// with idle queue. skipSelfRecheck blocks loops after recheck / auto-batch.
|
|
838
|
+
// Also skipped when no files were modified, or when a quiet AI decision
|
|
839
|
+
// refuses (simple tasks, pure build, nothing worth re-verifying).
|
|
840
|
+
const wantSelfRecheck =
|
|
841
|
+
!!final.result &&
|
|
842
|
+
!this.cancelled &&
|
|
843
|
+
!hasQueued &&
|
|
844
|
+
this.cfg.selfRecheckEnabled &&
|
|
845
|
+
!this.skipSelfRecheck &&
|
|
846
|
+
!this.isSelfRecheckTurn;
|
|
847
|
+
|
|
848
|
+
let queuedRecheck = false;
|
|
849
|
+
if (wantSelfRecheck) {
|
|
850
|
+
this.preRecheckAssistantText = this.turnAssistantText;
|
|
851
|
+
// Strip COMPLEXITY wrapper so recheck + suggestions see the real ask.
|
|
852
|
+
this.suggestionUserText = stripDirectiveWrappers(this.turnUserText) || this.turnUserText;
|
|
853
|
+
this.preRecheckFileOps = cloneFileOps(this.fileOps);
|
|
854
|
+
this.setLiveStep("Deciding if self-recheck is needed\u2026");
|
|
855
|
+
this.changed();
|
|
856
|
+
const recheck = await this.maybePlanSelfRecheck();
|
|
857
|
+
this.setLiveStep(undefined);
|
|
858
|
+
// User may cancel during the quiet decision call — first turn still
|
|
859
|
+
// succeeded; never queue a recheck after cancel.
|
|
860
|
+
if (this.cancelled) {
|
|
861
|
+
this.preRecheckFileOps = new Map();
|
|
862
|
+
this.preRecheckAssistantText = "";
|
|
863
|
+
} else if (recheck) {
|
|
864
|
+
queuedRecheck = true;
|
|
865
|
+
// Front of queue; mark skip so the recheck turn never re-arms itself.
|
|
866
|
+
this.queue.unshift(
|
|
867
|
+
textPrompt(recheck, this.turnReplyTo, undefined, { skipSelfRecheck: true }),
|
|
868
|
+
);
|
|
869
|
+
this.changed();
|
|
870
|
+
if (pingDone) {
|
|
871
|
+
// Interim status + first-turn file list (final Done comes after recheck).
|
|
872
|
+
const firstFiles = summarizeFileOps(this.preRecheckFileOps, this.cwd);
|
|
873
|
+
await this.notify(
|
|
874
|
+
`\u{1F50D} Self-recheck \u2014 bugs, logic gaps, related follow-through (once)\u2026\n\n` +
|
|
875
|
+
`\u{1F4C1} After first turn\n${firstFiles}`,
|
|
876
|
+
{ loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb },
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
} else {
|
|
880
|
+
// No recheck — clear frozen first-turn ops (nothing to split later).
|
|
881
|
+
this.preRecheckFileOps = new Map();
|
|
882
|
+
this.preRecheckAssistantText = "";
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (!queuedRecheck) {
|
|
887
|
+
// Post-turn suggestions on successful, non-cancelled Done with idle queue —
|
|
888
|
+
// both foreground and background (so switch-to-session can re-show them).
|
|
889
|
+
let doneMarkup = switchKb;
|
|
890
|
+
// After a recheck pass, rebuild Done with split first-turn / recheck files.
|
|
891
|
+
let doneText = this.isSelfRecheckTurn
|
|
892
|
+
? this.completionMessageSplit(final.result?.stopReason, startedAt, streamedOutput)
|
|
893
|
+
: liveMsg;
|
|
894
|
+
if (final.result && !this.cancelled && !hasQueued) {
|
|
895
|
+
const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
|
|
896
|
+
// Only auto-queue high-need follow-ups when the user is watching;
|
|
897
|
+
// background sessions store buttons for the Done ping / switch replay.
|
|
898
|
+
autoQueue: this.foreground,
|
|
899
|
+
});
|
|
900
|
+
doneText = sug.text;
|
|
901
|
+
doneMarkup = sug.markup;
|
|
902
|
+
}
|
|
903
|
+
if (pingDone) await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: doneMarkup });
|
|
904
|
+
// Clear frozen first-turn ops after final Done (recheck path done).
|
|
905
|
+
if (this.isSelfRecheckTurn) this.preRecheckFileOps = new Map();
|
|
906
|
+
}
|
|
565
907
|
} else if (final.error) {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if (
|
|
908
|
+
// If the self-recheck pass itself failed, still surface Done for the
|
|
909
|
+
// original work (split files + suggestions) so the user is not stuck.
|
|
910
|
+
if (this.isSelfRecheckTurn && !hasQueued) {
|
|
911
|
+
const switchKb = this.switchKeyboard();
|
|
912
|
+
const pingDone = canPing && (this.foreground || !hasQueued);
|
|
913
|
+
let doneText =
|
|
914
|
+
this.completionMessageSplit(undefined, startedAt, streamedOutput) +
|
|
915
|
+
`\n\n\u26A0\uFE0F Self-recheck failed: ${final.error.message}`;
|
|
916
|
+
if (!this.cancelled) {
|
|
917
|
+
const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
|
|
918
|
+
autoQueue: this.foreground,
|
|
919
|
+
});
|
|
920
|
+
doneText = sug.text;
|
|
921
|
+
if (pingDone) {
|
|
922
|
+
await this.notify(doneText, {
|
|
923
|
+
loud: true,
|
|
924
|
+
replyTo: this.turnReplyTo,
|
|
925
|
+
replyMarkup: sug.markup,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
} else if (pingDone) {
|
|
929
|
+
await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
|
|
930
|
+
}
|
|
931
|
+
this.preRecheckFileOps = new Map();
|
|
932
|
+
} else {
|
|
933
|
+
const transient = isTransientError(final.error);
|
|
934
|
+
const liveMsg = this.errorMessage(final.error, startedAt, final.attempts, transient);
|
|
935
|
+
if (canPing) await this.notify(liveMsg, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
|
|
936
|
+
}
|
|
569
937
|
}
|
|
570
938
|
} catch (err) {
|
|
571
939
|
// Unexpected failure outside the prompt path (e.g. while finalizing).
|
|
572
940
|
await this.streamer?.finalize().catch(() => {});
|
|
573
|
-
const
|
|
574
|
-
this.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
941
|
+
const errMsg = (err as Error).message;
|
|
942
|
+
this.setSessionComment(
|
|
943
|
+
buildLastTurnSummary({
|
|
944
|
+
userText: this.turnUserText,
|
|
945
|
+
assistantText: this.turnAssistantText,
|
|
946
|
+
fileOps: this.fileOps,
|
|
947
|
+
error: errMsg,
|
|
948
|
+
}),
|
|
949
|
+
);
|
|
950
|
+
this.setLiveStep(undefined);
|
|
951
|
+
// If the self-recheck pass itself blew up, still surface Done for the
|
|
952
|
+
// original work (split files + suggestions) so the user is not stuck.
|
|
953
|
+
if (this.isSelfRecheckTurn && this.queue.length === 0) {
|
|
954
|
+
const switchKb = this.switchKeyboard();
|
|
955
|
+
const canPing = this.foreground || this.cfg.notifyOtherSessions;
|
|
956
|
+
let doneText =
|
|
957
|
+
this.completionMessageSplit(undefined, startedAt, this.streamer?.hasOutput ?? false) +
|
|
958
|
+
`\n\n\u26A0\uFE0F Self-recheck failed: ${errMsg}`;
|
|
959
|
+
try {
|
|
960
|
+
if (!this.cancelled) {
|
|
961
|
+
const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
|
|
962
|
+
autoQueue: this.foreground,
|
|
963
|
+
});
|
|
964
|
+
doneText = sug.text;
|
|
965
|
+
if (canPing) {
|
|
966
|
+
await this.notify(doneText, {
|
|
967
|
+
loud: true,
|
|
968
|
+
replyTo: this.turnReplyTo,
|
|
969
|
+
replyMarkup: sug.markup,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
} else if (canPing) {
|
|
973
|
+
await this.notify(doneText, {
|
|
974
|
+
loud: true,
|
|
975
|
+
replyTo: this.turnReplyTo,
|
|
976
|
+
replyMarkup: switchKb,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
} catch (e2) {
|
|
980
|
+
log.debug(`recheck catch recovery failed: ${(e2 as Error).message}`);
|
|
981
|
+
if (canPing) {
|
|
982
|
+
await this.notify(doneText, {
|
|
983
|
+
loud: true,
|
|
984
|
+
replyTo: this.turnReplyTo,
|
|
985
|
+
replyMarkup: switchKb,
|
|
986
|
+
}).catch(() => {});
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
this.preRecheckFileOps = new Map();
|
|
990
|
+
} else {
|
|
991
|
+
const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${errMsg}`;
|
|
992
|
+
this.lastCompletion = msg;
|
|
993
|
+
if (this.foreground || this.cfg.notifyOtherSessions) {
|
|
994
|
+
const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
|
|
995
|
+
await this.notify(`${from}${msg}`, {
|
|
996
|
+
loud: true,
|
|
997
|
+
replyTo: this.turnReplyTo,
|
|
998
|
+
replyMarkup: this.switchKeyboard(),
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
578
1001
|
}
|
|
579
1002
|
} finally {
|
|
580
1003
|
this.typing.stop();
|
|
581
1004
|
this.streamer = undefined;
|
|
1005
|
+
this.capturingQuiet = false;
|
|
1006
|
+
this.quietCaptureBuf = "";
|
|
582
1007
|
this.busy = false;
|
|
583
1008
|
this.activity(false);
|
|
584
1009
|
// The in-flight turn we may have been following live is over.
|
|
@@ -587,6 +1012,9 @@ export class SessionRuntime {
|
|
|
587
1012
|
// the bar is removed from the status panel, session cards and switch
|
|
588
1013
|
// messages. The finished streamed bubble keeps its own (frozen) bar.
|
|
589
1014
|
this.progress = undefined;
|
|
1015
|
+
this.planEntries = undefined;
|
|
1016
|
+
// Prefer stored summary on cards once idle (clear live step if still set).
|
|
1017
|
+
if (!this.liveStep || this.sessionComment) this.liveStep = undefined;
|
|
590
1018
|
this.changed();
|
|
591
1019
|
}
|
|
592
1020
|
|
|
@@ -601,6 +1029,183 @@ export class SessionRuntime {
|
|
|
601
1029
|
}
|
|
602
1030
|
}
|
|
603
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* Quietly ask for 1–3 follow-ups, attach buttons to the Done text, store them
|
|
1034
|
+
* for switch-replay, and optionally queue auto-approved items as **one**
|
|
1035
|
+
* numbered multi-step prompt (`1) …\n2) …`).
|
|
1036
|
+
*/
|
|
1037
|
+
private async collectAndApplySuggestions(
|
|
1038
|
+
doneText: string,
|
|
1039
|
+
switchKb: InlineKeyboard | undefined,
|
|
1040
|
+
opts?: { autoQueue?: boolean },
|
|
1041
|
+
): Promise<{ text: string; markup?: InlineKeyboard }> {
|
|
1042
|
+
if (!this.cfg.suggestionsEnabled || !this.sessionId) {
|
|
1043
|
+
return { text: doneText, markup: switchKb };
|
|
1044
|
+
}
|
|
1045
|
+
let suggestions: Suggestion[] = [];
|
|
1046
|
+
try {
|
|
1047
|
+
suggestions = await this.fetchSuggestionsQuiet();
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
log.debug(`suggestions fetch failed: ${(e as Error).message}`);
|
|
1050
|
+
}
|
|
1051
|
+
if (suggestions.length === 0) return { text: doneText, markup: switchKb };
|
|
1052
|
+
|
|
1053
|
+
const batchId = ++this.suggestionBatchSeq;
|
|
1054
|
+
this.suggestionBatches.set(batchId, suggestions);
|
|
1055
|
+
// Bound memory: keep last ~20 batches.
|
|
1056
|
+
if (this.suggestionBatches.size > 20) {
|
|
1057
|
+
const oldest = [...this.suggestionBatches.keys()].sort((a, b) => a - b)[0]!;
|
|
1058
|
+
this.suggestionBatches.delete(oldest);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const thr = this.cfg.suggestionsAutoApprovePct;
|
|
1062
|
+
const autoQueue = opts?.autoQueue !== false;
|
|
1063
|
+
const auto = autoQueue ? autoApproveSuggestions(suggestions, thr) : [];
|
|
1064
|
+
let text = doneText;
|
|
1065
|
+
let banner: string;
|
|
1066
|
+
if (auto.length > 0) {
|
|
1067
|
+
const batched = formatBatchedSuggestionsPrompt(auto);
|
|
1068
|
+
const lines = auto.map((s, i) => ` ${i + 1}) ${s.need}% \u2014 ${s.text}`);
|
|
1069
|
+
const autoBlock =
|
|
1070
|
+
`\n\n\u{1F4A1} Auto-running ${auto.length} suggestion${auto.length === 1 ? "" : "s"}` +
|
|
1071
|
+
` as one prompt (\u2265 ${thr}% need):\n${lines.join("\n")}`;
|
|
1072
|
+
text += autoBlock;
|
|
1073
|
+
banner = `\u{1F4A1} Suggestions (auto-running ${auto.length} as one prompt):\n${lines.join("\n")}`;
|
|
1074
|
+
// Single queue entry — agent executes 1) 2) 3) in one turn.
|
|
1075
|
+
// skipSelfRecheck: auto-follow-ups must not arm another recheck cycle.
|
|
1076
|
+
this.queue.push(textPrompt(batched, this.turnReplyTo, undefined, { skipSelfRecheck: true }));
|
|
1077
|
+
this.changed();
|
|
1078
|
+
} else {
|
|
1079
|
+
text += "\n\n\u{1F4A1} Suggestions \u2014 tap one to continue:";
|
|
1080
|
+
banner = "\u{1F4A1} Suggestions \u2014 tap one to continue:";
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Keep for switch-to-session replay (and for Done pings that already include
|
|
1084
|
+
// the same keyboard). Cleared when a new turn starts.
|
|
1085
|
+
this.pendingSuggestions = { batchId, suggestions, banner };
|
|
1086
|
+
|
|
1087
|
+
const markup = suggestionsKeyboard(batchId, suggestions, switchKb);
|
|
1088
|
+
return { text, markup };
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Decide whether to queue a self-recheck turn.
|
|
1093
|
+
* - Hard skip when no files were modified this turn.
|
|
1094
|
+
* - Quiet AI decision: refuse (simple / not needed) or write recheck prompt.
|
|
1095
|
+
* - Returns the full recheck turn text, or undefined to skip.
|
|
1096
|
+
*/
|
|
1097
|
+
private async maybePlanSelfRecheck(): Promise<string | undefined> {
|
|
1098
|
+
if (this.fileOps.size === 0) {
|
|
1099
|
+
log.info(`chat ${this.chatId}: self-recheck skipped (no files modified)`);
|
|
1100
|
+
return undefined;
|
|
1101
|
+
}
|
|
1102
|
+
if (this.cancelled) return undefined;
|
|
1103
|
+
const user =
|
|
1104
|
+
this.suggestionUserText ||
|
|
1105
|
+
stripDirectiveWrappers(this.turnUserText) ||
|
|
1106
|
+
this.turnUserText;
|
|
1107
|
+
const did = this.turnAssistantText;
|
|
1108
|
+
const files = summarizeFileOpsShort(this.fileOps);
|
|
1109
|
+
|
|
1110
|
+
let decision;
|
|
1111
|
+
try {
|
|
1112
|
+
decision = await this.fetchSelfRecheckDecisionQuiet(user, did, files);
|
|
1113
|
+
} catch (e) {
|
|
1114
|
+
log.debug(`self-recheck decision failed: ${(e as Error).message}; skipping`);
|
|
1115
|
+
return undefined;
|
|
1116
|
+
}
|
|
1117
|
+
if (this.cancelled) return undefined;
|
|
1118
|
+
if (!decision.needed) {
|
|
1119
|
+
log.info(
|
|
1120
|
+
`chat ${this.chatId}: self-recheck skipped by agent` +
|
|
1121
|
+
(decision.reason ? ` (${decision.reason})` : ""),
|
|
1122
|
+
);
|
|
1123
|
+
return undefined;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Optional env template overrides the AI-written body when set.
|
|
1127
|
+
if (this.cfg.selfRecheckPrompt) {
|
|
1128
|
+
return buildSelfRecheckPrompt(user, did, this.cfg.selfRecheckPrompt);
|
|
1129
|
+
}
|
|
1130
|
+
if (decision.prompt.trim()) {
|
|
1131
|
+
return composeSelfRecheckTurn(decision.prompt, user, did);
|
|
1132
|
+
}
|
|
1133
|
+
// needed=true but empty prompt — fall back to built-in default template.
|
|
1134
|
+
return buildSelfRecheckPrompt(user, did);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Quiet JSON: should we recheck, and if so what prompt? Never streams. */
|
|
1138
|
+
private async fetchSelfRecheckDecisionQuiet(
|
|
1139
|
+
user: string,
|
|
1140
|
+
did: string,
|
|
1141
|
+
filesSummary: string,
|
|
1142
|
+
): Promise<ReturnType<typeof parseSelfRecheckDecision>> {
|
|
1143
|
+
if (!this.sessionId) return { needed: false, reason: "no session" };
|
|
1144
|
+
const prompt = buildSelfRecheckDecisionPrompt(user, did, filesSummary);
|
|
1145
|
+
this.capturingQuiet = true;
|
|
1146
|
+
this.quietCaptureBuf = "";
|
|
1147
|
+
try {
|
|
1148
|
+
await this.acp.prompt(this.sessionId, [{ type: "text", text: prompt }]);
|
|
1149
|
+
return parseSelfRecheckDecision(this.quietCaptureBuf);
|
|
1150
|
+
} finally {
|
|
1151
|
+
this.capturingQuiet = false;
|
|
1152
|
+
this.quietCaptureBuf = "";
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** Quiet JSON suggestion turn — never streams to Telegram. */
|
|
1157
|
+
private async fetchSuggestionsQuiet(): Promise<Suggestion[]> {
|
|
1158
|
+
if (!this.sessionId) return [];
|
|
1159
|
+
// Prefer the original user ask (before self-recheck) so need scores stay honest.
|
|
1160
|
+
const user =
|
|
1161
|
+
this.suggestionUserText ||
|
|
1162
|
+
stripDirectiveWrappers(this.turnUserText) ||
|
|
1163
|
+
this.turnUserText;
|
|
1164
|
+
const didParts = [this.preRecheckAssistantText, this.turnAssistantText].filter((s) => s?.trim());
|
|
1165
|
+
const did = didParts.join("\n") || this.turnAssistantText;
|
|
1166
|
+
const prompt = buildSuggestionsPrompt(user, did);
|
|
1167
|
+
this.capturingQuiet = true;
|
|
1168
|
+
this.quietCaptureBuf = "";
|
|
1169
|
+
try {
|
|
1170
|
+
await this.acp.prompt(this.sessionId, [{ type: "text", text: prompt }]);
|
|
1171
|
+
return parseSuggestions(this.quietCaptureBuf);
|
|
1172
|
+
} finally {
|
|
1173
|
+
this.capturingQuiet = false;
|
|
1174
|
+
this.quietCaptureBuf = "";
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** Resolve a tapped suggestion button; returns the prompt text or undefined. */
|
|
1179
|
+
takeSuggestion(batchId: number, index: number): string | undefined {
|
|
1180
|
+
const batch = this.suggestionBatches.get(batchId);
|
|
1181
|
+
if (!batch) return undefined;
|
|
1182
|
+
const s = batch[index];
|
|
1183
|
+
if (!s) return undefined;
|
|
1184
|
+
return s.text;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** Attribute a finished turn's credits/context to the active saved account. */
|
|
1188
|
+
private recordAccountUsage(): void {
|
|
1189
|
+
const meta = this.contextInfo();
|
|
1190
|
+
// Grok's metadata credits are typically a running session total — store the
|
|
1191
|
+
// per-turn delta so /accounts totals stay accurate across many turns.
|
|
1192
|
+
let turnCredits: number | undefined;
|
|
1193
|
+
if (typeof meta?.credits === "number" && Number.isFinite(meta.credits)) {
|
|
1194
|
+
const delta = meta.credits - this.lastReportedCredits;
|
|
1195
|
+
turnCredits = delta > 0 ? delta : meta.credits > 0 && this.lastReportedCredits === 0 ? meta.credits : undefined;
|
|
1196
|
+
if (meta.credits >= this.lastReportedCredits) this.lastReportedCredits = meta.credits;
|
|
1197
|
+
else this.lastReportedCredits = meta.credits; // reset if agent restarted counters
|
|
1198
|
+
}
|
|
1199
|
+
try {
|
|
1200
|
+
this.accountRotator?.recordTurnUsage({
|
|
1201
|
+
credits: turnCredits,
|
|
1202
|
+
contextPct: meta?.contextUsagePercentage,
|
|
1203
|
+
});
|
|
1204
|
+
} catch {
|
|
1205
|
+
/* non-fatal */
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
604
1209
|
/**
|
|
605
1210
|
* Show subagent ("crew") status transitions for the given (already
|
|
606
1211
|
* chat-attributed) subagents, so the user sees progress while the main agent
|
|
@@ -1022,6 +1627,28 @@ export class SessionRuntime {
|
|
|
1022
1627
|
return `\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`;
|
|
1023
1628
|
}
|
|
1024
1629
|
|
|
1630
|
+
/**
|
|
1631
|
+
* Final Done after a self-recheck: head + split file lists (first turn vs recheck).
|
|
1632
|
+
*/
|
|
1633
|
+
private completionMessageSplit(
|
|
1634
|
+
stopReason: string | undefined,
|
|
1635
|
+
startedAt: number,
|
|
1636
|
+
streamedOutput: boolean,
|
|
1637
|
+
): string {
|
|
1638
|
+
const head = this.doneHead(stopReason, startedAt, streamedOutput);
|
|
1639
|
+
const tags = this.hashtags();
|
|
1640
|
+
const files = summarizeFileOpsSplit(this.preRecheckFileOps, this.fileOps, this.cwd);
|
|
1641
|
+
const base = `${head}\n${files}`;
|
|
1642
|
+
this.lastCompletion = `${base}\n\n${tags}`;
|
|
1643
|
+
if (this.foreground) {
|
|
1644
|
+
return streamedOutput ? base : `${base}\n\n${tags}`;
|
|
1645
|
+
}
|
|
1646
|
+
return (
|
|
1647
|
+
`\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n` +
|
|
1648
|
+
`${summarizeFileOpsShort(this.preRecheckFileOps)} \u2192 recheck ${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1025
1652
|
/** The compact one-line status of a finished turn (no "end_turn" noise). */
|
|
1026
1653
|
private doneHead(stopReason: string | undefined, startedAt: number, streamedOutput: boolean): string {
|
|
1027
1654
|
const elapsed = fmtDuration(Date.now() - startedAt);
|
|
@@ -1077,7 +1704,16 @@ export class SessionRuntime {
|
|
|
1077
1704
|
|
|
1078
1705
|
private async flushQueue(): Promise<void> {
|
|
1079
1706
|
if (this.queue.length === 0 || this.busy) return;
|
|
1080
|
-
|
|
1707
|
+
// Meta / system turns (self-recheck, auto-suggestion batches) must run
|
|
1708
|
+
// alone: merging them with user messages corrupts the prompt and can drop
|
|
1709
|
+
// the one-shot skipSelfRecheck guard via text concatenation.
|
|
1710
|
+
const head = this.queue[0]!;
|
|
1711
|
+
const isMeta =
|
|
1712
|
+
!!head.skipSelfRecheck ||
|
|
1713
|
+
isSelfRecheckPrompt(head.text);
|
|
1714
|
+
const batch = isMeta
|
|
1715
|
+
? this.queue.shift()!
|
|
1716
|
+
: mergeInputs(this.queue.splice(0, this.queue.length));
|
|
1081
1717
|
if (this.foreground) await this.notify("\u25B6\uFE0F Processing queued message\u2026");
|
|
1082
1718
|
void this.runTurn(batch);
|
|
1083
1719
|
}
|
|
@@ -1087,36 +1723,73 @@ export class SessionRuntime {
|
|
|
1087
1723
|
this.sessionUpdateCount++;
|
|
1088
1724
|
const kind = update.sessionUpdate;
|
|
1089
1725
|
|
|
1726
|
+
// Quiet meta turns (follow-up suggestions): capture prose only, never stream.
|
|
1727
|
+
if (this.capturingQuiet) {
|
|
1728
|
+
if (kind === "agent_message_chunk") {
|
|
1729
|
+
const text = contentText(update.content);
|
|
1730
|
+
if (text) this.quietCaptureBuf += text;
|
|
1731
|
+
}
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1090
1735
|
// Accumulate the turn's file-change summary + image-scan text even when this
|
|
1091
1736
|
// session is in the background (its output isn't streamed here, but the
|
|
1092
1737
|
// completion message still reports what changed / which images were made).
|
|
1093
1738
|
if (kind === "tool_call" || kind === "tool_call_update") {
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
if (
|
|
1098
|
-
|
|
1739
|
+
// Merge early so background live-step + file ops use full title/args.
|
|
1740
|
+
const tid = update.toolCallId || "";
|
|
1741
|
+
const mergedEarly = mergeToolSnapshot(tid ? this.toolCallCache.get(tid) : undefined, update);
|
|
1742
|
+
if (tid) this.toolCallCache.set(tid, mergedEarly);
|
|
1743
|
+
|
|
1744
|
+
if (mergedEarly.rawInput) this.imageScanText += " " + JSON.stringify(mergedEarly.rawInput);
|
|
1745
|
+
if (mergedEarly.title) this.imageScanText += " " + mergedEarly.title;
|
|
1746
|
+
if (Array.isArray(mergedEarly.content_blocks)) {
|
|
1747
|
+
this.imageScanText += " " + JSON.stringify(mergedEarly.content_blocks);
|
|
1099
1748
|
}
|
|
1100
|
-
|
|
1101
|
-
if (
|
|
1102
|
-
const fo = fileOpFromUpdate(
|
|
1749
|
+
const ct = contentText(mergedEarly.content);
|
|
1750
|
+
if (ct) this.imageScanText += " " + ct;
|
|
1751
|
+
const fo = fileOpFromUpdate(mergedEarly);
|
|
1103
1752
|
if (fo) this.fileOps.set(fo.path, mergeFileOp(this.fileOps.get(fo.path), fo.op));
|
|
1753
|
+
// Live card step — always, even for background sessions.
|
|
1754
|
+
const step = stepFromToolUpdate(mergedEarly);
|
|
1755
|
+
if (step) this.setLiveStep(step);
|
|
1104
1756
|
} else if (kind === "agent_message_chunk") {
|
|
1105
|
-
const text = update.content
|
|
1106
|
-
if (
|
|
1757
|
+
const text = contentText(update.content);
|
|
1758
|
+
if (text) {
|
|
1759
|
+
this.imageScanText += text;
|
|
1760
|
+
this.turnAssistantText += text;
|
|
1761
|
+
}
|
|
1762
|
+
} else if (kind === "agent_thought_chunk") {
|
|
1763
|
+
const text = contentText(update.content);
|
|
1764
|
+
if (text?.trim()) this.setLiveStep(stepFromThought(text));
|
|
1765
|
+
} else if (kind === "plan") {
|
|
1766
|
+
// Always track plan entries (background too) so switch-to-live restores the board.
|
|
1767
|
+
const entries = parsePlanUpdate(update);
|
|
1768
|
+
if (entries?.length) {
|
|
1769
|
+
this.planEntries = entries;
|
|
1770
|
+
const one = renderPlanOneLine(entries);
|
|
1771
|
+
if (one) this.setLiveStep(one);
|
|
1772
|
+
this.changed();
|
|
1773
|
+
}
|
|
1107
1774
|
}
|
|
1108
1775
|
|
|
1109
1776
|
// Only the live foreground turn streams to Telegram.
|
|
1110
1777
|
if (!this.foreground || !this.streamer) return;
|
|
1111
1778
|
|
|
1779
|
+
if (kind === "plan") {
|
|
1780
|
+
if (this.planEntries?.length) {
|
|
1781
|
+
this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
|
|
1782
|
+
}
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1112
1785
|
if (kind === "agent_message_chunk") {
|
|
1113
|
-
const text = update.content
|
|
1114
|
-
if (
|
|
1786
|
+
const text = contentText(update.content);
|
|
1787
|
+
if (text) this.streamer.appendOutput(text);
|
|
1115
1788
|
return;
|
|
1116
1789
|
}
|
|
1117
1790
|
if (kind === "agent_thought_chunk") {
|
|
1118
|
-
const text = update.content
|
|
1119
|
-
if (
|
|
1791
|
+
const text = contentText(update.content);
|
|
1792
|
+
if (text) this.streamer.appendThought(text);
|
|
1120
1793
|
return;
|
|
1121
1794
|
}
|
|
1122
1795
|
if (kind === "tool_call" || kind === "tool_call_update") {
|
|
@@ -1124,31 +1797,40 @@ export class SessionRuntime {
|
|
|
1124
1797
|
const id = update.toolCallId || "";
|
|
1125
1798
|
const status = (update.status || "").toLowerCase();
|
|
1126
1799
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
}
|
|
1134
|
-
// For completed/failed: show once (so the user sees the final status).
|
|
1135
|
-
const doneKey = (id || update.title || "") + ":done";
|
|
1136
|
-
if (status === "completed" || status === "failed") {
|
|
1137
|
-
if (this.shownToolIds.has(doneKey)) return;
|
|
1138
|
-
this.shownToolIds.add(doneKey);
|
|
1139
|
-
}
|
|
1140
|
-
} else {
|
|
1141
|
-
// Initial tool_call: dedupe by id to avoid double-showing.
|
|
1142
|
-
const shownKey = id || `tool_call:${update.title ?? ""}`;
|
|
1143
|
-
if (this.shownToolIds.has(shownKey)) return;
|
|
1144
|
-
this.shownToolIds.add(shownKey);
|
|
1800
|
+
// Snapshot already merged above for file-ops / live step.
|
|
1801
|
+
const merged = (id && this.toolCallCache.get(id)) || mergeToolSnapshot(undefined, update);
|
|
1802
|
+
|
|
1803
|
+
// Skip hollow shells with nothing useful yet.
|
|
1804
|
+
if (!snapshotHasDetail(merged) && status !== "completed" && status !== "failed") {
|
|
1805
|
+
return;
|
|
1145
1806
|
}
|
|
1146
1807
|
|
|
1147
|
-
|
|
1808
|
+
// Status-only mid-flight patches (no new content/input): skip if we already
|
|
1809
|
+
// painted this tool once — upsert would be a no-op anyway.
|
|
1810
|
+
if (kind === "tool_call_update" && (status === "pending" || status === "in_progress")) {
|
|
1811
|
+
const hasNewContent =
|
|
1812
|
+
(Array.isArray(update.content_blocks) && update.content_blocks.length > 0) ||
|
|
1813
|
+
(Array.isArray(update.content) && (update.content as unknown[]).length > 0) ||
|
|
1814
|
+
(!!update.rawInput && Object.keys(update.rawInput).length > 0) ||
|
|
1815
|
+
update.rawOutput !== undefined;
|
|
1816
|
+
const key = id || `tool_call:${update.title ?? ""}`;
|
|
1817
|
+
if (!hasNewContent && this.shownToolIds.has(key)) return;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
const md = formatToolCall(merged, {
|
|
1148
1821
|
showDiffs: this.cfg.showEditDiffs,
|
|
1149
1822
|
diffMaxLines: this.cfg.diffMaxLines,
|
|
1150
1823
|
});
|
|
1151
|
-
if (md)
|
|
1824
|
+
if (!md) return;
|
|
1825
|
+
|
|
1826
|
+
// One live card per toolCallId: replace in place as output streams
|
|
1827
|
+
// (no spam of new code sections). Session/agent context keeps full output.
|
|
1828
|
+
const key = id || `tool_call:${merged.title ?? merged.name ?? ""}`;
|
|
1829
|
+
this.shownToolIds.add(key);
|
|
1830
|
+
if (status === "completed" || status === "failed") {
|
|
1831
|
+
this.shownToolIds.add(key + ":done");
|
|
1832
|
+
}
|
|
1833
|
+
this.streamer.upsertTool(id || undefined, md);
|
|
1152
1834
|
}
|
|
1153
1835
|
}
|
|
1154
1836
|
|
|
@@ -1188,8 +1870,8 @@ export class SessionRuntime {
|
|
|
1188
1870
|
}
|
|
1189
1871
|
if (opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
|
|
1190
1872
|
await this.api.sendMessage(this.chatId, text, extra);
|
|
1191
|
-
} catch {
|
|
1192
|
-
|
|
1873
|
+
} catch (e) {
|
|
1874
|
+
log.debug("notify failed:", (e as Error).message);
|
|
1193
1875
|
}
|
|
1194
1876
|
}
|
|
1195
1877
|
|