@sayknow-cli/coding-agent 0.3.9 → 0.3.11
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 +21 -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 +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/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/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/update-cli.ts +24 -63
- 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 +107 -11
- 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/internal-urls/docs-index.generated.ts +1 -1
- 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.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/skc-runtime/psmux-detect.ts +17 -2
- 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
|
@@ -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 {
|
|
@@ -407,20 +409,18 @@ function streamIntervalMs(): number {
|
|
|
407
409
|
return Math.max(200, Number(process.env.SKC_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 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
|
|
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.SKC_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
|
);
|
|
@@ -9,7 +9,7 @@ import type { Settings } from "../config/settings";
|
|
|
9
9
|
import type { DaemonRuntimeInfo } from "../daemon/control-types";
|
|
10
10
|
import { resolveSkcRuntimeSpawnInfo } 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 "SKC <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 SKC session: path, worktree, or dir [--mpreset]" },
|
|
2552
2635
|
{ command: "session_recent", description: "List recent SKC sessions" },
|
|
2553
2636
|
{ command: "session_close", description: "Close a SKC-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. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Discover project and user runtime skills without loading full skill content.
|
|
2
|
+
|
|
3
|
+
<instruction>
|
|
4
|
+
- Searches only custom runtime skill locations: project `.skc/skills` and user `~/.skc/skills`.
|
|
5
|
+
- Built-in, bundled, and internal workflow skills are intentionally excluded.
|
|
6
|
+
- Returns thin metadata only: name, description, source scope, path, and use conditions when present.
|
|
7
|
+
- To load a selected skill's full `SKILL.md`, invoke it through the existing `skill` tool with the exact `name` returned here.
|
|
8
|
+
</instruction>
|
|
9
|
+
|
|
10
|
+
Input:
|
|
11
|
+
- `query` (optional): words to match against skill name, description, source, or use conditions.
|
|
12
|
+
- `source` (optional): `all`, `project`, or `user`.
|
|
13
|
+
- `limit` (optional): maximum results, 1-50.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AuthCredentialSelector, AuthCredentialSelectorKind } from "@sayknow-cli/ai";
|
|
2
|
+
|
|
3
|
+
export interface CliCredentialSelector {
|
|
4
|
+
provider?: string;
|
|
5
|
+
selector: AuthCredentialSelector;
|
|
6
|
+
raw: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const SELECTOR_KINDS = new Set<AuthCredentialSelectorKind>(["id", "email", "account", "project"]);
|
|
10
|
+
|
|
11
|
+
function parseSelectorBody(body: string): AuthCredentialSelector | undefined {
|
|
12
|
+
const separator = body.indexOf(":");
|
|
13
|
+
if (separator === -1) {
|
|
14
|
+
if (body.includes("@")) return { kind: "email", value: body };
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
const rawKind = body.slice(0, separator);
|
|
18
|
+
const value = body.slice(separator + 1).trim();
|
|
19
|
+
if (!SELECTOR_KINDS.has(rawKind as AuthCredentialSelectorKind) || value.length === 0) return undefined;
|
|
20
|
+
return { kind: rawKind as AuthCredentialSelectorKind, value };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseCliCredentialSelector(raw: string): CliCredentialSelector {
|
|
24
|
+
const trimmed = raw.trim();
|
|
25
|
+
const slash = trimmed.indexOf("/");
|
|
26
|
+
const provider = slash === -1 ? undefined : trimmed.slice(0, slash).trim();
|
|
27
|
+
const body = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
|
|
28
|
+
const selector = parseSelectorBody(body);
|
|
29
|
+
if (!selector || provider === "") {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Invalid --credential selector "${raw}". Use email:name@example.com, id:123, account:<id>, project:<id>, or provider/email:name@example.com.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return { ...(provider ? { provider } : {}), selector, raw };
|
|
35
|
+
}
|
package/src/sdk.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "@sayknow-cli/agent-core";
|
|
11
11
|
import {
|
|
12
12
|
type Api,
|
|
13
|
+
type AuthCredentialSelector,
|
|
13
14
|
type CredentialDisabledEvent,
|
|
14
15
|
type Message,
|
|
15
16
|
type Model,
|
|
@@ -40,7 +41,7 @@ import {
|
|
|
40
41
|
import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled, jobElapsedMs } from "./async";
|
|
41
42
|
import { loadCapability } from "./capability";
|
|
42
43
|
import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
|
|
43
|
-
import { ModelRegistry } from "./config/model-registry";
|
|
44
|
+
import { kNoAuth, ModelRegistry } from "./config/model-registry";
|
|
44
45
|
import {
|
|
45
46
|
defaultModelPerProvider,
|
|
46
47
|
formatModelString,
|
|
@@ -274,6 +275,8 @@ export interface CreateAgentSessionOptions {
|
|
|
274
275
|
/** Optional provider-facing session identifier for prompt caches and sticky auth selection.
|
|
275
276
|
* Keeps persisted session files isolated while reusing provider-side caches. */
|
|
276
277
|
providerSessionId?: string;
|
|
278
|
+
/** Runtime credential selector for multi-account auth pools. */
|
|
279
|
+
credentialSelector?: { provider?: string; selector: AuthCredentialSelector; raw: string };
|
|
277
280
|
|
|
278
281
|
/** Custom tools to register (in addition to built-in tools). Accepts both CustomTool and ToolDefinition. */
|
|
279
282
|
customTools?: (CustomTool | ToolDefinition)[];
|
|
@@ -905,10 +908,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
905
908
|
startupCredentialDisabledEvents.push(event);
|
|
906
909
|
}
|
|
907
910
|
});
|
|
911
|
+
let runtimeCredentialSelectorInstalled = false;
|
|
912
|
+
const installRuntimeCredentialSelector = (provider: string): void => {
|
|
913
|
+
if (!options.credentialSelector || runtimeCredentialSelectorInstalled) return;
|
|
914
|
+
authStorage.setRuntimeCredentialSelector(provider, options.credentialSelector.selector);
|
|
915
|
+
runtimeCredentialSelectorInstalled = true;
|
|
916
|
+
};
|
|
917
|
+
const earlyCredentialSelectorProvider = options.credentialSelector?.provider ?? options.model?.provider;
|
|
918
|
+
if (earlyCredentialSelectorProvider) {
|
|
919
|
+
installRuntimeCredentialSelector(earlyCredentialSelectorProvider);
|
|
920
|
+
}
|
|
908
921
|
const settings = options.settings ?? (await logger.time("settings", Settings.init, { cwd, agentDir }));
|
|
909
922
|
modelRegistry.applyConfiguredModelBindings(settings);
|
|
910
923
|
logger.time("initializeWithSettings", initializeWithSettings, settings);
|
|
911
|
-
|
|
924
|
+
const canRefreshModelsBeforeCredentialSelector =
|
|
925
|
+
!options.credentialSelector || runtimeCredentialSelectorInstalled || options.modelRegistry !== undefined;
|
|
926
|
+
if (!options.modelRegistry && canRefreshModelsBeforeCredentialSelector) {
|
|
912
927
|
modelRegistry.refreshInBackground();
|
|
913
928
|
}
|
|
914
929
|
// Kick off workspace tree discovery early. The native workspace scan returns
|
|
@@ -974,7 +989,29 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
974
989
|
return cached;
|
|
975
990
|
}
|
|
976
991
|
|
|
977
|
-
const
|
|
992
|
+
const credentialSelector =
|
|
993
|
+
options.credentialSelector && !runtimeCredentialSelectorInstalled
|
|
994
|
+
? options.credentialSelector.provider === undefined ||
|
|
995
|
+
options.credentialSelector.provider === candidate.provider
|
|
996
|
+
? options.credentialSelector.selector
|
|
997
|
+
: undefined
|
|
998
|
+
: undefined;
|
|
999
|
+
if (options.credentialSelector?.provider && options.credentialSelector.provider !== candidate.provider) {
|
|
1000
|
+
modelApiKeyAvailability.set(availabilityKey, false);
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
const key = await modelRegistry.getApiKey(candidate, providerSessionId, { credentialSelector }).catch(error => {
|
|
1004
|
+
if (credentialSelector) {
|
|
1005
|
+
logger.debug("Credential selector did not match model availability candidate", {
|
|
1006
|
+
provider: candidate.provider,
|
|
1007
|
+
model: candidate.id,
|
|
1008
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1009
|
+
});
|
|
1010
|
+
return undefined;
|
|
1011
|
+
}
|
|
1012
|
+
throw error;
|
|
1013
|
+
});
|
|
1014
|
+
const hasKey = Boolean(key) && (!credentialSelector || key !== kNoAuth);
|
|
978
1015
|
modelApiKeyAvailability.set(availabilityKey, hasKey);
|
|
979
1016
|
return hasKey;
|
|
980
1017
|
};
|
|
@@ -1049,6 +1086,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1049
1086
|
const taskDepth = options.taskDepth ?? 0;
|
|
1050
1087
|
|
|
1051
1088
|
let thinkingLevel = options.thinkingLevel;
|
|
1089
|
+
const hasExplicitDefaultThinkingLevel = settings.has("defaultThinkingLevel");
|
|
1090
|
+
let thinkingLevelFromSchemaDefault = false;
|
|
1052
1091
|
|
|
1053
1092
|
// If session has data and includes a thinking entry, restore it
|
|
1054
1093
|
if (thinkingLevel === undefined && hasExistingSession && hasThinkingEntry) {
|
|
@@ -1059,13 +1098,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1059
1098
|
thinkingLevel = defaultRoleSpec.thinkingLevel;
|
|
1060
1099
|
}
|
|
1061
1100
|
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1101
|
+
// An explicit user/project default should win over the model's bundled
|
|
1102
|
+
// defaultLevel. The schema default is only a final fallback so model metadata
|
|
1103
|
+
// can keep driving first-run behavior until the user chooses "Set as default".
|
|
1104
|
+
if (thinkingLevel === undefined && hasExplicitDefaultThinkingLevel) {
|
|
1105
|
+
thinkingLevel = settings.get("defaultThinkingLevel");
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1064
1108
|
if (thinkingLevel === undefined && model?.thinking?.defaultLevel !== undefined) {
|
|
1065
1109
|
thinkingLevel = model.thinking.defaultLevel;
|
|
1066
1110
|
}
|
|
1111
|
+
|
|
1067
1112
|
if (thinkingLevel === undefined) {
|
|
1068
1113
|
thinkingLevel = settings.get("defaultThinkingLevel");
|
|
1114
|
+
thinkingLevelFromSchemaDefault = true;
|
|
1069
1115
|
}
|
|
1070
1116
|
if (model) {
|
|
1071
1117
|
const resolvedModel = model;
|
|
@@ -1610,6 +1656,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1610
1656
|
if (resolved) {
|
|
1611
1657
|
model = resolved;
|
|
1612
1658
|
modelFallbackMessage = undefined;
|
|
1659
|
+
if (thinkingLevelFromSchemaDefault && resolved.thinking?.defaultLevel !== undefined) {
|
|
1660
|
+
thinkingLevel = resolved.thinking.defaultLevel;
|
|
1661
|
+
thinkingLevelFromSchemaDefault = false;
|
|
1662
|
+
}
|
|
1663
|
+
thinkingLevel = resolveThinkingLevelForModel(resolved, thinkingLevel);
|
|
1613
1664
|
} else {
|
|
1614
1665
|
modelFallbackMessage = `Model "${options.modelPattern}" not found`;
|
|
1615
1666
|
}
|
|
@@ -1662,6 +1713,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1662
1713
|
}
|
|
1663
1714
|
}
|
|
1664
1715
|
|
|
1716
|
+
if (options.credentialSelector && !runtimeCredentialSelectorInstalled) {
|
|
1717
|
+
const credentialProvider = options.credentialSelector.provider ?? model?.provider;
|
|
1718
|
+
if (!credentialProvider) {
|
|
1719
|
+
throw new Error(
|
|
1720
|
+
`--credential ${options.credentialSelector.raw} requires a resolved model or an explicit provider prefix`,
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
installRuntimeCredentialSelector(credentialProvider);
|
|
1724
|
+
if (!options.modelRegistry && !canRefreshModelsBeforeCredentialSelector) {
|
|
1725
|
+
modelRegistry.refreshInBackground();
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1665
1728
|
const customCommandsResult: CustomCommandsLoadResult = { commands: [], errors: [] };
|
|
1666
1729
|
|
|
1667
1730
|
let extensionRunner: ExtensionRunner | undefined;
|