@gajae-code/coding-agent 0.9.1 → 0.9.2
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/CHANGELOG.md +24 -0
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/notify-cli.d.ts +5 -0
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/config/model-profile-activation.d.ts +7 -7
- package/dist/types/config/model-registry.d.ts +7 -3
- package/dist/types/config/model-resolver.d.ts +2 -2
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +9 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -2
- package/dist/types/modes/components/thinking-selector.d.ts +6 -1
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/notifications/config-commands.d.ts +29 -0
- package/dist/types/notifications/index.d.ts +12 -1
- package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
- package/dist/types/notifications/threaded-render.d.ts +2 -0
- package/dist/types/notifications/topic-registry.d.ts +4 -5
- package/dist/types/runtime-credential-selector.d.ts +7 -0
- package/dist/types/sdk.d.ts +7 -1
- package/dist/types/session/agent-session.d.ts +6 -3
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/skill-discovery.d.ts +40 -0
- package/package.json +7 -7
- package/src/cli/args.ts +8 -0
- package/src/cli/notify-cli.ts +27 -1
- package/src/cli.ts +5 -0
- package/src/commands/launch.ts +5 -0
- package/src/commands/notify.ts +2 -2
- package/src/config/model-profile-activation.ts +23 -7
- package/src/config/model-registry.ts +21 -4
- package/src/config/model-resolver.ts +5 -16
- package/src/config/settings-schema.ts +3 -2
- package/src/config/settings.ts +24 -1
- package/src/extensibility/runtime-skill-discovery.ts +229 -0
- package/src/gjc-runtime/launch-tmux.ts +72 -12
- package/src/gjc-runtime/psmux-detect.ts +17 -2
- package/src/gjc-runtime/team-runtime.ts +35 -3
- package/src/main.ts +26 -3
- package/src/modes/components/settings-selector.ts +3 -3
- package/src/modes/components/thinking-selector.ts +79 -18
- package/src/modes/controllers/input-controller.ts +50 -9
- package/src/modes/controllers/selector-controller.ts +7 -5
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +48 -15
- package/src/notifications/config-commands.ts +70 -0
- package/src/notifications/index.ts +156 -9
- package/src/notifications/rich-render.ts +1 -0
- package/src/notifications/telegram-daemon-cli.ts +13 -5
- package/src/notifications/telegram-daemon.ts +87 -4
- package/src/notifications/threaded-render.ts +14 -0
- package/src/notifications/topic-registry.ts +9 -8
- package/src/prompts/tools/skill-discovery.md +13 -0
- package/src/runtime-credential-selector.ts +35 -0
- package/src/sdk.ts +68 -5
- package/src/session/agent-session.ts +42 -18
- package/src/system-prompt.ts +4 -6
- package/src/tools/index.ts +4 -0
- package/src/tools/skill-discovery.ts +73 -0
- package/src/tools/skill.ts +15 -2
|
@@ -36,17 +36,27 @@ interface RenderInitialMessagesOptions {
|
|
|
36
36
|
preserveExistingChat?: boolean;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
function cloneRenderArgs(args: Record<string, unknown>): Record<string, unknown> {
|
|
40
|
+
try {
|
|
41
|
+
return structuredClone(args);
|
|
42
|
+
} catch {
|
|
43
|
+
return { ...args };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
export function argsWithPartialJson(args: unknown, partialJson: unknown): unknown {
|
|
40
48
|
if (typeof partialJson !== "string" || !args || typeof args !== "object" || Array.isArray(args)) return args;
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
49
|
+
// Keep the transient streaming buffer on a renderer-only snapshot. The live
|
|
50
|
+
// assistant message args are later validated/executed, so UI-only metadata or
|
|
51
|
+
// renderer mutations must never share that object reference.
|
|
52
|
+
const renderArgs = cloneRenderArgs(args as Record<string, unknown>);
|
|
53
|
+
Object.defineProperty(renderArgs, "__partialJson", {
|
|
44
54
|
value: partialJson,
|
|
45
55
|
enumerable: false,
|
|
46
56
|
configurable: true,
|
|
47
57
|
writable: true,
|
|
48
58
|
});
|
|
49
|
-
return
|
|
59
|
+
return renderArgs;
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
type QueuedMessages = {
|
|
@@ -619,7 +629,11 @@ export class UiHelpers {
|
|
|
619
629
|
}
|
|
620
630
|
|
|
621
631
|
queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
|
|
622
|
-
|
|
632
|
+
const entry: CompactionQueuedMessage = { text, mode };
|
|
633
|
+
if (mode === "followUp") {
|
|
634
|
+
entry.followUpQueuePolicy = "sequential";
|
|
635
|
+
}
|
|
636
|
+
this.ctx.compactionQueuedMessages.push(entry);
|
|
623
637
|
this.ctx.editor.addToHistory(text);
|
|
624
638
|
this.ctx.editor.setText("");
|
|
625
639
|
this.ctx.updatePendingMessagesDisplay();
|
|
@@ -633,6 +647,11 @@ export class UiHelpers {
|
|
|
633
647
|
return this.#hasSkillInvocations(text) || this.isKnownSlashCommand(text);
|
|
634
648
|
}
|
|
635
649
|
|
|
650
|
+
#compactionFollowUpQueuePolicy(message: CompactionQueuedMessage): "sequential" | undefined {
|
|
651
|
+
if (message.mode !== "followUp") return undefined;
|
|
652
|
+
return message.followUpQueuePolicy ?? "sequential";
|
|
653
|
+
}
|
|
654
|
+
|
|
636
655
|
async #deliverQueuedSkillMessage(message: CompactionQueuedMessage): Promise<boolean> {
|
|
637
656
|
const invocations = parseSkillInvocations(message.text, this.ctx.skillCommands ?? new Map());
|
|
638
657
|
if (invocations.length === 0) {
|
|
@@ -675,6 +694,13 @@ export class UiHelpers {
|
|
|
675
694
|
continue;
|
|
676
695
|
}
|
|
677
696
|
|
|
697
|
+
const promptOptions =
|
|
698
|
+
message.mode === "followUp"
|
|
699
|
+
? {
|
|
700
|
+
streamingBehavior: message.mode,
|
|
701
|
+
followUpQueuePolicy: this.#compactionFollowUpQueuePolicy(message),
|
|
702
|
+
}
|
|
703
|
+
: { streamingBehavior: message.mode };
|
|
678
704
|
await this.ctx.session.promptCustomMessage(
|
|
679
705
|
{
|
|
680
706
|
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
|
@@ -683,7 +709,7 @@ export class UiHelpers {
|
|
|
683
709
|
details,
|
|
684
710
|
attribution: "user",
|
|
685
711
|
},
|
|
686
|
-
|
|
712
|
+
promptOptions,
|
|
687
713
|
);
|
|
688
714
|
}
|
|
689
715
|
|
|
@@ -703,7 +729,11 @@ export class UiHelpers {
|
|
|
703
729
|
return;
|
|
704
730
|
}
|
|
705
731
|
await this.ctx.withLocalSubmission(message.text, () =>
|
|
706
|
-
message.mode === "followUp"
|
|
732
|
+
message.mode === "followUp"
|
|
733
|
+
? this.ctx.session.followUp(message.text, undefined, {
|
|
734
|
+
followUpQueuePolicy: this.#compactionFollowUpQueuePolicy(message),
|
|
735
|
+
})
|
|
736
|
+
: this.ctx.session.steer(message.text),
|
|
707
737
|
);
|
|
708
738
|
}
|
|
709
739
|
|
|
@@ -794,14 +824,17 @@ export class UiHelpers {
|
|
|
794
824
|
// `restoreQueue` rather than rethrown, so we use the primitive
|
|
795
825
|
// recordLocalSubmission and dispose manually in the catch.
|
|
796
826
|
const disposeFirstPrompt = this.ctx.recordLocalSubmission(firstPrompt.text);
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
827
|
+
const firstPromptOptions =
|
|
828
|
+
firstPrompt.mode === "followUp"
|
|
829
|
+
? {
|
|
830
|
+
streamingBehavior: "followUp" as const,
|
|
831
|
+
followUpQueuePolicy: this.#compactionFollowUpQueuePolicy(firstPrompt),
|
|
832
|
+
}
|
|
833
|
+
: { streamingBehavior: "steer" as const };
|
|
834
|
+
const promptPromise = this.ctx.session.prompt(firstPrompt.text, firstPromptOptions).catch((error: unknown) => {
|
|
835
|
+
disposeFirstPrompt();
|
|
836
|
+
restoreQueue(error);
|
|
837
|
+
});
|
|
805
838
|
|
|
806
839
|
for (const message of rest) {
|
|
807
840
|
await this.#deliverQueuedMessage(message);
|
|
@@ -20,6 +20,76 @@ export interface ConfigCommandChange {
|
|
|
20
20
|
redact?: boolean;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export type TelegramControlCommandName = "reasoning" | "usage" | "context" | "compact";
|
|
24
|
+
|
|
25
|
+
export type TelegramControlCommand =
|
|
26
|
+
| { name: "reasoning"; action: "cycle" | "status" | "set"; level?: string }
|
|
27
|
+
| { name: "usage" }
|
|
28
|
+
| { name: "context" }
|
|
29
|
+
| { name: "compact"; instructions?: string };
|
|
30
|
+
|
|
31
|
+
export type TelegramControlCommandParseResult =
|
|
32
|
+
| { kind: "none" }
|
|
33
|
+
| { kind: "ignored"; commandName: TelegramControlCommandName }
|
|
34
|
+
| { kind: "command"; command: TelegramControlCommand }
|
|
35
|
+
| { kind: "invalid"; commandName: TelegramControlCommandName; usage: string };
|
|
36
|
+
|
|
37
|
+
const TELEGRAM_CONTROL_COMMANDS = new Set<TelegramControlCommandName>(["reasoning", "usage", "context", "compact"]);
|
|
38
|
+
const TELEGRAM_REASONING_LEVELS = new Set(["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
39
|
+
|
|
40
|
+
function splitTelegramBotSuffix(rawCommand: string): { name: string; suffix?: string } {
|
|
41
|
+
const [name, suffix] = rawCommand.toLowerCase().split("@", 2);
|
|
42
|
+
return suffix ? { name, suffix } : { name };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function telegramControlCommandUsage(commandName: TelegramControlCommandName): string {
|
|
46
|
+
switch (commandName) {
|
|
47
|
+
case "reasoning":
|
|
48
|
+
return "Usage: /reasoning [cycle|inherit|off|minimal|low|medium|high|xhigh|max]";
|
|
49
|
+
case "usage":
|
|
50
|
+
return "Usage: /usage";
|
|
51
|
+
case "context":
|
|
52
|
+
return "Usage: /context";
|
|
53
|
+
case "compact":
|
|
54
|
+
return "Usage: /compact [instructions]";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Parse deterministic Telegram session-control commands. Recognised roots fail closed. */
|
|
59
|
+
export function parseTelegramControlCommand(text: string, botUsername?: string): TelegramControlCommandParseResult {
|
|
60
|
+
const trimmed = text.trim();
|
|
61
|
+
if (!trimmed.startsWith("/")) return { kind: "none" };
|
|
62
|
+
const [rawRoot, ...rest] = trimmed.slice(1).split(/\s+/);
|
|
63
|
+
if (!rawRoot) return { kind: "none" };
|
|
64
|
+
const { name: root, suffix } = splitTelegramBotSuffix(rawRoot);
|
|
65
|
+
if (!TELEGRAM_CONTROL_COMMANDS.has(root as TelegramControlCommandName)) return { kind: "none" };
|
|
66
|
+
const commandName = root as TelegramControlCommandName;
|
|
67
|
+
if (suffix && (!botUsername || suffix !== botUsername.toLowerCase())) return { kind: "ignored", commandName };
|
|
68
|
+
const usage = telegramControlCommandUsage(commandName);
|
|
69
|
+
|
|
70
|
+
switch (commandName) {
|
|
71
|
+
case "usage":
|
|
72
|
+
case "context":
|
|
73
|
+
return rest.length === 0
|
|
74
|
+
? { kind: "command", command: { name: commandName } }
|
|
75
|
+
: { kind: "invalid", commandName, usage };
|
|
76
|
+
case "compact": {
|
|
77
|
+
const instructions = trimmed.slice(rawRoot.length + 1).trim();
|
|
78
|
+
return { kind: "command", command: instructions ? { name: "compact", instructions } : { name: "compact" } };
|
|
79
|
+
}
|
|
80
|
+
case "reasoning": {
|
|
81
|
+
if (rest.length === 0) return { kind: "command", command: { name: "reasoning", action: "status" } };
|
|
82
|
+
if (rest.length !== 1) return { kind: "invalid", commandName, usage };
|
|
83
|
+
const levelOrAction = rest[0]!.toLowerCase();
|
|
84
|
+
if (levelOrAction === "cycle") return { kind: "command", command: { name: "reasoning", action: "cycle" } };
|
|
85
|
+
if (TELEGRAM_REASONING_LEVELS.has(levelOrAction)) {
|
|
86
|
+
return { kind: "command", command: { name: "reasoning", action: "set", level: levelOrAction } };
|
|
87
|
+
}
|
|
88
|
+
return { kind: "invalid", commandName, usage };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
23
93
|
/**
|
|
24
94
|
* Parse an in-thread config command. Returns the requested change, or
|
|
25
95
|
* `undefined` when the text is not a recognised config command (so the daemon
|
|
@@ -24,11 +24,13 @@ import * as fs from "node:fs";
|
|
|
24
24
|
import * as os from "node:os";
|
|
25
25
|
import * as path from "node:path";
|
|
26
26
|
import { promisify } from "node:util";
|
|
27
|
+
import { ThinkingLevel } from "@gajae-code/agent-core";
|
|
27
28
|
import type { ImageContent, TextContent } from "@gajae-code/ai";
|
|
28
29
|
import { NotificationServer } from "@gajae-code/natives";
|
|
29
30
|
import { logger, postmortem } from "@gajae-code/utils";
|
|
30
31
|
import { Settings } from "../config/settings";
|
|
31
32
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../extensibility/extensions";
|
|
33
|
+
import { parseThinkingLevel } from "../thinking";
|
|
32
34
|
import { registerAskAnswerSource } from "../tools/ask-answer-registry";
|
|
33
35
|
import { registerTelegramFileSink } from "./attachment-registry";
|
|
34
36
|
import {
|
|
@@ -407,20 +409,18 @@ function streamIntervalMs(): number {
|
|
|
407
409
|
return Math.max(200, Number(process.env.GJC_NOTIFICATIONS_STREAM_INTERVAL_MS) || 500);
|
|
408
410
|
}
|
|
409
411
|
// Max chars of a turn's assistant text carried by the FINALIZED turn_stream (and
|
|
410
|
-
// the pre-ask capture).
|
|
411
|
-
//
|
|
412
|
-
// splitTelegramHtml
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
// (unset, NaN, Infinity, <= 0) falls back to the default, so the cap can never
|
|
417
|
-
// be unbounded. Live frames are intentionally NOT raised — they stay one
|
|
412
|
+
// the pre-ask capture). Finalized turns default to the bounded full-turn ceiling
|
|
413
|
+
// because split-capable clients such as the Telegram daemon schedule each
|
|
414
|
+
// splitTelegramHtml chunk through the shared rate-limit pool. Operators who want
|
|
415
|
+
// glanceable summaries can lower this with GJC_NOTIFICATIONS_TURN_MAX. The value
|
|
416
|
+
// is always clamped to a finite [280, TURN_TEXT_MAX_CEILING] range so the cap can
|
|
417
|
+
// never be unbounded. Live frames are intentionally NOT raised — they stay one
|
|
418
418
|
// editable preview message rather than fanning a long in-progress turn across
|
|
419
419
|
// sends.
|
|
420
420
|
const TURN_TEXT_MAX_CEILING = 40_000;
|
|
421
421
|
function turnTextMax(): number {
|
|
422
422
|
const raw = Number(process.env.GJC_NOTIFICATIONS_TURN_MAX);
|
|
423
|
-
if (!Number.isFinite(raw) || raw <= 0) return
|
|
423
|
+
if (!Number.isFinite(raw) || raw <= 0) return TURN_TEXT_MAX_CEILING;
|
|
424
424
|
return Math.min(TURN_TEXT_MAX_CEILING, Math.max(280, raw));
|
|
425
425
|
}
|
|
426
426
|
function resolveSettings(settingsOverride?: Settings): ResolvedSettings {
|
|
@@ -487,6 +487,121 @@ function mapAnswerToGate(
|
|
|
487
487
|
return { selected: [] };
|
|
488
488
|
}
|
|
489
489
|
|
|
490
|
+
interface NotificationControlCommandPayload {
|
|
491
|
+
name?: unknown;
|
|
492
|
+
action?: unknown;
|
|
493
|
+
level?: unknown;
|
|
494
|
+
instructions?: unknown;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function parseControlCommandPayload(json: string | undefined): NotificationControlCommandPayload | undefined {
|
|
498
|
+
if (!json) return undefined;
|
|
499
|
+
try {
|
|
500
|
+
const parsed = JSON.parse(json) as unknown;
|
|
501
|
+
return parsed && typeof parsed === "object" ? (parsed as NotificationControlCommandPayload) : undefined;
|
|
502
|
+
} catch {
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function formatCompactTokenCount(value: number | null | undefined): string {
|
|
508
|
+
if (value == null) return "unknown";
|
|
509
|
+
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1))}m`;
|
|
510
|
+
if (value >= 1_000) return `${Number((value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1))}k`;
|
|
511
|
+
return value.toLocaleString();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function formatContextUsageLine(ctx: ExtensionContext): string {
|
|
515
|
+
const usage = ctx.getContextUsage();
|
|
516
|
+
if (!usage) return "Context usage unavailable.";
|
|
517
|
+
const tokens = formatCompactTokenCount(usage.tokens);
|
|
518
|
+
const window = formatCompactTokenCount(usage.contextWindow);
|
|
519
|
+
const pct = usage.percent == null ? "unknown" : `${usage.percent.toFixed(1)}%`;
|
|
520
|
+
return `Context: ${tokens}/${window} ${pct}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function formatLocalUsage(ctx: ExtensionContext): string {
|
|
524
|
+
const stats = ctx.sessionManager.getUsageStatistics();
|
|
525
|
+
return [
|
|
526
|
+
"Usage",
|
|
527
|
+
`Input tokens: ${stats.input}`,
|
|
528
|
+
`Output tokens: ${stats.output}`,
|
|
529
|
+
`Cache read tokens: ${stats.cacheRead}`,
|
|
530
|
+
`Cache write tokens: ${stats.cacheWrite}`,
|
|
531
|
+
`Premium requests: ${stats.premiumRequests}`,
|
|
532
|
+
`Cost: $${stats.cost.toFixed(6)}`,
|
|
533
|
+
].join("\n");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function cycleTelegramThinking(api: ExtensionAPI): ThinkingLevel | undefined {
|
|
537
|
+
const levels = [
|
|
538
|
+
ThinkingLevel.Off,
|
|
539
|
+
ThinkingLevel.Minimal,
|
|
540
|
+
ThinkingLevel.Low,
|
|
541
|
+
ThinkingLevel.Medium,
|
|
542
|
+
ThinkingLevel.High,
|
|
543
|
+
ThinkingLevel.XHigh,
|
|
544
|
+
ThinkingLevel.Max,
|
|
545
|
+
];
|
|
546
|
+
const current = api.getThinkingLevel() ?? ThinkingLevel.Off;
|
|
547
|
+
const currentIndex = levels.indexOf(current as (typeof levels)[number]);
|
|
548
|
+
const next = levels[(currentIndex + 1) % levels.length];
|
|
549
|
+
if (!next) return undefined;
|
|
550
|
+
api.setThinkingLevel(next);
|
|
551
|
+
return api.getThinkingLevel() ?? next;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export async function executeNotificationControlCommand(
|
|
555
|
+
command: NotificationControlCommandPayload | undefined,
|
|
556
|
+
ctx: ExtensionContext,
|
|
557
|
+
api: ExtensionAPI,
|
|
558
|
+
): Promise<{ status: "ok" | "error" | "unavailable"; message: string }> {
|
|
559
|
+
if (!command || typeof command.name !== "string") return { status: "error", message: "Invalid control command." };
|
|
560
|
+
switch (command.name) {
|
|
561
|
+
case "reasoning": {
|
|
562
|
+
const current = api.getThinkingLevel() ?? ThinkingLevel.Off;
|
|
563
|
+
if (command.action === "status") return { status: "ok", message: `Reasoning effort: ${current}` };
|
|
564
|
+
if (command.action === "cycle") {
|
|
565
|
+
const next = cycleTelegramThinking(api);
|
|
566
|
+
return next
|
|
567
|
+
? { status: "ok", message: `Reasoning effort set to ${next}.` }
|
|
568
|
+
: { status: "unavailable", message: "Reasoning effort unavailable for this session." };
|
|
569
|
+
}
|
|
570
|
+
if (command.action === "set" && typeof command.level === "string") {
|
|
571
|
+
const parsed = parseThinkingLevel(command.level);
|
|
572
|
+
if (!parsed) return { status: "error", message: "Invalid reasoning effort." };
|
|
573
|
+
api.setThinkingLevel(parsed);
|
|
574
|
+
return { status: "ok", message: `Reasoning effort set to ${api.getThinkingLevel() ?? ThinkingLevel.Off}.` };
|
|
575
|
+
}
|
|
576
|
+
return { status: "error", message: "Invalid reasoning command." };
|
|
577
|
+
}
|
|
578
|
+
case "usage":
|
|
579
|
+
return { status: "ok", message: formatLocalUsage(ctx) };
|
|
580
|
+
case "context":
|
|
581
|
+
return { status: "ok", message: formatContextUsageLine(ctx) };
|
|
582
|
+
case "compact": {
|
|
583
|
+
const before = ctx.getContextUsage()?.tokens;
|
|
584
|
+
try {
|
|
585
|
+
await ctx.compact(typeof command.instructions === "string" ? command.instructions : undefined);
|
|
586
|
+
} catch (err) {
|
|
587
|
+
return {
|
|
588
|
+
status: "error",
|
|
589
|
+
message: `Compaction failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
const after = ctx.getContextUsage()?.tokens;
|
|
593
|
+
if (before != null && after != null)
|
|
594
|
+
return {
|
|
595
|
+
status: "ok",
|
|
596
|
+
message: `Compaction complete. Tokens: ${before} -> ${after} (saved ${before - after}).`,
|
|
597
|
+
};
|
|
598
|
+
return { status: "ok", message: "Compaction complete." };
|
|
599
|
+
}
|
|
600
|
+
default:
|
|
601
|
+
return { status: "error", message: "Unknown control command." };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
490
605
|
/** Register the interactive `ask` answer source for a session (the ask tool
|
|
491
606
|
* races the local UI against a remote reply). Returns the deregister disposer. */
|
|
492
607
|
function registerInteractiveAnswerSource(
|
|
@@ -703,6 +818,38 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
|
|
|
703
818
|
}
|
|
704
819
|
}
|
|
705
820
|
}
|
|
821
|
+
if (inbound.kind === "control_command") {
|
|
822
|
+
if (!runtime || !inbound.requestId) return;
|
|
823
|
+
void executeNotificationControlCommand(parseControlCommandPayload(inbound.commandJson), ctx, api)
|
|
824
|
+
.then(result => {
|
|
825
|
+
runtime?.server.pushFrame(
|
|
826
|
+
JSON.stringify({
|
|
827
|
+
type: "control_command_result",
|
|
828
|
+
sessionId: id,
|
|
829
|
+
requestId: inbound.requestId,
|
|
830
|
+
updateId: inbound.updateId,
|
|
831
|
+
status: result.status,
|
|
832
|
+
message: result.message,
|
|
833
|
+
}),
|
|
834
|
+
);
|
|
835
|
+
})
|
|
836
|
+
.catch(err => {
|
|
837
|
+
try {
|
|
838
|
+
runtime?.server.pushFrame(
|
|
839
|
+
JSON.stringify({
|
|
840
|
+
type: "control_command_result",
|
|
841
|
+
sessionId: id,
|
|
842
|
+
requestId: inbound.requestId,
|
|
843
|
+
updateId: inbound.updateId,
|
|
844
|
+
status: "error",
|
|
845
|
+
message: `Control command failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
846
|
+
}),
|
|
847
|
+
);
|
|
848
|
+
} catch (pushErr) {
|
|
849
|
+
logger.warn(`notifications: control_command_result failed: ${String(pushErr)}`);
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
}
|
|
706
853
|
});
|
|
707
854
|
|
|
708
855
|
try {
|
|
@@ -49,6 +49,7 @@ export function shouldPromoteRich(input: { enabled?: boolean; send: ThreadedSend
|
|
|
49
49
|
send.editable !== true &&
|
|
50
50
|
typeof send.richMarkdown === "string" &&
|
|
51
51
|
send.richMarkdown.trim().length > 0 &&
|
|
52
|
+
send.richMarkdown.length <= RICH_MESSAGE_LIMIT &&
|
|
52
53
|
typeof send.text === "string" &&
|
|
53
54
|
send.text.length > 0
|
|
54
55
|
);
|
|
@@ -15,7 +15,9 @@ type TelegramDaemonRunner = {
|
|
|
15
15
|
type TelegramDaemonConstructor = new (opts: TelegramDaemonOptions) => TelegramDaemonRunner;
|
|
16
16
|
|
|
17
17
|
export interface RunDaemonInternalDeps {
|
|
18
|
-
SettingsImpl?: {
|
|
18
|
+
SettingsImpl?: {
|
|
19
|
+
init: (options?: { agentDir?: string }) => Promise<Pick<Settings, "get" | "getAgentDir" | "set" | "flush">>;
|
|
20
|
+
};
|
|
19
21
|
DaemonImpl?: TelegramDaemonConstructor;
|
|
20
22
|
processPid?: number;
|
|
21
23
|
pidAlive?: (pid: number) => boolean;
|
|
@@ -61,7 +63,7 @@ function asIdleTimeoutMs(value: unknown): number {
|
|
|
61
63
|
export function createLightweightDaemonSettings(input: {
|
|
62
64
|
agentDir: string;
|
|
63
65
|
rawConfig?: unknown;
|
|
64
|
-
}): Pick<Settings, "get" | "getAgentDir" | "set"> {
|
|
66
|
+
}): Pick<Settings, "get" | "getAgentDir" | "set" | "flush"> {
|
|
65
67
|
const rawConfig = input.rawConfig && typeof input.rawConfig === "object" ? input.rawConfig : {};
|
|
66
68
|
return {
|
|
67
69
|
get(pathName: string): unknown {
|
|
@@ -120,12 +122,18 @@ export function createLightweightDaemonSettings(input: {
|
|
|
120
122
|
});
|
|
121
123
|
setByPath(rawConfig as Record<string, unknown>, segments, value);
|
|
122
124
|
},
|
|
123
|
-
|
|
125
|
+
async flush(): Promise<void> {
|
|
126
|
+
// The set() above is synchronously durable (it awaits the atomic tmp+rename
|
|
127
|
+
// write under the shared file lock), so there is never a pending save to
|
|
128
|
+
// flush. Present so the daemon can await flush() uniformly regardless of
|
|
129
|
+
// which Settings implementation is injected.
|
|
130
|
+
},
|
|
131
|
+
} as Pick<Settings, "get" | "getAgentDir" | "set" | "flush">;
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
export async function loadLightweightDaemonSettings(
|
|
127
135
|
agentDir: string,
|
|
128
|
-
): Promise<Pick<Settings, "get" | "getAgentDir" | "set">> {
|
|
136
|
+
): Promise<Pick<Settings, "get" | "getAgentDir" | "set" | "flush">> {
|
|
129
137
|
const configPath = path.join(agentDir, "config.yml");
|
|
130
138
|
let rawConfig: unknown = {};
|
|
131
139
|
try {
|
|
@@ -139,7 +147,7 @@ export async function loadLightweightDaemonSettings(
|
|
|
139
147
|
async function resolveDaemonSettings(
|
|
140
148
|
agentDir: string,
|
|
141
149
|
deps: RunDaemonInternalDeps,
|
|
142
|
-
): Promise<Pick<Settings, "get" | "getAgentDir" | "set">> {
|
|
150
|
+
): Promise<Pick<Settings, "get" | "getAgentDir" | "set" | "flush">> {
|
|
143
151
|
if (deps.SettingsImpl) return await deps.SettingsImpl.init({ agentDir });
|
|
144
152
|
return await loadLightweightDaemonSettings(agentDir);
|
|
145
153
|
}
|
|
@@ -9,7 +9,7 @@ import type { Settings } from "../config/settings";
|
|
|
9
9
|
import type { DaemonRuntimeInfo } from "../daemon/control-types";
|
|
10
10
|
import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
|
|
11
11
|
import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
|
|
12
|
-
import { parseInThreadConfigCommand, parseRichToggleCommand } from "./config-commands";
|
|
12
|
+
import { parseInThreadConfigCommand, parseRichToggleCommand, parseTelegramControlCommand } from "./config-commands";
|
|
13
13
|
import { daemonPaths } from "./daemon-paths";
|
|
14
14
|
import {
|
|
15
15
|
buildCompactChoiceGrid,
|
|
@@ -111,6 +111,21 @@ export const CLIENT_PING_PONG_CAPABILITY = "client_ping_pong";
|
|
|
111
111
|
export const NOTIFICATION_PROTOCOL_VERSION = 2;
|
|
112
112
|
|
|
113
113
|
const nodeFs: TelegramDaemonFs = fs.promises as unknown as TelegramDaemonFs;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Durably persist a `/rich` toggle. A real {@link Settings} exposes
|
|
117
|
+
* `flushOrThrow()`, which rejects on a failed config.yml write (its `set()` is a
|
|
118
|
+
* fire-and-forget whose background save swallows errors). The lightweight daemon
|
|
119
|
+
* settings has no `flushOrThrow` — its `set()` already wrote durably and throws
|
|
120
|
+
* on failure — so its plain `flush()` no-op drain is sufficient.
|
|
121
|
+
*/
|
|
122
|
+
async function flushRichToggleSettings(settings: Settings): Promise<void> {
|
|
123
|
+
if (typeof settings.flushOrThrow === "function") {
|
|
124
|
+
await settings.flushOrThrow();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await settings.flush();
|
|
128
|
+
}
|
|
114
129
|
const RATE_LIMIT_FLUSH_INTERVAL_MS = 1_000;
|
|
115
130
|
// How often the daemon rescans for newly-started sessions. This MUST run
|
|
116
131
|
// independently of the Telegram getUpdates long-poll (up to 25s): otherwise a
|
|
@@ -1550,6 +1565,7 @@ export class TelegramNotificationDaemon {
|
|
|
1550
1565
|
"image_attachment",
|
|
1551
1566
|
"file_attachment",
|
|
1552
1567
|
"config_update",
|
|
1568
|
+
"control_command_result",
|
|
1553
1569
|
]);
|
|
1554
1570
|
|
|
1555
1571
|
private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
|
|
@@ -1913,7 +1929,7 @@ export class TelegramNotificationDaemon {
|
|
|
1913
1929
|
// upstream edit/send path, so off behavior is byte-identical.
|
|
1914
1930
|
if (
|
|
1915
1931
|
shouldPromoteRich({
|
|
1916
|
-
enabled: this.opts.rich?.enabled
|
|
1932
|
+
enabled: this.opts.rich?.enabled !== false,
|
|
1917
1933
|
send,
|
|
1918
1934
|
})
|
|
1919
1935
|
) {
|
|
@@ -2219,16 +2235,22 @@ export class TelegramNotificationDaemon {
|
|
|
2219
2235
|
// Rename the topic if the title changed (e.g. the session title was
|
|
2220
2236
|
// auto-generated after the topic was first created). This runs on
|
|
2221
2237
|
// every identity frame, but does NOT re-send the bulleted message.
|
|
2238
|
+
// Only commit the new registry name after Telegram accepts the edit:
|
|
2239
|
+
// a transient editForumTopic failure must remain retryable on the
|
|
2240
|
+
// next identity re-assert instead of leaving the remote topic stuck
|
|
2241
|
+
// at the provisional "GJC <id>" name forever.
|
|
2222
2242
|
const name = this.topicNameFor(session.sessionId, msg);
|
|
2223
|
-
if (this.topics.
|
|
2243
|
+
if (this.topics.needsRename(session.sessionId, name)) {
|
|
2224
2244
|
try {
|
|
2225
2245
|
await this.botApi.call("editForumTopic", {
|
|
2226
2246
|
chat_id: this.opts.chatId,
|
|
2227
2247
|
message_thread_id: Number(topicId),
|
|
2228
2248
|
name,
|
|
2229
2249
|
});
|
|
2250
|
+
this.topics.markNameApplied(session.sessionId, name);
|
|
2230
2251
|
} catch {
|
|
2231
|
-
// Best-effort rename; never block delivery.
|
|
2252
|
+
// Best-effort rename; never block delivery. Leave the old
|
|
2253
|
+
// registry name intact so a later identity frame retries.
|
|
2232
2254
|
}
|
|
2233
2255
|
}
|
|
2234
2256
|
// Send the full bulleted identity header EXACTLY ONCE per topic.
|
|
@@ -2415,6 +2437,15 @@ export class TelegramNotificationDaemon {
|
|
|
2415
2437
|
}
|
|
2416
2438
|
try {
|
|
2417
2439
|
await this.opts.settings.set("notifications.telegram.rich.enabled", desired);
|
|
2440
|
+
// Confirm success only after a DURABLE write. The real Settings.set is
|
|
2441
|
+
// a synchronous fire-and-forget whose queued save (Settings.#saveNow)
|
|
2442
|
+
// swallows write errors, and Settings.flush() inherits that — neither
|
|
2443
|
+
// rejects on a failed config.yml write. flushOrThrow() rethrows the
|
|
2444
|
+
// durable-write failure so it lands in the catch below (in-memory
|
|
2445
|
+
// isolated Settings short-circuit and never throw). The lightweight
|
|
2446
|
+
// daemon settings has no flushOrThrow: its set() already wrote durably
|
|
2447
|
+
// (and throws on failure), so its flush() is only a no-op drain.
|
|
2448
|
+
await flushRichToggleSettings(this.opts.settings);
|
|
2418
2449
|
} catch (err) {
|
|
2419
2450
|
logger.warn(
|
|
2420
2451
|
`notifications: /rich settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
|
|
@@ -2468,6 +2499,45 @@ export class TelegramNotificationDaemon {
|
|
|
2468
2499
|
const injectedText = repliedOriginal
|
|
2469
2500
|
? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
|
|
2470
2501
|
: baseInjectedText;
|
|
2502
|
+
const control = hasMedia
|
|
2503
|
+
? { kind: "none" as const }
|
|
2504
|
+
: parseTelegramControlCommand(inbound.text, this.botUsername);
|
|
2505
|
+
if (control.kind !== "none") {
|
|
2506
|
+
await this.rememberSeenUpdateId(inbound.updateId);
|
|
2507
|
+
const sendControlNotice = async (body: string): Promise<void> => {
|
|
2508
|
+
try {
|
|
2509
|
+
await this.botApi.call("sendMessage", {
|
|
2510
|
+
chat_id: this.opts.chatId,
|
|
2511
|
+
message_thread_id: Number(inbound.threadId),
|
|
2512
|
+
text: body,
|
|
2513
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2514
|
+
});
|
|
2515
|
+
} catch {
|
|
2516
|
+
// Best-effort control feedback; never convert to user input.
|
|
2517
|
+
}
|
|
2518
|
+
};
|
|
2519
|
+
if (control.kind === "ignored") return;
|
|
2520
|
+
if (control.kind === "invalid") {
|
|
2521
|
+
await sendControlNotice(control.usage);
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
if (session?.ws.readyState !== WebSocket.OPEN) {
|
|
2525
|
+
await sendControlNotice("Session control unavailable: session is disconnected.");
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
session.ws.send(
|
|
2529
|
+
JSON.stringify({
|
|
2530
|
+
type: "control_command",
|
|
2531
|
+
sessionId: inbound.sessionId,
|
|
2532
|
+
token: session.token,
|
|
2533
|
+
requestId: `tg:${inbound.updateId}`,
|
|
2534
|
+
updateId: inbound.updateId,
|
|
2535
|
+
threadId: inbound.threadId,
|
|
2536
|
+
command: control.command,
|
|
2537
|
+
}),
|
|
2538
|
+
);
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2471
2541
|
const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
|
|
2472
2542
|
// A plain (non-config) message while an ask is pending for this session
|
|
2473
2543
|
// answers that ask as free-input — instead of starting a new user turn.
|
|
@@ -2484,6 +2554,15 @@ export class TelegramNotificationDaemon {
|
|
|
2484
2554
|
}),
|
|
2485
2555
|
);
|
|
2486
2556
|
await this.rememberSeenUpdateId(inbound.updateId);
|
|
2557
|
+
await this.botApi
|
|
2558
|
+
.call("sendMessage", {
|
|
2559
|
+
chat_id: this.opts.chatId,
|
|
2560
|
+
message_thread_id: Number(inbound.threadId),
|
|
2561
|
+
text: "Received as an answer to the pending ask.",
|
|
2562
|
+
})
|
|
2563
|
+
.catch(error => {
|
|
2564
|
+
logger.warn(`telegram: failed to acknowledge pending ask reply: ${String(error)}`);
|
|
2565
|
+
});
|
|
2487
2566
|
if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
|
|
2488
2567
|
return;
|
|
2489
2568
|
}
|
|
@@ -2548,6 +2627,10 @@ export class TelegramNotificationDaemon {
|
|
|
2548
2627
|
{ command: "lean", description: "Mirror assistant text + tool names only (default)" },
|
|
2549
2628
|
{ command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
|
|
2550
2629
|
{ command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
|
|
2630
|
+
{ command: "reasoning", description: "Show or change reasoning effort in this session" },
|
|
2631
|
+
{ command: "usage", description: "Show provider/local usage for this session" },
|
|
2632
|
+
{ command: "context", description: "Show current context usage for this session" },
|
|
2633
|
+
{ command: "compact", description: "Compact this session: /compact [instructions]" },
|
|
2551
2634
|
{ command: "session_create", description: "Create a GJC session: path, worktree, or dir [--mpreset]" },
|
|
2552
2635
|
{ command: "session_recent", description: "List recent GJC sessions" },
|
|
2553
2636
|
{ command: "session_close", description: "Close a GJC-managed session" },
|
|
@@ -77,6 +77,9 @@ interface ThreadedFrame {
|
|
|
77
77
|
// config_update
|
|
78
78
|
verbosity?: unknown;
|
|
79
79
|
redact?: unknown;
|
|
80
|
+
// control_command_result
|
|
81
|
+
status?: unknown;
|
|
82
|
+
message?: unknown;
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
function str(v: unknown): string | undefined {
|
|
@@ -218,6 +221,17 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
|
|
|
218
221
|
}
|
|
219
222
|
: undefined;
|
|
220
223
|
}
|
|
224
|
+
case "control_command_result": {
|
|
225
|
+
const message = str(frame.message);
|
|
226
|
+
if (!message) return undefined;
|
|
227
|
+
const status = str(frame.status);
|
|
228
|
+
const prefix = status === "ok" ? "✅" : status === "unavailable" ? "⚠️" : "❌";
|
|
229
|
+
return {
|
|
230
|
+
method: "sendMessage",
|
|
231
|
+
lane: "idle",
|
|
232
|
+
text: finalizeTelegramHtml(`${prefix} ${escapeHtml(truncate(message, 1200))}`),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
221
235
|
default:
|
|
222
236
|
return undefined;
|
|
223
237
|
}
|
|
@@ -120,15 +120,16 @@ export class TopicRegistry {
|
|
|
120
120
|
return record ? !record.identitySent : true;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
* caller should `editForumTopic`), `false` when already current or unknown.
|
|
126
|
-
*/
|
|
127
|
-
applyName(sessionId: string, name: string): boolean {
|
|
123
|
+
/** Whether a known session topic's applied title differs from `name`. */
|
|
124
|
+
needsRename(sessionId: string, name: string): boolean {
|
|
128
125
|
const record = this.topics.get(sessionId);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
126
|
+
return record !== undefined && record.name !== name;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Commit a successfully-applied Telegram topic title. */
|
|
130
|
+
markNameApplied(sessionId: string, name: string): void {
|
|
131
|
+
const record = this.topics.get(sessionId);
|
|
132
|
+
if (record) record.name = name;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
/** Remove a session topic record after Telegram deletes the topic. */
|