@sayknow-cli/coding-agent 0.5.26 → 0.6.0

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/config/settings-schema.d.ts +25 -5
  3. package/dist/types/decisions/keyword-learning.d.ts +61 -0
  4. package/dist/types/decisions/llm-backend.d.ts +13 -1
  5. package/dist/types/decisions/prompt-triage.d.ts +42 -0
  6. package/dist/types/decisions/skill-routing.d.ts +41 -6
  7. package/dist/types/hooks/native-prompt-routing.d.ts +21 -0
  8. package/dist/types/hooks/native-skill-hook.d.ts +3 -0
  9. package/dist/types/hooks/skill-keywords.d.ts +9 -0
  10. package/dist/types/hooks/skill-state.d.ts +20 -3
  11. package/dist/types/hooks/ui-skill-keywords.d.ts +15 -0
  12. package/dist/types/sdk/session.d.ts +3 -13
  13. package/dist/types/session/agent-session.d.ts +8 -0
  14. package/dist/types/session/auth-storage-discovery.d.ts +13 -0
  15. package/dist/types/tools/browser.d.ts +2 -2
  16. package/package.json +7 -7
  17. package/scripts/eval-skill-routing.ts +37 -12
  18. package/src/config/settings-schema.ts +27 -5
  19. package/src/decisions/index.ts +8 -2
  20. package/src/decisions/keyword-learning.ts +678 -0
  21. package/src/decisions/llm-backend.ts +213 -67
  22. package/src/decisions/prompt-triage.ts +163 -0
  23. package/src/decisions/skill-routing.ts +39 -56
  24. package/src/decisions/typesafe-backend.ts +3 -0
  25. package/src/hooks/native-prompt-routing.ts +190 -0
  26. package/src/hooks/native-skill-hook.ts +21 -12
  27. package/src/hooks/skill-keywords.ts +9 -0
  28. package/src/hooks/skill-state.ts +41 -10
  29. package/src/hooks/ui-skill-keywords.ts +67 -10
  30. package/src/internal-urls/docs-index.generated.ts +1 -1
  31. package/src/sdk/session.ts +5 -82
  32. package/src/session/agent-session.ts +137 -37
  33. package/src/session/auth-storage-discovery.ts +83 -0
@@ -203,7 +203,9 @@ 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
205
  import { createDecisionService } from "../decisions";
206
- import { createSemanticSkillRouter, type SkillRouter } from "../decisions/skill-routing";
206
+ import { loadLearnedKeywordDefinitions, observeRouting } from "../decisions/keyword-learning";
207
+ import { createPromptTriage, type PromptTriager } from "../decisions/prompt-triage";
208
+ import type { BundledSkcUiSkillName } from "../defaults/skc-ui-skills";
207
209
  import { loadCapability } from "../discovery";
208
210
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
209
211
  import { MAX_EDIT_FILE_BYTES } from "../edit/read-file";
@@ -267,7 +269,11 @@ import {
267
269
  detectPrimarySkillKeyword,
268
270
  ensureWorkflowSkillActivationState,
269
271
  } from "../hooks/skill-state";
