@sayknow-cli/coding-agent 0.5.25 → 0.5.26

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/coding-agent",
4
- "version": "0.5.25",
4
+ "version": "0.5.26",
5
5
  "description": "Sayknow-CLI CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
@@ -54,12 +54,12 @@
54
54
  "@agentclientprotocol/sdk": "1.3.0",
55
55
  "@babel/parser": "^7.29.3",
56
56
  "@mozilla/readability": "^0.6.0",
57
- "@sayknow-cli/stats": "0.5.25",
58
- "@sayknow-cli/agent-core": "0.5.25",
59
- "@sayknow-cli/ai": "0.5.25",
60
- "@sayknow-cli/natives": "0.5.25",
61
- "@sayknow-cli/tui": "0.5.25",
62
- "@sayknow-cli/utils": "0.5.25",
57
+ "@sayknow-cli/stats": "0.5.26",
58
+ "@sayknow-cli/agent-core": "0.5.26",
59
+ "@sayknow-cli/ai": "0.5.26",
60
+ "@sayknow-cli/natives": "0.5.26",
61
+ "@sayknow-cli/tui": "0.5.26",
62
+ "@sayknow-cli/utils": "0.5.26",
63
63
  "@puppeteer/browsers": "^2.13.0",
64
64
  "@types/turndown": "5.0.6",
65
65
  "@xterm/headless": "^6.0.0",
@@ -5,6 +5,7 @@ import { getThinkingLevelMetadata } from "../thinking-metadata";
5
5
  import { EDIT_MODES } from "../utils/edit-mode";
6
6
  import { CONFIGURABLE_SEARCH_PROVIDER_IDS } from "../web/search/types";
7
7
  import type { ModelSelectorValue } from "./model-selector-value";
8
+ import { TASK_MODEL_SPECIALTY_IDS } from "./task-model-specialties";
8
9
 
9
10
  const THINKING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as readonly Effort[];
10
11
  const DEFAULT_THINKING_LEVELS = ["off", ...THINKING_EFFORTS] as const;
@@ -192,6 +193,11 @@ interface RecordDef<T> {
192
193
  type: "record";
193
194
  default: Record<string, T>;
194
195
  valueSchema?: RecordValueDef;
196
+ /**
197
+ * Closed key set. When present, reconciliation rejects any other key instead of
198
+ * silently carrying a typo that no consumer will ever read.
199
+ */
200
+ keys?: readonly string[];
195
201
  ui?: UiBase;
196
202
  }
197
203
 
