@sayknow-cli/coding-agent 0.3.8 → 0.3.10
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 +13 -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 +11 -3
- package/dist/types/config/model-resolver.d.ts +2 -2
- package/dist/types/config/settings-schema.d.ts +15 -1
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -2
- package/dist/types/modes/components/thinking-selector.d.ts +7 -2
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
- package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
- package/dist/types/notifications/config-commands.d.ts +38 -0
- package/dist/types/notifications/config.d.ts +16 -0
- package/dist/types/notifications/index.d.ts +14 -1
- package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
- package/dist/types/notifications/reply-sent-store.d.ts +53 -0
- package/dist/types/notifications/rich-draft.d.ts +68 -0
- package/dist/types/notifications/rich-render.d.ts +90 -0
- package/dist/types/notifications/telegram-daemon.d.ts +22 -0
- package/dist/types/notifications/telegram-reference.d.ts +7 -0
- package/dist/types/notifications/threaded-render.d.ts +10 -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/skc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/skill-discovery.d.ts +40 -0
- package/dist/types/utils/pasted-image-path.d.ts +30 -0
- package/package.json +7 -7
- package/src/cli/args.ts +8 -0
- package/src/cli/notify-cli.ts +38 -10
- package/src/cli.ts +5 -0
- package/src/commands/launch.ts +5 -0
- package/src/commands/notify.ts +2 -2
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-profile-activation.ts +23 -7
- package/src/config/model-registry.ts +107 -11
- package/src/config/model-resolver.ts +5 -16
- package/src/config/settings-schema.ts +11 -2
- package/src/config/settings.ts +24 -1
- package/src/extensibility/runtime-skill-discovery.ts +229 -0
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/lsp/client.ts +1 -0
- package/src/main.ts +26 -3
- package/src/modes/components/footer.ts +7 -2
- package/src/modes/components/settings-selector.ts +3 -3
- package/src/modes/components/thinking-selector.ts +83 -20
- package/src/modes/controllers/event-controller.ts +11 -2
- package/src/modes/controllers/extension-ui-controller.ts +8 -1
- package/src/modes/controllers/input-controller.ts +62 -26
- package/src/modes/controllers/selector-controller.ts +43 -0
- package/src/modes/interactive-mode.ts +5 -0
- package/src/modes/rpc/rpc-mode.ts +3 -9
- package/src/modes/rpc/rpc-socket-security.ts +13 -1
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/injected-user-submission.ts +94 -0
- package/src/modes/utils/ui-helpers.ts +48 -15
- package/src/notifications/config-commands.ts +90 -0
- package/src/notifications/config.ts +27 -0
- package/src/notifications/index.ts +184 -14
- package/src/notifications/lifecycle-commands.ts +37 -16
- package/src/notifications/lifecycle-control-runtime.ts +8 -3
- package/src/notifications/reply-sent-store.ts +134 -0
- package/src/notifications/rich-draft.ts +107 -0
- package/src/notifications/rich-render.ts +143 -0
- package/src/notifications/telegram-daemon.ts +431 -91
- package/src/notifications/telegram-reference.ts +17 -0
- package/src/notifications/threaded-render.ts +42 -1
- package/src/notifications/topic-registry.ts +9 -8
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/prompts/tools/skill-discovery.md +13 -0
- package/src/runtime-credential-selector.ts +35 -0
- package/src/sdk.ts +104 -13
- package/src/session/agent-session.ts +48 -22
- package/src/skc-runtime/psmux-detect.ts +17 -2
- package/src/skc-runtime/tmux-common.ts +1 -0
- package/src/skc-runtime/ultragoal-runtime.ts +10 -1
- package/src/slash-commands/builtin-registry.ts +78 -1
- 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
- package/src/utils/pasted-image-path.ts +170 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ImageContent, TextContent } from "@sayknow-cli/ai";
|
|
2
|
+
import type { InteractiveModeContext } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalize the content passed to an extension `sendUserMessage` call into a
|
|
6
|
+
* plain text string plus its image attachments. Mirrors the normalization in
|
|
7
|
+
* `AgentSession.sendUserMessage` (text parts joined with "\n") so the resulting
|
|
8
|
+
* text matches the eventual user `message_start` payload.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeInjectedUserContent(content: string | (TextContent | ImageContent)[]): {
|
|
11
|
+
text: string;
|
|
12
|
+
images: ImageContent[];
|
|
13
|
+
imageCount: number;
|
|
14
|
+
} {
|
|
15
|
+
if (typeof content === "string") {
|
|
16
|
+
return { text: content, images: [], imageCount: 0 };
|
|
17
|
+
}
|
|
18
|
+
const textParts: string[] = [];
|
|
19
|
+
const images: ImageContent[] = [];
|
|
20
|
+
for (const part of content) {
|
|
21
|
+
if (part.type === "text") textParts.push(part.text);
|
|
22
|
+
else images.push(part);
|
|
23
|
+
}
|
|
24
|
+
const text = textParts.join("\n");
|
|
25
|
+
return { text, images, imageCount: images.length };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record a remotely/programmatically injected user message (e.g. Telegram
|
|
30
|
+
* inbound routed through the extension API) into the interactive TUI, so it is
|
|
31
|
+
* captured in prompt history and shown immediately instead of only appearing
|
|
32
|
+
* once the eventual `message_start` event lands.
|
|
33
|
+
*
|
|
34
|
+
* Local TUI submissions never reach this path (they go through
|
|
35
|
+
* `session.prompt(...)` / `startPendingSubmission`), so this cannot double-add
|
|
36
|
+
* local prompt history.
|
|
37
|
+
*
|
|
38
|
+
* - Always adds the injected text to editor prompt history.
|
|
39
|
+
* - Idle injections optimistically render the user message and record a pending
|
|
40
|
+
* injected optimistic signature (a counting Map, so multiple idle injections
|
|
41
|
+
* before the first `message_start` do not clobber each other); the later user
|
|
42
|
+
* `message_start` consumes one count and skips both the duplicate chat add and
|
|
43
|
+
* the defensive editor clear (so a locally typed draft is preserved).
|
|
44
|
+
* - Busy/queued injections refresh the pending-message display, which the
|
|
45
|
+
* caller has already populated by invoking `session.sendUserMessage(...)`
|
|
46
|
+
* before this helper.
|
|
47
|
+
*
|
|
48
|
+
* This helper never clears the editor text.
|
|
49
|
+
*/
|
|
50
|
+
export function applyInjectedUserSubmission(
|
|
51
|
+
ctx: InteractiveModeContext,
|
|
52
|
+
input: { content: string | (TextContent | ImageContent)[]; queued: boolean },
|
|
53
|
+
): void {
|
|
54
|
+
const { text, images, imageCount } = normalizeInjectedUserContent(input.content);
|
|
55
|
+
ctx.editor.addToHistory(text);
|
|
56
|
+
|
|
57
|
+
if (input.queued) {
|
|
58
|
+
ctx.updatePendingMessagesDisplay();
|
|
59
|
+
ctx.ui.requestRender();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
incrementInjectedOptimisticSignature(ctx, `${text}\u0000${imageCount}`);
|
|
64
|
+
ctx.addMessageToChat({
|
|
65
|
+
role: "user",
|
|
66
|
+
content: [{ type: "text", text }, ...images],
|
|
67
|
+
attribution: "user",
|
|
68
|
+
timestamp: Date.now(),
|
|
69
|
+
});
|
|
70
|
+
ctx.ui.requestRender();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Record one pending optimistic render for an injected user message.
|
|
75
|
+
*
|
|
76
|
+
* Injected sends are fire-and-forget and multiple idle injections can be
|
|
77
|
+
* rendered before the first `message_start` arrives, so a counting Map (not a
|
|
78
|
+
* single slot) tracks how many optimistic renders are outstanding per signature.
|
|
79
|
+
*/
|
|
80
|
+
export function incrementInjectedOptimisticSignature(ctx: InteractiveModeContext, signature: string): void {
|
|
81
|
+
ctx.optimisticInjectedSignatures.set(signature, (ctx.optimisticInjectedSignatures.get(signature) ?? 0) + 1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Consume one pending injected optimistic render for `signature`. Decrements the
|
|
86
|
+
* count (deleting the key at zero) and returns whether one was outstanding.
|
|
87
|
+
*/
|
|
88
|
+
export function consumeInjectedOptimisticSignature(ctx: InteractiveModeContext, signature: string): boolean {
|
|
89
|
+
const count = ctx.optimisticInjectedSignatures.get(signature) ?? 0;
|
|
90
|
+
if (count <= 0) return false;
|
|
91
|
+
if (count === 1) ctx.optimisticInjectedSignatures.delete(signature);
|
|
92
|
+
else ctx.optimisticInjectedSignatures.set(signature, count - 1);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
@@ -37,17 +37,27 @@ interface RenderInitialMessagesOptions {
|
|
|
37
37
|
preserveExistingChat?: boolean;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function cloneRenderArgs(args: Record<string, unknown>): Record<string, unknown> {
|
|
41
|
+
try {
|
|
42
|
+
return structuredClone(args);
|
|
43
|
+
} catch {
|
|
44
|
+
return { ...args };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
export function argsWithPartialJson(args: unknown, partialJson: unknown): unknown {
|
|
41
49
|
if (typeof partialJson !== "string" || !args || typeof args !== "object" || Array.isArray(args)) return args;
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
50
|
+
// Keep the transient streaming buffer on a renderer-only snapshot. The live
|
|
51
|
+
// assistant message args are later validated/executed, so UI-only metadata or
|
|
52
|
+
// renderer mutations must never share that object reference.
|
|
53
|
+
const renderArgs = cloneRenderArgs(args as Record<string, unknown>);
|
|
54
|
+
Object.defineProperty(renderArgs, "__partialJson", {
|
|
45
55
|
value: partialJson,
|
|
46
56
|
enumerable: false,
|
|
47
57
|
configurable: true,
|
|
48
58
|
writable: true,
|
|
49
59
|
});
|
|
50
|
-
return
|
|
60
|
+
return renderArgs;
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
type QueuedMessages = {
|
|
@@ -625,7 +635,11 @@ export class UiHelpers {
|
|
|
625
635
|
}
|
|
626
636
|
|
|
627
637
|
queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
|
|
628
|
-
|
|
638
|
+
const entry: CompactionQueuedMessage = { text, mode };
|
|
639
|
+
if (mode === "followUp") {
|
|
640
|
+
entry.followUpQueuePolicy = "sequential";
|
|
641
|
+
}
|
|
642
|
+
this.ctx.compactionQueuedMessages.push(entry);
|
|
629
643
|
this.ctx.editor.addToHistory(text);
|
|
630
644
|
this.ctx.editor.setText("");
|
|
631
645
|
this.ctx.updatePendingMessagesDisplay();
|
|
@@ -639,6 +653,11 @@ export class UiHelpers {
|
|
|
639
653
|
return this.#hasSkillInvocations(text) || this.isKnownSlashCommand(text);
|
|
640
654
|
}
|
|
641
655
|
|
|
656
|
+
#compactionFollowUpQueuePolicy(message: CompactionQueuedMessage): "sequential" | undefined {
|
|
657
|
+
if (message.mode !== "followUp") return undefined;
|
|
658
|
+
return message.followUpQueuePolicy ?? "sequential";
|
|
659
|
+
}
|
|
660
|
+
|
|
642
661
|
async #deliverQueuedSkillMessage(message: CompactionQueuedMessage): Promise<boolean> {
|
|
643
662
|
const invocations = parseSkillInvocations(message.text, this.ctx.skillCommands ?? new Map());
|
|
644
663
|
if (invocations.length === 0) {
|
|
@@ -681,6 +700,13 @@ export class UiHelpers {
|
|
|
681
700
|
continue;
|
|
682
701
|
}
|
|
683
702
|
|
|
703
|
+
const promptOptions =
|
|
704
|
+
message.mode === "followUp"
|
|
705
|
+
? {
|
|
706
|
+
streamingBehavior: message.mode,
|
|
707
|
+
followUpQueuePolicy: this.#compactionFollowUpQueuePolicy(message),
|
|
708
|
+
}
|
|
709
|
+
: { streamingBehavior: message.mode };
|
|
684
710
|
await this.ctx.session.promptCustomMessage(
|
|
685
711
|
{
|
|
686
712
|
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
|
@@ -689,7 +715,7 @@ export class UiHelpers {
|
|
|
689
715
|
details,
|
|
690
716
|
attribution: "user",
|
|
691
717
|
},
|
|
692
|
-
|
|
718
|
+
promptOptions,
|
|
693
719
|
);
|
|
694
720
|
}
|
|
695
721
|
|
|
@@ -709,7 +735,11 @@ export class UiHelpers {
|
|
|
709
735
|
return;
|
|
710
736
|
}
|
|
711
737
|
await this.ctx.withLocalSubmission(message.text, () =>
|
|
712
|
-
message.mode === "followUp"
|
|
738
|
+
message.mode === "followUp"
|
|
739
|
+
? this.ctx.session.followUp(message.text, undefined, {
|
|
740
|
+
followUpQueuePolicy: this.#compactionFollowUpQueuePolicy(message),
|
|
741
|
+
})
|
|
742
|
+
: this.ctx.session.steer(message.text),
|
|
713
743
|
);
|
|
714
744
|
}
|
|
715
745
|
|
|
@@ -800,14 +830,17 @@ export class UiHelpers {
|
|
|
800
830
|
// `restoreQueue` rather than rethrown, so we use the primitive
|
|
801
831
|
// recordLocalSubmission and dispose manually in the catch.
|
|
802
832
|
const disposeFirstPrompt = this.ctx.recordLocalSubmission(firstPrompt.text);
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
833
|
+
const firstPromptOptions =
|
|
834
|
+
firstPrompt.mode === "followUp"
|
|
835
|
+
? {
|
|
836
|
+
streamingBehavior: "followUp" as const,
|
|
837
|
+
followUpQueuePolicy: this.#compactionFollowUpQueuePolicy(firstPrompt),
|
|
838
|
+
}
|
|
839
|
+
: { streamingBehavior: "steer" as const };
|
|
840
|
+
const promptPromise = this.ctx.session.prompt(firstPrompt.text, firstPromptOptions).catch((error: unknown) => {
|
|
841
|
+
disposeFirstPrompt();
|
|
842
|
+
restoreQueue(error);
|
|
843
|
+
});
|
|
811
844
|
|
|
812
845
|
for (const message of rest) {
|
|
813
846
|
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
|
|
@@ -48,3 +118,23 @@ export function parseInThreadConfigCommand(text: string): ConfigCommandChange |
|
|
|
48
118
|
return undefined;
|
|
49
119
|
}
|
|
50
120
|
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse a `/rich on|off` toggle. Returns `true`/`false` for a recognised
|
|
124
|
+
* on/off argument, or `undefined` otherwise (not a `/rich` command, or `/rich`
|
|
125
|
+
* with a missing/invalid argument). This is intentionally SEPARATE from
|
|
126
|
+
* `parseInThreadConfigCommand`: `/verbose`/`/redact` are producer/session config
|
|
127
|
+
* forwarded over the WS, whereas rich is Telegram-daemon delivery policy handled
|
|
128
|
+
* daemon-locally, so it never becomes a `config_command` frame or a user turn.
|
|
129
|
+
*/
|
|
130
|
+
export function parseRichToggleCommand(text: string): boolean | undefined {
|
|
131
|
+
const trimmed = text.trim();
|
|
132
|
+
if (!trimmed.startsWith("/")) return undefined;
|
|
133
|
+
const [rawCommand, ...rest] = trimmed.slice(1).split(/\s+/);
|
|
134
|
+
// Accept the "/rich@botname" form Telegram appends in group chats.
|
|
135
|
+
if (rawCommand?.toLowerCase().split("@")[0] !== "rich") return undefined;
|
|
136
|
+
const arg = rest[0]?.toLowerCase();
|
|
137
|
+
if (arg === "on" || arg === "true" || arg === "1") return true;
|
|
138
|
+
if (arg === "off" || arg === "false" || arg === "0") return false;
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
@@ -16,6 +16,12 @@ export interface NotificationConfig {
|
|
|
16
16
|
redact: boolean;
|
|
17
17
|
verbosity: "lean" | "verbose";
|
|
18
18
|
idleTimeoutMs: number;
|
|
19
|
+
rich: {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
};
|
|
22
|
+
richDraft: {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
};
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
/** Read typed config from Settings. */
|
|
@@ -35,6 +41,12 @@ export function getNotificationConfig(settings: Settings): NotificationConfig {
|
|
|
35
41
|
redact: settings.get("notifications.redact"),
|
|
36
42
|
verbosity: settings.get("notifications.verbosity") === "verbose" ? "verbose" : "lean",
|
|
37
43
|
idleTimeoutMs: settings.get("notifications.daemon.idleTimeoutMs"),
|
|
44
|
+
rich: {
|
|
45
|
+
enabled: settings.get("notifications.telegram.rich.enabled"),
|
|
46
|
+
},
|
|
47
|
+
richDraft: {
|
|
48
|
+
enabled: settings.get("notifications.telegram.richDraft.enabled"),
|
|
49
|
+
},
|
|
38
50
|
};
|
|
39
51
|
}
|
|
40
52
|
|
|
@@ -59,6 +71,20 @@ export function isGloballyConfigured(cfg: NotificationConfig): boolean {
|
|
|
59
71
|
);
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Per-run opt-out for completion notifications, honored before settings lookups.
|
|
76
|
+
*
|
|
77
|
+
* `SKC_NOTIFY=off` (also `0` / `false`, case-insensitive) suppresses the
|
|
78
|
+
* completion notification surface for this process only. `config.yml` is
|
|
79
|
+
* untouched and child processes inherit the env var, which lets non-interactive
|
|
80
|
+
* fleet runs (`skc -p --no-session`) stay silent even when a user-level/global
|
|
81
|
+
* completion notification configuration is enabled.
|
|
82
|
+
*/
|
|
83
|
+
export function completionNotifyDisabledByEnv(env: NodeJS.ProcessEnv): boolean {
|
|
84
|
+
const v = env.SKC_NOTIFY?.trim().toLowerCase();
|
|
85
|
+
return v === "off" || v === "0" || v === "false";
|
|
86
|
+
}
|
|
87
|
+
|
|
62
88
|
/** Resolve whether the notifications extension should be registered at SDK startup. */
|
|
63
89
|
export function shouldRegisterNotificationsExtension(input: {
|
|
64
90
|
env: NodeJS.ProcessEnv;
|
|
@@ -71,6 +97,7 @@ export function shouldRegisterNotificationsExtension(input: {
|
|
|
71
97
|
currentAgentType?: string;
|
|
72
98
|
}): boolean {
|
|
73
99
|
if ((input.taskDepth ?? 0) > 0 || input.parentTaskPrefix || input.currentAgentType) return false;
|
|
100
|
+
if (completionNotifyDisabledByEnv(input.env)) return false;
|
|
74
101
|
if (input.env.SKC_NOTIFICATIONS === "0") return false;
|
|
75
102
|
if (input.env.SKC_NOTIFICATIONS === "1" || input.env.SKC_NOTIFICATIONS_TOKEN) return true;
|
|
76
103
|
return input.cfg ? isGloballyConfigured(input.cfg) : false;
|
|
@@ -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 "@sayknow-cli/agent-core";
|
|
27
28
|
import type { ImageContent, TextContent } from "@sayknow-cli/ai";
|
|
28
29
|
import { NotificationServer } from "@sayknow-cli/natives";
|
|
29
30
|
import { logger, postmortem } from "@sayknow-cli/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 {
|
|
@@ -90,6 +92,8 @@ export interface SessionCreateFrame {
|
|
|
90
92
|
target: SessionCreateTarget;
|
|
91
93
|
/** Reference to the daemon-written, once-consumed startup-prompt file. */
|
|
92
94
|
startupPromptRef?: string;
|
|
95
|
+
/** Model profile preset to activate for the spawned session (--mpreset). */
|
|
96
|
+
modelPreset?: string;
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
/** Close (hard-kill, history preserved) a session. */
|
|
@@ -356,6 +360,9 @@ interface SessionRuntime {
|
|
|
356
360
|
liveRef?: string;
|
|
357
361
|
lastLiveAt?: number;
|
|
358
362
|
lastLiveText?: string;
|
|
363
|
+
/** True between turn_end and the next turn_start: drops late async message_update
|
|
364
|
+
* frames so a stale live edit can never be emitted after the finalized turn. */
|
|
365
|
+
turnClosed?: boolean;
|
|
359
366
|
/** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
|
|
360
367
|
cancelPostmortemCleanup: () => void;
|
|
361
368
|
}
|
|
@@ -383,6 +390,8 @@ const defaultConfig: NotificationConfig = {
|
|
|
383
390
|
redact: false,
|
|
384
391
|
verbosity: "lean",
|
|
385
392
|
idleTimeoutMs: 60_000,
|
|
393
|
+
rich: { enabled: true },
|
|
394
|
+
richDraft: { enabled: false },
|
|
386
395
|
};
|
|
387
396
|
|
|
388
397
|
export function notificationsEnabled(): boolean {
|
|
@@ -400,20 +409,18 @@ function streamIntervalMs(): number {
|
|
|
400
409
|
return Math.max(200, Number(process.env.SKC_NOTIFICATIONS_STREAM_INTERVAL_MS) || 500);
|
|
401
410
|
}
|
|
402
411
|
// Max chars of a turn's assistant text carried by the FINALIZED turn_stream (and
|
|
403
|
-
// the pre-ask capture).
|
|
404
|
-
//
|
|
405
|
-
// splitTelegramHtml
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
// (unset, NaN, Infinity, <= 0) falls back to the default, so the cap can never
|
|
410
|
-
// 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 SKC_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
|
|
411
418
|
// editable preview message rather than fanning a long in-progress turn across
|
|
412
419
|
// sends.
|
|
413
420
|
const TURN_TEXT_MAX_CEILING = 40_000;
|
|
414
421
|
function turnTextMax(): number {
|
|
415
422
|
const raw = Number(process.env.SKC_NOTIFICATIONS_TURN_MAX);
|
|
416
|
-
if (!Number.isFinite(raw) || raw <= 0) return
|
|
423
|
+
if (!Number.isFinite(raw) || raw <= 0) return TURN_TEXT_MAX_CEILING;
|
|
417
424
|
return Math.min(TURN_TEXT_MAX_CEILING, Math.max(280, raw));
|
|
418
425
|
}
|
|
419
426
|
function resolveSettings(settingsOverride?: Settings): ResolvedSettings {
|
|
@@ -480,6 +487,121 @@ function mapAnswerToGate(
|
|
|
480
487
|
return { selected: [] };
|
|
481
488
|
}
|
|
482
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
|
+
|
|
483
605
|
/** Register the interactive `ask` answer source for a session (the ask tool
|
|
484
606
|
* races the local UI against a remote reply). Returns the deregister disposer. */
|
|
485
607
|
function registerInteractiveAnswerSource(
|
|
@@ -696,6 +818,38 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
|
|
|
696
818
|
}
|
|
697
819
|
}
|
|
698
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
|
+
}
|
|
699
853
|
});
|
|
700
854
|
|
|
701
855
|
try {
|
|
@@ -997,7 +1151,10 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
|
|
|
997
1151
|
api.on("turn_start", (_event, ctx) => {
|
|
998
1152
|
const id = sessionId(ctx);
|
|
999
1153
|
const rt = runtimes.get(id);
|
|
1000
|
-
if (!rt
|
|
1154
|
+
if (!rt) return;
|
|
1155
|
+
// A new turn is live: re-open the live-stream window (see turnClosed).
|
|
1156
|
+
rt.turnClosed = false;
|
|
1157
|
+
if (rt.pendingInbound.size === 0) return;
|
|
1001
1158
|
for (const updateId of rt.pendingInbound) {
|
|
1002
1159
|
try {
|
|
1003
1160
|
rt.server.pushFrame(JSON.stringify({ type: "inbound_ack", sessionId: id, updateId, state: "consumed" }));
|
|
@@ -1093,15 +1250,25 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
|
|
|
1093
1250
|
// rate-limit pool before sending to Telegram.
|
|
1094
1251
|
// Push the in-flight turn's assistant text as a finalized turn_stream, deduped
|
|
1095
1252
|
// against what was already flushed for this turn (the pre-ask lead-in).
|
|
1096
|
-
const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined): void => {
|
|
1253
|
+
const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
|
|
1097
1254
|
if (!text || text === rt.preAskFlushedText) return;
|
|
1098
1255
|
rt.preAskFlushedText = text;
|
|
1256
|
+
// Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
|
|
1257
|
+
// live message, never a fresh (rich-promotable) send. If live frames were
|
|
1258
|
+
// async-queued and none landed before this flush, allocate the per-turn ref
|
|
1259
|
+
// now so the finalized frame always carries a messageRef → the daemon keeps it
|
|
1260
|
+
// editable (HTML edit) and never rich-promotes a streamed final.
|
|
1261
|
+
if (finalAnswer && rt.stream && rt.liveRef === undefined) {
|
|
1262
|
+
rt.turnSeq = (rt.turnSeq ?? 0) + 1;
|
|
1263
|
+
rt.liveRef = String(rt.turnSeq);
|
|
1264
|
+
}
|
|
1099
1265
|
try {
|
|
1100
1266
|
rt.server.pushFrame(
|
|
1101
1267
|
JSON.stringify({
|
|
1102
1268
|
type: "turn_stream",
|
|
1103
1269
|
sessionId: id,
|
|
1104
1270
|
phase: "finalized",
|
|
1271
|
+
finalAnswer,
|
|
1105
1272
|
text,
|
|
1106
1273
|
...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
|
|
1107
1274
|
}),
|
|
@@ -1122,7 +1289,7 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
|
|
|
1122
1289
|
const id = sessionId(ctx);
|
|
1123
1290
|
const rt = runtimes.get(id);
|
|
1124
1291
|
if (!rt || rt.redact) return;
|
|
1125
|
-
flushTurnText(rt, id, rt.currentTurnText);
|
|
1292
|
+
flushTurnText(rt, id, rt.currentTurnText, false);
|
|
1126
1293
|
});
|
|
1127
1294
|
|
|
1128
1295
|
api.on("turn_end", (event, ctx) => {
|
|
@@ -1130,12 +1297,15 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
|
|
|
1130
1297
|
const rt = runtimes.get(id);
|
|
1131
1298
|
if (!rt) return;
|
|
1132
1299
|
const text = rt.redact ? undefined : summaryFromMessage(event.message, turnTextMax());
|
|
1133
|
-
if (text) flushTurnText(rt, id, text);
|
|
1300
|
+
if (text) flushTurnText(rt, id, text, true);
|
|
1134
1301
|
// Reset per-turn streaming state so the next turn starts fresh and a later
|
|
1135
1302
|
// turn with identical text is not falsely deduped.
|
|
1136
1303
|
rt.currentTurnText = undefined;
|
|
1137
1304
|
rt.preAskFlushedText = undefined;
|
|
1138
1305
|
rt.liveRef = undefined;
|
|
1306
|
+
// Close the live-stream window: any message_update queued after turn_end is
|
|
1307
|
+
// dropped so it can never emit a stale live edit past the finalized turn.
|
|
1308
|
+
rt.turnClosed = true;
|
|
1139
1309
|
rt.lastLiveAt = undefined;
|
|
1140
1310
|
rt.lastLiveText = undefined;
|
|
1141
1311
|
});
|
|
@@ -1147,7 +1317,7 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
|
|
|
1147
1317
|
api.on("message_update", (event, ctx) => {
|
|
1148
1318
|
const id = sessionId(ctx);
|
|
1149
1319
|
const rt = runtimes.get(id);
|
|
1150
|
-
if (!rt?.stream || rt.redact) return;
|
|
1320
|
+
if (!rt?.stream || rt.redact || rt.turnClosed) return;
|
|
1151
1321
|
if ((event.message as { role?: unknown }).role !== "assistant") return;
|
|
1152
1322
|
if (rt.liveRef === undefined) {
|
|
1153
1323
|
rt.turnSeq = (rt.turnSeq ?? 0) + 1;
|