270
- import { buildUiSkillActivationContext } from "../hooks/ui-skill-keywords";
272
+ import {
273
+ buildUiSkillActivationContext,
274
+ buildUiSkillDirectiveForSkill,
275
+ detectUiSkillKeywords,
276
+ } from "../hooks/ui-skill-keywords";
271
277
  import { initializeLocalRoot, type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
272
278
  import { shutdownAll as shutdownAllLspClients } from "../lsp/client";
273
279
  import { resolveMemoryBackend } from "../memory-backend";
@@ -533,6 +539,14 @@ export interface AgentSessionConfig {
533
539
  toolRegistry?: Map<string, AgentTool>;
534
540
  /** Tool-session factory context used to lazily attach workflow-gate-only tools. */
535
541
  workflowGateToolSession?: ToolSession;
542
+ /**
543
+ * Stage two of workflow routing: before a genuine user turn, ask a small model
544
+ * through the session's own transport which SKC workflow the prompt calls for.
545
+ * Hosts that own an interactive user (`createAgentSession`) turn this on; a bare
546
+ * session leaves it off so a scripted or injected transport is never consumed by a
547
+ * call the host did not script. `decisions.enabled` still governs the user side.
548
+ */
549
+ semanticWorkflowRouting?: boolean;
536
550
  /** Current session pre-LLM message transform pipeline */
537
551
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
538
552
  /** Provider payload hook used by the active session request path */
@@ -1851,11 +1865,18 @@ export class AgentSession {
1851
1865
  #modelRegistry: ModelRegistry;
1852
1866
 
1853
1867
  /** Built on first use; the decision service resolves model and credential lazily. */
1854
- #semanticSkillRouter?: SkillRouter;
1868
+ #promptTriager?: PromptTriager;
1869
+
1870
+ /**
1871
+ * UI skill the semantic stage picked for this turn, when the regex table did
1872
+ * not. Cleared per prompt: it describes one sentence, not the session.
1873
+ */
1874
+ #semanticUiSkill?: BundledSkcUiSkillName;
1855
1875
 
1856
1876
  // Tool registry and prompt builder for extensions
1857
1877
  #toolRegistry: Map<string, AgentTool>;
1858
1878
  #workflowGateToolSession: ToolSession | undefined;
1879
+ #semanticWorkflowRouting: boolean;
1859
1880
  #transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
1860
1881
  #onPayload: SimpleStreamOptions["onPayload"] | undefined;
1861
1882
  #onResponse: SimpleStreamOptions["onResponse"] | undefined;
@@ -1979,6 +2000,12 @@ export class AgentSession {
1979
2000
  nonEditDeterminations: 0,
1980
2001
  };
1981
2002
  #promptInFlightCount = 0;
2003
+ /**
2004
+ * A genuine user prompt is being routed to a workflow before its turn starts. Counts
2005
+ * as streaming so a second `prompt()` queues or rejects exactly as it would mid-turn,
2006
+ * and `abort()` cancels the routing through the preflight signal.
2007
+ */
2008
+ #workflowRoutingInFlight = false;
1982
2009
  #agentEventHandlersInFlight = 0;
1983
2010
  #queuedExtensionEventCount = 0;
1984
2011
  #extensionTurnGeneration = 0;
@@ -2405,6 +2432,7 @@ export class AgentSession {
2405
2432
  }
2406
2433
  this.#toolRegistry = config.toolRegistry ?? new Map();
2407
2434
  this.#workflowGateToolSession = config.workflowGateToolSession;
2435
+ this.#semanticWorkflowRouting = config.semanticWorkflowRouting ?? false;
2408
2436
  this.#requestedToolNames = config.requestedToolNames;
2409
2437
  this.#explicitlyDisabledTools = config.explicitlyDisabledTools === true;
2410
2438
  this.#transformContext = config.transformContext ?? (messages => messages);
@@ -5497,7 +5525,7 @@ export class AgentSession {
5497
5525
 
5498
5526
  /** Whether agent is currently streaming a response */
5499
5527
  get isStreaming(): boolean {
5500
- return this.agent.state.isStreaming || this.#promptInFlightCount > 0;
5528
+ return this.agent.state.isStreaming || this.#promptInFlightCount > 0 || this.#workflowRoutingInFlight;
5501
5529
  }
5502
5530
 
5503
5531
  /** Wait until streaming and session settlement work are fully settled. */
@@ -7560,55 +7588,112 @@ export class AgentSession {
7560
7588
  * @throws Error if no model selected or no API key available (when not streaming)
7561
7589
  */
7562
7590
  /**
7563
- * Semantic workflow routing for prompts the keyword table cannot express.
7591
+ * Per-turn prompt triage: every routing question SKC asks about a user's
7592
+ * sentence, answered in one place.
7564
7593
  *
7565
7594
  * Runs in this process, not the hook process: the hook only receives paths and
7566
7595
  * config, so it has no model registry and no credentials to call anything with.
7567
7596
  *
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.
7597
+ * Three stages, cheapest first, and each one shrinks the next:
7598
+ *
7599
+ * 1. **Hand-written tables.** The eighteen workflow keywords and the twenty UI
7600
+ * regexes. Free, deterministic, measured at zero false positives, so they
7601
+ * are not gated behind the opt-in setting.
7602
+ * 2. **Learned patterns.** Two-stem rules mined from previous semantic answers
7603
+ * (`decisions/keyword-learning.ts`). Also free, also deterministic, and the
7604
+ * reason a phrasing the user repeats stops costing a model call.
7605
+ * 3. **One typed decision** for whatever stages 1 and 2 left unanswered. Both
7606
+ * questions ride the same call, so adding UI routing cost no round trip.
7575
7607
  *
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.
7608
+ * Whatever stage three answers is fed back into stage two, which is what makes
7609
+ * the keyword table self-populating rather than a list somebody has to grow by
7610
+ * hand — the original list recalled 0/9 on Korean prompts for exactly that
7611
+ * reason.
7580
7612
  *
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.
7613
+ * Keyword hits activate here rather than returning early. An earlier version
7614
+ * returned early on the assumption that the deterministic stage had already
7615
+ * activated the workflow, which holds only under the Codex host where `skc
7616
+ * codex-native-hook` runs on `UserPromptSubmit`. This session never fires that
7617
+ * hook, so the early return meant an enumerated keyword activated *nothing*.
7618
+ *
7619
+ * Deliberately best-effort — a disabled setting, a missing credential, a
7620
+ * timeout, a nonsense answer or low confidence all resolve to "no activation",
7621
+ * which is precisely the behaviour before this stage existed.
7584
7622
  */
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);
7623
+ async #routeWorkflowSemantically(text: string, signal: AbortSignal): Promise<void> {
7624
+ this.#semanticUiSkill = undefined;
7625
+ const learningEnabled = this.settings.get("decisions.keywordLearning");
7626
+ // Loaded per turn rather than cached on the session: a second session in the
7627
+ // same repo promotes patterns too, and a user who watched one get learned
7628
+ // expects the next prompt to use it, not the next restart.
7629
+ const learned = learningEnabled ? await loadLearnedKeywordDefinitions() : [];
7630
+ const keyword = detectPrimarySkillKeyword(text, learned);
7591
7631
  if (keyword) {
7632
+ logger.debug("agent-session: workflow keyword match", {
7633
+ skill: keyword.skill,
7634
+ learned: keyword.learned === true,
7635
+ });
7592
7636
  await this.#activateWorkflowSkill(keyword.skill);
7593
- return;
7594
7637
  }
7595
-
7596
- // Stage two costs a model call, so it stays opt-in.
7597
- if (!this.settings.get("decisions.enabled")) return;
7638
+ // The regex table answers the UI question for free when it matches; asking
7639
+ // the model as well would pay for an answer already in hand.
7640
+ const uiMatched = detectUiSkillKeywords(text).length > 0;
7641
+ if (keyword && uiMatched) return;
7642
+
7643
+ // Stage three costs a model call: the host must have opted the session in and
7644
+ // the user must not have turned the setting (on by default) off.
7645
+ if (!this.#semanticWorkflowRouting || !this.settings.get("decisions.enabled")) return;
7646
+ // Only the model call counts as streaming. Flagging the keyword stage too made
7647
+ // `isStreaming` flicker true for one microtask on every prompt, which a poller
7648
+ // mistook for the turn having started.
7649
+ this.#workflowRoutingInFlight = true;
7598
7650
  try {
7599
- this.#semanticSkillRouter ??= createSemanticSkillRouter(
7651
+ this.#promptTriager ??= createPromptTriage(
7600
7652
  createDecisionService({
7601
7653
  registry: this.#modelRegistry,
7602
7654
  settings: this.settings,
7603
7655
  sessionId: this.sessionManager.getSessionId(),
7656
+ preferredProvider: () => this.model?.provider,
7657
+ // The turn's own transport, not a bare `completeSimple`: the same proxy,
7658
+ // credential-invalidation and retry wrapping the main request gets, and
7659
+ // a host that injects a stream function (tests, embedders) intercepts the
7660
+ // decision too instead of watching it go to the network behind its back.
7661
+ completeImpl: async (model, context, options) =>
7662
+ (await this.agent.streamFn(model, context, options)).result(),
7604
7663
  enabled: true,
7605
7664
  }),
7606
7665
  );
7607
- const skill = await this.#semanticSkillRouter(text);
7608
- if (!skill) return;
7609
- await this.#activateWorkflowSkill(skill);
7666
+ const started = Date.now();
7667
+ const triage = await this.#promptTriager({
7668
+ text,
7669
+ skipWorkflow: Boolean(keyword),
7670
+ skipUiSkill: uiMatched,
7671
+ signal,
7672
+ });
7673
+ logger.debug("agent-session: prompt triage", {
7674
+ workflow: triage?.workflow ?? null,
7675
+ uiSkill: triage?.uiSkill ?? null,
7676
+ durationMs: Date.now() - started,
7677
+ });
7678
+ if (!triage || signal.aborted) return;
7679
+ if (triage.uiSkill) this.#semanticUiSkill = triage.uiSkill;
7680
+ if (keyword) return;
7681
+ if (triage.workflow) await this.#activateWorkflowSkill(triage.workflow);
7682
+ if (learningEnabled) {
7683
+ // Awaited, not fired and forgotten: an unawaited write racing the next
7684
+ // turn's read is how a learned pattern gets lost on the prompt that was
7685
+ // supposed to promote it. The store is a few KB and already in memory.
7686
+ await observeRouting({
7687
+ text,
7688
+ skill: triage.workflow,
7689
+ confidence: triage.workflowConfidence,
7690
+ calibrated: triage.calibrated,
7691
+ });
7692
+ }
7610
7693
  } catch (error) {
7611
- logger.debug("agent-session: semantic workflow routing failed", { error: String(error) });
7694
+ logger.debug("agent-session: prompt triage failed", { error: String(error) });
7695
+ } finally {
7696
+ this.#workflowRoutingInFlight = false;
7612
7697
  }
7613
7698
  }
7614
7699
 
@@ -7701,7 +7786,12 @@ export class AgentSession {
7701
7786
  // deep-interview with the setting off and ralplan with it on. Both are plausible
7702
7787
  // readings and ralplan is the better one here, but the point is that enabling this
7703
7788
  // can *change* an activation rather than only add one where there was none.
7704
- if (claimsGenuineUserIntent && !this.isStreaming) await this.#routeWorkflowSemantically(expandedText);
7789
+ if (claimsGenuineUserIntent && !this.isStreaming) {
7790
+ const routingGeneration = this.#promptGeneration;
7791
+ const routingSignal = this.#promptPreflightAbortController.signal;
7792
+ await this.#routeWorkflowSemantically(expandedText, routingSignal);
7793
+ this.#throwIfPromptPreflightCancelled(routingGeneration, routingSignal);
7794
+ }
7705
7795
 
7706
7796
  // If streaming, queue via steer() or followUp() based on option
7707
7797
  if (this.isStreaming) {
@@ -12033,12 +12123,22 @@ export class AgentSession {
12033
12123
  * still there — while a wrong match would load a design skill onto a database task.
12034
12124
  * That asymmetry is why a reminder is the right shape here and a forced tool call is
12035
12125
  * not.
12126
+ *
12127
+ * The three frontend prompts the patterns missed are now covered by the typed
12128
+ * decision in `#routeWorkflowSemantically`, which rides the same call as workflow
12129
+ * routing and therefore costs nothing extra. The patterns stay in front of it: when
12130
+ * they match, the model is never asked.
12036
12131
  */
12037
12132
  #createUiSkillPrelude(promptText: string): AgentMessage | undefined {
12038
12133
  if (this.#planModeState?.enabled) return undefined;
12039
- const directive = buildUiSkillActivationContext(promptText);
12134
+ const matched = buildUiSkillActivationContext(promptText);
12135
+ const directive =
12136
+ matched ?? (this.#semanticUiSkill ? buildUiSkillDirectiveForSkill(this.#semanticUiSkill) : null);
12040
12137
  if (!directive) return undefined;
12041
- logger.debug("agent-session: bundled UI skill matched", { promptChars: promptText.length });
12138
+ logger.debug("agent-session: bundled UI skill matched", {
12139
+ promptChars: promptText.length,
12140
+ source: matched ? "pattern" : "semantic",
12141
+ });
12042
12142
  return {
12043
12143
  role: "developer",
12044
12144
  content: [{ type: "text", text: `<system-reminder>\n${directive}\n</system-reminder>` }],
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Credential store discovery, on its own so a short-lived process can open the
3
+ * store without importing the SDK.
4
+ *
5
+ * `sdk/session.ts` re-exports `discoverAuthStorage` and remains the public entry
6
+ * point. Measured: importing `sdk/session` costs ~400ms of module graph; the
7
+ * Codex prompt hook runs once per prompt and already spends 260ms, so it opens
8
+ * credentials through this file instead.
9
+ */
10
+ import { getAgentDbPath, getAgentDir } from "@sayknow-cli/utils";
11
+ import { resolveConfigValue } from "../config/resolve-config-value";
12
+ import { resolveAuthBrokerConfig } from "./auth-broker-config";
13
+ import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./auth-storage";
14
+
15
+ /**
16
+ * Create an AuthStorage instance.
17
+ *
18
+ * Default: local SQLite store at `<agentDir>/agent.db`.
19
+ *
20
+ * Broker mode: when `SKC_AUTH_BROKER_URL` is set, credentials are pulled from
21
+ * a remote auth-broker over the wire. Refresh tokens never leave the broker;
22
+ * the client receives access tokens with `refresh = "__remote__"` and calls
23
+ * back into the broker through the {@link AuthStorageOptions.refreshOAuthCredential}
24
+ * override to re-mint access tokens when needed.
25
+ */
26
+ export async function discoverAuthStorage(agentDir: string = getAgentDir()): Promise<AuthStorage> {
27
+ const brokerConfig = await resolveAuthBrokerConfig();
28
+ const credentialRankingMode = resolveCredentialRankingMode();
29
+ if (brokerConfig) {
30
+ const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
31
+ const initialResult = await client.fetchSnapshot();
32
+ if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot");
33
+ const store = new RemoteAuthCredentialStore({ client, initialSnapshot: initialResult.snapshot });
34
+ // Refresh + usage hooks live on RemoteAuthCredentialStore; AuthStorage
35
+ // discovers them automatically when no explicit option overrides them.
36
+ const storage = new AuthStorage(store, {
37
+ configValueResolver: resolveConfigValue,
38
+ sourceLabel: `broker ${brokerConfig.url}`,
39
+ credentialRankingMode,
40
+ });
41
+ try {
42
+ await storage.reload();
43
+ } catch (error) {
44
+ try {
45
+ storage.close();
46
+ } catch {
47
+ // Preserve the initial reload failure.
48
+ }
49
+ throw error;
50
+ }
51
+ return storage;
52
+ }
53
+ const dbPath = getAgentDbPath(agentDir);
54
+ const storage = await AuthStorage.create(dbPath, {
55
+ configValueResolver: resolveConfigValue,
56
+ sourceLabel: `local ${dbPath}`,
57
+ credentialRankingMode,
58
+ });
59
+ try {
60
+ await storage.reload();
61
+ } catch (error) {
62
+ try {
63
+ storage.close();
64
+ } catch {
65
+ // Preserve the initial reload failure.
66
+ }
67
+ throw error;
68
+ }
69
+ return storage;
70
+ }
71
+
72
+ /**
73
+ * Opt-in multi-account credential ranking mode, read from the
74
+ * `SKC_CREDENTIAL_RANKING_MODE` env var. Unset/unknown → `undefined`, leaving
75
+ * {@link AuthStorage}'s default (`balanced`) untouched. `earliest-reset`
76
+ * switches to earliest-expiry-first selection so soon-to-reset tumbling-window
77
+ * quota is drained before it is lost.
78
+ */
79
+ function resolveCredentialRankingMode(): "balanced" | "earliest-reset" | undefined {
80
+ const raw = process.env.SKC_CREDENTIAL_RANKING_MODE?.trim();
81
+ if (raw === "balanced" || raw === "earliest-reset") return raw;
82
+ return undefined;
83
+ }