grok-telegram-bot 2.2.4 → 2.3.1
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 +7 -4
- package/CHANGELOG.md +72 -23
- package/README.md +18 -8
- package/package.json +1 -1
- package/src/app/stt.ts +5 -2
- package/src/app/types.ts +16 -2
- package/src/bot/account-rotator.ts +61 -2
- package/src/bot/bot.ts +10 -0
- package/src/bot/callback.ts +74 -0
- package/src/bot/chat-controller.ts +90 -8
- package/src/bot/handlers/accounts.ts +4 -4
- package/src/bot/handlers/menu.ts +22 -5
- package/src/bot/handlers/projects.ts +11 -5
- package/src/bot/handlers/voice.ts +21 -4
- package/src/bot/image-return.ts +118 -14
- package/src/bot/prompt-content.ts +24 -5
- package/src/bot/reauth-controller.ts +2 -2
- package/src/bot/session-runtime.ts +165 -57
- package/src/grok/client.ts +20 -4
- package/src/grok/types.ts +18 -3
- package/src/render/image-output.ts +12 -0
- package/src/sessions/history.ts +2 -0
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
import { basename } from "node:path";
|
|
8
8
|
import { type Api, InlineKeyboard } from "grammy";
|
|
9
9
|
import {
|
|
10
|
-
type GrokClient,
|
|
11
|
-
isAccountRotationError,
|
|
10
|
+
type GrokClient,
|
|
11
|
+
isAccountRotationError,
|
|
12
12
|
isContextExhaustedError,
|
|
13
|
+
isSessionLifecycleError,
|
|
13
14
|
isTransientError,
|
|
14
15
|
type SessionMetadata,
|
|
15
16
|
} from "../grok/client.js";
|
|
@@ -31,7 +32,8 @@ import { type FileOp, fileOpFromUpdate, mergeFileOp, summarizeFileOps, summarize
|
|
|
31
32
|
import { isActiveStatus, renderSubagentTransition, statusKey } from "../render/subagent.js";
|
|
32
33
|
import type { PendingStage, SubagentInfo } from "../grok/types.js";
|
|
33
34
|
import { ResponseStreamer } from "../stream/streamer.js";
|
|
34
|
-
import {
|
|
35
|
+
import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
|
|
36
|
+
import { collectTurnImagePaths, sendImages } from "./image-return.js";
|
|
35
37
|
import { buildContentBlocks, mergeInputs } from "./prompt-content.js";
|
|
36
38
|
import {
|
|
37
39
|
backoffSchedule,
|
|
@@ -99,10 +101,10 @@ export class SessionRuntime {
|
|
|
99
101
|
private turnCount = 0;
|
|
100
102
|
/** Telegram message id of the current turn's prompt, so replies thread to it. */
|
|
101
103
|
private turnReplyTo: number | undefined;
|
|
102
|
-
private imageScanText = "";
|
|
103
|
-
private sentImagesThisTurn = new Set<string>();
|
|
104
|
-
/** Monotonic count used to reject ACP "success" responses with no turn updates. */
|
|
105
|
-
private sessionUpdateCount = 0;
|
|
104
|
+
private imageScanText = "";
|
|
105
|
+
private sentImagesThisTurn = new Set<string>();
|
|
106
|
+
/** Monotonic count used to reject ACP "success" responses with no turn updates. */
|
|
107
|
+
private sessionUpdateCount = 0;
|
|
106
108
|
private readonly listener: (sessionId: string, update: SessionUpdate) => void;
|
|
107
109
|
private primingContext: string | undefined;
|
|
108
110
|
private watcher: TailWatcher | undefined;
|
|
@@ -216,8 +218,11 @@ export class SessionRuntime {
|
|
|
216
218
|
this.typing.stop();
|
|
217
219
|
this.stopWatch();
|
|
218
220
|
if (this.streamer) {
|
|
219
|
-
|
|
221
|
+
// Finalize off the critical path so project/session switches never wait
|
|
222
|
+
// on Telegram edits of the previous live stream.
|
|
223
|
+
const prev = this.streamer;
|
|
220
224
|
this.streamer = undefined;
|
|
225
|
+
void prev.finalize().catch(() => {});
|
|
221
226
|
}
|
|
222
227
|
}
|
|
223
228
|
this.changed();
|
|
@@ -252,6 +257,7 @@ export class SessionRuntime {
|
|
|
252
257
|
// в”Ђв”Ђ sessions в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
|
|
253
258
|
|
|
254
259
|
async startNewSession(cwd: string, projectName?: string): Promise<void> {
|
|
260
|
+
await this.accountRotator?.waitForIdle();
|
|
255
261
|
if (this.busy) await this.cancel();
|
|
256
262
|
await this.bindNewSession(cwd, projectName);
|
|
257
263
|
}
|
|
@@ -426,6 +432,9 @@ export class SessionRuntime {
|
|
|
426
432
|
}
|
|
427
433
|
|
|
428
434
|
private async ensureSession(): Promise<void> {
|
|
435
|
+
// Account rotation restarts the process globally. Do not bind a new chat
|
|
436
|
+
// to a candidate account until the owner has finished probing it.
|
|
437
|
+
await this.accountRotator?.waitForIdle();
|
|
429
438
|
if (this.rebindPending && this.sessionId) {
|
|
430
439
|
// The ACP process is frequently mid-restart the first time we re-bind
|
|
431
440
|
// (auto-restart after a crash, or a fresh bot boot), so a single attempt
|
|
@@ -514,14 +523,17 @@ export class SessionRuntime {
|
|
|
514
523
|
const content = buildContentBlocks(input, {
|
|
515
524
|
reasoning: reasoningDirective(this.reasoning),
|
|
516
525
|
priming: this.primingContext,
|
|
526
|
+
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
517
527
|
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
518
528
|
});
|
|
519
529
|
this.primingContext = undefined;
|
|
520
530
|
|
|
521
531
|
try {
|
|
522
532
|
const outcome = await this.runPromptWithRetries(content);
|
|
523
|
-
const
|
|
524
|
-
let final =
|
|
533
|
+
const rebound = await this.maybeRecoverAgentSession(input, outcome);
|
|
534
|
+
let final = rebound ?? outcome;
|
|
535
|
+
const recovered = await this.maybeAutoFork(input, final);
|
|
536
|
+
final = recovered ?? final;
|
|
525
537
|
// Last resort: if the turn still failed, rotate through other saved
|
|
526
538
|
// accounts (once) and retry on each until one works.
|
|
527
539
|
const rotated = await this.maybeRotateAccount(input, final);
|
|
@@ -624,6 +636,51 @@ export class SessionRuntime {
|
|
|
624
636
|
return pct !== undefined && pct >= threshold;
|
|
625
637
|
}
|
|
626
638
|
|
|
639
|
+
/** A shared-process restart invalidates this runtime's ACP session binding,
|
|
640
|
+
* but says nothing about account health. Wait for any account probe to
|
|
641
|
+
* settle, re-bind/fork this chat on the selected account, and retry once. */
|
|
642
|
+
private async maybeRecoverAgentSession(
|
|
643
|
+
input: PromptInput,
|
|
644
|
+
outcome: { result?: PromptResult; error?: Error; attempts: number },
|
|
645
|
+
): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
|
|
646
|
+
if (
|
|
647
|
+
!outcome.error ||
|
|
648
|
+
!isSessionLifecycleError(outcome.error) ||
|
|
649
|
+
this.cancelled ||
|
|
650
|
+
(this.streamer?.hasOutput ?? false)
|
|
651
|
+
) {
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
await this.accountRotator?.waitForIdle();
|
|
656
|
+
if (this.cancelled) return outcome;
|
|
657
|
+
const previousId = this.sessionId;
|
|
658
|
+
this.sessionLive = false;
|
|
659
|
+
this.rebindPending = Boolean(previousId);
|
|
660
|
+
await this.ensureSession();
|
|
661
|
+
this.shownToolIds = new Set();
|
|
662
|
+
this.subagentShown = new Map();
|
|
663
|
+
this.streamer?.setFooter(this.hashtags());
|
|
664
|
+
const retryContent = buildContentBlocks(input, {
|
|
665
|
+
reasoning: reasoningDirective(this.reasoning),
|
|
666
|
+
priming: this.primingContext,
|
|
667
|
+
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
668
|
+
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
669
|
+
});
|
|
670
|
+
this.primingContext = undefined;
|
|
671
|
+
log.info(
|
|
672
|
+
`chat ${this.chatId} recovered lifecycle error on the active account` +
|
|
673
|
+
(previousId && this.sessionId !== previousId
|
|
674
|
+
? ` with fresh session ${this.sessionId?.slice(0, 8)}`
|
|
675
|
+
: " by re-binding its session"),
|
|
676
|
+
);
|
|
677
|
+
return this.runPromptWithRetries(retryContent);
|
|
678
|
+
} catch (error) {
|
|
679
|
+
log.warn(`chat ${this.chatId} session recovery failed: ${(error as Error).message}`);
|
|
680
|
+
return { error: error as Error, attempts: outcome.attempts };
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
627
684
|
/**
|
|
628
685
|
* Auto-fork-on-error recovery. When a turn fails with a *transient* error (or
|
|
629
686
|
* a context-exhaustion error) and nothing was streamed to the user, the
|
|
@@ -670,6 +727,7 @@ export class SessionRuntime {
|
|
|
670
727
|
const forkContent = buildContentBlocks(input, {
|
|
671
728
|
reasoning: reasoningDirective(this.reasoning),
|
|
672
729
|
priming: transcript ? buildPriming(transcript) : undefined,
|
|
730
|
+
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
673
731
|
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
674
732
|
});
|
|
675
733
|
return this.runPromptWithRetries(forkContent);
|
|
@@ -692,27 +750,59 @@ export class SessionRuntime {
|
|
|
692
750
|
input: PromptInput,
|
|
693
751
|
final: { result?: PromptResult; error?: Error; attempts: number },
|
|
694
752
|
): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
|
|
695
|
-
const rotator = this.accountRotator;
|
|
696
|
-
if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
753
|
+
const rotator = this.accountRotator;
|
|
754
|
+
if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
|
|
755
|
+
if (isSessionLifecycleError(final.error)) return undefined;
|
|
756
|
+
const originalError = final.error;
|
|
757
|
+
const observed = rotator.state();
|
|
758
|
+
return rotator.withRotationLock(observed, async (changed) => {
|
|
759
|
+
if (this.cancelled) return final;
|
|
760
|
+
if (changed) {
|
|
761
|
+
if (this.streamer?.hasOutput ?? false) return undefined;
|
|
762
|
+
const current = rotator.state();
|
|
763
|
+
const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
|
|
764
|
+
if (this.foreground) {
|
|
765
|
+
await this.notify(
|
|
766
|
+
`\u{1F504} Reusing ${current.activeLabel ?? "the account selected by another chat"} with a fresh session…`,
|
|
767
|
+
{ replyTo: this.turnReplyTo },
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
log.info(`chat ${this.chatId} reusing account generation ${current.generation} selected by another chat`);
|
|
771
|
+
try {
|
|
772
|
+
await this.bindNewSession(this.cwd, this.projectName);
|
|
773
|
+
} catch (error) {
|
|
774
|
+
return { error: error as Error, attempts: final.attempts };
|
|
775
|
+
}
|
|
776
|
+
this.shownToolIds = new Set();
|
|
777
|
+
this.subagentShown = new Map();
|
|
778
|
+
this.streamer?.setFooter(this.hashtags());
|
|
779
|
+
const content = buildContentBlocks(input, {
|
|
780
|
+
reasoning: reasoningDirective(this.reasoning),
|
|
781
|
+
priming: transcript ? buildPriming(transcript) : undefined,
|
|
782
|
+
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
783
|
+
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
784
|
+
});
|
|
785
|
+
return this.runPromptWithRetries(content);
|
|
786
|
+
}
|
|
787
|
+
// A quota-exhausted or access-denied response cannot be recovered by retrying
|
|
788
|
+
// this login. Quarantine it before choosing targets, so later rotations do not
|
|
789
|
+
// cycle back to a known-bad account. This intentionally happens before the
|
|
790
|
+
// partial-stream guard: we must not retry/rotate a partial reply, but its
|
|
791
|
+
// account still needs to be skipped during a future rotation.
|
|
792
|
+
if (isAccountRotationError(originalError)) {
|
|
793
|
+
await rotator.markFailed(observed.activeId, originalError.message);
|
|
794
|
+
}
|
|
795
|
+
if (this.streamer?.hasOutput ?? false) return undefined;
|
|
796
|
+
const targets = await rotator.targets().catch(() => [] as { id: string; label: string }[]);
|
|
707
797
|
if (targets.length === 0) return undefined;
|
|
708
798
|
|
|
709
799
|
const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
|
|
710
|
-
const errors: string[] = [`\u2022 previous: ${
|
|
800
|
+
const errors: string[] = [`\u2022 previous: ${originalError.message}`];
|
|
711
801
|
let last = final;
|
|
712
802
|
|
|
713
803
|
for (const t of targets) {
|
|
714
804
|
if (this.cancelled) return last;
|
|
715
|
-
const failReason = last.error ??
|
|
805
|
+
const failReason = last.error ?? originalError;
|
|
716
806
|
if (this.foreground) {
|
|
717
807
|
await this.notify(formatAccountSwitchNotice(t.label, failReason), { replyTo: this.turnReplyTo });
|
|
718
808
|
}
|
|
@@ -736,30 +826,32 @@ export class SessionRuntime {
|
|
|
736
826
|
const content = buildContentBlocks(input, {
|
|
737
827
|
reasoning: reasoningDirective(this.reasoning),
|
|
738
828
|
priming: transcript ? buildPriming(transcript) : undefined,
|
|
739
|
-
|
|
829
|
+
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
830
|
+
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
740
831
|
});
|
|
741
832
|
log.info(
|
|
742
833
|
`chat ${this.chatId} auto-rotating to account ${t.label}` +
|
|
743
|
-
(isAccountRotationError(failReason) ? " (previous account unavailable)" : ""),
|
|
834
|
+
(isAccountRotationError(failReason) ? " (previous account unavailable)" : ""),
|
|
744
835
|
);
|
|
745
836
|
// runPromptWithRetries already skips backoff for 402 / balance exhausted.
|
|
746
837
|
last = await this.runPromptWithRetries(content);
|
|
747
|
-
if (last.result && !this.cancelled) {
|
|
838
|
+
if (last.result && !this.cancelled) {
|
|
748
839
|
if (this.foreground) {
|
|
749
840
|
await this.notify(`\u2705 Recovered on ${t.label}.`, { replyTo: this.turnReplyTo });
|
|
750
841
|
}
|
|
751
842
|
return last;
|
|
752
|
-
}
|
|
753
|
-
if (last.error && isAccountRotationError(last.error)) {
|
|
754
|
-
await rotator.markFailed(t.id, last.error.message);
|
|
755
|
-
}
|
|
756
|
-
if (this.cancelled || (this.streamer?.hasOutput ?? false)) return last;
|
|
757
|
-
errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
|
|
843
|
+
}
|
|
844
|
+
if (last.error && isAccountRotationError(last.error)) {
|
|
845
|
+
await rotator.markFailed(t.id, last.error.message);
|
|
846
|
+
}
|
|
847
|
+
if (this.cancelled || (this.streamer?.hasOutput ?? false)) return last;
|
|
848
|
+
errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
|
|
758
849
|
}
|
|
759
850
|
|
|
760
851
|
// One full cycle done and still failing — stop with a combined report.
|
|
761
852
|
const combined = new Error(`Tried ${targets.length + 1} account(s), all failed:\n${errors.join("\n")}`);
|
|
762
853
|
return { error: combined, attempts: last.attempts };
|
|
854
|
+
});
|
|
763
855
|
}
|
|
764
856
|
|
|
765
857
|
/**
|
|
@@ -776,21 +868,21 @@ export class SessionRuntime {
|
|
|
776
868
|
): Promise<{ result?: PromptResult; error?: Error; attempts: number }> {
|
|
777
869
|
const delays = this.cfg.promptRetryAttempts > 0 ? backoffSchedule(this.cfg.promptRetryAttempts) : [];
|
|
778
870
|
const totalAttempts = delays.length + 1;
|
|
779
|
-
let attempt = 0;
|
|
780
|
-
for (;;) {
|
|
781
|
-
attempt++;
|
|
782
|
-
try {
|
|
783
|
-
const updatesBeforePrompt = this.sessionUpdateCount;
|
|
784
|
-
const result = await this.acp.prompt(this.sessionId!, content);
|
|
785
|
-
// A healthy ACP turn emits at least one session/update (text, thought,
|
|
786
|
-
// or tool event) before resolving session/prompt. Grok can otherwise
|
|
787
|
-
// report a successful end-turn after an upstream model failure; never
|
|
788
|
-
// present that as a completed user request.
|
|
789
|
-
await sleep(0);
|
|
790
|
-
if (this.sessionUpdateCount === updatesBeforePrompt) {
|
|
791
|
-
throw new Error("Empty agent response — Grok ended the turn without any output or tool activity");
|
|
792
|
-
}
|
|
793
|
-
return { result, attempts: attempt };
|
|
871
|
+
let attempt = 0;
|
|
872
|
+
for (;;) {
|
|
873
|
+
attempt++;
|
|
874
|
+
try {
|
|
875
|
+
const updatesBeforePrompt = this.sessionUpdateCount;
|
|
876
|
+
const result = await this.acp.prompt(this.sessionId!, content);
|
|
877
|
+
// A healthy ACP turn emits at least one session/update (text, thought,
|
|
878
|
+
// or tool event) before resolving session/prompt. Grok can otherwise
|
|
879
|
+
// report a successful end-turn after an upstream model failure; never
|
|
880
|
+
// present that as a completed user request.
|
|
881
|
+
await sleep(0);
|
|
882
|
+
if (this.sessionUpdateCount === updatesBeforePrompt) {
|
|
883
|
+
throw new Error("Empty agent response — Grok ended the turn without any output or tool activity");
|
|
884
|
+
}
|
|
885
|
+
return { result, attempts: attempt };
|
|
794
886
|
} catch (err) {
|
|
795
887
|
const error = err as Error;
|
|
796
888
|
const canRecover = !this.cancelled && !(this.streamer?.hasOutput ?? false);
|
|
@@ -803,7 +895,7 @@ export class SessionRuntime {
|
|
|
803
895
|
attempt <= delays.length &&
|
|
804
896
|
canRecover &&
|
|
805
897
|
!forkInstead &&
|
|
806
|
-
!isAccountRotationError(error) &&
|
|
898
|
+
!isAccountRotationError(error) &&
|
|
807
899
|
isTransientError(error);
|
|
808
900
|
if (!willRetry) return { error, attempts: attempt };
|
|
809
901
|
const waitMs = delays[attempt - 1]!;
|
|
@@ -859,6 +951,7 @@ export class SessionRuntime {
|
|
|
859
951
|
const delays = this.cfg.promptRetryAttempts > 0 ? backoffSchedule(this.cfg.promptRetryAttempts) : [RETRY_BASE_MS];
|
|
860
952
|
const resumeContent = buildContentBlocks(textPrompt(RESUME_INSTRUCTION), {
|
|
861
953
|
reasoning: reasoningDirective(this.reasoning),
|
|
954
|
+
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
862
955
|
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
863
956
|
});
|
|
864
957
|
|
|
@@ -888,17 +981,26 @@ export class SessionRuntime {
|
|
|
888
981
|
return last;
|
|
889
982
|
}
|
|
890
983
|
|
|
891
|
-
/** Send any fresh images the agent produced this turn (
|
|
984
|
+
/** Send any fresh images the agent produced this turn (Imagine, screenshots…). */
|
|
892
985
|
private async sendTurnImages(): Promise<void> {
|
|
893
|
-
if (!this.cfg.sendAgentImages
|
|
894
|
-
|
|
986
|
+
if (!this.cfg.sendAgentImages) return;
|
|
987
|
+
// Always check session images/ + assets/ even when the agent never named a
|
|
988
|
+
// path in text — image_gen writes under ~/.grok/sessions/.../images/.
|
|
989
|
+
const paths = collectTurnImagePaths({
|
|
990
|
+
scanText: this.imageScanText,
|
|
991
|
+
cwd: this.cwd,
|
|
992
|
+
sessionId: this.sessionId,
|
|
993
|
+
since: this.turnStartedAt,
|
|
994
|
+
});
|
|
895
995
|
if (paths.length === 0) return;
|
|
896
996
|
try {
|
|
897
|
-
await sendImages(this.api, this.chatId, paths, {
|
|
997
|
+
const n = await sendImages(this.api, this.chatId, paths, {
|
|
898
998
|
since: this.turnStartedAt,
|
|
899
999
|
already: this.sentImagesThisTurn,
|
|
900
1000
|
max: this.cfg.agentImagesMax,
|
|
1001
|
+
replyTo: this.turnReplyTo,
|
|
901
1002
|
});
|
|
1003
|
+
if (n > 0) log.info(`chat ${this.chatId}: sent ${n} agent image file(s)`);
|
|
902
1004
|
} catch {
|
|
903
1005
|
/* non-fatal */
|
|
904
1006
|
}
|
|
@@ -980,10 +1082,10 @@ export class SessionRuntime {
|
|
|
980
1082
|
void this.runTurn(batch);
|
|
981
1083
|
}
|
|
982
1084
|
|
|
983
|
-
private onUpdate(sessionId: string, update: SessionUpdate): void {
|
|
984
|
-
if (!this.busy || sessionId !== this.sessionId) return;
|
|
985
|
-
this.sessionUpdateCount++;
|
|
986
|
-
const kind = update.sessionUpdate;
|
|
1085
|
+
private onUpdate(sessionId: string, update: SessionUpdate): void {
|
|
1086
|
+
if (!this.busy || sessionId !== this.sessionId) return;
|
|
1087
|
+
this.sessionUpdateCount++;
|
|
1088
|
+
const kind = update.sessionUpdate;
|
|
987
1089
|
|
|
988
1090
|
// Accumulate the turn's file-change summary + image-scan text even when this
|
|
989
1091
|
// session is in the background (its output isn't streamed here, but the
|
|
@@ -991,6 +1093,12 @@ export class SessionRuntime {
|
|
|
991
1093
|
if (kind === "tool_call" || kind === "tool_call_update") {
|
|
992
1094
|
if (update.rawInput) this.imageScanText += " " + JSON.stringify(update.rawInput);
|
|
993
1095
|
if (update.title) this.imageScanText += " " + update.title;
|
|
1096
|
+
// Tool results often carry the saved path only in content_blocks (Imagine).
|
|
1097
|
+
if (Array.isArray(update.content_blocks)) {
|
|
1098
|
+
this.imageScanText += " " + JSON.stringify(update.content_blocks);
|
|
1099
|
+
}
|
|
1100
|
+
// Some agents put free-form result text on `content`.
|
|
1101
|
+
if (update.content?.text) this.imageScanText += " " + update.content.text;
|
|
994
1102
|
const fo = fileOpFromUpdate(update);
|
|
995
1103
|
if (fo) this.fileOps.set(fo.path, mergeFileOp(this.fileOps.get(fo.path), fo.op));
|
|
996
1104
|
} else if (kind === "agent_message_chunk") {
|
package/src/grok/client.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { EventEmitter } from "node:events";
|
|
|
15
15
|
import { createLogger } from "../logger.js";
|
|
16
16
|
import { hasLogin } from "../app/grok-credentials.js";
|
|
17
17
|
import { contextWindowFor, DEFAULT_MODEL, KNOWN_MODELS } from "./models.js";
|
|
18
|
+
import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
|
|
18
19
|
import { PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
19
20
|
import { SessionLog } from "./session-log.js";
|
|
20
21
|
import { JsonRpcTransport } from "./transport.js";
|
|
@@ -58,6 +59,11 @@ const ACCOUNT_EXHAUSTED_RE =
|
|
|
58
59
|
* saved login may be permitted, while same-account retries cannot help. */
|
|
59
60
|
const ACCOUNT_ACCESS_DENIED_RE =
|
|
60
61
|
/\b403\b|forbidden|access denied/i;
|
|
62
|
+
/** Process/session lifecycle failures are not evidence that the active login
|
|
63
|
+
* is bad. They require a session re-bind on the current process generation,
|
|
64
|
+
* never account rotation. */
|
|
65
|
+
const SESSION_LIFECYCLE_RE =
|
|
66
|
+
/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;
|
|
61
67
|
|
|
62
68
|
export class GrokError extends Error {
|
|
63
69
|
constructor(
|
|
@@ -122,7 +128,14 @@ export function isAccountRotationError(err: Error): boolean {
|
|
|
122
128
|
return false;
|
|
123
129
|
}
|
|
124
130
|
|
|
131
|
+
export function isSessionLifecycleError(err: Error): boolean {
|
|
132
|
+
return SESSION_LIFECYCLE_RE.test(err.message);
|
|
133
|
+
}
|
|
134
|
+
|
|
125
135
|
export function isTransientError(err: Error): boolean {
|
|
136
|
+
// Retrying the same stale session cannot recover a process-generation
|
|
137
|
+
// mismatch. SessionRuntime owns the immediate re-bind + one safe retry.
|
|
138
|
+
if (isSessionLifecycleError(err)) return false;
|
|
126
139
|
// Quota exhaustion and access denial are permanent for this login — rotate,
|
|
127
140
|
// never back off and retry the same credentials.
|
|
128
141
|
if (isAccountRotationError(err)) return false;
|
|
@@ -264,9 +277,10 @@ export class GrokClient extends EventEmitter {
|
|
|
264
277
|
this.availableModels = KNOWN_MODELS.map((m) => ({ modelId: m.modelId, name: m.name, description: m.description }));
|
|
265
278
|
}
|
|
266
279
|
|
|
267
|
-
async start(): Promise<void> {
|
|
280
|
+
async start(notifyRestarted = false): Promise<void> {
|
|
268
281
|
this.stopped = false;
|
|
269
282
|
await this.connect();
|
|
283
|
+
if (notifyRestarted) this.emit("restarted");
|
|
270
284
|
}
|
|
271
285
|
|
|
272
286
|
private async connect(): Promise<void> {
|
|
@@ -528,9 +542,7 @@ export class GrokClient extends EventEmitter {
|
|
|
528
542
|
this.stopped = true;
|
|
529
543
|
this.restartAttempts = 0;
|
|
530
544
|
await this.killCurrent();
|
|
531
|
-
this.
|
|
532
|
-
await this.connect();
|
|
533
|
-
this.emit("restarted");
|
|
545
|
+
await this.start(true);
|
|
534
546
|
}
|
|
535
547
|
|
|
536
548
|
private killCurrent(): Promise<void> {
|
|
@@ -728,8 +740,12 @@ export class GrokClient extends EventEmitter {
|
|
|
728
740
|
* leading reasoning directive, fork/priming preamble) removed, for a clean log. */
|
|
729
741
|
private cleanUserText(content: ContentBlock[]): string {
|
|
730
742
|
let t = this.visibleText(content);
|
|
743
|
+
// Strip bot-injected appendices (image rules first, then progress — progress
|
|
744
|
+
// is always last when both are present).
|
|
731
745
|
const pi = t.indexOf(PROGRESS_DIRECTIVE);
|
|
732
746
|
if (pi !== -1) t = t.slice(0, pi).trimEnd();
|
|
747
|
+
const ii = t.indexOf(IMAGE_OUTPUT_DIRECTIVE);
|
|
748
|
+
if (ii !== -1) t = t.slice(0, ii).trimEnd();
|
|
733
749
|
const marker = "User's new message:\n";
|
|
734
750
|
const mi = t.lastIndexOf(marker);
|
|
735
751
|
if (mi !== -1) t = t.slice(mi + marker.length);
|
package/src/grok/types.ts
CHANGED
|
@@ -26,12 +26,23 @@ export interface JsonRpcNotification {
|
|
|
26
26
|
|
|
27
27
|
export type JsonRpcMessage = JsonRpcResponse & JsonRpcNotification & { method?: string };
|
|
28
28
|
|
|
29
|
-
/** A content block in a prompt or message. */
|
|
29
|
+
/** A content block in a prompt or message (ACP ContentBlock subset). */
|
|
30
30
|
export interface ContentBlock {
|
|
31
|
-
type: "text" | "image" | "resource";
|
|
31
|
+
type: "text" | "image" | "audio" | "resource" | "resource_link";
|
|
32
32
|
text?: string;
|
|
33
33
|
data?: string;
|
|
34
34
|
mimeType?: string;
|
|
35
|
+
/** resource_link */
|
|
36
|
+
uri?: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
size?: number;
|
|
39
|
+
/** embedded resource */
|
|
40
|
+
resource?: {
|
|
41
|
+
uri: string;
|
|
42
|
+
mimeType?: string;
|
|
43
|
+
text?: string;
|
|
44
|
+
blob?: string;
|
|
45
|
+
};
|
|
35
46
|
[k: string]: unknown;
|
|
36
47
|
}
|
|
37
48
|
|
|
@@ -47,7 +58,11 @@ export interface InitializeResult {
|
|
|
47
58
|
authMethods?: AuthMethod[];
|
|
48
59
|
agentCapabilities?: {
|
|
49
60
|
loadSession?: boolean;
|
|
50
|
-
promptCapabilities?: {
|
|
61
|
+
promptCapabilities?: {
|
|
62
|
+
image?: boolean;
|
|
63
|
+
audio?: boolean;
|
|
64
|
+
embeddedContext?: boolean;
|
|
65
|
+
};
|
|
51
66
|
};
|
|
52
67
|
agentInfo?: { name?: string; version?: string };
|
|
53
68
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt appendix so the agent keeps generated images in the session media
|
|
3
|
+
* folder and mentions absolute paths (the bot delivers those as Telegram files).
|
|
4
|
+
*
|
|
5
|
+
* Keep tidy-idempotent (no trailing spaces / 3+ blank lines) so
|
|
6
|
+
* `cleanStoredText` can strip it by exact match after extractProgress/tidy.
|
|
7
|
+
*/
|
|
8
|
+
export const IMAGE_OUTPUT_DIRECTIVE = [
|
|
9
|
+
"IMAGE OUTPUT RULES:",
|
|
10
|
+
"When generating images (image_gen / image_edit) or saving image files, write them under the current Grok session media folder (session images/ or assets/) or the project images/ directory — not random temp locations.",
|
|
11
|
+
"Always mention the absolute path of each image file you create in your reply so the client can deliver it as a downloadable Telegram file.",
|
|
12
|
+
].join("\n");
|
package/src/sessions/history.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Reads only the tail of large logs to stay fast.
|
|
4
4
|
*/
|
|
5
5
|
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
6
|
+
import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
|
|
6
7
|
import { extractProgress, PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
7
8
|
import type { HistoryEntry, HistoryRole } from "./types.js";
|
|
8
9
|
|
|
@@ -161,6 +162,7 @@ function cleanStoredText(text: string): string {
|
|
|
161
162
|
if (!text) return text;
|
|
162
163
|
let t = extractProgress(text).cleaned;
|
|
163
164
|
if (t.includes(PROGRESS_DIRECTIVE)) t = t.split(PROGRESS_DIRECTIVE).join("").trim();
|
|
165
|
+
if (t.includes(IMAGE_OUTPUT_DIRECTIVE)) t = t.split(IMAGE_OUTPUT_DIRECTIVE).join("").trim();
|
|
164
166
|
return t;
|
|
165
167
|
}
|
|
166
168
|
|