@intentic/sandbox-contract 1.159.0 → 1.160.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.
- package/dist/agent-catalog.d.ts +1 -0
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +1 -0
- package/dist/agent-catalog.js.map +1 -1
- package/dist/contracts/agent.contract.d.ts +28 -4
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.js +2 -1
- package/dist/contracts/agent.contract.js.map +1 -1
- package/dist/contracts/agents.contract.d.ts +195 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +7 -2
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/claude.contract.d.ts +26 -0
- package/dist/contracts/claude.contract.d.ts.map +1 -1
- package/dist/contracts/claude.contract.js +2 -1
- package/dist/contracts/claude.contract.js.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +1 -0
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.d.ts +13 -0
- package/dist/contracts/git.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.js +2 -1
- package/dist/contracts/git.contract.js.map +1 -1
- package/dist/contracts/grok.contract.d.ts +2 -0
- package/dist/contracts/grok.contract.d.ts.map +1 -1
- package/dist/contracts/kimi.contract.d.ts +26 -0
- package/dist/contracts/kimi.contract.d.ts.map +1 -1
- package/dist/contracts/kimi.contract.js +2 -1
- package/dist/contracts/kimi.contract.js.map +1 -1
- package/dist/contracts/memory.contract.d.ts +34 -0
- package/dist/contracts/memory.contract.d.ts.map +1 -0
- package/dist/contracts/memory.contract.js +9 -0
- package/dist/contracts/memory.contract.js.map +1 -0
- package/dist/contracts/panels.contract.d.ts +1 -0
- package/dist/contracts/panels.contract.d.ts.map +1 -1
- package/dist/contracts/push.contract.d.ts +1 -1
- package/dist/contracts/push.contract.js +2 -2
- package/dist/contracts/push.contract.js.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +2 -0
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +32 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.js +2 -1
- package/dist/contracts/settings.contract.js.map +1 -1
- package/dist/contracts/system.contract.d.ts +15 -2
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.js +2 -1
- package/dist/contracts/system.contract.js.map +1 -1
- package/dist/contracts/translator.contract.d.ts +13 -3
- package/dist/contracts/translator.contract.d.ts.map +1 -1
- package/dist/contracts/translator.contract.js +1 -1
- package/dist/contracts/translator.contract.js.map +1 -1
- package/dist/contracts/workspace.contract.d.ts +38 -5
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.js +2 -1
- package/dist/contracts/workspace.contract.js.map +1 -1
- package/dist/events.d.ts +100 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +12 -3
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +429 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/model-order.d.ts +1 -0
- package/dist/model-order.d.ts.map +1 -1
- package/dist/model-order.js +1 -0
- package/dist/model-order.js.map +1 -1
- package/dist/path-refs.d.ts +1 -0
- package/dist/path-refs.d.ts.map +1 -1
- package/dist/path-refs.js +3 -0
- package/dist/path-refs.js.map +1 -1
- package/dist/quick-model.d.ts +13 -0
- package/dist/quick-model.d.ts.map +1 -0
- package/dist/quick-model.js +33 -0
- package/dist/quick-model.js.map +1 -0
- package/dist/schemas.d.ts +298 -10
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +126 -11
- package/dist/schemas.js.map +1 -1
- package/dist/title.d.ts +6 -0
- package/dist/title.d.ts.map +1 -0
- package/dist/title.js +149 -0
- package/dist/title.js.map +1 -0
- package/package.json +2 -2
- package/src/agent-catalog.ts +6 -0
- package/src/contracts/agent.contract.ts +14 -1
- package/src/contracts/agents.contract.ts +25 -1
- package/src/contracts/claude.contract.ts +4 -1
- package/src/contracts/git.contract.ts +6 -0
- package/src/contracts/kimi.contract.ts +13 -2
- package/src/contracts/memory.contract.ts +13 -0
- package/src/contracts/push.contract.ts +2 -2
- package/src/contracts/settings.contract.ts +5 -1
- package/src/contracts/system.contract.ts +4 -0
- package/src/contracts/translator.contract.ts +3 -2
- package/src/contracts/workspace.contract.ts +6 -0
- package/src/events.ts +47 -6
- package/src/index.ts +7 -1
- package/src/model-order.test.ts +40 -1
- package/src/model-order.ts +15 -0
- package/src/path-refs.test.ts +27 -1
- package/src/path-refs.ts +16 -0
- package/src/quick-model.test.ts +88 -0
- package/src/quick-model.ts +103 -0
- package/src/schemas.test.ts +23 -1
- package/src/schemas.ts +412 -35
- package/src/title.test.ts +130 -0
- package/src/title.ts +254 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { oc } from "@orpc/contract";
|
|
2
2
|
import {
|
|
3
|
+
CommitMessageDraftSchema,
|
|
4
|
+
CommitMessageSchema,
|
|
3
5
|
CommitResultSchema,
|
|
4
6
|
CommitSchema,
|
|
5
7
|
DiscardSchema,
|
|
@@ -39,6 +41,10 @@ import {
|
|
|
39
41
|
// Changes panel renders; commit/discard take optional `paths` for per-file actions.
|
|
40
42
|
export const gitContract = {
|
|
41
43
|
changes: oc.route({ method: "GET", path: "/git/changes" }).output(GitChangesSchema),
|
|
44
|
+
// Drafts a commit message for what the commit box is about to record, on the sandbox's quick model (the
|
|
45
|
+
// cheap rung — see quick-model.ts). Workspace-wide like `changes` and for the same reason: one commit box,
|
|
46
|
+
// one message, every staged repo. POST because it spends a model call, not because it writes anything.
|
|
47
|
+
commitMessage: oc.route({ method: "POST", path: "/git/commit-message" }).input(CommitMessageDraftSchema).output(CommitMessageSchema),
|
|
42
48
|
// The git-history graph over one repo's real commits: the repo list (for the tree affordance + switcher),
|
|
43
49
|
// one repo's commit log, and lazy per-commit detail (changed files, then a file's before/after AT the
|
|
44
50
|
// commit). Read-only — commit/discard on the working tree stay the write path (above).
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { oc } from "@orpc/contract";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
AccountIdSchema,
|
|
4
|
+
AccountRenameSchema,
|
|
5
|
+
KimiConnectSchema,
|
|
6
|
+
ModelsSchema,
|
|
7
|
+
OauthAccountListSchema,
|
|
8
|
+
OauthAccountSchema,
|
|
9
|
+
OkSchema,
|
|
10
|
+
} from "../schemas.js";
|
|
3
11
|
|
|
4
12
|
// Kimi Code (Moonshot) — the sandbox owns the credential, an API key rather than an OAuth grant. Kimi speaks the
|
|
5
13
|
// Anthropic Messages protocol, so a Kimi turn runs on the SAME Claude Code harness as Claude, with the harness
|
|
6
14
|
// pointed at Moonshot's Anthropic-compatible endpoint and authenticated with the stored key (see agent.routes).
|
|
7
15
|
// `connect` stores a pasted key as a new account and returns it; `models` is the live catalog for the picker;
|
|
8
|
-
// `accounts` lists connected keys (tokens never ride back); `
|
|
16
|
+
// `accounts` lists connected keys (tokens never ride back); `rename` renames one (a pasted key carries no
|
|
17
|
+
// identity to derive a name from, so this is the ONLY way a Kimi row gets a distinguishing name);
|
|
18
|
+
// `disconnect` clears the one named by id.
|
|
9
19
|
export const kimiContract = {
|
|
10
20
|
connect: oc.route({ method: "POST", path: "/kimi/account/connect" }).input(KimiConnectSchema).output(OauthAccountSchema),
|
|
11
21
|
// Kimi/Moonshot's live models for the picker — the source of valid ids (see kimi-models.ts).
|
|
12
22
|
models: oc.route({ method: "GET", path: "/kimi/models" }).output(ModelsSchema),
|
|
13
23
|
accounts: oc.route({ method: "GET", path: "/kimi/accounts" }).output(OauthAccountListSchema),
|
|
24
|
+
rename: oc.route({ method: "POST", path: "/kimi/account/rename" }).input(AccountRenameSchema).output(OauthAccountSchema),
|
|
14
25
|
disconnect: oc.route({ method: "POST", path: "/kimi/account/disconnect" }).input(AccountIdSchema).output(OkSchema),
|
|
15
26
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { oc } from "@orpc/contract";
|
|
2
|
+
import { MemoryFileQuerySchema, MemoryFileSchema, MemoryListSchema, MemoryWriteSchema, OkSchema } from "../schemas.js";
|
|
3
|
+
|
|
4
|
+
// The agent's persistent memory notes (.intentic/claude/projects/<project>/memory) — the one browser surface
|
|
5
|
+
// for that control-plane subtree (see schemas.ts). Read for the memory panel; write/delete so the owner can
|
|
6
|
+
// curate what the agent remembers (fix a stale fact, drop a wrong one). oRPC's OpenAPI codec reads non-GET
|
|
7
|
+
// input from the JSON body, so write and delete send {project, name} in the body (same as /workspace/entry).
|
|
8
|
+
export const memoryContract = {
|
|
9
|
+
list: oc.route({ method: "GET", path: "/memory" }).output(MemoryListSchema),
|
|
10
|
+
read: oc.route({ method: "GET", path: "/memory/file" }).input(MemoryFileQuerySchema).output(MemoryFileSchema),
|
|
11
|
+
write: oc.route({ method: "PUT", path: "/memory/file" }).input(MemoryWriteSchema).output(OkSchema),
|
|
12
|
+
delete: oc.route({ method: "DELETE", path: "/memory/file" }).input(MemoryFileQuerySchema).output(OkSchema),
|
|
13
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { oc } from "@orpc/contract";
|
|
2
|
-
import { OkSchema, PushConfigQuerySchema, PushConfigSchema, PushEndpointSchema, PushSubscriptionSchema } from "../schemas.js";
|
|
2
|
+
import { OkSchema, PushConfigQuerySchema, PushConfigSchema, PushEndpointSchema, PushSubscriptionSchema, PushTestSchema } from "../schemas.js";
|
|
3
3
|
|
|
4
4
|
// Web-push notifications for this sandbox. The daemon owns the VAPID keypair and the subscription list (see
|
|
5
5
|
// push/push-store.ts for why the key lives on the history volume), and sends on the three moments where the
|
|
@@ -13,5 +13,5 @@ export const pushContract = {
|
|
|
13
13
|
config: oc.route({ method: "GET", path: "/push/config" }).input(PushConfigQuerySchema).output(PushConfigSchema),
|
|
14
14
|
subscribe: oc.route({ method: "POST", path: "/push/subscribe" }).input(PushSubscriptionSchema).output(OkSchema),
|
|
15
15
|
unsubscribe: oc.route({ method: "POST", path: "/push/unsubscribe" }).input(PushEndpointSchema).output(OkSchema),
|
|
16
|
-
test: oc.route({ method: "POST", path: "/push/test" }).output(
|
|
16
|
+
test: oc.route({ method: "POST", path: "/push/test" }).output(PushTestSchema),
|
|
17
17
|
};
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { oc } from "@orpc/contract";
|
|
2
|
-
import { CleanerSavingsSchema, OkSchema, SandboxSettingsSchema } from "../schemas.js";
|
|
2
|
+
import { BuiltinPromptSchema, BuiltinPromptTextSchema, CleanerSavingsSchema, OkSchema, SandboxSettingsSchema } from "../schemas.js";
|
|
3
3
|
|
|
4
4
|
// Per-sandbox agent settings (.intentic/settings.json). `get` returns the current flags with defaults applied
|
|
5
5
|
// when the file is absent; `set` overwrites them. `savings` reports the output-cleaner token savings aggregated
|
|
6
6
|
// from the live filter-stats ledger (the rtk-`gain` surface — read-only, so a UI card can show what's working).
|
|
7
|
+
// `builtinPrompt` returns one of the two built-in system prompts as text — Intentic's own, or Claude Code's
|
|
8
|
+
// read out of the installed CLI — so the settings page can SHOW the prompt behind a mode instead of asking the
|
|
9
|
+
// user to trust a description of it, and can fork either into a custom one.
|
|
7
10
|
export const settingsContract = {
|
|
8
11
|
get: oc.route({ method: "GET", path: "/settings" }).output(SandboxSettingsSchema),
|
|
9
12
|
set: oc.route({ method: "POST", path: "/settings" }).input(SandboxSettingsSchema).output(OkSchema),
|
|
10
13
|
savings: oc.route({ method: "GET", path: "/settings/savings" }).output(CleanerSavingsSchema),
|
|
14
|
+
builtinPrompt: oc.route({ method: "GET", path: "/settings/system-prompt/{base}" }).input(BuiltinPromptSchema).output(BuiltinPromptTextSchema),
|
|
11
15
|
};
|
|
@@ -2,6 +2,7 @@ import { eventIterator, oc } from "@orpc/contract";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { SystemEventSchema } from "../events.js";
|
|
4
4
|
import {
|
|
5
|
+
DaemonSessionSchema,
|
|
5
6
|
HostTunnelInputSchema,
|
|
6
7
|
HostTunnelSchema,
|
|
7
8
|
InfoSchema,
|
|
@@ -19,6 +20,9 @@ import {
|
|
|
19
20
|
// connection simply never joins the roster.
|
|
20
21
|
export const systemContract = {
|
|
21
22
|
info: oc.route({ method: "GET", path: "/info" }).output(InfoSchema),
|
|
23
|
+
// Exchange the request's verified bearer (a Google ID token — or a still-valid session, which makes this
|
|
24
|
+
// route sliding renewal) for a daemon-minted session, the credential every steady-state call presents.
|
|
25
|
+
session: oc.route({ method: "POST", path: "/system/session" }).output(DaemonSessionSchema),
|
|
22
26
|
events: oc
|
|
23
27
|
.route({ method: "GET", path: "/events" })
|
|
24
28
|
.input(z.object({ clientId: z.string().optional() }))
|
|
@@ -4,7 +4,8 @@ import { KeyedProviderSchema, OkSchema, TranslatorAccountsSchema, TranslatorComp
|
|
|
4
4
|
|
|
5
5
|
// Routed-provider subscriptions (Sandbox ▸ Agent). The bundled translator (CLIProxyAPI) runs a non-Claude model
|
|
6
6
|
// UNDER the Claude Code harness on the user's SUBSCRIPTION, so each provider connects via an OAuth login rather
|
|
7
|
-
// than an API key.
|
|
7
|
+
// than an API key. A provider can hold several accounts side by side (the translator balances across them);
|
|
8
|
+
// `accounts` lists what's connected per provider and `disconnect` clears ONE account by its auth-file `name`.
|
|
8
9
|
//
|
|
9
10
|
// Two login shapes ride one pair of routes. Codex and Grok mint a one-time device `code`: the user enters it at
|
|
10
11
|
// the provider's site and the translator polls to completion in the background, so the UI just polls `accounts`
|
|
@@ -21,6 +22,6 @@ export const translatorContract = {
|
|
|
21
22
|
complete: oc.route({ method: "POST", path: "/translator/{provider}/complete" }).input(TranslatorCompleteSchema).output(OkSchema),
|
|
22
23
|
disconnect: oc
|
|
23
24
|
.route({ method: "POST", path: "/translator/{provider}/disconnect" })
|
|
24
|
-
.input(z.object({ provider: KeyedProviderSchema }))
|
|
25
|
+
.input(z.object({ provider: KeyedProviderSchema, name: z.string().min(1) }))
|
|
25
26
|
.output(OkSchema),
|
|
26
27
|
};
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
WorkspaceFileQuerySchema,
|
|
20
20
|
WorkspaceFileSchema,
|
|
21
21
|
WorkspaceGraphSchema,
|
|
22
|
+
WorkspaceHealthQuerySchema,
|
|
23
|
+
WorkspaceHealthSchema,
|
|
22
24
|
WorkspaceInstallResultSchema,
|
|
23
25
|
WorkspaceInstallSchema,
|
|
24
26
|
WorkspaceMoveSchema,
|
|
@@ -45,6 +47,10 @@ export const workspaceContract = {
|
|
|
45
47
|
// Ranked groups, match-reason tags, freshness, resumable cursor. `mode` narrows to one verb; default is
|
|
46
48
|
// auto-mode fusion. (Implementation detail: the daemon backs this with a resident in-process iq engine.)
|
|
47
49
|
search: oc.route({ method: "GET", path: "/workspace/search" }).input(WorkspaceSearchQuerySchema).output(WorkspaceSearchResultSchema),
|
|
50
|
+
// One repository's health in numbers: churn × complexity per file, index totals, and the import graph's
|
|
51
|
+
// top modules — the `hotspots` and `map` rankings the CLI prints, shaped for a panel. Repo-scoped, because
|
|
52
|
+
// "the codebase" is a repo, not the whole /work drop.
|
|
53
|
+
health: oc.route({ method: "GET", path: "/workspace/health" }).input(WorkspaceHealthQuerySchema).output(WorkspaceHealthSchema),
|
|
48
54
|
// Deterministic, no-LLM classification of the dropped workspace into coarse buckets (repositories / documents
|
|
49
55
|
// / media / archives / other). Read-only proposal: the browser renders it and applies accepted moves via the
|
|
50
56
|
// existing /workspace/move route — this route never touches the tree.
|
package/src/events.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
AgentProviderSchema,
|
|
4
4
|
AgentReplySchema,
|
|
5
5
|
AgentSummarySchema,
|
|
6
|
+
LandConflictSchema,
|
|
6
7
|
PermissionModeSchema,
|
|
7
8
|
RateLimitInfoSchema,
|
|
8
9
|
UsageWindowSchema,
|
|
@@ -79,7 +80,7 @@ export const ContextUsageSchema = z.object({
|
|
|
79
80
|
export type ContextUsage = z.infer<typeof ContextUsageSchema>;
|
|
80
81
|
|
|
81
82
|
// ACP-aligned tool taxonomy (Agent Client Protocol's ToolKind, verbatim): what a tool call *does*, driving
|
|
82
|
-
// the card icon and
|
|
83
|
+
// the card icon and the live-writes bookkeeping regardless of which backend named the tool.
|
|
83
84
|
export const ToolKindSchema = z.enum(["read", "edit", "delete", "move", "search", "execute", "think", "fetch", "other"]);
|
|
84
85
|
export type ToolKind = z.infer<typeof ToolKindSchema>;
|
|
85
86
|
|
|
@@ -136,12 +137,16 @@ export type RestoredToolCall = z.infer<typeof RestoredToolCallSchema>;
|
|
|
136
137
|
export const RestoredMessageSchema = z.object({
|
|
137
138
|
role: z.enum(["user", "assistant"]),
|
|
138
139
|
text: z.string(),
|
|
140
|
+
// Files the user attached to this turn (user bubbles only) as workspace-relative paths, recovered from
|
|
141
|
+
// the stored prompt's attachment note — so a reopened tab redraws chips, not the injected protocol text.
|
|
142
|
+
attachments: z.array(z.string()).optional(),
|
|
139
143
|
thinking: z.string().optional(),
|
|
140
144
|
tools: z.array(RestoredToolCallSchema).optional(),
|
|
141
145
|
});
|
|
142
146
|
export type RestoredMessage = z.infer<typeof RestoredMessageSchema>;
|
|
143
147
|
|
|
144
148
|
export const SessionTranscriptSchema = z.object({ messages: z.array(RestoredMessageSchema) });
|
|
149
|
+
export const AgentTranscriptSchema = SessionTranscriptSchema.extend({ sessionId: z.string().optional() });
|
|
145
150
|
|
|
146
151
|
// One frame from an agent turn, relayed to the UI. `kind`-discriminated. The daemon normalizes the SDK's
|
|
147
152
|
// ~40 SDKMessage types down to this union: high-value block types get a dedicated frame
|
|
@@ -152,16 +157,26 @@ export const SessionTranscriptSchema = z.object({ messages: z.array(RestoredMess
|
|
|
152
157
|
// `parentToolUseId` tags frames produced inside a subagent (Task tool).
|
|
153
158
|
export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
154
159
|
z.object({ kind: z.literal("session"), sessionId: z.string() }),
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
/* First frame of an isolated turn: the conversation's worktree identity — its branch (agent/<id>) and the
|
|
161
|
+
* ROOT repo's short base sha (the checkout moment). Emitted before any provider frames.
|
|
162
|
+
*
|
|
163
|
+
* `unenforced` marks the degraded container: no CAP_SYS_ADMIN, so the turn's worktree could not be
|
|
164
|
+
* bind-mounted over the workspace root and the harness is rewriting tool paths into it instead. That
|
|
165
|
+
* fallback covers what arrives as tool input and not what a subprocess computes for itself, so the
|
|
166
|
+
* operator needs to know — this state used to be one line in the daemon log at boot, and the way it got
|
|
167
|
+
* noticed was files appearing in the main tree from agents that were supposed to be on branches. */
|
|
168
|
+
z.object({ kind: z.literal("worktree"), branch: z.string(), base: z.string(), unenforced: z.boolean().optional() }),
|
|
158
169
|
// Emitted after a clean isolated turn whose delta auto-landed (or failed to): landed ⇒ the work is now
|
|
159
170
|
// UNCOMMITTED changes in the main tree (the Changes panel is the review); conflicts ⇒ it stayed safely in
|
|
160
|
-
// the worktree
|
|
171
|
+
// the worktree, and each named path carries WHY it would not apply (see LandConflictSchema) so the report
|
|
172
|
+
// can say whether the user's own copy is at risk or the main line simply moved on underneath the agent.
|
|
173
|
+
// held ⇒ auto-land is off for this agent: nothing was applied and nothing failed — the delta is waiting
|
|
174
|
+
// on the branch for a deliberate Land (landed is false, conflicts absent).
|
|
161
175
|
z.object({
|
|
162
176
|
kind: z.literal("landed"),
|
|
163
177
|
landed: z.boolean(),
|
|
164
|
-
conflicts: z.array(
|
|
178
|
+
conflicts: z.array(LandConflictSchema).optional(),
|
|
179
|
+
held: z.boolean().optional(),
|
|
165
180
|
}),
|
|
166
181
|
// The SDK's init handshake; carries the model it actually resolved for the turn.
|
|
167
182
|
z.object({ kind: z.literal("init"), model: z.string() }),
|
|
@@ -266,12 +281,38 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
266
281
|
// Codex ran the turn but warned about it (fallback model metadata) — a notice, not a failure.
|
|
267
282
|
"codex-advisory",
|
|
268
283
|
"codex-reauth",
|
|
284
|
+
// The Claude subscription credential is dead (revoked, or its refresh token rejected) and only a
|
|
285
|
+
// reconnect fixes it. Distinct from "no account connected": the account IS there, so the UI can
|
|
286
|
+
// offer reconnect where the user already is and replay the message that bounced.
|
|
287
|
+
"claude-reauth",
|
|
288
|
+
// The API refused this turn's token MID-FLIGHT — nearly always one superseded by a rotation,
|
|
289
|
+
// which Anthropic retires the moment its successor is minted. Distinct from claude-reauth: the
|
|
290
|
+
// account is fine and the daemon re-mints on the spot, so this frame is a notice about a turn
|
|
291
|
+
// that resumed itself, not a request for the user to do anything. It only reaches the client
|
|
292
|
+
// when the resume could NOT start, which is when reconnecting really is the fix.
|
|
293
|
+
"claude-token-refused",
|
|
294
|
+
// The harness read the message as a slash command it doesn't have, and discarded everything
|
|
295
|
+
// after the name — the model never saw the message. Nothing was processed, so the client holds
|
|
296
|
+
// the text back instead of leaving the user to retype it (same treatment as claude-reauth).
|
|
297
|
+
"unknown-command",
|
|
269
298
|
"grok-model-invalid",
|
|
270
299
|
"codex-model-invalid",
|
|
271
300
|
"subscription-required",
|
|
272
301
|
"agent-busy",
|
|
273
302
|
])
|
|
274
303
|
.optional(),
|
|
304
|
+
// rate_limit only: when the exhausted window reopens (epoch seconds, from the stream's own
|
|
305
|
+
// rate_limit_event or the account's persisted usage windows), and what the daemon did about it —
|
|
306
|
+
// "scheduled" = auto-resume is on and this turn re-runs itself a minute after `resetsAt`;
|
|
307
|
+
// "available" = the daemon remembered the failed turn and enabling the autoResumeOnLimit setting
|
|
308
|
+
// arms that same resume, which is what the chat's offer banner hangs off. Absent together when the
|
|
309
|
+
// reset instant is unknown (nothing to schedule against).
|
|
310
|
+
resetsAt: z.number().optional(),
|
|
311
|
+
autoResume: z.enum(["scheduled", "available"]).optional(),
|
|
312
|
+
// rate_limit only: the account whose allowance is spent, as the DAEMON resolved it (the client's own
|
|
313
|
+
// selection can be empty, which means "the provider's first"). It is what lets the chat offer the
|
|
314
|
+
// provider's OTHER accounts as a resume-now instead of a wait — see /agent/resume-limit.
|
|
315
|
+
account: z.string().optional(),
|
|
275
316
|
}),
|
|
276
317
|
z.object({ kind: z.literal("done") }),
|
|
277
318
|
]);
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { intenticContract } from "./contracts/intentic.contract.js";
|
|
|
17
17
|
import { inventoryContract } from "./contracts/inventory.contract.js";
|
|
18
18
|
import { kimiContract } from "./contracts/kimi.contract.js";
|
|
19
19
|
import { logsContract } from "./contracts/logs.contract.js";
|
|
20
|
+
import { memoryContract } from "./contracts/memory.contract.js";
|
|
20
21
|
import { panelsContract } from "./contracts/panels.contract.js";
|
|
21
22
|
import { portsContract } from "./contracts/ports.contract.js";
|
|
22
23
|
import { pushContract } from "./contracts/push.contract.js";
|
|
@@ -46,6 +47,7 @@ export { intenticContract } from "./contracts/intentic.contract.js";
|
|
|
46
47
|
export { inventoryContract } from "./contracts/inventory.contract.js";
|
|
47
48
|
export { kimiContract } from "./contracts/kimi.contract.js";
|
|
48
49
|
export { logsContract } from "./contracts/logs.contract.js";
|
|
50
|
+
export { memoryContract } from "./contracts/memory.contract.js";
|
|
49
51
|
export { panelsContract } from "./contracts/panels.contract.js";
|
|
50
52
|
export { portsContract } from "./contracts/ports.contract.js";
|
|
51
53
|
export { pushContract } from "./contracts/push.contract.js";
|
|
@@ -65,8 +67,10 @@ export * from "./agent-catalog.js";
|
|
|
65
67
|
export * from "./hostnames.js";
|
|
66
68
|
export * from "./model-order.js";
|
|
67
69
|
export * from "./path-refs.js";
|
|
70
|
+
export * from "./quick-model.js";
|
|
68
71
|
export * from "./schemas.js";
|
|
69
72
|
export * from "./terminal-protocol.js";
|
|
73
|
+
export * from "./title.js";
|
|
70
74
|
|
|
71
75
|
// The aggregated contract — implemented on the server by the per-domain route factories and consumed by the
|
|
72
76
|
// browser's typed oRPC client (ContractRouterClient<typeof sandboxContract>). The wire paths it declares are
|
|
@@ -92,6 +96,7 @@ export const sandboxContract = {
|
|
|
92
96
|
workspace: workspaceContract,
|
|
93
97
|
inventory: inventoryContract,
|
|
94
98
|
logs: logsContract,
|
|
99
|
+
memory: memoryContract,
|
|
95
100
|
panels: panelsContract,
|
|
96
101
|
ports: portsContract,
|
|
97
102
|
push: pushContract,
|
|
@@ -110,4 +115,5 @@ export const SANDBOX_ROUTES: readonly ContractRoute[] = contractRoutes(sandboxCo
|
|
|
110
115
|
export const SANDBOX_ROUTE_NAMES: readonly string[] = SANDBOX_ROUTES.map((route) => route.name);
|
|
111
116
|
|
|
112
117
|
// The contract route a concrete browser request belongs to, bound to this build's route table.
|
|
113
|
-
export const sandboxRouteName = (method: string, pathWithQuery: string): string | undefined =>
|
|
118
|
+
export const sandboxRouteName = (method: string, pathWithQuery: string): string | undefined =>
|
|
119
|
+
routeNameForRequest(SANDBOX_ROUTES, method, pathWithQuery);
|
package/src/model-order.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "vitest";
|
|
2
|
-
import { compareModelIds, compareUnrankedModelIds, familyOf, releaseOf, tierRankOf } from "./model-order.js";
|
|
2
|
+
import { compareCheapestFirst, compareModelIds, compareUnrankedModelIds, familyOf, releaseOf, tierRankOf } from "./model-order.js";
|
|
3
3
|
|
|
4
4
|
/* The order every provider's catalog is served and browsed in. The rule exists because only Anthropic publishes
|
|
5
5
|
* a ranking: the OpenAI-compatible endpoints behind Codex, Gemini, Kimi and Grok hand back a SET, and taking
|
|
@@ -135,3 +135,42 @@ test("sorts an unversioned rolling alias under the releases that name their vers
|
|
|
135
135
|
expect(releaseOf("kimi-latest").version).toEqual([]);
|
|
136
136
|
expect(["kimi-latest", "kimi-k2-0711-preview"].toSorted(compareModelIds)).toEqual(["kimi-k2-0711-preview", "kimi-latest"]);
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
// --- the cheap end (compareCheapestFirst) ---------------------------------------------------------------
|
|
140
|
+
// What the quick model behind a one-click helper resolves against: the same tier scale, read for the weakest
|
|
141
|
+
// row instead of the strongest.
|
|
142
|
+
|
|
143
|
+
test("opens on the efficient rung and buries the frontier one — the exact inverse of the picker's order", () => {
|
|
144
|
+
const claude = ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"];
|
|
145
|
+
|
|
146
|
+
expect(claude.toSorted(compareCheapestFirst)).toEqual(["claude-haiku-4-5-20251001", "claude-sonnet-5", "claude-opus-5"]);
|
|
147
|
+
expect(claude.toSorted(compareModelIds)).toEqual(["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("keeps an UNRANKED family off the cheap end, where a plain reversal would have seated it first", () => {
|
|
151
|
+
// The whole reason this is not `-compareModelIds`. An id with no tier word is the provider's base line, and
|
|
152
|
+
// an unheard-of family is likelier the next flagship than the next budget tier — so both orders agree it is
|
|
153
|
+
// not the efficient rung, and a helper never spends frontier money on a commit message.
|
|
154
|
+
expect(["gpt-5.6", "gpt-5.4-mini"].toSorted(compareCheapestFirst)).toEqual(["gpt-5.4-mini", "gpt-5.6"]);
|
|
155
|
+
expect(["claude-mythos-1", "claude-haiku-4-5", "claude-sonnet-5"].toSorted(compareCheapestFirst).at(-1)).toBe("claude-mythos-1");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("takes the NEWEST build of the cheap rung, not merely any of them", () => {
|
|
159
|
+
// Within one tier the release rule runs unchanged: cheap is a tier, not an excuse to serve a stale model.
|
|
160
|
+
expect(["claude-haiku-4-5-20251001", "claude-haiku-4-5-20260210"].toSorted(compareCheapestFirst)[0]).toBe("claude-haiku-4-5-20260210");
|
|
161
|
+
expect(["gemini-3-flash-lite", "gemini-2-flash-lite"].toSorted(compareCheapestFirst)[0]).toBe("gemini-3-flash-lite");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("finds each vendor's own cheap rung, including a re-served open-weights row", () => {
|
|
165
|
+
expect(["gemini-3-pro", "gemini-3-flash", "gemini-3-flash-lite"].toSorted(compareCheapestFirst)[0]).toBe("gemini-3-flash-lite");
|
|
166
|
+
// Google's channel vends gpt-oss beside Gemini's own line; it is there to be the cheap option, and `oss`
|
|
167
|
+
// is what says so — without that word the id carries no tier at all and would sink to the bottom.
|
|
168
|
+
expect(["claude-opus-4-6-thinking", "gpt-oss-120b-medium"].toSorted(compareCheapestFirst)[0]).toBe("gpt-oss-120b-medium");
|
|
169
|
+
expect(["grok-4", "grok-4-fast"].toSorted(compareCheapestFirst)[0]).toBe("grok-4-fast");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("falls back on the newest release for a catalog that publishes no cheap tier at all", () => {
|
|
173
|
+
// Kimi names no tier word anywhere, so every row is UNRANKED and the tier term cancels. Serving the newest
|
|
174
|
+
// of what it does publish is the honest answer — there is no cheaper rung to find.
|
|
175
|
+
expect(["kimi-k2-0711-preview", "kimi-k2-0905-preview"].toSorted(compareCheapestFirst)[0]).toBe("kimi-k2-0905-preview");
|
|
176
|
+
});
|
package/src/model-order.ts
CHANGED
|
@@ -138,3 +138,18 @@ export const compareModelIds = (left: string, right: string): number =>
|
|
|
138
138
|
* So a set breaks its own ties on the id. Which sibling that seats first is arbitrary — but it is the same
|
|
139
139
|
* arbitrary answer every refresh, which is the property `default` actually needs. */
|
|
140
140
|
export const compareUnrankedModelIds = (left: string, right: string): number => compareModelIds(left, right) || left.localeCompare(right);
|
|
141
|
+
|
|
142
|
+
/* THE SAME TIER SCALE READ FROM THE OTHER END, for the one caller that wants the WEAKEST model rather than the
|
|
143
|
+
* strongest: the quick model behind a one-click helper (the commit box's autofill). A picker orders a catalog by
|
|
144
|
+
* what a user reaches for; this orders it by what a helper should spend, and the two are exact opposites — so
|
|
145
|
+
* they share TIER_RANK rather than each naming its own list of cheap ids.
|
|
146
|
+
*
|
|
147
|
+
* The direction of UNRANKED is the reason this can't just be compareModelIds reversed. There, an unrecognized
|
|
148
|
+
* family LEADS, because an id carrying no tier word is the provider's base line and a family nobody here has
|
|
149
|
+
* heard of is likelier the next flagship than the next budget tier. Reversing would therefore seat exactly that
|
|
150
|
+
* unknown-probably-flagship id as the cheap pick. So unknown sinks to LAST here too — both orders agree it is
|
|
151
|
+
* not the efficient rung — and the cheap end is only ever a family whose tier word is actually recognized.
|
|
152
|
+
* Falling off the end of a catalog with no efficient tier at all (Kimi publishes none) is then honest: the
|
|
153
|
+
* newest of what it does publish, chosen by the release tiebreak below. */
|
|
154
|
+
export const compareCheapestFirst = (left: string, right: string): number =>
|
|
155
|
+
tierRankOf(familyOf(right)) - tierRankOf(familyOf(left)) || compareRelease(releaseOf(left), releaseOf(right));
|
package/src/path-refs.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import { rankRefCandidates, referenceTails } from "./path-refs.js";
|
|
2
|
+
import { isTestPath, rankRefCandidates, referenceTails } from "./path-refs.js";
|
|
3
3
|
|
|
4
4
|
describe("referenceTails", () => {
|
|
5
5
|
test("offers the reference itself first, then shorter tails of it", () => {
|
|
@@ -40,3 +40,29 @@ describe("rankRefCandidates", () => {
|
|
|
40
40
|
expect(rankRefCandidates("src/foo.ts", ["src/foo.ts", "vendor/src/foo.ts"])).toEqual(["src/foo.ts", "vendor/src/foo.ts"]);
|
|
41
41
|
});
|
|
42
42
|
});
|
|
43
|
+
|
|
44
|
+
test("isTestPath: test files, fixture dirs and runner configs — never product code that merely says 'test'", () => {
|
|
45
|
+
for (const path of [
|
|
46
|
+
`src/agents/land.test.ts`,
|
|
47
|
+
`src/pages/Foo.spec.tsx`,
|
|
48
|
+
`_apps/cli/src/cli.e2e.test.ts`,
|
|
49
|
+
`src/e2e-harness.ts`,
|
|
50
|
+
`pkg/__tests__/helper.ts`,
|
|
51
|
+
`_libs/iq-recall/src/__fixtures__/transcripts/a.jsonl`,
|
|
52
|
+
`vitest.config.ts`,
|
|
53
|
+
`_apps/web/vitest.workspace.config.mts`,
|
|
54
|
+
`playwright.config.ts`,
|
|
55
|
+
]) {
|
|
56
|
+
expect(isTestPath(path), path).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
for (const path of [
|
|
59
|
+
`src/pages/testimonials.vue`,
|
|
60
|
+
`src/latest.ts`,
|
|
61
|
+
`src/test-utils.ts`,
|
|
62
|
+
`contest/results.ts`,
|
|
63
|
+
`src/attestation.spec.md.bak`,
|
|
64
|
+
`docs/testing.md`,
|
|
65
|
+
]) {
|
|
66
|
+
expect(isTestPath(path), path).toBe(false);
|
|
67
|
+
}
|
|
68
|
+
});
|
package/src/path-refs.ts
CHANGED
|
@@ -41,3 +41,19 @@ export const rankRefCandidates = (tail: string, paths: readonly string[]): reado
|
|
|
41
41
|
.filter((path) => path === tail || path.endsWith(`/${tail}`))
|
|
42
42
|
.toSorted((a, b) => a.split(`/`).length - b.split(`/`).length || a.length - b.length || (a < b ? -1 : 1))
|
|
43
43
|
.slice(0, MAX_REF_CANDIDATES);
|
|
44
|
+
|
|
45
|
+
/* IS THIS FILE TEST CODE — the one classification rule for every surface that splits a diff into "the
|
|
46
|
+
* change" and "the proof". The agent review header answers "how much of this is tests?" with it; anything
|
|
47
|
+
* else that wants the split (fleet cards, commit summaries) must use this same predicate, because two
|
|
48
|
+
* classifiers that disagree turn the readout into a lie the user can't detect.
|
|
49
|
+
*
|
|
50
|
+
* Convention-based, matching what this monorepo (and the ecosystems it scaffolds) actually writes: a
|
|
51
|
+
* `.test.` / `.spec.` filename in any extension, a `__tests__` / `__fixtures__` directory anywhere on the
|
|
52
|
+
* path, an `e2e-harness`, or a test-runner config. Deliberately NOT "anything containing 'test'": a
|
|
53
|
+
* `testimonials/` page or a `latest.ts` is product code, and a false "tests" tag is worse than a missed one —
|
|
54
|
+
* it tells a reviewer not to look. */
|
|
55
|
+
const TEST_DIRS = new Set([`__tests__`, `__fixtures__`, `__mocks__`, `__snapshots__`]);
|
|
56
|
+
const TEST_FILE = /(?:^|\/)(?:[^/]+\.(?:test|spec)\.[^/.]+|[^/]*\.e2e\.[^/]+|e2e-harness\.[^/]+|(?:vitest|jest|playwright)(?:\.[\w-]+)*\.config\.[^/]+)$/;
|
|
57
|
+
|
|
58
|
+
export const isTestPath = (path: string): boolean =>
|
|
59
|
+
TEST_FILE.test(path) || path.split(`/`).some((segment) => TEST_DIRS.has(segment));
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { type QuickModelSource, quickModelKey, resolveQuickModel } from "./quick-model.js";
|
|
3
|
+
|
|
4
|
+
/* Which model a one-click helper spends. The rule answers two surfaces at once — the daemon runs it, the
|
|
5
|
+
* browser names it in the sparkle's tooltip — so what these tests pin is that a sandbox's connections alone
|
|
6
|
+
* decide it, with no stored id to go stale. */
|
|
7
|
+
|
|
8
|
+
// Catalogs as their providers actually publish them: Claude's ranked list, the rest in registry order.
|
|
9
|
+
const CLAUDE: QuickModelSource = { provider: `claude`, ready: true, models: [`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5-20251001`] };
|
|
10
|
+
const GOOGLE: QuickModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-flash`, `gemini-3-flash-lite`, `gemini-3-pro`] };
|
|
11
|
+
const CODEX: QuickModelSource = { provider: `codex`, ready: true, models: [`gpt-5.4-mini`, `gpt-5.6`] };
|
|
12
|
+
const KIMI: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2-0711-preview`, `kimi-k2-0905-preview`] };
|
|
13
|
+
|
|
14
|
+
const offline = (source: QuickModelSource): QuickModelSource => ({ ...source, ready: false });
|
|
15
|
+
|
|
16
|
+
test("reaches for the efficient rung of the one connected provider, never its flagship", () => {
|
|
17
|
+
expect(resolveQuickModel([CLAUDE], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("spends the FREE channel over the subscription when both offer the same rung", () => {
|
|
21
|
+
// Both publish a cheap-tier row, so nothing separates them on capability — and one of them costs the user
|
|
22
|
+
// nothing while the other eats headroom they watch. Clicking sparkle should not quietly bill the Claude plan.
|
|
23
|
+
expect(resolveQuickModel([CLAUDE, GOOGLE], ``)).toEqual({ provider: `gemini`, model: `gemini-3-flash-lite` });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("puts tier ahead of cost — a free frontier model is still the wrong tool for a commit message", () => {
|
|
27
|
+
// Google connected but publishing only its Pro line. Ordering on price first would seat a flagship here,
|
|
28
|
+
// which is the exact outcome the feature exists to avoid.
|
|
29
|
+
const proOnly: QuickModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-pro`] };
|
|
30
|
+
|
|
31
|
+
expect(resolveQuickModel([CLAUDE, proOnly], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("prefers a subscription to a metered key at equal tier, since only one of them charges per click", () => {
|
|
35
|
+
const kimiCheap: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2-mini`] };
|
|
36
|
+
const claudeCheap: QuickModelSource = { provider: `claude`, ready: true, models: [`claude-haiku-4-5`] };
|
|
37
|
+
|
|
38
|
+
expect(resolveQuickModel([kimiCheap, claudeCheap], ``)?.provider).toBe(`claude`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("answers the same thing however the connected providers happen to be listed", () => {
|
|
42
|
+
// The daemon assembles these from live stores and the browser from its own refs; neither order is a fact.
|
|
43
|
+
const answers = [
|
|
44
|
+
resolveQuickModel([CLAUDE, GOOGLE, CODEX], ``),
|
|
45
|
+
resolveQuickModel([CODEX, CLAUDE, GOOGLE], ``),
|
|
46
|
+
resolveQuickModel([GOOGLE, CODEX, CLAUDE], ``),
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
expect(new Set(answers.map((answer) => quickModelKey(answer!))).size).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("honours a pinned model verbatim, including an id no catalog lists yet", () => {
|
|
53
|
+
expect(resolveQuickModel([CLAUDE, GOOGLE], `claude:claude-opus-5`)).toEqual({ provider: `claude`, model: `claude-opus-5` });
|
|
54
|
+
// The picker's custom-id escape hatch reaches here too: a catalog can lag a release, and running something
|
|
55
|
+
// other than what the settings row names would be the worse failure.
|
|
56
|
+
expect(resolveQuickModel([CLAUDE], `claude:claude-haiku-9`)).toEqual({ provider: `claude`, model: `claude-haiku-9` });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("falls back to Auto when the pinned provider is no longer connected", () => {
|
|
60
|
+
// Rather than failing every click with a credential error while the sandbox can plainly still answer.
|
|
61
|
+
expect(resolveQuickModel([offline(CLAUDE), GOOGLE], `claude:claude-haiku-4-5-20251001`)).toEqual({
|
|
62
|
+
provider: `gemini`,
|
|
63
|
+
model: `gemini-3-flash-lite`,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("ignores a malformed pin instead of running an empty model id", () => {
|
|
68
|
+
for (const pinned of [`claude`, `claude:`, `:claude-haiku-4-5`, ` `]) {
|
|
69
|
+
expect(resolveQuickModel([CLAUDE], pinned)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("serves the newest of a catalog that publishes no cheap tier at all", () => {
|
|
74
|
+
// Kimi names no tier word anywhere. There is no cheaper rung to find, so the newest row is the honest answer.
|
|
75
|
+
expect(resolveQuickModel([KIMI], ``)).toEqual({ provider: `kimi`, model: `kimi-k2-0905-preview` });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("reports nothing when no account is connected, so the button can say so instead of failing on click", () => {
|
|
79
|
+
expect(resolveQuickModel([offline(CLAUDE), offline(GOOGLE)], ``)).toBeUndefined();
|
|
80
|
+
expect(resolveQuickModel([], `claude:claude-haiku-4-5`)).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("skips a connected provider whose catalog has not loaded yet", () => {
|
|
84
|
+
const unloaded: QuickModelSource = { provider: `grok`, ready: true, models: [] };
|
|
85
|
+
|
|
86
|
+
expect(resolveQuickModel([unloaded, CLAUDE], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
87
|
+
expect(resolveQuickModel([unloaded], ``)).toBeUndefined();
|
|
88
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ACCESS_COST, accessFor, PROVIDERS } from "./agent-catalog.js";
|
|
2
|
+
import { compareCheapestFirst, familyOf, tierRankOf } from "./model-order.js";
|
|
3
|
+
import type { NativeProvider } from "./schemas.js";
|
|
4
|
+
|
|
5
|
+
/* THE QUICK MODEL — the cheap, fast model a one-click helper spends instead of the frontier model the chat runs
|
|
6
|
+
* on. Today that is the commit box's autofill; anything else of that shape (a branch name, a PR description)
|
|
7
|
+
* reads the same answer, which is the reason this is a `quickModel` setting rather than a commit-message one.
|
|
8
|
+
*
|
|
9
|
+
* The rule lives in the contract because BOTH sides need the same answer for different jobs: the daemon runs
|
|
10
|
+
* the model, and the browser has to NAME it — in the sparkle's tooltip and in the settings row's "Auto (…)"
|
|
11
|
+
* label — before anything has been run. Two implementations would drift precisely where it matters most, since
|
|
12
|
+
* a tooltip promising Haiku while the daemon bills Opus is worse than no tooltip.
|
|
13
|
+
*
|
|
14
|
+
* The default is DERIVED, NEVER STORED. `quickModel` ships as "" and that empty string means "work it out from
|
|
15
|
+
* whatever is connected right now", so connecting a Google account tomorrow improves the default by itself and
|
|
16
|
+
* disconnecting the pinned provider degrades to Auto instead of to a dead button. Same instinct as the rest of
|
|
17
|
+
* this repo's model handling: model-order.ts derives tier and recency from the id and curates nothing, and the
|
|
18
|
+
* web's defaultModelFor reads the live catalog rather than naming an id that a release will falsify. */
|
|
19
|
+
|
|
20
|
+
// One provider's standing in the decision: whether a turn on it can be sent at all, and what its catalog holds.
|
|
21
|
+
// ACP agents are deliberately not expressible here — an ACP row's model id is empty because the agent owns its
|
|
22
|
+
// own model, so there is no cheap rung to point it at.
|
|
23
|
+
export interface QuickModelSource {
|
|
24
|
+
readonly provider: NativeProvider;
|
|
25
|
+
// The same connection predicate every other surface gates on (access.ts web-side, the daemon's own account
|
|
26
|
+
// stores daemon-side). A catalog is never empty by construction, so "has rows" says nothing about "can send".
|
|
27
|
+
readonly ready: boolean;
|
|
28
|
+
readonly models: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface QuickModelChoice {
|
|
32
|
+
readonly provider: NativeProvider;
|
|
33
|
+
readonly model: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A pinned selection on the wire: `${provider}:${modelId}`, the same key shape the model picker already mints
|
|
37
|
+
// for its entries (PickerEntry.key). Empty ⇒ Auto.
|
|
38
|
+
export const quickModelKey = (choice: QuickModelChoice): string => `${choice.provider}:${choice.model}`;
|
|
39
|
+
|
|
40
|
+
// Split on the FIRST colon only: a provider id never contains one and a model id might.
|
|
41
|
+
const parsePinned = (pinned: string): QuickModelChoice | undefined => {
|
|
42
|
+
const separator = pinned.indexOf(`:`);
|
|
43
|
+
if (separator <= 0 || separator === pinned.length - 1) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return { provider: pinned.slice(0, separator) as NativeProvider, model: pinned.slice(separator + 1) };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// The cheapest row a provider publishes — its whole catalog read from the cheap end. Undefined for a catalog
|
|
50
|
+
// that hasn't loaded yet, which is a real state: every provider serves a floor, but only once something has
|
|
51
|
+
// asked it.
|
|
52
|
+
const cheapestOf = (source: QuickModelSource): string | undefined => source.models.toSorted(compareCheapestFirst)[0];
|
|
53
|
+
|
|
54
|
+
// Where a provider's cheapest row sits on the shared tier scale, and therefore how well it answers the question
|
|
55
|
+
// this whole module asks. UNRANKED (-1) is a genuine last place: it means the id carries no tier word we know,
|
|
56
|
+
// so the row is the provider's base line rather than its budget one.
|
|
57
|
+
const tierOf = (model: string): number => tierRankOf(familyOf(model));
|
|
58
|
+
|
|
59
|
+
// PROVIDERS order, as the final tiebreak. Arbitrary, but the SAME arbitrary answer on every read — the property
|
|
60
|
+
// compareUnrankedModelIds exists to guarantee, and the one a default actually needs.
|
|
61
|
+
const providerOrder = (provider: NativeProvider): number => PROVIDERS.findIndex((entry) => entry.value === provider);
|
|
62
|
+
|
|
63
|
+
// How much a call on this provider costs at the margin. Every native provider declares an access kind, so the
|
|
64
|
+
// fallback is unreachable — it exists because AgentProvider is a bare string on the wire.
|
|
65
|
+
const costOf = (provider: NativeProvider): number => {
|
|
66
|
+
const access = accessFor(provider);
|
|
67
|
+
return access === undefined ? ACCESS_COST.key : ACCESS_COST[access.kind];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/* Which model a quick helper runs, given what this sandbox has connected. `pinned` is the stored setting.
|
|
71
|
+
*
|
|
72
|
+
* A pin only holds while its provider is READY: an account the user disconnected would otherwise leave the
|
|
73
|
+
* button failing on every click with a credential error, when the sandbox can plainly still answer. Auto taking
|
|
74
|
+
* over is the same move the composer already makes when a live catalog stops offering the selected model.
|
|
75
|
+
*
|
|
76
|
+
* Auto ranks candidates on TIER FIRST, then cost. That order is the point of the feature: the helper exists to
|
|
77
|
+
* not be the frontier model, so a free flagship is still the wrong tool, while a free Haiku-class row and a
|
|
78
|
+
* subscription Haiku-class row differ only in whose quota they spend. Cost then breaks that tie towards the
|
|
79
|
+
* channel the user is not paying per token for — and against the one they are.
|
|
80
|
+
*
|
|
81
|
+
* Undefined when nothing is connected: the caller renders a disabled control that says so, rather than a live
|
|
82
|
+
* button that fails on click. */
|
|
83
|
+
export const resolveQuickModel = (sources: readonly QuickModelSource[], pinned: string): QuickModelChoice | undefined => {
|
|
84
|
+
const requested = parsePinned(pinned);
|
|
85
|
+
if (requested !== undefined && sources.some((source) => source.provider === requested.provider && source.ready)) {
|
|
86
|
+
// Taken verbatim, unvalidated against the catalog on purpose: the picker already offers a custom-id
|
|
87
|
+
// escape hatch for a model a catalog hasn't caught up with, and second-guessing the user's own id here
|
|
88
|
+
// would silently run a different model than the settings row names.
|
|
89
|
+
return requested;
|
|
90
|
+
}
|
|
91
|
+
const candidates = sources
|
|
92
|
+
.filter((source) => source.ready)
|
|
93
|
+
.flatMap((source) => {
|
|
94
|
+
const model = cheapestOf(source);
|
|
95
|
+
return model === undefined ? [] : [{ provider: source.provider, model }];
|
|
96
|
+
});
|
|
97
|
+
return candidates.toSorted(
|
|
98
|
+
(left, right) =>
|
|
99
|
+
tierOf(right.model) - tierOf(left.model) ||
|
|
100
|
+
costOf(left.provider) - costOf(right.provider) ||
|
|
101
|
+
providerOrder(left.provider) - providerOrder(right.provider),
|
|
102
|
+
)[0];
|
|
103
|
+
};
|