@sayknow-cli/coding-agent 0.5.21 → 0.5.23
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 +17 -0
- package/dist/types/cli/setup-cli.d.ts +15 -1
- package/dist/types/commands/setup.d.ts +6 -0
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/decisions/index.d.ts +18 -0
- package/dist/types/decisions/llm-backend.d.ts +51 -0
- package/dist/types/decisions/skill-routing.d.ts +10 -0
- package/dist/types/decisions/types.d.ts +91 -0
- package/dist/types/decisions/typesafe-backend.d.ts +15 -0
- package/dist/types/hooks/skill-state.d.ts +6 -0
- package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
- package/dist/types/modes/components/typesafe-key-prompt.d.ts +23 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +9 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +0 -9
- package/dist/types/setup/decision-provider.d.ts +24 -0
- package/dist/types/setup/model-onboarding-guidance.d.ts +5 -0
- package/package.json +7 -7
- package/scripts/eval-skill-routing.ts +173 -0
- package/src/cli/setup-cli.ts +53 -1
- package/src/commands/setup.ts +5 -0
- package/src/config/settings-schema.ts +12 -0
- package/src/decisions/index.ts +84 -0
- package/src/decisions/llm-backend.ts +356 -0
- package/src/decisions/skill-routing.ts +123 -0
- package/src/decisions/types.ts +119 -0
- package/src/decisions/typesafe-backend.ts +168 -0
- package/src/hooks/skill-keywords.ts +56 -0
- package/src/hooks/skill-state.ts +18 -2
- package/src/modes/components/provider-onboarding-selector.ts +13 -1
- package/src/modes/components/typesafe-key-prompt.ts +108 -0
- package/src/modes/controllers/selector-controller.ts +44 -0
- package/src/modes/interactive-mode.ts +4 -0
- package/src/modes/types.ts +2 -0
- package/src/prompts/agents/architect.md +1 -1
- package/src/prompts/agents/critic.md +1 -1
- package/src/prompts/agents/planner.md +1 -1
- package/src/sdk/bus/native-runtime-compatibility.ts +30 -3
- package/src/session/agent-session.ts +135 -2
- package/src/setup/decision-provider.ts +94 -0
- package/src/setup/model-onboarding-guidance.ts +7 -1
- package/src/slash-commands/builtin-registry.ts +18 -1
|
@@ -150,6 +150,7 @@ import { ToolExecutionComponent } from "../components/tool-execution";
|
|
|
150
150
|
import type { StatusLineSettings } from "../components/tool-status-header";
|
|
151
151
|
import { TranscriptViewerOverlay, transcriptViewerEntries } from "../components/transcript-viewer-overlay";
|
|
152
152
|
import { TreeSelectorComponent } from "../components/tree-selector";
|
|
153
|
+
import { TypeSafeKeyPromptComponent } from "../components/typesafe-key-prompt";
|
|
153
154
|
import { UserMessageSelectorComponent } from "../components/user-message-selector";
|
|
154
155
|
import type { JobsObserver } from "../jobs-observer";
|
|
155
156
|
import type { SessionObserverRegistry } from "../session-observer-registry";
|
|
@@ -879,6 +880,8 @@ export class SelectorController {
|
|
|
879
880
|
void this.showOAuthSelector("login");
|
|
880
881
|
} else if (action === "import-credentials") {
|
|
881
882
|
void this.#handleCredentialImport();
|
|
883
|
+
} else if (action === "typesafe-key") {
|
|
884
|
+
this.showTypeSafeKeyPrompt();
|
|
882
885
|
} else {
|
|
883
886
|
this.ctx.showStatus(formatProviderOnboardingCommandGuide());
|
|
884
887
|
}
|
|
@@ -892,6 +895,47 @@ export class SelectorController {
|
|
|
892
895
|
});
|
|
893
896
|
}
|
|
894
897
|
|
|
898
|
+
/**
|
|
899
|
+
* Take a TypeSafe key and verify it before storing.
|
|
900
|
+
*
|
|
901
|
+
* Verification is not optional here. The decision service fails open by design, so an
|
|
902
|
+
* unverified bad key produces no error anywhere: decisions silently keep coming from
|
|
903
|
+
* the user's own model while the UI claims TypeSafe is on. Better to keep the prompt
|
|
904
|
+
* open and say the key was rejected.
|
|
905
|
+
*/
|
|
906
|
+
showTypeSafeKeyPrompt(): void {
|
|
907
|
+
this.showSelector(done => {
|
|
908
|
+
let prompt: TypeSafeKeyPromptComponent | undefined;
|
|
909
|
+
prompt = new TypeSafeKeyPromptComponent(
|
|
910
|
+
({ apiKey }) => {
|
|
911
|
+
prompt?.setBusy(true);
|
|
912
|
+
void (async () => {
|
|
913
|
+
try {
|
|
914
|
+
const { formatTypeSafeKeyResult, setTypeSafeKey } = await import("../../setup/decision-provider");
|
|
915
|
+
const result = await setTypeSafeKey({ apiKey });
|
|
916
|
+
if (result.error) {
|
|
917
|
+
prompt?.setError(result.error);
|
|
918
|
+
this.ctx.ui.requestRender();
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
done();
|
|
922
|
+
this.ctx.showStatus(formatTypeSafeKeyResult(result));
|
|
923
|
+
} catch (error) {
|
|
924
|
+
prompt?.setError(error instanceof Error ? error.message : String(error));
|
|
925
|
+
this.ctx.ui.requestRender();
|
|
926
|
+
}
|
|
927
|
+
})();
|
|
928
|
+
},
|
|
929
|
+
() => {
|
|
930
|
+
done();
|
|
931
|
+
this.ctx.ui.requestRender();
|
|
932
|
+
},
|
|
933
|
+
() => this.ctx.ui.requestRender(),
|
|
934
|
+
);
|
|
935
|
+
return { component: prompt, focus: prompt };
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
895
939
|
async #handleCredentialImport(): Promise<void> {
|
|
896
940
|
this.ctx.showStatus("Scanning for existing Claude Code / Codex CLI credentials…");
|
|
897
941
|
const preview = await runExternalCredentialAutoImport({
|
|
@@ -2133,6 +2133,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2133
2133
|
this.#selectorController.showEffortSelector();
|
|
2134
2134
|
}
|
|
2135
2135
|
|
|
2136
|
+
showTypeSafeKeyPrompt(): void {
|
|
2137
|
+
this.#selectorController.showTypeSafeKeyPrompt();
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2136
2140
|
showProviderOnboarding(): void {
|
|
2137
2141
|
this.#selectorController.showProviderOnboarding();
|
|
2138
2142
|
}
|
package/src/modes/types.ts
CHANGED
|
@@ -321,6 +321,8 @@ export interface InteractiveModeContext {
|
|
|
321
321
|
showModelSelector(options?: { temporaryOnly?: boolean }): void;
|
|
322
322
|
showEffortSelector(): void;
|
|
323
323
|
showProviderOnboarding(): void;
|
|
324
|
+
/** Open the TypeSafe key prompt (typed decisions; not a chat model). */
|
|
325
|
+
showTypeSafeKeyPrompt(): void;
|
|
324
326
|
showPluginSelector(mode?: "install" | "uninstall"): void;
|
|
325
327
|
showUserMessageSelector(): void;
|
|
326
328
|
showTreeSelector(): void;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: architect
|
|
3
3
|
description: Read-only architecture and code-review agent with severity-rated findings and status verdicts
|
|
4
|
-
tools: read, search, find, lsp, ast_grep, web_search, bash, report_finding, irc
|
|
4
|
+
tools: read, search, find, lsp, ast_grep, web_search, bash, report_finding, skill, irc
|
|
5
5
|
thinking-level: high
|
|
6
6
|
blocking: true
|
|
7
7
|
forkContext: allowed
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: critic
|
|
3
3
|
description: Read-only plan critic that approves only actionable, verifiable execution plans
|
|
4
|
-
tools: read, search, find, lsp, ast_grep, web_search, bash, irc
|
|
4
|
+
tools: read, search, find, lsp, ast_grep, web_search, bash, skill, irc
|
|
5
5
|
thinking-level: high
|
|
6
6
|
bashAllowedPrefixes:
|
|
7
7
|
- skc ralplan --write
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: planner
|
|
3
3
|
description: Read-only planning agent for sequencing, acceptance criteria, risks, and handoff shape
|
|
4
|
-
tools: read, search, find, lsp, ast_grep, web_search, bash, irc
|
|
4
|
+
tools: read, search, find, lsp, ast_grep, web_search, bash, skill, irc
|
|
5
5
|
thinking-level: medium
|
|
6
6
|
bashAllowedPrefixes:
|
|
7
7
|
- skc ralplan --write
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
|
|
1
3
|
const REQUIRED_WORKFLOW_ARBITRATION_METHODS = ["registerArbitratedAsk", "retireIfUnclaimed", "stopAndWait"] as const;
|
|
2
4
|
|
|
3
5
|
export class NativeRuntimeCompatibilityError extends Error {
|
|
@@ -8,12 +10,20 @@ export class NativeRuntimeCompatibilityError extends Error {
|
|
|
8
10
|
readonly runtimeVersion: string,
|
|
9
11
|
readonly nativeVersion: string,
|
|
10
12
|
readonly workflowArbitrationAvailable: boolean,
|
|
13
|
+
readonly nativeModulePath: string | null = null,
|
|
11
14
|
) {
|
|
15
|
+
const loadedFrom = nativeModulePath ? ` (loaded from ${nativeModulePath})` : "";
|
|
16
|
+
const causes = [
|
|
17
|
+
...(runtimeVersion === nativeVersion
|
|
18
|
+
? []
|
|
19
|
+
: [`loaded native version is ${nativeVersion}${loadedFrom}, expected ${runtimeVersion}`]),
|
|
20
|
+
...(workflowArbitrationAvailable ? [] : [`required workflow arbitration methods are missing${loadedFrom}`]),
|
|
21
|
+
];
|
|
12
22
|
super(
|
|
13
23
|
`Incompatible @sayknow-cli/natives for @sayknow-cli/coding-agent@${runtimeVersion}: ` +
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
`
|
|
24
|
+
`${causes.join("; ")}. ` +
|
|
25
|
+
`Reinstall matching @sayknow-cli/coding-agent and @sayknow-cli/natives packages, ` +
|
|
26
|
+
`and remove any stale nested node_modules copy of @sayknow-cli/natives that shadows it.`,
|
|
17
27
|
);
|
|
18
28
|
this.name = "NativeRuntimeCompatibilityError";
|
|
19
29
|
}
|
|
@@ -27,10 +37,26 @@ function hasWorkflowArbitrationMethods(notificationServer: unknown): boolean {
|
|
|
27
37
|
);
|
|
28
38
|
}
|
|
29
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Where the process actually loaded `@sayknow-cli/natives` from. Diagnostic only:
|
|
42
|
+
* a nested `node_modules` copy wins resolution over a workspace link, so the path
|
|
43
|
+
* is the difference between "reinstall something" and a one-line fix.
|
|
44
|
+
*/
|
|
45
|
+
function resolveNativeModulePath(): string | null {
|
|
46
|
+
try {
|
|
47
|
+
const resolved = import.meta.resolve?.("@sayknow-cli/natives");
|
|
48
|
+
if (typeof resolved !== "string") return null;
|
|
49
|
+
return resolved.startsWith("file:") ? fileURLToPath(resolved) : resolved;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
export function assertNativeRuntimeCompatibility(input: {
|
|
31
56
|
runtimeVersion: string;
|
|
32
57
|
nativeVersion: string;
|
|
33
58
|
notificationServer: unknown;
|
|
59
|
+
nativeModulePath?: string | null;
|
|
34
60
|
}): void {
|
|
35
61
|
const workflowArbitrationAvailable = hasWorkflowArbitrationMethods(input.notificationServer);
|
|
36
62
|
if (input.runtimeVersion !== input.nativeVersion || !workflowArbitrationAvailable)
|
|
@@ -38,5 +64,6 @@ export function assertNativeRuntimeCompatibility(input: {
|
|
|
38
64
|
input.runtimeVersion,
|
|
39
65
|
input.nativeVersion,
|
|
40
66
|
workflowArbitrationAvailable,
|
|
67
|
+
input.nativeModulePath ?? resolveNativeModulePath(),
|
|
41
68
|
);
|
|
42
69
|
}
|
|
@@ -202,6 +202,8 @@ import { onAppendOnlyModeChanged } from "../config/settings";
|
|
|
202
202
|
import type { SettingPath } from "../config/settings-schema";
|
|
203
203
|
import { getDefault } from "../config/settings-schema";
|
|
204
204
|
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
|
|
205
|
+
import { createDecisionService } from "../decisions";
|
|
206
|
+
import { createSemanticSkillRouter, type SkillRouter } from "../decisions/skill-routing";
|
|
205
207
|
import { loadCapability } from "../discovery";
|
|
206
208
|
import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
|
|
207
209
|
import { MAX_EDIT_FILE_BYTES } from "../edit/read-file";
|
|
@@ -260,7 +262,12 @@ import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slas
|
|
|
260
262
|
import { GoalRuntime } from "../goals/runtime";
|
|
261
263
|
import type { Goal, GoalModeState } from "../goals/state";
|
|
262
264
|
import type { HindsightSessionState } from "../hindsight/state";
|
|
263
|
-
import {
|
|
265
|
+
import {
|
|
266
|
+
buildSkillStopOutput,
|
|
267
|
+
detectPrimarySkillKeyword,
|
|
268
|
+
ensureWorkflowSkillActivationState,
|
|
269
|
+
} from "../hooks/skill-state";
|
|
270
|
+
import { buildUiSkillActivationContext } from "../hooks/ui-skill-keywords";
|
|
264
271
|
import { initializeLocalRoot, type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
|
|
265
272
|
import { shutdownAll as shutdownAllLspClients } from "../lsp/client";
|
|
266
273
|
import { resolveMemoryBackend } from "../memory-backend";
|
|
@@ -307,6 +314,7 @@ import {
|
|
|
307
314
|
} from "../skc-runtime/session-state-sidecar";
|
|
308
315
|
import { requestSkcWorkerIntegrationAttempt } from "../skc-runtime/team-runtime";
|
|
309
316
|
import {
|
|
317
|
+
type CanonicalSkcWorkflowSkill,
|
|
310
318
|
isCanonicalSkcWorkflowSkill,
|
|
311
319
|
readVisibleSkillActiveState,
|
|
312
320
|
syncSkillActiveState,
|
|
@@ -1842,6 +1850,9 @@ export class AgentSession {
|
|
|
1842
1850
|
// Model registry for API key resolution
|
|
1843
1851
|
#modelRegistry: ModelRegistry;
|
|
1844
1852
|
|
|
1853
|
+
/** Built on first use; the decision service resolves model and credential lazily. */
|
|
1854
|
+
#semanticSkillRouter?: SkillRouter;
|
|
1855
|
+
|
|
1845
1856
|
// Tool registry and prompt builder for extensions
|
|
1846
1857
|
#toolRegistry: Map<string, AgentTool>;
|
|
1847
1858
|
#workflowGateToolSession: ToolSession | undefined;
|
|
@@ -7548,6 +7559,78 @@ export class AgentSession {
|
|
|
7548
7559
|
* @throws Error if streaming and no streamingBehavior specified
|
|
7549
7560
|
* @throws Error if no model selected or no API key available (when not streaming)
|
|
7550
7561
|
*/
|
|
7562
|
+
/**
|
|
7563
|
+
* Semantic workflow routing for prompts the keyword table cannot express.
|
|
7564
|
+
*
|
|
7565
|
+
* Runs in this process, not the hook process: the hook only receives paths and
|
|
7566
|
+
* config, so it has no model registry and no credentials to call anything with.
|
|
7567
|
+
*
|
|
7568
|
+
* **The keyword table is not consulted here, and that is deliberate.** An earlier
|
|
7569
|
+
* version returned early on a keyword hit, on the assumption that the deterministic
|
|
7570
|
+
* stage had already activated the workflow. That assumption holds only under the
|
|
7571
|
+
* Codex host, where `skc codex-native-hook` runs on `UserPromptSubmit`. This session
|
|
7572
|
+
* never fires that hook, so the early return meant a prompt containing an enumerated
|
|
7573
|
+
* keyword activated *nothing at all* — strictly worse than before the keywords
|
|
7574
|
+
* existed, because the semantic stage had been handling those phrasings.
|
|
7575
|
+
*
|
|
7576
|
+
* Keywords remain advisory in this host, as they always were: the routing rules in
|
|
7577
|
+
* the system prompt describe them to the model. Only this stage activates, and only
|
|
7578
|
+
* when it is confident enough to be worth the mutation guard and Stop hook that
|
|
7579
|
+
* activation switches on.
|
|
7580
|
+
*
|
|
7581
|
+
* Deliberately best-effort — a disabled setting, a missing credential, a timeout, a
|
|
7582
|
+
* nonsense answer or low confidence all resolve to "no activation", which is
|
|
7583
|
+
* precisely the behaviour before this stage existed.
|
|
7584
|
+
*/
|
|
7585
|
+
async #routeWorkflowSemantically(text: string): Promise<void> {
|
|
7586
|
+
// Stage one: the keyword table. Free, deterministic, and measured at zero false
|
|
7587
|
+
// positives, so it is not gated behind the opt-in setting — gating it was why an
|
|
7588
|
+
// enumerated phrase activated nothing in this host while the Codex hook activated
|
|
7589
|
+
// it fine. Activating here makes the two hosts agree.
|
|
7590
|
+
const keyword = detectPrimarySkillKeyword(text);
|
|
7591
|
+
if (keyword) {
|
|
7592
|
+
await this.#activateWorkflowSkill(keyword.skill);
|
|
7593
|
+
return;
|
|
7594
|
+
}
|
|
7595
|
+
|
|
7596
|
+
// Stage two costs a model call, so it stays opt-in.
|
|
7597
|
+
if (!this.settings.get("decisions.enabled")) return;
|
|
7598
|
+
try {
|
|
7599
|
+
this.#semanticSkillRouter ??= createSemanticSkillRouter(
|
|
7600
|
+
createDecisionService({
|
|
7601
|
+
registry: this.#modelRegistry,
|
|
7602
|
+
settings: this.settings,
|
|
7603
|
+
sessionId: this.sessionManager.getSessionId(),
|
|
7604
|
+
enabled: true,
|
|
7605
|
+
}),
|
|
7606
|
+
);
|
|
7607
|
+
const skill = await this.#semanticSkillRouter(text);
|
|
7608
|
+
if (!skill) return;
|
|
7609
|
+
await this.#activateWorkflowSkill(skill);
|
|
7610
|
+
} catch (error) {
|
|
7611
|
+
logger.debug("agent-session: semantic workflow routing failed", { error: String(error) });
|
|
7612
|
+
}
|
|
7613
|
+
}
|
|
7614
|
+
|
|
7615
|
+
/**
|
|
7616
|
+
* Seed workflow state and attach the ask tool.
|
|
7617
|
+
*
|
|
7618
|
+
* Both routing stages funnel through here, and both are best-effort: a failure to
|
|
7619
|
+
* write state must never take down the user's turn, so it is logged and swallowed.
|
|
7620
|
+
*/
|
|
7621
|
+
async #activateWorkflowSkill(skill: CanonicalSkcWorkflowSkill): Promise<void> {
|
|
7622
|
+
try {
|
|
7623
|
+
await ensureWorkflowSkillActivationState({
|
|
7624
|
+
cwd: this.sessionManager.getCwd(),
|
|
7625
|
+
skill,
|
|
7626
|
+
sessionId: this.sessionManager.getSessionId(),
|
|
7627
|
+
});
|
|
7628
|
+
this.#attachAskTool();
|
|
7629
|
+
} catch (error) {
|
|
7630
|
+
logger.debug("agent-session: workflow activation failed", { skill, error: String(error) });
|
|
7631
|
+
}
|
|
7632
|
+
}
|
|
7633
|
+
|
|
7551
7634
|
async prompt(text: string, options?: PromptOptions): Promise<void> {
|
|
7552
7635
|
this.#assertRecoveryHydrationPromoted();
|
|
7553
7636
|
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
|
|
@@ -7606,6 +7689,20 @@ export class AgentSession {
|
|
|
7606
7689
|
const deepInterviewUserIntentEpoch =
|
|
7607
7690
|
claimsGenuineUserIntent && !this.isStreaming ? this.#claimDeepInterviewUserIntent() : undefined;
|
|
7608
7691
|
|
|
7692
|
+
// The keyword table in `hooks/skill-keywords.ts` is a list of literal strings, so a
|
|
7693
|
+
// Korean phrasing of "plan this before you touch code" activates nothing. Ask a
|
|
7694
|
+
// cheap model instead, and only for real user turns. Any failure leaves routing to
|
|
7695
|
+
// the system prompt, exactly as before.
|
|
7696
|
+
//
|
|
7697
|
+
// Ordering matters: this runs *after* the deep-interview intent claim above but
|
|
7698
|
+
// before streaming, so a workflow it activates is in place before the ambiguity
|
|
7699
|
+
// detector in `skc-runtime/deep-interview-ambiguity.ts` would otherwise seed one.
|
|
7700
|
+
// Verified end to end: "설계가 위험해 보여 … 승인받을 문서부터 만들자" seeds
|
|
7701
|
+
// deep-interview with the setting off and ralplan with it on. Both are plausible
|
|
7702
|
+
// readings and ralplan is the better one here, but the point is that enabling this
|
|
7703
|
+
// can *change* an activation rather than only add one where there was none.
|
|
7704
|
+
if (claimsGenuineUserIntent && !this.isStreaming) await this.#routeWorkflowSemantically(expandedText);
|
|
7705
|
+
|
|
7609
7706
|
// If streaming, queue via steer() or followUp() based on option
|
|
7610
7707
|
if (this.isStreaming) {
|
|
7611
7708
|
if (!options?.streamingBehavior) {
|
|
@@ -7639,6 +7736,7 @@ export class AgentSession {
|
|
|
7639
7736
|
const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force");
|
|
7640
7737
|
const eagerTodoPrelude =
|
|
7641
7738
|
!options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
|
|
7739
|
+
const uiSkillPrelude = options?.synthetic ? undefined : this.#createUiSkillPrelude(expandedText);
|
|
7642
7740
|
|
|
7643
7741
|
const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
|
|
7644
7742
|
if (options?.images) {
|
|
@@ -7667,7 +7765,13 @@ export class AgentSession {
|
|
|
7667
7765
|
try {
|
|
7668
7766
|
await this.#promptWithMessage(message, expandedText, {
|
|
7669
7767
|
...options,
|
|
7670
|
-
prependMessages:
|
|
7768
|
+
prependMessages:
|
|
7769
|
+
eagerTodoPrelude || uiSkillPrelude
|
|
7770
|
+
? [
|
|
7771
|
+
...(uiSkillPrelude ? [uiSkillPrelude] : []),
|
|
7772
|
+
...(eagerTodoPrelude ? [eagerTodoPrelude.message] : []),
|
|
7773
|
+
]
|
|
7774
|
+
: undefined,
|
|
7671
7775
|
admissionLease: admission,
|
|
7672
7776
|
resetRetryReplaySafety: true,
|
|
7673
7777
|
});
|
|
@@ -11914,6 +12018,35 @@ export class AgentSession {
|
|
|
11914
12018
|
});
|
|
11915
12019
|
}
|
|
11916
12020
|
|
|
12021
|
+
/**
|
|
12022
|
+
* Deterministic activation for the bundled frontend UI/UX skills.
|
|
12023
|
+
*
|
|
12024
|
+
* `hooks/ui-skill-keywords.ts` already knows how to match a prompt against all
|
|
12025
|
+
* thirteen bundled skills in Korean and English, but the only caller was the Codex
|
|
12026
|
+
* `UserPromptSubmit` hook — which this host never fires. In an SKC session the skills
|
|
12027
|
+
* were therefore advertised solely by a sentence in the system prompt, leaving it to
|
|
12028
|
+
* the model to notice and obey. That is not activation, it is hope.
|
|
12029
|
+
*
|
|
12030
|
+
* The matcher is deliberately conservative and measured that way: on sixteen real
|
|
12031
|
+
* prompts it caught 5 of 8 frontend requests and produced **zero** false positives on
|
|
12032
|
+
* the 8 backend ones. Missing a match costs nothing — the system-prompt sentence is
|
|
12033
|
+
* still there — while a wrong match would load a design skill onto a database task.
|
|
12034
|
+
* That asymmetry is why a reminder is the right shape here and a forced tool call is
|
|
12035
|
+
* not.
|
|
12036
|
+
*/
|
|
12037
|
+
#createUiSkillPrelude(promptText: string): AgentMessage | undefined {
|
|
12038
|
+
if (this.#planModeState?.enabled) return undefined;
|
|
12039
|
+
const directive = buildUiSkillActivationContext(promptText);
|
|
12040
|
+
if (!directive) return undefined;
|
|
12041
|
+
logger.debug("agent-session: bundled UI skill matched", { promptChars: promptText.length });
|
|
12042
|
+
return {
|
|
12043
|
+
role: "developer",
|
|
12044
|
+
content: [{ type: "text", text: `<system-reminder>\n${directive}\n</system-reminder>` }],
|
|
12045
|
+
attribution: "agent",
|
|
12046
|
+
timestamp: Date.now(),
|
|
12047
|
+
};
|
|
12048
|
+
}
|
|
12049
|
+
|
|
11917
12050
|
#createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
|
|
11918
12051
|
const eagerTodosEnabled = this.settings.get("todo.eager");
|
|
11919
12052
|
const todosEnabled = this.settings.get("todo.enabled");
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store a TypeSafe key so the typed-decision service can use the hosted model.
|
|
3
|
+
*
|
|
4
|
+
* TypeSafe is **not** a chat provider: no streaming, no messages, no text output. So it
|
|
5
|
+
* must not be written into `models.yml` the way `addApiCompatibleProvider` writes
|
|
6
|
+
* OpenAI-compatible endpoints — that would invent a chat model that cannot answer a
|
|
7
|
+
* chat request and would show up in the model picker as something users could select.
|
|
8
|
+
*
|
|
9
|
+
* Only the credential is stored. The decision backend reads it through the same
|
|
10
|
+
* `getApiKeyForProvider` path every other provider uses, so adding a key turns the
|
|
11
|
+
* backend on and removing it turns the backend off, with nothing else to configure.
|
|
12
|
+
*/
|
|
13
|
+
import { AuthStorage } from "@sayknow-cli/ai";
|
|
14
|
+
import { getAgentDbPath } from "@sayknow-cli/utils";
|
|
15
|
+
import { TYPESAFE_PROVIDER } from "../decisions";
|
|
16
|
+
|
|
17
|
+
const VERIFY_ENDPOINT = "https://api.typesafe.ai/v1/models";
|
|
18
|
+
const VERIFY_TIMEOUT_MS = 10_000;
|
|
19
|
+
|
|
20
|
+
export interface TypeSafeKeySetupResult {
|
|
21
|
+
provider: string;
|
|
22
|
+
verified: boolean;
|
|
23
|
+
/** Present when verification ran and failed; the key is not stored in that case. */
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Verify a key against the live API before storing it.
|
|
29
|
+
*
|
|
30
|
+
* Storing an unverified key is worse than refusing it: the decision service fails open,
|
|
31
|
+
* so a bad key produces no error anywhere — it silently falls back to the user's own
|
|
32
|
+
* model forever, and the user believes TypeSafe is active.
|
|
33
|
+
*/
|
|
34
|
+
export async function verifyTypeSafeKey(apiKey: string, fetchImpl: typeof fetch = fetch): Promise<string | null> {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetchImpl(VERIFY_ENDPOINT, {
|
|
39
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
40
|
+
signal: controller.signal,
|
|
41
|
+
});
|
|
42
|
+
if (response.ok) return null;
|
|
43
|
+
if (response.status === 401 || response.status === 403) return "key rejected by TypeSafe (401/403)";
|
|
44
|
+
return `TypeSafe returned ${response.status}`;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return `could not reach TypeSafe: ${error instanceof Error ? error.message : String(error)}`;
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SetTypeSafeKeyOptions {
|
|
53
|
+
apiKey: string;
|
|
54
|
+
/** Skip the live check. Only for offline setup; the key may be wrong. */
|
|
55
|
+
skipVerify?: boolean;
|
|
56
|
+
fetchImpl?: typeof fetch;
|
|
57
|
+
dbPath?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function setTypeSafeKey(options: SetTypeSafeKeyOptions): Promise<TypeSafeKeySetupResult> {
|
|
61
|
+
const apiKey = options.apiKey.trim();
|
|
62
|
+
if (!apiKey) throw new Error("TypeSafe API key must not be empty");
|
|
63
|
+
|
|
64
|
+
let verified = false;
|
|
65
|
+
if (!options.skipVerify) {
|
|
66
|
+
const failure = await verifyTypeSafeKey(apiKey, options.fetchImpl ?? fetch);
|
|
67
|
+
if (failure) return { provider: TYPESAFE_PROVIDER, verified: false, error: failure };
|
|
68
|
+
verified = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const authStorage = await AuthStorage.create(options.dbPath ?? getAgentDbPath());
|
|
72
|
+
try {
|
|
73
|
+
await authStorage.set(TYPESAFE_PROVIDER, { type: "api_key", key: apiKey });
|
|
74
|
+
} finally {
|
|
75
|
+
authStorage.close();
|
|
76
|
+
}
|
|
77
|
+
return { provider: TYPESAFE_PROVIDER, verified };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function removeTypeSafeKey(dbPath?: string): Promise<void> {
|
|
81
|
+
const authStorage = await AuthStorage.create(dbPath ?? getAgentDbPath());
|
|
82
|
+
try {
|
|
83
|
+
await authStorage.remove(TYPESAFE_PROVIDER);
|
|
84
|
+
} finally {
|
|
85
|
+
authStorage.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatTypeSafeKeyResult(result: TypeSafeKeySetupResult): string {
|
|
90
|
+
if (result.error) return `✗ TypeSafe key not stored: ${result.error}`;
|
|
91
|
+
return result.verified
|
|
92
|
+
? "✓ TypeSafe key verified and stored. Typed decisions will use the hosted model."
|
|
93
|
+
: "✓ TypeSafe key stored (unverified). Typed decisions will try the hosted model.";
|
|
94
|
+
}
|
|
@@ -6,6 +6,11 @@ export const MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND = "/provider add --preset
|
|
|
6
6
|
|
|
7
7
|
export const MODEL_ONBOARDING_SETUP_COMMAND = "skc setup provider";
|
|
8
8
|
export const MODEL_ONBOARDING_OAUTH_COMMAND = "/provider login [provider-id] or /login [provider-id]";
|
|
9
|
+
/**
|
|
10
|
+
* TypeSafe is not a chat model and never appears in the model list, so the only way a
|
|
11
|
+
* user learns it exists is from the surfaces where they go to add credentials.
|
|
12
|
+
*/
|
|
13
|
+
export const MODEL_ONBOARDING_TYPESAFE_COMMAND = "/provider typesafe";
|
|
9
14
|
|
|
10
15
|
export function formatModelOnboardingGuidance(): string {
|
|
11
16
|
return [
|
|
@@ -15,12 +20,13 @@ export function formatModelOnboardingGuidance(): string {
|
|
|
15
20
|
`Provider presets: ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND} --preset <preset>).`,
|
|
16
21
|
`API-compatible custom providers: ${MODEL_ONBOARDING_API_PROVIDER_COMMAND}.`,
|
|
17
22
|
`OAuth/subscription providers: ${MODEL_ONBOARDING_OAUTH_COMMAND}.`,
|
|
23
|
+
`Typed decisions (not a chat model): ${MODEL_ONBOARDING_TYPESAFE_COMMAND} adds a TypeSafe key.`,
|
|
18
24
|
"Then run /model to select a configured model or assign it to a target.",
|
|
19
25
|
].join("\n");
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export function formatModelOnboardingInlineHint(): string {
|
|
23
|
-
return `Add MiniMax/GLM presets with ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND}; custom API providers with ${MODEL_ONBOARDING_API_PROVIDER_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND}); OAuth/subscription with ${MODEL_ONBOARDING_OAUTH_COMMAND}; then run /model for DEFAULT, EXECUTOR, ARCHITECT, PLANNER, and CRITIC.`;
|
|
29
|
+
return `Add MiniMax/GLM presets with ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND}; custom API providers with ${MODEL_ONBOARDING_API_PROVIDER_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND}); OAuth/subscription with ${MODEL_ONBOARDING_OAUTH_COMMAND}; TypeSafe typed decisions with ${MODEL_ONBOARDING_TYPESAFE_COMMAND}; then run /model for DEFAULT, EXECUTOR, ARCHITECT, PLANNER, and CRITIC.`;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
export function formatNoModelOnboardingError(): string {
|
|
@@ -1288,7 +1288,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1288
1288
|
{
|
|
1289
1289
|
name: "provider",
|
|
1290
1290
|
description: "Set up API-compatible providers or login providers",
|
|
1291
|
-
inlineHint: "add|login",
|
|
1291
|
+
inlineHint: "add|login|typesafe",
|
|
1292
1292
|
allowArgs: true,
|
|
1293
1293
|
handle: async (command, runtime) => {
|
|
1294
1294
|
const args = command.args.trim();
|
|
@@ -1296,6 +1296,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1296
1296
|
await runtime.output(providerSetupUsage());
|
|
1297
1297
|
return commandConsumed();
|
|
1298
1298
|
}
|
|
1299
|
+
if (args === "typesafe") {
|
|
1300
|
+
await runtime.output(
|
|
1301
|
+
"TypeSafe key entry needs an interactive terminal.\n" +
|
|
1302
|
+
"Run it in the TUI (/provider typesafe) or from a shell:\n" +
|
|
1303
|
+
" TYPESAFE_API_KEY=<key> skc setup typesafe\n" +
|
|
1304
|
+
" skc setup typesafe --remove",
|
|
1305
|
+
);
|
|
1306
|
+
return commandConsumed();
|
|
1307
|
+
}
|
|
1299
1308
|
if (args === "login" || args.startsWith("login ")) {
|
|
1300
1309
|
const providerId = args.slice("login".length).trim();
|
|
1301
1310
|
const loginCommand = providerId ? `/login ${providerId}` : "/login [provider-id]";
|
|
@@ -1361,6 +1370,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1361
1370
|
runtime.ctx.editor.setText("");
|
|
1362
1371
|
return;
|
|
1363
1372
|
}
|
|
1373
|
+
// TypeSafe is not a chat model, so it cannot live in the model list. A direct
|
|
1374
|
+
// subcommand keeps it one step away from `/model`, where users actually look
|
|
1375
|
+
// for "add a key", instead of buried in the onboarding menu.
|
|
1376
|
+
if (args === "typesafe") {
|
|
1377
|
+
runtime.ctx.showTypeSafeKeyPrompt();
|
|
1378
|
+
runtime.ctx.editor.setText("");
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1364
1381
|
if (args.startsWith("add ")) {
|
|
1365
1382
|
const parsed = parseProviderSetupSlashArgs(args.slice(4));
|
|
1366
1383
|
try {
|