@intentic/sandbox-contract 1.176.2 → 1.209.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/README.md +65 -0
- package/dist/agent-catalog.d.ts +15 -2
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +50 -1
- package/dist/agent-catalog.js.map +1 -1
- package/dist/agent-run-model.d.ts +3 -0
- package/dist/agent-run-model.d.ts.map +1 -0
- package/dist/agent-run-model.js +10 -0
- package/dist/agent-run-model.js.map +1 -0
- package/dist/capability-env.d.ts +2 -0
- package/dist/capability-env.d.ts.map +1 -0
- package/dist/capability-env.js +2 -0
- package/dist/capability-env.js.map +1 -0
- package/dist/chores/chores.d.ts +9 -0
- package/dist/chores/chores.d.ts.map +1 -1
- package/dist/chores/chores.js +257 -18
- package/dist/chores/chores.js.map +1 -1
- package/dist/chores/digest.js +0 -0
- package/dist/chores/digest.js.map +1 -1
- package/dist/chores/extension-update.d.ts +9 -0
- package/dist/chores/extension-update.d.ts.map +1 -0
- package/dist/chores/extension-update.js +15 -0
- package/dist/chores/extension-update.js.map +1 -0
- package/dist/chores/fix-deps.d.ts +10 -0
- package/dist/chores/fix-deps.d.ts.map +1 -0
- package/dist/chores/fix-deps.js +18 -0
- package/dist/chores/fix-deps.js.map +1 -0
- package/dist/chores/index.d.ts +7 -2
- package/dist/chores/index.d.ts.map +1 -1
- package/dist/chores/index.js +4 -1
- package/dist/chores/index.js.map +1 -1
- package/dist/chores/probes.d.ts +1 -0
- package/dist/chores/probes.d.ts.map +1 -1
- package/dist/chores/probes.js +131 -3
- package/dist/chores/probes.js.map +1 -1
- package/dist/chores/stack.d.ts +27 -0
- package/dist/chores/stack.d.ts.map +1 -0
- package/dist/chores/stack.js +121 -0
- package/dist/chores/stack.js.map +1 -0
- package/dist/chores/verdict.d.ts +2 -1
- package/dist/chores/verdict.d.ts.map +1 -1
- package/dist/chores/verdict.js +57 -7
- package/dist/chores/verdict.js.map +1 -1
- package/dist/contract-lock.d.ts +3 -0
- package/dist/contract-lock.d.ts.map +1 -0
- package/dist/contract-lock.js +35 -0
- package/dist/contract-lock.js.map +1 -0
- package/dist/contracts/activity.contract.d.ts +10 -0
- package/dist/contracts/activity.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.d.ts +230 -20
- 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 +847 -1
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +3 -1
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/automations.contract.d.ts +89 -0
- package/dist/contracts/automations.contract.d.ts.map +1 -1
- package/dist/contracts/automations.contract.js +3 -1
- package/dist/contracts/automations.contract.js.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +108 -32
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/capabilities.contract.js +5 -1
- package/dist/contracts/capabilities.contract.js.map +1 -1
- package/dist/contracts/chores.contract.d.ts +43 -0
- package/dist/contracts/chores.contract.d.ts.map +1 -1
- package/dist/contracts/ci.contract.d.ts +5 -0
- package/dist/contracts/ci.contract.d.ts.map +1 -1
- package/dist/contracts/ci.contract.js +2 -2
- package/dist/contracts/ci.contract.js.map +1 -1
- package/dist/contracts/claude.contract.d.ts +3 -14
- package/dist/contracts/claude.contract.d.ts.map +1 -1
- package/dist/contracts/claude.contract.js +2 -3
- package/dist/contracts/claude.contract.js.map +1 -1
- package/dist/contracts/drafts.contract.d.ts +4 -0
- package/dist/contracts/drafts.contract.d.ts.map +1 -1
- package/dist/contracts/endpoints.contract.d.ts +28 -12
- package/dist/contracts/endpoints.contract.d.ts.map +1 -1
- package/dist/contracts/endpoints.contract.js +9 -0
- package/dist/contracts/endpoints.contract.js.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +399 -69
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.js +15 -1
- package/dist/contracts/extensions.contract.js.map +1 -1
- package/dist/contracts/git.contract.d.ts +209 -11
- package/dist/contracts/git.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.js +12 -2
- package/dist/contracts/git.contract.js.map +1 -1
- package/dist/contracts/grok.contract.d.ts +0 -13
- package/dist/contracts/grok.contract.d.ts.map +1 -1
- package/dist/contracts/grok.contract.js +1 -2
- package/dist/contracts/grok.contract.js.map +1 -1
- package/dist/contracts/host.contract.d.ts +39 -0
- package/dist/contracts/host.contract.d.ts.map +1 -1
- package/dist/contracts/host.contract.js +3 -2
- package/dist/contracts/host.contract.js.map +1 -1
- package/dist/contracts/loops.contract.d.ts +343 -0
- package/dist/contracts/loops.contract.d.ts.map +1 -0
- package/dist/contracts/loops.contract.js +11 -0
- package/dist/contracts/loops.contract.js.map +1 -0
- package/dist/contracts/panels.contract.d.ts +6 -0
- package/dist/contracts/panels.contract.d.ts.map +1 -1
- package/dist/contracts/personas.contract.d.ts +112 -0
- package/dist/contracts/personas.contract.d.ts.map +1 -0
- package/dist/contracts/personas.contract.js +13 -0
- package/dist/contracts/personas.contract.js.map +1 -0
- package/dist/contracts/ports.contract.d.ts +1 -0
- package/dist/contracts/ports.contract.d.ts.map +1 -1
- package/dist/contracts/prepush.contract.d.ts +1 -0
- package/dist/contracts/prepush.contract.d.ts.map +1 -1
- package/dist/contracts/prepush.contract.js.map +1 -1
- package/dist/contracts/{codex.contract.d.ts → providers.contract.d.ts} +11 -3
- package/dist/contracts/providers.contract.d.ts.map +1 -0
- package/dist/contracts/providers.contract.js +6 -0
- package/dist/contracts/providers.contract.js.map +1 -0
- package/dist/contracts/public.contract.d.ts +24 -0
- package/dist/contracts/public.contract.d.ts.map +1 -0
- package/dist/contracts/public.contract.js +8 -0
- package/dist/contracts/public.contract.js.map +1 -0
- package/dist/contracts/push.contract.d.ts +3 -3
- package/dist/contracts/secrets.contract.d.ts +9 -0
- package/dist/contracts/secrets.contract.d.ts.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +16 -1
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/sessions.contract.js +1 -1
- package/dist/contracts/sessions.contract.js.map +1 -1
- package/dist/contracts/settings.contract.d.ts +352 -58
- 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/share.contract.d.ts +55 -0
- package/dist/contracts/share.contract.d.ts.map +1 -0
- package/dist/contracts/share.contract.js +9 -0
- package/dist/contracts/share.contract.js.map +1 -0
- package/dist/contracts/skills.contract.d.ts +41 -0
- package/dist/contracts/skills.contract.d.ts.map +1 -0
- package/dist/contracts/skills.contract.js +9 -0
- package/dist/contracts/skills.contract.js.map +1 -0
- package/dist/contracts/system.contract.d.ts +218 -22
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.js +10 -2
- package/dist/contracts/system.contract.js.map +1 -1
- package/dist/contracts/workflows.contract.d.ts +575 -0
- package/dist/contracts/workflows.contract.d.ts.map +1 -0
- package/dist/contracts/workflows.contract.js +13 -0
- package/dist/contracts/workflows.contract.js.map +1 -0
- package/dist/contracts/workspace.contract.d.ts +18 -4
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.js +6 -3
- package/dist/contracts/workspace.contract.js.map +1 -1
- package/dist/conversation-ids.d.ts +2 -0
- package/dist/conversation-ids.d.ts.map +1 -0
- package/dist/conversation-ids.js +113 -0
- package/dist/conversation-ids.js.map +1 -0
- package/dist/events.d.ts +523 -17
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +106 -13
- package/dist/events.js.map +1 -1
- package/dist/history-state.d.ts +3 -0
- package/dist/history-state.d.ts.map +1 -0
- package/dist/history-state.js +53 -0
- package/dist/history-state.js.map +1 -0
- package/dist/hostnames.d.ts +4 -0
- package/dist/hostnames.d.ts.map +1 -1
- package/dist/hostnames.js +7 -2
- package/dist/hostnames.js.map +1 -1
- package/dist/index.d.ts +3793 -478
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +41 -17
- package/dist/index.js.map +1 -1
- package/dist/listener-protocol.d.ts +77 -0
- package/dist/listener-protocol.d.ts.map +1 -0
- package/dist/listener-protocol.js +33 -0
- package/dist/listener-protocol.js.map +1 -0
- package/dist/model-order.d.ts +1 -0
- package/dist/model-order.d.ts.map +1 -1
- package/dist/model-order.js +16 -0
- package/dist/model-order.js.map +1 -1
- package/dist/output-fields.d.ts +30 -0
- package/dist/output-fields.d.ts.map +1 -0
- package/dist/output-fields.js +57 -0
- package/dist/output-fields.js.map +1 -0
- package/dist/path-refs.d.ts.map +1 -1
- package/dist/path-refs.js.map +1 -1
- package/dist/publish-drafts.d.ts +7 -0
- package/dist/publish-drafts.d.ts.map +1 -0
- package/dist/publish-drafts.js +20 -0
- package/dist/publish-drafts.js.map +1 -0
- package/dist/quick-model.d.ts +2 -1
- package/dist/quick-model.d.ts.map +1 -1
- package/dist/quick-model.js +18 -14
- package/dist/quick-model.js.map +1 -1
- package/dist/routes.d.ts +3 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +63 -0
- package/dist/routes.js.map +1 -1
- package/dist/runtime-state.d.ts +33 -0
- package/dist/runtime-state.d.ts.map +1 -0
- package/dist/runtime-state.js +14 -0
- package/dist/runtime-state.js.map +1 -0
- package/dist/schemas.d.ts +4938 -866
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +1011 -192
- package/dist/schemas.js.map +1 -1
- package/dist/search-globs.d.ts +6 -0
- package/dist/search-globs.d.ts.map +1 -0
- package/dist/search-globs.js +39 -0
- package/dist/search-globs.js.map +1 -0
- package/dist/share-paths.d.ts +8 -0
- package/dist/share-paths.d.ts.map +1 -0
- package/dist/share-paths.js +21 -0
- package/dist/share-paths.js.map +1 -0
- package/dist/state-portability.d.ts +8 -0
- package/dist/state-portability.d.ts.map +1 -0
- package/dist/state-portability.js +4 -0
- package/dist/state-portability.js.map +1 -0
- package/dist/title.d.ts.map +1 -1
- package/dist/title.js +1 -1
- package/dist/title.js.map +1 -1
- package/dist/tunnel-ids.d.ts +1 -0
- package/dist/tunnel-ids.d.ts.map +1 -1
- package/dist/tunnel-ids.js +1 -0
- package/dist/tunnel-ids.js.map +1 -1
- package/dist/versions.d.ts +4 -0
- package/dist/versions.d.ts.map +1 -0
- package/dist/versions.js +15 -0
- package/dist/versions.js.map +1 -0
- package/dist/workflow-faults.d.ts +4 -0
- package/dist/workflow-faults.d.ts.map +1 -0
- package/dist/workflow-faults.js +115 -0
- package/dist/workflow-faults.js.map +1 -0
- package/dist/workspace-state.d.ts +282 -3
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +198 -12
- package/dist/workspace-state.js.map +1 -1
- package/package.json +30 -6
- package/src/agent-catalog.test.ts +147 -7
- package/src/agent-catalog.ts +233 -21
- package/src/agent-run-model.test.ts +60 -0
- package/src/agent-run-model.ts +51 -0
- package/src/capability-env.ts +10 -0
- package/src/capability-ledger.test.ts +110 -0
- package/src/chores/chores.test.ts +32 -0
- package/src/chores/chores.ts +456 -32
- package/src/chores/digest.ts +0 -0
- package/src/chores/extension-update.ts +37 -0
- package/src/chores/fix-deps.ts +40 -0
- package/src/chores/index.ts +7 -2
- package/src/chores/probes.test.ts +155 -3
- package/src/chores/probes.ts +209 -4
- package/src/chores/stack.test.ts +126 -0
- package/src/chores/stack.ts +244 -0
- package/src/chores/verdict.test.ts +357 -19
- package/src/chores/verdict.ts +102 -22
- package/src/contract-lock.test.ts +17 -0
- package/src/contract-lock.ts +66 -0
- package/src/contracts/agent.contract.ts +6 -0
- package/src/contracts/agents.contract.ts +12 -0
- package/src/contracts/automations.contract.ts +17 -1
- package/src/contracts/capabilities.contract.ts +22 -0
- package/src/contracts/ci.contract.ts +10 -2
- package/src/contracts/claude.contract.ts +8 -4
- package/src/contracts/endpoints.contract.ts +21 -0
- package/src/contracts/extensions.contract.ts +42 -0
- package/src/contracts/git.contract.ts +33 -6
- package/src/contracts/grok.contract.ts +4 -3
- package/src/contracts/host.contract.ts +13 -2
- package/src/contracts/loops.contract.ts +62 -0
- package/src/contracts/personas.contract.ts +56 -0
- package/src/contracts/prepush.contract.ts +4 -3
- package/src/contracts/providers.contract.ts +23 -0
- package/src/contracts/public.contract.ts +14 -0
- package/src/contracts/sessions.contract.ts +5 -1
- package/src/contracts/settings.contract.ts +5 -0
- package/src/contracts/share.contract.ts +20 -0
- package/src/contracts/skills.contract.ts +24 -0
- package/src/contracts/system.contract.ts +27 -3
- package/src/contracts/workflows.contract.ts +78 -0
- package/src/contracts/workspace.contract.ts +10 -2
- package/src/conversation-ids.test.ts +25 -0
- package/src/conversation-ids.ts +160 -0
- package/src/events.test.ts +20 -1
- package/src/events.ts +404 -43
- package/src/history-state.ts +129 -0
- package/src/hostnames.ts +20 -6
- package/src/index.ts +60 -17
- package/src/listener-protocol.ts +96 -0
- package/src/model-order.test.ts +32 -1
- package/src/model-order.ts +56 -3
- package/src/output-fields.ts +111 -0
- package/src/path-refs.test.ts +4 -4
- package/src/path-refs.ts +5 -5
- package/src/publish-drafts.ts +59 -0
- package/src/quick-model.test.ts +70 -27
- package/src/quick-model.ts +76 -40
- package/src/routes.test.ts +91 -3
- package/src/routes.ts +135 -0
- package/src/runtime-state.test.ts +45 -0
- package/src/runtime-state.ts +124 -0
- package/src/schemas.test.ts +16 -29
- package/src/schemas.ts +3526 -553
- package/src/search-globs.test.ts +46 -0
- package/src/search-globs.ts +76 -0
- package/src/share-paths.test.ts +48 -0
- package/src/share-paths.ts +68 -0
- package/src/state-portability.ts +56 -0
- package/src/title.test.ts +20 -1
- package/src/title.ts +12 -3
- package/src/tunnel-ids.ts +15 -1
- package/src/versions.test.ts +39 -0
- package/src/versions.ts +48 -0
- package/src/workflow-faults.test.ts +127 -0
- package/src/workflow-faults.ts +188 -0
- package/src/workspace-state.test.ts +189 -17
- package/src/workspace-state.ts +500 -30
- package/dist/contracts/codex.contract.d.ts.map +0 -1
- package/dist/contracts/codex.contract.js +0 -6
- package/dist/contracts/codex.contract.js.map +0 -1
- package/dist/contracts/gemini.contract.d.ts +0 -16
- package/dist/contracts/gemini.contract.d.ts.map +0 -1
- package/dist/contracts/gemini.contract.js +0 -6
- package/dist/contracts/gemini.contract.js.map +0 -1
- package/dist/contracts/kimi.contract.d.ts +0 -16
- package/dist/contracts/kimi.contract.d.ts.map +0 -1
- package/dist/contracts/kimi.contract.js +0 -6
- package/dist/contracts/kimi.contract.js.map +0 -1
- package/dist/contracts/komodo.contract.d.ts +0 -125
- package/dist/contracts/komodo.contract.d.ts.map +0 -1
- package/dist/contracts/komodo.contract.js +0 -14
- package/dist/contracts/komodo.contract.js.map +0 -1
- package/dist/contracts/memory.contract.d.ts +0 -34
- package/dist/contracts/memory.contract.d.ts.map +0 -1
- package/dist/contracts/memory.contract.js +0 -9
- package/dist/contracts/memory.contract.js.map +0 -1
- package/src/contracts/codex.contract.ts +0 -10
- package/src/contracts/gemini.contract.ts +0 -11
- package/src/contracts/kimi.contract.ts +0 -9
- package/src/contracts/komodo.contract.ts +0 -36
- package/src/contracts/memory.contract.ts +0 -13
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/* A DECLARED OUTPUT SHAPE — the answer to "what does this session produce?", written once and used three ways.
|
|
4
|
+
*
|
|
5
|
+
* An agentic session's natural output is prose, and prose is unusable as an input to the next session: a step
|
|
6
|
+
* that must hand "the three files worth changing" to the step after it cannot hand over a paragraph that
|
|
7
|
+
* mentions them. So a session that feeds another one declares its shape here, and that one declaration
|
|
8
|
+
* becomes: the sentence in the prompt that tells the model what to write, the validator that decides whether
|
|
9
|
+
* it complied, and the table the run view renders. Written once because the three drift apart the moment they
|
|
10
|
+
* are written twice — a prompt asking for `files` and a validator wanting `paths` fails on every iteration and
|
|
11
|
+
* says nothing useful about why.
|
|
12
|
+
*
|
|
13
|
+
* WHY A FIELD LIST AND NOT JSON SCHEMA. JSON Schema is strictly more expressive and completely unauthorable in
|
|
14
|
+
* a form: nobody designing a workflow is going to hand-write `{"type":"object","properties":{...}}`, and a UI
|
|
15
|
+
* that generates it becomes a schema editor, which is a product of its own. Four scalar types plus a string
|
|
16
|
+
* list covers what one session actually hands another — a verdict, a count, a list of paths, a summary — and
|
|
17
|
+
* anything past that is better carried as a file the next step reads.
|
|
18
|
+
*
|
|
19
|
+
* `description` IS REQUIRED, and that is the field that decides whether this works at all. `{name: "risk"}`
|
|
20
|
+
* gets you the model's guess at what risk means; `{name: "risk", description: "high | medium | low — how
|
|
21
|
+
* likely this change is to break something at runtime"}` gets you the answer to the question you asked.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Restricted to a JS-identifier-ish shape: these become object keys the prompt spells out literally, and a
|
|
25
|
+
// name with a quote or a newline in it produces a prompt that cannot be complied with.
|
|
26
|
+
const FIELD_NAME = /^[a-zA-Z_][a-zA-Z0-9_]{0,39}$/;
|
|
27
|
+
|
|
28
|
+
export const OutputFieldSchema = z.object({
|
|
29
|
+
name: z.string().regex(FIELD_NAME),
|
|
30
|
+
type: z.enum(["string", "number", "boolean", "string[]"]),
|
|
31
|
+
// What the field means, in the words the model is given. Not optional — see the note above.
|
|
32
|
+
description: z.string().min(1),
|
|
33
|
+
// An absent optional field validates; an absent required one does not, and the iteration is told which.
|
|
34
|
+
required: z.boolean(),
|
|
35
|
+
});
|
|
36
|
+
export type OutputField = z.infer<typeof OutputFieldSchema>;
|
|
37
|
+
|
|
38
|
+
// How many fields one output may declare. A shape past this is not a handoff, it is a report — and a report is
|
|
39
|
+
// what the prose half of the document is for.
|
|
40
|
+
export const OUTPUT_FIELDS_MAX = 16;
|
|
41
|
+
|
|
42
|
+
/* Repeated names make the declaration internally contradictory: object validation can only keep one rule for
|
|
43
|
+
* a key, while a consumer looking the field up sees whichever copy it happens to ask for first. Reject them at
|
|
44
|
+
* the declaration boundary, before either interpretation gets a chance to exist. Exported because graph-level
|
|
45
|
+
* validation uses the same fact to explain the fault in the designer before a save is attempted. */
|
|
46
|
+
export const duplicateOutputFieldNames = (fields: readonly Pick<OutputField, "name">[]): string[] => {
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
const repeated = new Set<string>();
|
|
49
|
+
for (const field of fields) {
|
|
50
|
+
if (seen.has(field.name)) {
|
|
51
|
+
repeated.add(field.name);
|
|
52
|
+
}
|
|
53
|
+
seen.add(field.name);
|
|
54
|
+
}
|
|
55
|
+
return [...repeated];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const OutputFieldsSchema = z
|
|
59
|
+
.array(OutputFieldSchema)
|
|
60
|
+
.min(1)
|
|
61
|
+
.max(OUTPUT_FIELDS_MAX)
|
|
62
|
+
.superRefine((fields, context) => {
|
|
63
|
+
for (const name of duplicateOutputFieldNames(fields)) {
|
|
64
|
+
context.addIssue({ code: "custom", message: `Output field names must be unique; "${name}" is repeated.` });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const validatorFor = (field: OutputField): z.ZodType => {
|
|
69
|
+
if (field.type === "number") {
|
|
70
|
+
return z.number();
|
|
71
|
+
}
|
|
72
|
+
if (field.type === "boolean") {
|
|
73
|
+
return z.boolean();
|
|
74
|
+
}
|
|
75
|
+
if (field.type === "string[]") {
|
|
76
|
+
return z.array(z.string());
|
|
77
|
+
}
|
|
78
|
+
return z.string();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/* The declared shape as a validator. Unknown keys are ALLOWED THROUGH: a model that answered everything asked
|
|
82
|
+
* of it and then added a `notes` key has complied, and failing it there would burn an iteration teaching it to
|
|
83
|
+
* write less. What is enforced is that every required field is present and every present field has the
|
|
84
|
+
* declared type — the two things the reader downstream is entitled to assume.
|
|
85
|
+
*/
|
|
86
|
+
export const fieldsValidator = (fields: readonly OutputField[]): z.ZodType =>
|
|
87
|
+
z.looseObject(Object.fromEntries(fields.map((field) => [field.name, field.required ? validatorFor(field) : validatorFor(field).optional()])));
|
|
88
|
+
|
|
89
|
+
// A worked example of the declared shape, so the prompt can show rather than describe. Values are the field's
|
|
90
|
+
// own description — a model copying the example's structure has the description in front of it as it fills each
|
|
91
|
+
// slot, which is where it is needed rather than in a legend three lines up.
|
|
92
|
+
export const fieldsExample = (fields: readonly OutputField[]): Record<string, unknown> =>
|
|
93
|
+
Object.fromEntries(
|
|
94
|
+
fields.map((field) => {
|
|
95
|
+
const hint = `${field.description}${field.required ? "" : " (optional — omit if it does not apply)"}`;
|
|
96
|
+
if (field.type === "number") {
|
|
97
|
+
return [field.name, 0];
|
|
98
|
+
}
|
|
99
|
+
if (field.type === "boolean") {
|
|
100
|
+
return [field.name, false];
|
|
101
|
+
}
|
|
102
|
+
if (field.type === "string[]") {
|
|
103
|
+
return [field.name, [hint]];
|
|
104
|
+
}
|
|
105
|
+
return [field.name, hint];
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// One line per field, for surfaces with no room to render an example: "risk (string, required) — how likely …".
|
|
110
|
+
export const describeFields = (fields: readonly OutputField[]): string =>
|
|
111
|
+
fields.map((field) => `- \`${field.name}\` (${field.type}${field.required ? ", required" : ", optional"}) — ${field.description}`).join(`\n`);
|
package/src/path-refs.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ 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", () => {
|
|
6
|
-
expect(referenceTails("
|
|
6
|
+
expect(referenceTails("_editor/web/src/foo.ts", "/work")).toEqual(["_editor/web/src/foo.ts", "web/src/foo.ts", "src/foo.ts"]);
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
test("anchors an absolute path under the workspace root", () => {
|
|
@@ -45,12 +45,12 @@ test("isTestPath: test files, fixture dirs and runner configs — never product
|
|
|
45
45
|
for (const path of [
|
|
46
46
|
`src/agents/land.test.ts`,
|
|
47
47
|
`src/pages/Foo.spec.tsx`,
|
|
48
|
-
`
|
|
48
|
+
`_deploy/cli/src/cli.e2e.test.ts`,
|
|
49
49
|
`src/e2e-harness.ts`,
|
|
50
50
|
`pkg/__tests__/helper.ts`,
|
|
51
|
-
`
|
|
51
|
+
`_search/iq-recall/src/__fixtures__/transcripts/a.jsonl`,
|
|
52
52
|
`vitest.config.ts`,
|
|
53
|
-
`
|
|
53
|
+
`_editor/web/vitest.workspace.config.mts`,
|
|
54
54
|
`playwright.config.ts`,
|
|
55
55
|
]) {
|
|
56
56
|
expect(isTestPath(path), path).toBe(true);
|
package/src/path-refs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Which file a NAMED reference means. A path written in prose is only loosely anchored to the workspace: an
|
|
2
|
-
* agent that has been working in `
|
|
3
|
-
* isolated worktree prints `/history/worktrees/<id>/
|
|
2
|
+
* agent that has been working in `_editor/web/src` writes `pages/workspace/Foo.vue`, and a turn running in an
|
|
3
|
+
* isolated worktree prints `/history/worktrees/<id>/_editor/web/src/foo.ts` — neither is the workspace-relative
|
|
4
4
|
* path the file routes speak, but both END in it.
|
|
5
5
|
*
|
|
6
6
|
* So a reference is resolved by matching progressively shorter TAILS of it against the real tree. The rules
|
|
@@ -53,7 +53,7 @@ export const rankRefCandidates = (tail: string, paths: readonly string[]): reado
|
|
|
53
53
|
* `testimonials/` page or a `latest.ts` is product code, and a false "tests" tag is worse than a missed one —
|
|
54
54
|
* it tells a reviewer not to look. */
|
|
55
55
|
const TEST_DIRS = new Set([`__tests__`, `__fixtures__`, `__mocks__`, `__snapshots__`]);
|
|
56
|
-
const TEST_FILE =
|
|
56
|
+
const TEST_FILE =
|
|
57
|
+
/(?:^|\/)(?:[^/]+\.(?:test|spec)\.[^/.]+|[^/]*\.e2e\.[^/]+|e2e-harness\.[^/]+|(?:vitest|jest|playwright)(?:\.[\w-]+)*\.config\.[^/]+)$/;
|
|
57
58
|
|
|
58
|
-
export const isTestPath = (path: string): boolean =>
|
|
59
|
-
TEST_FILE.test(path) || path.split(`/`).some((segment) => TEST_DIRS.has(segment));
|
|
59
|
+
export const isTestPath = (path: string): boolean => TEST_FILE.test(path) || path.split(`/`).some((segment) => TEST_DIRS.has(segment));
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* HOW AN APPROVED POST GETS SENT — the shared half, written once because the daemon acts on it, the app draws
|
|
2
|
+
* it, and the two have to agree on the same seconds.
|
|
3
|
+
*
|
|
4
|
+
* THERE IS NO PUBLISHER AUTOMATION ANY MORE, and its absence is the design. Publishing used to be a scheduled
|
|
5
|
+
* automation: a cron waking every few minutes, running a shell guard over the drafts directory, almost always
|
|
6
|
+
* finding nothing — a job whose entire job was to ask "yet?" forever. It also made the approve button
|
|
7
|
+
* conditional on a row in a list nobody had asked for: delete the automation and approvals silently went
|
|
8
|
+
* nowhere, with the button still there and still saying yes. The daemon owns publishing now
|
|
9
|
+
* (drafts-publisher.ts). It knows the moment a draft comes due because it is the process that wrote the draft,
|
|
10
|
+
* so it sleeps until exactly then and costs nothing in between.
|
|
11
|
+
*
|
|
12
|
+
* APPROVAL IS NOT "SEND NOW", IT IS "SEND UNLESS I STOP YOU". A post is public and permanent the instant it
|
|
13
|
+
* lands, and the gap between realising and clicking is about two seconds — so an approved draft carrying no
|
|
14
|
+
* date of its own is dated HOLD into the future, and the queue counts it down in the open. The hold is stored
|
|
15
|
+
* as an ordinary scheduledAt rather than as a new state, which is what keeps it honest: it survives a restart,
|
|
16
|
+
* it reads as one number in the same place a scheduled post's date is already read, and calling it off is the
|
|
17
|
+
* "put it back in review" click that was there before any of this.
|
|
18
|
+
*
|
|
19
|
+
* A MINUTE, because that is the whole width of the decision: long enough to catch the wrong word you see the
|
|
20
|
+
* moment the row stops being a form, short enough that approving something still feels like sending it. */
|
|
21
|
+
export const APPROVAL_HOLD_MS = 60_000;
|
|
22
|
+
|
|
23
|
+
/* WHO CAN BE SENT BY CODE ALONE. A connector reached through a real API with a stored credential is a request
|
|
24
|
+
* the daemon can make itself — no model, no browser, no turn — and it either got a 200 or it did not. A
|
|
25
|
+
* connector that IS a logged-in browser session (reddit, x) has no such door: posting there means driving a
|
|
26
|
+
* page whose markup moves under you, past dialogs and rate screens nobody can enumerate in advance, which is
|
|
27
|
+
* precisely the work an agent turn exists to absorb.
|
|
28
|
+
*
|
|
29
|
+
* So the split follows what the platform actually offers rather than what would be cheaper, and it is stated
|
|
30
|
+
* here rather than guessed at a call site — because being wrong in the optimistic direction means a post that
|
|
31
|
+
* silently never goes out. A platform absent from this set is published by an agent turn, which always works
|
|
32
|
+
* and merely costs more. */
|
|
33
|
+
export const DIRECT_PUBLISH_PLATFORMS: ReadonlySet<string> = new Set(["discord"]);
|
|
34
|
+
|
|
35
|
+
/* WHAT THE PUBLISH TURN IS TOLD, for the drafts no API can carry. It NAMES the drafts rather than saying "go
|
|
36
|
+
* and look": the daemon has already decided what is due, and a turn that re-derives that decision can disagree
|
|
37
|
+
* with it — sending something the owner pulled back a second ago, or skipping something it judged not ready.
|
|
38
|
+
* The turn's job is the part only it can do, which is working the platform's own UI.
|
|
39
|
+
*
|
|
40
|
+
* It still writes the outcome back into the file, because the file is where the queue reads it, and a post
|
|
41
|
+
* that went out without saying so is a post the owner sends twice. */
|
|
42
|
+
export const publishTurnPrompt = (drafts: readonly { readonly id: string; readonly platform: string }[]): string =>
|
|
43
|
+
[
|
|
44
|
+
`Publish these approved post drafts, which are due now. They live in .intentic/drafts/, one JSON file each:`,
|
|
45
|
+
``,
|
|
46
|
+
...drafts.map((draft) => `- ${draft.id}.json (${draft.platform})`),
|
|
47
|
+
``,
|
|
48
|
+
`Take them ONE AT A TIME, and for each:`,
|
|
49
|
+
`1. Read the file. Set "status":"posting" BEFORE you act, so a turn that dies here cannot double-post.`,
|
|
50
|
+
`2. Post exactly its "content" — with its "title", "target" and "media" — using that platform's skill.`,
|
|
51
|
+
` A "target" that is a URL means this draft is a REPLY to whatever is at it: open that exact URL and`,
|
|
52
|
+
` reply where it lands. On reddit a comment permalink (.../comments/<post>/<slug>/<comment>/) has to`,
|
|
53
|
+
` nest under that comment rather than becoming a new top-level comment on the thread.`,
|
|
54
|
+
`3. Set "status":"posted" plus "postedAt" (epoch ms) and, when the platform gives you one, "postedUrl".`,
|
|
55
|
+
` If it failed, set "status":"failed" plus an "error" saying what went wrong in plain words the owner`,
|
|
56
|
+
` can act on, then move to the next draft instead of retrying in a loop.`,
|
|
57
|
+
``,
|
|
58
|
+
`Never rewrite the content — the owner approved these exact words. Never touch a draft not listed above.`,
|
|
59
|
+
].join(`\n`);
|
package/src/quick-model.test.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { expect, test } from "vitest";
|
|
2
|
-
import { type QuickModelSource, quickModelKey,
|
|
2
|
+
import { type QuickModelChoice, type QuickModelSource, quickModelKey, resolveQuickModels } from "./quick-model.js";
|
|
3
3
|
|
|
4
|
-
/* Which
|
|
5
|
-
* browser names
|
|
6
|
-
* decide it, with no stored id to go stale
|
|
4
|
+
/* Which models a small automatic helper spends, and in which order. The rule answers two surfaces at once —
|
|
5
|
+
* the daemon walks it, the browser names its head in the settings row — so what these tests pin is that a
|
|
6
|
+
* sandbox's connections alone decide it, with no stored id to go stale, and that there is always a rung
|
|
7
|
+
* underneath the first one whenever the sandbox has another account to reach for. */
|
|
7
8
|
|
|
8
9
|
// Catalogs as their providers actually publish them: Claude's ranked list, the rest in registry order.
|
|
9
10
|
const CLAUDE: QuickModelSource = { provider: `claude`, ready: true, models: [`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5-20251001`] };
|
|
@@ -13,14 +14,19 @@ const KIMI: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k
|
|
|
13
14
|
|
|
14
15
|
const offline = (source: QuickModelSource): QuickModelSource => ({ ...source, ready: false });
|
|
15
16
|
|
|
17
|
+
// The model that answers when nothing goes wrong — the head of the chain, which is what most of what follows is
|
|
18
|
+
// about and what every surface naming the spend up front reads.
|
|
19
|
+
const head = (sources: readonly QuickModelSource[], pinned: readonly string[]): QuickModelChoice | undefined =>
|
|
20
|
+
resolveQuickModels(sources, pinned)[0];
|
|
21
|
+
|
|
16
22
|
test("reaches for the efficient rung of the one connected provider, never its flagship", () => {
|
|
17
|
-
expect(
|
|
23
|
+
expect(head([CLAUDE], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
18
24
|
});
|
|
19
25
|
|
|
20
26
|
test("spends the FREE channel over the subscription when both offer the same rung", () => {
|
|
21
27
|
// 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.
|
|
23
|
-
expect(
|
|
28
|
+
// nothing while the other eats headroom they watch. A background helper should not quietly bill the Claude plan.
|
|
29
|
+
expect(head([CLAUDE, GOOGLE], [])).toEqual({ provider: `gemini`, model: `gemini-3-flash-lite` });
|
|
24
30
|
});
|
|
25
31
|
|
|
26
32
|
test("puts tier ahead of cost — a free frontier model is still the wrong tool for a commit message", () => {
|
|
@@ -28,37 +34,33 @@ test("puts tier ahead of cost — a free frontier model is still the wrong tool
|
|
|
28
34
|
// which is the exact outcome the feature exists to avoid.
|
|
29
35
|
const proOnly: QuickModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-pro`] };
|
|
30
36
|
|
|
31
|
-
expect(
|
|
37
|
+
expect(head([CLAUDE, proOnly], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
32
38
|
});
|
|
33
39
|
|
|
34
40
|
test("uses stable provider order when two subscriptions offer the same tier", () => {
|
|
35
41
|
const kimiCheap: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2-mini`] };
|
|
36
42
|
const claudeCheap: QuickModelSource = { provider: `claude`, ready: true, models: [`claude-haiku-4-5`] };
|
|
37
43
|
|
|
38
|
-
expect(
|
|
44
|
+
expect(head([kimiCheap, claudeCheap], [])?.provider).toBe(`claude`);
|
|
39
45
|
});
|
|
40
46
|
|
|
41
47
|
test("answers the same thing however the connected providers happen to be listed", () => {
|
|
42
48
|
// 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
|
-
];
|
|
49
|
+
const answers = [head([CLAUDE, GOOGLE, CODEX], []), head([CODEX, CLAUDE, GOOGLE], []), head([GOOGLE, CODEX, CLAUDE], [])];
|
|
48
50
|
|
|
49
51
|
expect(new Set(answers.map((answer) => quickModelKey(answer!))).size).toBe(1);
|
|
50
52
|
});
|
|
51
53
|
|
|
52
54
|
test("honours a pinned model verbatim, including an id no catalog lists yet", () => {
|
|
53
|
-
expect(
|
|
55
|
+
expect(head([CLAUDE, GOOGLE], [`claude:claude-opus-5`])).toEqual({ provider: `claude`, model: `claude-opus-5` });
|
|
54
56
|
// The picker's custom-id escape hatch reaches here too: a catalog can lag a release, and running something
|
|
55
57
|
// other than what the settings row names would be the worse failure.
|
|
56
|
-
expect(
|
|
58
|
+
expect(head([CLAUDE], [`claude:claude-haiku-9`])).toEqual({ provider: `claude`, model: `claude-haiku-9` });
|
|
57
59
|
});
|
|
58
60
|
|
|
59
61
|
test("falls back to Auto when the pinned provider is no longer connected", () => {
|
|
60
62
|
// Rather than failing every click with a credential error while the sandbox can plainly still answer.
|
|
61
|
-
expect(
|
|
63
|
+
expect(head([offline(CLAUDE), GOOGLE], [`claude:claude-haiku-4-5-20251001`])).toEqual({
|
|
62
64
|
provider: `gemini`,
|
|
63
65
|
model: `gemini-3-flash-lite`,
|
|
64
66
|
});
|
|
@@ -66,34 +68,75 @@ test("falls back to Auto when the pinned provider is no longer connected", () =>
|
|
|
66
68
|
|
|
67
69
|
test("ignores a malformed pin instead of running an empty model id", () => {
|
|
68
70
|
for (const pinned of [`claude`, `claude:`, `:claude-haiku-4-5`, ` `]) {
|
|
69
|
-
expect(
|
|
71
|
+
expect(head([CLAUDE], [pinned])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
70
72
|
}
|
|
71
73
|
});
|
|
72
74
|
|
|
73
75
|
test("serves the newest of a catalog that publishes no cheap tier at all", () => {
|
|
74
76
|
// Kimi names no tier word anywhere. There is no cheaper rung to find, so the newest row is the honest answer.
|
|
75
|
-
expect(
|
|
77
|
+
expect(head([KIMI], [])).toEqual({ provider: `kimi`, model: `kimi-k3` });
|
|
76
78
|
});
|
|
77
79
|
|
|
78
80
|
test("reports nothing when no account is connected, so the button can say so instead of failing on click", () => {
|
|
79
|
-
expect(
|
|
80
|
-
expect(
|
|
81
|
+
expect(head([offline(CLAUDE), offline(GOOGLE)], [])).toBeUndefined();
|
|
82
|
+
expect(resolveQuickModels([offline(CLAUDE), offline(GOOGLE)], [])).toEqual([]);
|
|
83
|
+
expect(head([], [`claude:claude-haiku-4-5`])).toBeUndefined();
|
|
81
84
|
});
|
|
82
85
|
|
|
83
86
|
test("skips a connected provider whose catalog has not loaded yet", () => {
|
|
84
87
|
const unloaded: QuickModelSource = { provider: `grok`, ready: true, models: [] };
|
|
85
88
|
|
|
86
|
-
expect(
|
|
87
|
-
expect(
|
|
89
|
+
expect(head([unloaded, CLAUDE], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
90
|
+
expect(head([unloaded], [])).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/* THE CHAIN — what the daemon walks when the model at the top of it refuses. A spent allowance is the ordinary
|
|
94
|
+
* case, not the exotic one: the account a helper shares with the chat runs out mid-afternoon, and the whole
|
|
95
|
+
* point of the list is that the click still lands on the next rung down. */
|
|
96
|
+
|
|
97
|
+
test("keeps the pinned models in the order they were written", () => {
|
|
98
|
+
expect(resolveQuickModels([CLAUDE, GOOGLE, CODEX], [`codex:gpt-5.6`, `gemini:gemini-3-flash`, `claude:claude-haiku-4-5-20251001`])).toEqual([
|
|
99
|
+
{ provider: `codex`, model: `gpt-5.6` },
|
|
100
|
+
{ provider: `gemini`, model: `gemini-3-flash` },
|
|
101
|
+
{ provider: `claude`, model: `claude-haiku-4-5-20251001` },
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("drops a pin whose provider went away and keeps the rest of the order intact", () => {
|
|
106
|
+
expect(resolveQuickModels([CLAUDE, offline(GOOGLE), CODEX], [`codex:gpt-5.6`, `gemini:gemini-3-flash`, `claude:claude-haiku-4-5`])).toEqual([
|
|
107
|
+
{ provider: `codex`, model: `gpt-5.6` },
|
|
108
|
+
{ provider: `claude`, model: `claude-haiku-4-5` },
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("stops at the end of a pinned list rather than reaching for an account the user left out", () => {
|
|
113
|
+
// Google and Kimi are connected and cheaper. The user wrote down one model, so one model is what this may
|
|
114
|
+
// spend — a pin exists precisely to keep a helper off the accounts it does not name.
|
|
115
|
+
expect(resolveQuickModels([CLAUDE, GOOGLE, KIMI], [`claude:claude-haiku-4-5`])).toEqual([{ provider: `claude`, model: `claude-haiku-4-5` }]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("names each model once, however many times the list repeats it", () => {
|
|
119
|
+
// The list is edited by hand; a duplicate would spend a second attempt proving the same account is out.
|
|
120
|
+
expect(resolveQuickModels([CLAUDE], [`claude:claude-haiku-4-5`, `claude:claude-haiku-4-5`])).toEqual([
|
|
121
|
+
{ provider: `claude`, model: `claude-haiku-4-5` },
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("Auto is a ladder too — every connected provider's cheap rung, best first", () => {
|
|
126
|
+
expect(resolveQuickModels([CLAUDE, GOOGLE, KIMI], [])).toEqual([
|
|
127
|
+
{ provider: `gemini`, model: `gemini-3-flash-lite` },
|
|
128
|
+
{ provider: `claude`, model: `claude-haiku-4-5-20251001` },
|
|
129
|
+
{ provider: `kimi`, model: `kimi-k3` },
|
|
130
|
+
]);
|
|
88
131
|
});
|
|
89
132
|
|
|
90
133
|
/* A MODEL ENDPOINT the user configured is a provider like any other here, and the reason it has to be is the
|
|
91
134
|
* settings row: its options are built from the same picker catalog, so a pin naming one that this resolver
|
|
92
|
-
* dropped would print one model's name
|
|
135
|
+
* dropped would print one model's name in the settings row and spend a different account entirely. */
|
|
93
136
|
const OLLAMA: QuickModelSource = { provider: `endpoint/ollama`, ready: true, models: [`qwen3-coder`, `gemma3-27b`] };
|
|
94
137
|
|
|
95
138
|
test("honours a pin on a configured endpoint — the whole id, not the half before its slash", () => {
|
|
96
|
-
expect(
|
|
139
|
+
expect(head([CLAUDE, OLLAMA], [`endpoint/ollama:qwen3-coder`])).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
|
|
97
140
|
// And it round-trips through the key shape the picker mints, which is where the slash-not-colon rule earns
|
|
98
141
|
// itself: parsePinned splits on the FIRST colon, so an `endpoint:ollama` id would have parsed the provider
|
|
99
142
|
// as "endpoint" and the model as "ollama:qwen3-coder" — a pin that silently resolves to nothing.
|
|
@@ -104,12 +147,12 @@ test("leaves Auto to the providers whose price is known, rather than reaching fo
|
|
|
104
147
|
// Claude publishes a Haiku-class row; the endpoint's ids carry no tier word at all, so they are UNRANKED and
|
|
105
148
|
// lose on tier. What a turn on a user's own model API costs is not a fact this repo holds, and Auto should
|
|
106
149
|
// not be asserting one.
|
|
107
|
-
expect(
|
|
150
|
+
expect(head([CLAUDE, OLLAMA], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
108
151
|
});
|
|
109
152
|
|
|
110
153
|
test("still answers from an endpoint when it is the only thing configured", () => {
|
|
111
154
|
// No tier word in either id, so the shared id-derived ordering decides between them exactly as it does for
|
|
112
155
|
// Kimi above — the point here is that a sandbox whose only model API is its owner's still gets an answer
|
|
113
156
|
// rather than the disabled "nothing connected" button.
|
|
114
|
-
expect(
|
|
157
|
+
expect(head([offline(CLAUDE), OLLAMA], [])).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
|
|
115
158
|
});
|
package/src/quick-model.ts
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
|
-
import { ACCESS_COST, accessFor, PROVIDERS } from "./agent-catalog.js";
|
|
1
|
+
import { ACCESS_COST, accessFor, modelsFor, PROVIDERS } from "./agent-catalog.js";
|
|
2
2
|
import { compareCheapestFirst, familyOf, tierRankOf } from "./model-order.js";
|
|
3
3
|
import type { AgentProvider } from "./schemas.js";
|
|
4
4
|
|
|
5
|
-
/* THE QUICK MODEL — the cheap, fast model a
|
|
6
|
-
* on. Today that is the commit
|
|
7
|
-
* reads the same answer, which is the reason this is a `quickModel` setting
|
|
5
|
+
/* THE QUICK MODEL — the cheap, fast model a small automatic job spends instead of the frontier model the chat
|
|
6
|
+
* runs on. Today that is the commit message written when an agent's work lands; anything else of that shape (a
|
|
7
|
+
* branch name, a PR description) reads the same answer, which is the reason this is a `quickModel` setting
|
|
8
|
+
* rather than a commit-message one.
|
|
9
|
+
*
|
|
10
|
+
* IT IS AN ORDER, NOT A MODEL, and that is the whole shape of this file. A single pick is a single point of
|
|
11
|
+
* failure: the account it names spends its allowance on the chat all morning, and every job for the rest of the
|
|
12
|
+
* day fails on a limit while three other connected providers sit idle. So the setting is a LIST
|
|
13
|
+
* read top to bottom, the resolver hands back the whole ladder, and the daemon walks it until one answers.
|
|
14
|
+
* Nothing here decides WHICH failures are worth stepping over — that is the daemon's, since only it has run
|
|
15
|
+
* the call — this side only says what the running order is.
|
|
8
16
|
*
|
|
9
17
|
* 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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
18
|
+
* the model, and the browser has to NAME it — in the settings row's "Auto (…)" label — before anything has been
|
|
19
|
+
* run. Two implementations would drift precisely where it matters most, since a label promising Haiku while the
|
|
20
|
+
* daemon bills Opus is worse than no label.
|
|
13
21
|
*
|
|
14
|
-
* The default is DERIVED, NEVER STORED. `quickModel` ships
|
|
15
|
-
*
|
|
16
|
-
* disconnecting
|
|
22
|
+
* The default is DERIVED, NEVER STORED. `quickModel` ships EMPTY and that means "work it out from whatever is
|
|
23
|
+
* connected right now", so connecting a Google account tomorrow improves the default by itself and
|
|
24
|
+
* disconnecting a pinned provider degrades to Auto instead of to a dead button. Same instinct as the rest of
|
|
17
25
|
* this repo's model handling: model-order.ts derives tier and recency from the id and curates nothing, and the
|
|
18
26
|
* web's defaultModelFor reads the live catalog rather than naming an id that a release will falsify. */
|
|
19
27
|
|
|
@@ -42,12 +50,13 @@ export interface QuickModelChoice {
|
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
// A pinned selection on the wire: `${provider}:${modelId}`, the same key shape the model picker already mints
|
|
45
|
-
// for its entries (PickerEntry.key).
|
|
53
|
+
// for its entries (PickerEntry.key). An empty LIST of these ⇒ Auto.
|
|
46
54
|
export const quickModelKey = (choice: QuickModelChoice): string => `${choice.provider}:${choice.model}`;
|
|
47
55
|
|
|
48
56
|
// Split on the FIRST colon only: a provider id never contains one and a model id might. Exported because the
|
|
49
|
-
// key shape is shared: `
|
|
50
|
-
//
|
|
57
|
+
// key shape is shared: `agentRunModels` pins what a surface-started run opens with the same keys in the same
|
|
58
|
+
// order (agent-run-model.ts), and both the daemon (filling an unattended turn) and the dialogs that seed from
|
|
59
|
+
// it have to read one back.
|
|
51
60
|
export const parsePinned = (pinned: string): QuickModelChoice | undefined => {
|
|
52
61
|
const separator = pinned.indexOf(`:`);
|
|
53
62
|
if (separator <= 0 || separator === pinned.length - 1) {
|
|
@@ -56,6 +65,14 @@ export const parsePinned = (pinned: string): QuickModelChoice | undefined => {
|
|
|
56
65
|
return { provider: pinned.slice(0, separator), model: pinned.slice(separator + 1) };
|
|
57
66
|
};
|
|
58
67
|
|
|
68
|
+
/* A pin as a person reads it: the catalog's own label for the id, or the id itself for one the static catalog
|
|
69
|
+
* has not caught up with (the picker offers a custom-id escape hatch, so this is a real case rather than a
|
|
70
|
+
* defensive branch). Beside parsePinned because the two are always wanted together — by any surface that has to
|
|
71
|
+
* name what a click is about to spend BEFORE it spends it, and the two loudest of those are extensions that
|
|
72
|
+
* share no other code with each other. */
|
|
73
|
+
export const pinnedModelLabel = (choice: QuickModelChoice): string =>
|
|
74
|
+
modelsFor(choice.provider).find((option) => option.value === choice.model)?.label ?? choice.model;
|
|
75
|
+
|
|
59
76
|
// The cheapest row a provider publishes — its whole catalog read from the cheap end. Undefined for a catalog
|
|
60
77
|
// that hasn't loaded yet, which is a real state: every provider serves a floor, but only once something has
|
|
61
78
|
// asked it.
|
|
@@ -79,37 +96,56 @@ const costOf = (provider: AgentProvider): number => {
|
|
|
79
96
|
return access === undefined ? ACCESS_COST.key : ACCESS_COST[access.kind];
|
|
80
97
|
};
|
|
81
98
|
|
|
82
|
-
/*
|
|
99
|
+
/* AUTO — every connected provider's cheapest row, best-first, as a ladder rather than a winner.
|
|
83
100
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
101
|
+
* Ranked on TIER FIRST, then cost. That order is the point of the feature: the helper exists to not be the
|
|
102
|
+
* frontier model, so a free flagship is still the wrong tool, while a free Haiku-class row and a subscription
|
|
103
|
+
* Haiku-class row differ only in whose quota they spend. Cost then breaks that tie towards the channel the user
|
|
104
|
+
* is not paying per token for — and against the one they are.
|
|
87
105
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
* Undefined when nothing is connected: the caller renders a disabled control that says so, rather than a live
|
|
94
|
-
* button that fails on click. */
|
|
95
|
-
export const resolveQuickModel = (sources: readonly QuickModelSource[], pinned: string): QuickModelChoice | undefined => {
|
|
96
|
-
const requested = parsePinned(pinned);
|
|
97
|
-
if (requested !== undefined && sources.some((source) => source.provider === requested.provider && source.ready)) {
|
|
98
|
-
// Taken verbatim, unvalidated against the catalog on purpose: the picker already offers a custom-id
|
|
99
|
-
// escape hatch for a model a catalog hasn't caught up with, and second-guessing the user's own id here
|
|
100
|
-
// would silently run a different model than the settings row names.
|
|
101
|
-
return requested;
|
|
102
|
-
}
|
|
103
|
-
const candidates = sources
|
|
106
|
+
* The whole ladder, not just its head, because the same ranking that picks the best answer also states the best
|
|
107
|
+
* SECOND answer — and a sandbox with three accounts connected should not lose its commit messages for six hours
|
|
108
|
+
* because one of them is spent. */
|
|
109
|
+
const autoLadder = (sources: readonly QuickModelSource[]): readonly QuickModelChoice[] =>
|
|
110
|
+
sources
|
|
104
111
|
.filter((source) => source.ready)
|
|
105
112
|
.flatMap((source) => {
|
|
106
113
|
const model = cheapestOf(source);
|
|
107
114
|
return model === undefined ? [] : [{ provider: source.provider, model }];
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
})
|
|
116
|
+
.toSorted(
|
|
117
|
+
(left, right) =>
|
|
118
|
+
tierOf(right.model) - tierOf(left.model) ||
|
|
119
|
+
costOf(left.provider) - costOf(right.provider) ||
|
|
120
|
+
providerOrder(left.provider) - providerOrder(right.provider),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
/* WHICH MODELS A QUICK HELPER MAY RUN, IN THE ORDER IT SHOULD TRY THEM, given what this sandbox has connected.
|
|
124
|
+
* `pinned` is the stored setting: an ordered list of `${provider}:${model}` keys, empty for Auto.
|
|
125
|
+
*
|
|
126
|
+
* A pin only holds while its provider is READY: an account the user disconnected would otherwise sit at the
|
|
127
|
+
* head of the chain failing on a credential error, when the sandbox can plainly still answer. Dropping it is
|
|
128
|
+
* the same move the composer already makes when a live catalog stops offering the selected model.
|
|
129
|
+
*
|
|
130
|
+
* THE PINNED LIST IS THE WHOLE ANSWER whenever any of it survives that filter. Auto does NOT get appended
|
|
131
|
+
* underneath, and that is deliberate: a user who writes down three models has said which accounts this feature
|
|
132
|
+
* may spend, and quietly reaching for a fourth when all three are out is exactly the "spend an account they
|
|
133
|
+
* were steering away from" failure a pin exists to prevent. When NONE of the pins is connected any more the
|
|
134
|
+
* list has stopped saying anything about this sandbox, so Auto takes over rather than leaving a dead button.
|
|
135
|
+
*
|
|
136
|
+
* Empty when nothing is connected: the caller renders a control that says so, rather than a live button that
|
|
137
|
+
* fails on click. */
|
|
138
|
+
export const resolveQuickModels = (sources: readonly QuickModelSource[], pinned: readonly string[]): readonly QuickModelChoice[] => {
|
|
139
|
+
const ready = new Set(sources.filter((source) => source.ready).map((source) => source.provider));
|
|
140
|
+
const requested = pinned.flatMap((key) => {
|
|
141
|
+
// Taken verbatim, unvalidated against the catalog on purpose: the picker already offers a custom-id
|
|
142
|
+
// escape hatch for a model a catalog hasn't caught up with, and second-guessing the user's own id here
|
|
143
|
+
// would silently run a different model than the settings row names.
|
|
144
|
+
const choice = parsePinned(key);
|
|
145
|
+
return choice === undefined || !ready.has(choice.provider) ? [] : [choice];
|
|
146
|
+
});
|
|
147
|
+
// The same model twice would spend two attempts proving the same account is out — a real state, since the
|
|
148
|
+
// list is edited by hand and Auto's ladder can rank a provider the user has also pinned.
|
|
149
|
+
const chain = [...new Map(requested.map((choice) => [quickModelKey(choice), choice])).values()];
|
|
150
|
+
return chain.length > 0 ? chain : autoLadder(sources);
|
|
115
151
|
};
|