@sayknow-cli/coding-agent 0.5.24 → 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/CHANGELOG.md +13 -1
- package/dist/types/config/settings-schema.d.ts +40 -0
- package/dist/types/config/task-model-specialties.d.ts +55 -0
- package/dist/types/decisions/task-routing.d.ts +110 -1
- package/dist/types/i18n/messages/en.d.ts +15 -0
- package/dist/types/lsp/index.d.ts +1 -1
- package/dist/types/lsp/types.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +11 -0
- package/dist/types/task/index.d.ts +1 -1
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/task/types.d.ts +114 -18
- package/dist/types/tools/subagent.d.ts +2 -2
- package/package.json +7 -7
- package/src/config/settings-schema.ts +48 -7
- package/src/config/task-model-specialties.ts +131 -0
- package/src/decisions/task-routing.ts +401 -23
- package/src/i18n/messages/de.ts +16 -0
- package/src/i18n/messages/en.ts +16 -0
- package/src/i18n/messages/es.ts +16 -0
- package/src/i18n/messages/fr.ts +16 -0
- package/src/i18n/messages/ja.ts +16 -0
- package/src/i18n/messages/ko.ts +16 -0
- package/src/i18n/messages/zh.ts +16 -0
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +1 -1
- package/src/modes/components/model-selector.ts +275 -34
- package/src/modes/controllers/selector-controller.ts +50 -2
- package/src/modes/shared/agent-wire/command-dispatch.ts +1 -1
- package/src/prompts/tools/task.md +1 -0
- package/src/slash-commands/builtin-registry.ts +11 -9
- package/src/task/index.ts +98 -39
- package/src/task/receipt.ts +3 -0
- package/src/task/types.ts +44 -0
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.
|
|
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.
|
|
58
|
-
"@sayknow-cli/agent-core": "0.5.
|
|
59
|
-
"@sayknow-cli/ai": "0.5.
|
|
60
|
-
"@sayknow-cli/natives": "0.5.
|
|
61
|
-
"@sayknow-cli/tui": "0.5.
|
|
62
|
-
"@sayknow-cli/utils": "0.5.
|
|
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
|
|
|
@@ -2039,6 +2045,36 @@ export const SETTINGS_SCHEMA = {
|
|
|
2039
2045
|
"task.modelRouting.balancedModel": { type: "string", default: "" },
|
|
2040
2046
|
/** Most capable tier. Unclear cause, cross-cutting design, hard to undo. */
|
|
2041
2047
|
"task.modelRouting.deepModel": { type: "string", default: "" },
|
|
2048
|
+
/**
|
|
2049
|
+
* Frontend planning model — the **domain** axis.
|
|
2050
|
+
*
|
|
2051
|
+
* When a planning role (planner/architect) is handed work that clearly reads
|
|
2052
|
+
* as frontend/UI, it takes this model instead of the ladder's answer. A
|
|
2053
|
+
* design-strong model is not "more capable" than a code-strong one; this is a
|
|
2054
|
+
* lateral swap. Unset disables it entirely, and implementation roles are
|
|
2055
|
+
* never swapped by domain — the user asked for the design model to plan the
|
|
2056
|
+
* frontend, not to write it.
|
|
2057
|
+
*/
|
|
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
|
+
},
|
|
2042
2078
|
|
|
2043
2079
|
// TTSR
|
|
2044
2080
|
"ttsr.enabled": {
|
|
@@ -4068,15 +4104,20 @@ export function reconcileSettingsSchema(raw: Record<string, unknown>): {
|
|
|
4068
4104
|
}
|
|
4069
4105
|
if (!validSettingValue(definition, next))
|
|
4070
4106
|
issues.push({ path, kind: "invalid", detail: `Expected ${definition.type}.` });
|
|
4071
|
-
if (
|
|
4072
|
-
|
|
4073
|
-
"valueSchema" in definition
|
|
4074
|
-
definition.valueSchema &&
|
|
4075
|
-
validSettingValue(definition, next)
|
|
4076
|
-
) {
|
|
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;
|
|
4077
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
|
+
}
|
|
4078
4119
|
if (
|
|
4079
|
-
|
|
4120
|
+
valueSchema?.type === "model-selector-value" &&
|
|
4080
4121
|
!(typeof entry === "string" || (Array.isArray(entry) && entry.every(item => typeof item === "string")))
|
|
4081
4122
|
) {
|
|
4082
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
|
+
}
|