@@ -2050,6 +2056,25 @@ export const SETTINGS_SCHEMA = {
2050
2056
  * frontend, not to write it.
2051
2057
  */
2052
2058
  "task.modelRouting.frontendModel": { type: "string", default: "" },
2059
+ /**
2060
+ * Per-specialty models — the **work-kind** axis.
2061
+ *
2062
+ * Keys are the bounded ids in `config/task-model-specialties.ts`; values use the
2063
+ * same selector grammar as any other model setting, so a chain is allowed. A
2064
+ * specialty with no entry inherits the role's resolved model unchanged, which is
2065
+ * why the default is empty rather than pre-populated.
2066
+ *
2067
+ * These are routing hints, never agents: nothing here widens the canonical role
2068
+ * roster, model-profile role keys, tool grants, or spawn permissions. Saving one
2069
+ * does not enable `task.modelRouting.enabled`; the assignment surface reports the
2070
+ * disabled state instead of silently turning routing on.
2071
+ */
2072
+ "task.modelRouting.specialtyModels": {
2073
+ type: "record",
2074
+ default: {} as Record<string, ModelSelectorValue>,
2075
+ valueSchema: MODEL_SELECTOR_VALUE_SCHEMA,
2076
+ keys: TASK_MODEL_SPECIALTY_IDS,
2077
+ },
2053
2078
 
2054
2079
  // TTSR
2055
2080
  "ttsr.enabled": {
@@ -4079,15 +4104,20 @@ export function reconcileSettingsSchema(raw: Record<string, unknown>): {
4079
4104
  }
4080
4105
  if (!validSettingValue(definition, next))
4081
4106
  issues.push({ path, kind: "invalid", detail: `Expected ${definition.type}.` });
4082
- if (
4083
- definition.type === "record" &&
4084
- "valueSchema" in definition &&
4085
- definition.valueSchema &&
4086
- validSettingValue(definition, next)
4087
- ) {
4107
+ if (definition.type === "record" && validSettingValue(definition, next)) {
4108
+ const allowedKeys = "keys" in definition && definition.keys ? new Set<string>(definition.keys) : undefined;
4109
+ const valueSchema = "valueSchema" in definition ? definition.valueSchema : undefined;
4088
4110
  for (const [key, entry] of Object.entries(next as Record<string, unknown>)) {
4111
+ if (allowedKeys && !allowedKeys.has(key)) {
4112
+ issues.push({
4113
+ path: `${path}.${key}`,
4114
+ kind: "invalid",
4115
+ detail: `Unknown key. Expected one of: ${[...allowedKeys].join(", ")}.`,
4116
+ });
4117
+ continue;
4118
+ }
4089
4119
  if (
4090
- definition.valueSchema.type === "model-selector-value" &&
4120
+ valueSchema?.type === "model-selector-value" &&
4091
4121
  !(typeof entry === "string" || (Array.isArray(entry) && entry.every(item => typeof item === "string")))
4092
4122
  ) {
4093
4123
  issues.push({ path: `${path}.${key}`, kind: "invalid", detail: "Expected model-selector-value." });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Bounded work-specialty axis for subagent model selection.
3
+ *
4
+ * A canonical role model is a standing guess about the *average* assignment that
5
+ * role gets. The difficulty ladder in `decisions/task-routing.ts` moves that guess
6
+ * when one assignment is unusually hard or unusually mechanical. This axis moves it
7
+ * for a different reason: the assignment belongs to a *kind of work* the user has
8
+ * deliberately picked a model for — backend architecture, frontend design, plain
9
+ * implementation, test work, or review.
10
+ *
11
+ * Specialties are **not agents**. They never appear in agent discovery, task `agent`
12
+ * values, prompts, tool grants, spawn allowlists, or model-profile role keys. The
13
+ * canonical roster stays exactly `executor`, `architect`, `planner`, `critic`, and
14
+ * `default` remains the main-model assignment target. A specialty only ever selects
15
+ * a model for work that is already being routed to one of those roles.
16
+ */
17
+ import { normalizeModelSelectorValue } from "./model-selector-value";
18
+
19
+ /** Stable configuration/routing identifiers. Display strings are localized separately. */
20
+ export const TASK_MODEL_SPECIALTY_IDS = [
21
+ "backendArchitecture",
22
+ "frontendDesign",
23
+ "implementation",
24
+ "testing",
25
+ "review",
26
+ ] as const;
27
+
28
+ export type TaskModelSpecialty = (typeof TASK_MODEL_SPECIALTY_IDS)[number];
29
+
30
+ /**
31
+ * Which canonical role agents may receive each specialty.
32
+ *
33
+ * Planning roles share the two design specialties on purpose: the user's intent is
34
+ * "a backend-strong model plans backend work", not "planner and architect each get
35
+ * their own private backend model". Implementation roles never take a design
36
+ * specialty, mirroring the existing frontend-domain rule.
37
+ */
38
+ export const TASK_MODEL_SPECIALTY_ROLES: Readonly<Record<TaskModelSpecialty, readonly string[]>> = {
39
+ backendArchitecture: ["planner", "architect"],
40
+ frontendDesign: ["planner", "architect"],
41
+ implementation: ["executor"],
42
+ testing: ["executor"],
43
+ review: ["critic"],
44
+ };
45
+
46
+ /** Neutral classifier outcome: the work does not clearly belong to one specialty. */
47
+ export const TASK_MODEL_SPECIALTY_NONE = "none" as const;
48
+
49
+ /** Bounded-key predicate for the `task.modelRouting.specialtyModels` record. */
50
+ export function isTaskModelSpecialty(value: unknown): value is TaskModelSpecialty {
51
+ return typeof value === "string" && (TASK_MODEL_SPECIALTY_IDS as readonly string[]).includes(value);
52
+ }
53
+
54
+ /** Specialties a given canonical role agent is allowed to receive, in declaration order. */
55
+ export function specialtiesForRole(agentName: string): TaskModelSpecialty[] {
56
+ return TASK_MODEL_SPECIALTY_IDS.filter(specialty => TASK_MODEL_SPECIALTY_ROLES[specialty].includes(agentName));
57
+ }
58
+
59
+ /** Whether a specialty may be applied to work routed to this canonical role agent. */
60
+ export function specialtySupportsRole(specialty: TaskModelSpecialty, agentName: string): boolean {
61
+ return TASK_MODEL_SPECIALTY_ROLES[specialty].includes(agentName);
62
+ }
63
+
64
+ /** Where a composed candidate came from, so a receipt never overstates what was selected. */
65
+ export type TaskRoutingSource = "specialty" | "legacy-frontend" | "tier" | "baseline";
66
+
67
+ export interface TaskRoutingCandidate {
68
+ /** The configured selector, normalized but otherwise untouched. */
69
+ selector: string;
70
+ source: TaskRoutingSource;
71
+ /** Set when `source` is `specialty` or `legacy-frontend`. */
72
+ specialty?: TaskModelSpecialty;
73
+ /** Set when `source` is `tier`. */
74
+ tier?: string;
75
+ }
76
+
77
+ /**
78
+ * Identity used for deduplication.
79
+ *
80
+ * An explicit thinking suffix is part of the identity: `provider/model:high` and
81
+ * `provider/model:low` are different configured intents, and collapsing them would
82
+ * silently drop the user's effort choice from a chain.
83
+ */
84
+ export function specialtySelectorIdentity(selector: string): string {
85
+ return selector.trim().toLowerCase();
86
+ }
87
+
88
+ /** The `provider/model` part, ignoring any thinking suffix. Used for "same model" checks. */
89
+ export function specialtySelectorHead(selector: string): string {
90
+ return specialtySelectorIdentity(selector).split(":")[0]?.trim() ?? "";
91
+ }
92
+
93
+ /** Expand a configured selector value into normalized candidates tagged with their origin. */
94
+ export function toRoutingCandidates(
95
+ value: string | readonly string[] | undefined,
96
+ source: TaskRoutingSource,
97
+ extra?: { specialty?: TaskModelSpecialty; tier?: string },
98
+ ): TaskRoutingCandidate[] {
99
+ return normalizeModelSelectorValue(value)
100
+ .map(selector => selector.trim())
101
+ .filter(selector => selector.length > 0)
102
+ .map(selector => ({ selector, source, ...extra }));
103
+ }
104
+
105
+ /**
106
+ * Concatenate candidate segments, keeping the first occurrence of each identity.
107
+ *
108
+ * A selector that also exists in the baseline segment is attributed to `baseline`
109
+ * even when an earlier specialty segment introduced it. Resolving that entry proves
110
+ * only that the role's own configured model was usable — reporting it as a specialty
111
+ * hit would claim a routing decision that never happened.
112
+ */
113
+ export function dedupeRoutingCandidates(segments: readonly TaskRoutingCandidate[][]): TaskRoutingCandidate[] {
114
+ const flattened = segments.flat();
115
+ const baselineIdentities = new Set(
116
+ flattened.filter(candidate => candidate.source === "baseline").map(c => specialtySelectorIdentity(c.selector)),
117
+ );
118
+ const seen = new Set<string>();
119
+ const deduped: TaskRoutingCandidate[] = [];
120
+ for (const candidate of flattened) {
121
+ const identity = specialtySelectorIdentity(candidate.selector);
122
+ if (seen.has(identity)) continue;
123
+ seen.add(identity);
124
+ deduped.push(
125
+ candidate.source !== "baseline" && baselineIdentities.has(identity)
126
+ ? { selector: candidate.selector, source: "baseline" }
127
+ : candidate,
128
+ );
129
+ }
130
+ return deduped;
131
+ }