@intentic/sandbox-contract 1.247.0 → 1.248.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/contracts/agent.contract.d.ts +1 -0
  2. package/dist/contracts/agent.contract.d.ts.map +1 -1
  3. package/dist/contracts/personas.contract.d.ts +38 -4
  4. package/dist/contracts/personas.contract.d.ts.map +1 -1
  5. package/dist/contracts/personas.contract.js +10 -1
  6. package/dist/contracts/personas.contract.js.map +1 -1
  7. package/dist/contracts/runner.contract.d.ts +93 -93
  8. package/dist/contracts/settings.contract.d.ts +12 -2
  9. package/dist/contracts/settings.contract.d.ts.map +1 -1
  10. package/dist/definition.d.ts +8 -8
  11. package/dist/index.d.ts +51 -7
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +0 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/model-pins.d.ts +1 -3
  16. package/dist/model-pins.d.ts.map +1 -1
  17. package/dist/model-pins.js +3 -26
  18. package/dist/model-pins.js.map +1 -1
  19. package/dist/model-roles.d.ts +36 -8
  20. package/dist/model-roles.d.ts.map +1 -1
  21. package/dist/model-roles.js +48 -10
  22. package/dist/model-roles.js.map +1 -1
  23. package/dist/schemas/agent.d.ts +1 -0
  24. package/dist/schemas/agent.d.ts.map +1 -1
  25. package/dist/schemas/automations.d.ts.map +1 -1
  26. package/dist/schemas/automations.js.map +1 -1
  27. package/dist/schemas/personas.d.ts +48 -4
  28. package/dist/schemas/personas.d.ts.map +1 -1
  29. package/dist/schemas/personas.js +28 -5
  30. package/dist/schemas/personas.js.map +1 -1
  31. package/dist/schemas/settings.d.ts +6 -1
  32. package/dist/schemas/settings.d.ts.map +1 -1
  33. package/dist/schemas/settings.js +5 -6
  34. package/dist/schemas/settings.js.map +1 -1
  35. package/dist/workspace-state.d.ts +0 -5
  36. package/dist/workspace-state.d.ts.map +1 -1
  37. package/dist/workspace-state.js +0 -1
  38. package/dist/workspace-state.js.map +1 -1
  39. package/package.json +4 -4
  40. package/src/chores/chores.ts +1 -1
  41. package/src/chores/verdict.test.ts +2 -2
  42. package/src/chores/verdict.ts +3 -3
  43. package/src/contracts/personas.contract.ts +17 -0
  44. package/src/fast-tier.test.ts +1 -1
  45. package/src/fast-tier.ts +1 -1
  46. package/src/index.ts +0 -1
  47. package/src/model-pins.test.ts +32 -113
  48. package/src/model-pins.ts +44 -95
  49. package/src/model-roles.test.ts +52 -0
  50. package/src/model-roles.ts +119 -23
  51. package/src/schemas/automations.ts +6 -2
  52. package/src/schemas/personas.ts +85 -18
  53. package/src/schemas/settings.ts +22 -17
  54. package/src/workspace-state.test.ts +0 -1
  55. package/src/workspace-state.ts +0 -6
  56. package/dist/schemas/context.d.ts +0 -30
  57. package/dist/schemas/context.d.ts.map +0 -1
  58. package/dist/schemas/context.js +0 -34
  59. package/dist/schemas/context.js.map +0 -1
  60. package/src/schemas/context.ts +0 -87
@@ -23,19 +23,26 @@ import { z } from "zod";
23
23
  * went on the chat this morning, and one spent provider takes the role down for hours while three others sit
24
24
  * idle. Written in order, the next entry catches it.
25
25
  *
26
- * TWO KINDS, and the only thing that separates them is what an EMPTY list means. That is a real fork rather
27
- * than a leftover of the old grouping, and it is declared per role because it is a property of the job:
26
+ * AN EMPTY LIST IS THE JOB SWITCHED OFF, and NOTHING IS DERIVED FOR IT. A helper role used to fall to an
27
+ * "Auto ladder" worked out from whatever was connected — every provider's cheapest row, best-first — so a
28
+ * sandbox that had never been configured still spent somebody's account on commit messages and safety
29
+ * verdicts, on a recommendation this table invented, which re-ranked itself the day another account was
30
+ * connected. Not set now means not set: no auto-selection, no recommendation, and the owner names the models
31
+ * for a job or the job does not happen.
28
32
  *
29
- * helper — a one-shot. One prompt, no tools, one string back, and it is over. An empty list resolves to the
30
- * AUTO LADDER: every connected provider's cheapest row, best-first (model-pins.ts). Deriving is the
31
- * right default here because the job is small and repeatable, so the cost of a wrong guess is one
32
- * cheap call, and because a derived answer improves by itself when an account is connected tomorrow.
33
+ * TWO KINDS, and what separates them is what the CALLER does with that empty answer. It is declared per role
34
+ * because it is a property of the job:
35
+ *
36
+ * helper — a one-shot. One prompt, no tools, one string back, and it is over. With no list the job does not
37
+ * run at all: no commit subject is drafted, no session is renamed, no command is judged. Every one
38
+ * of them already had a road for "the model could not answer" (the derived title stands, the
39
+ * commit box stays empty, the gate falls to its standing rule), so an owner who wants none of them
40
+ * leaves the row empty and pays nothing.
33
41
  *
34
42
  * run — a whole session with tools and a worktree, started by a surface rather than by a person at a
35
- * composer. An empty list resolves to NOTHING, and the caller's own floor answers: the model the
36
- * owner picked for their chat. Nothing here can judge whether a job is worth the frontier tier, and
37
- * a wrong guess is billed in whole sessions rather than in tokens, so the honest fallback is a
38
- * choice they made rather than one this table invented.
43
+ * composer. With no list the caller's own floor answers: the model the owner picked for their chat.
44
+ * That is not this table recommending anything — it is a choice they already made, in front of
45
+ * them, on the composer they work in.
39
46
  *
40
47
  * EVERY ENTRY IS A FULL PIN (ModelPinSchema): which model, and how it runs — effort, thinking, speed, harness.
41
48
  * The helper roles carry them too, which they did not use to: a one-shot ran with reasoning forcibly off, so
@@ -51,6 +58,18 @@ import { z } from "zod";
51
58
  export const ModelRoleKindSchema = z.enum(["helper", "run"]);
52
59
  export type ModelRoleKind = z.infer<typeof ModelRoleKindSchema>;
53
60
 
61
+ /* WHAT STARTS A RUN, declared for the run roles alone.
62
+ *
63
+ * It is not a wire value and never has been: it decides which BLOCK a job is read in, and the argument for
64
+ * reading them apart is the same one that separates a helper from a run — an owner holds a session nobody is
65
+ * watching to a different budget from one they are sitting in front of. The settings page used to make that
66
+ * argument in a comment over a thirteen-row list ("the ones somebody presses ahead of the ones that fire on
67
+ * their own"), which is a claim no reader can check and no row has to honour. Declared, it draws the page.
68
+ *
69
+ * A HELPER DECLARES NONE, and that is the honest answer rather than a gap: nobody presses "write me a commit
70
+ * subject". It happens because something else did. */
71
+ export type ModelRoleTrigger = "pressed" | "unprompted";
72
+
54
73
  /* The shape of a row. `id` is a bare string HERE and narrowed on the exported type below, because the id union
55
74
  * is derived from this very table: a self-referential `satisfies` would be a type that has to know its own
56
75
  * answer before it can check it. */
@@ -62,6 +81,8 @@ interface ModelRoleRow {
62
81
  // cannot rather than restating it.
63
82
  readonly blurb: string;
64
83
  readonly kind: ModelRoleKind;
84
+ /** Runs only, and required for every one of them: see `ModelRoleTrigger`. */
85
+ readonly trigger?: ModelRoleTrigger;
65
86
  // The row's glyph, from the shared icon set. Here rather than in a web-side map because the whole value of
66
87
  // this table is that a role is declared ONCE; a second table keyed by the same ids is the drift this
67
88
  // replaced, moved one layer up.
@@ -71,7 +92,8 @@ interface ModelRoleRow {
71
92
  /* THE TABLE. Ordered as the settings page draws it, and the order is an argument about reach: the one-shots
72
93
  * first, because nobody chose a model for them and they run constantly; then the runs somebody's click starts;
73
94
  * then the runs that start themselves, which are the ones an owner is least likely to be watching and most
74
- * likely to want held to a budget.
95
+ * likely to want held to a budget. Those three are BLOCKS now (see MODEL_ROLE_BLOCKS) rather than an ordering
96
+ * this comment asserts and nothing holds to.
75
97
  *
76
98
  * The ids are the wire vocabulary: a turn carries one (AgentTurn.runRole), so they are kebab-case and stable,
77
99
  * and renaming one is a breaking change to the setting rather than a cosmetic edit. */
@@ -108,11 +130,23 @@ export const MODEL_ROLES = [
108
130
  kind: "helper",
109
131
  icon: "check-square",
110
132
  },
133
+ {
134
+ /* THE ONE HELPER THAT ANSWERS A CLASSIFICATION rather than writing prose: one card id, or none, from the
135
+ * owner's own short list (schemas/personas.ts `brief`). Cheap by construction, once per chat, and the
136
+ * job a small model does well, which is the whole argument for routing chats onto static cards rather
137
+ * than asking a model to compose a context per session. */
138
+ id: "persona-router",
139
+ label: "Persona routing",
140
+ blurb: "Which model reads a new chat's first message and picks the persona for it.",
141
+ kind: "helper",
142
+ icon: "users",
143
+ },
111
144
  {
112
145
  id: "pipeline-fix",
113
146
  label: "Pipeline fixes",
114
147
  blurb: "The agent started by Fix on a red pipeline.",
115
148
  kind: "run",
149
+ trigger: "pressed",
116
150
  icon: "wave-pulse",
117
151
  },
118
152
  {
@@ -120,6 +154,7 @@ export const MODEL_ROLES = [
120
154
  label: "Deployment fixes",
121
155
  blurb: "The agent started by Fix on a deployment that is down.",
122
156
  kind: "run",
157
+ trigger: "pressed",
123
158
  icon: "server",
124
159
  },
125
160
  {
@@ -127,6 +162,7 @@ export const MODEL_ROLES = [
127
162
  label: "Maintenance chores",
128
163
  blurb: "A chore run started from the Maintenance board.",
129
164
  kind: "run",
165
+ trigger: "pressed",
130
166
  icon: "wrench",
131
167
  },
132
168
  {
@@ -134,6 +170,7 @@ export const MODEL_ROLES = [
134
170
  label: "Documentation runs",
135
171
  blurb: "A pass over a repo's own documentation.",
136
172
  kind: "run",
173
+ trigger: "pressed",
137
174
  icon: "book",
138
175
  },
139
176
  {
@@ -141,6 +178,7 @@ export const MODEL_ROLES = [
141
178
  label: "Acceptance runs",
142
179
  blurb: "One session per story in an acceptance fan-out.",
143
180
  kind: "run",
181
+ trigger: "pressed",
144
182
  icon: "list-check",
145
183
  },
146
184
  {
@@ -148,27 +186,34 @@ export const MODEL_ROLES = [
148
186
  label: "Pre-push fixes",
149
187
  blurb: "The fix proposed when a check fails on the way to a push.",
150
188
  kind: "run",
189
+ trigger: "pressed",
151
190
  icon: "cloud-upload",
152
191
  },
153
192
  {
154
- id: "automation-wake",
155
- label: "Automation wakes",
156
- blurb: "A turn an automation fires: a schedule, a webhook, a message from outside.",
157
- kind: "run",
158
- icon: "clock",
159
- },
160
- {
193
+ /* PRESSED, ON THE STRENGTH OF THE APPROVAL. Nothing here runs until somebody reads the item and says
194
+ * yes, and that press is the start of this turn as much as Fix is the start of a pipeline run — the
195
+ * queue between the two is machinery, not a second decision. */
161
196
  id: "approval-queue",
162
197
  label: "Approvals queue",
163
198
  blurb: "The turn that publishes or acts on what you approved.",
164
199
  kind: "run",
200
+ trigger: "pressed",
165
201
  icon: "check-circle",
166
202
  },
203
+ {
204
+ id: "automation-wake",
205
+ label: "Automation wakes",
206
+ blurb: "A turn an automation fires: a schedule, a webhook, a message from outside.",
207
+ kind: "run",
208
+ trigger: "unprompted",
209
+ icon: "clock",
210
+ },
167
211
  {
168
212
  id: "extension-review",
169
213
  label: "Extension update reviews",
170
214
  blurb: "The agent that reads an extension update before it is applied.",
171
215
  kind: "run",
216
+ trigger: "unprompted",
172
217
  icon: "box",
173
218
  },
174
219
  {
@@ -176,6 +221,7 @@ export const MODEL_ROLES = [
176
221
  label: "Loop iterations",
177
222
  blurb: "Each round of a loop working towards its goal.",
178
223
  kind: "run",
224
+ trigger: "unprompted",
179
225
  icon: "repeat",
180
226
  },
181
227
  {
@@ -183,6 +229,7 @@ export const MODEL_ROLES = [
183
229
  label: "Watch wakes",
184
230
  blurb: "The turn a watch starts when the thing it was watching happens.",
185
231
  kind: "run",
232
+ trigger: "unprompted",
186
233
  icon: "eye",
187
234
  },
188
235
  {
@@ -190,6 +237,7 @@ export const MODEL_ROLES = [
190
237
  label: "Verify nudges",
191
238
  blurb: "The follow-up turn sent when work was left unverified.",
192
239
  kind: "run",
240
+ trigger: "unprompted",
193
241
  icon: "search",
194
242
  },
195
243
  {
@@ -200,6 +248,7 @@ export const MODEL_ROLES = [
200
248
  label: "Child agents",
201
249
  blurb: "What an agent's own subagents run on when it names no model for them.",
202
250
  kind: "run",
251
+ trigger: "unprompted",
203
252
  icon: "users",
204
253
  },
205
254
  ] as const satisfies readonly ModelRoleRow[];
@@ -215,10 +264,57 @@ export const MODEL_ROLE_IDS = MODEL_ROLES.map((role) => role.id) as readonly Mod
215
264
  * silently ignored. */
216
265
  export const ModelRoleSchema = z.enum(MODEL_ROLE_IDS as [ModelRole, ...ModelRole[]]);
217
266
 
218
- const BY_ID = new Map<string, ModelRoleSpec>(MODEL_ROLES.map((role) => [role.id, role]));
267
+ /* ═══ THE BLOCKS THE SETTINGS PAGE READS IN ═══
268
+ *
269
+ * EIGHTEEN JOBS IN ONE LIST IS A TABLE, NOT A PAGE. The unit being the role is right and is not in question —
270
+ * it is what lets an owner pin Opus to commit subjects without pinning it to every session title — but the cost
271
+ * lands on whoever opens the page: one unbroken run of eighteen rows, each with a name, a sentence, a control
272
+ * and a list under it, with no landmark to say where you are in it or which rows are like the one you came for.
273
+ *
274
+ * SO THE BLOCKS ARE DECLARED, AND THEY ARE THE DISTINCTIONS THE TABLE ALREADY MAKES. Nothing here is a fresh
275
+ * taxonomy invented for the layout: `kind` was always the difference between a one-shot and a whole session,
276
+ * and `trigger` is the sentence the old table wrote in a comment about its own ordering. A block is what those
277
+ * two answers already separate, given a name and a heading.
278
+ *
279
+ * THE LABELS LIVE HERE, beside the role labels, for the same reason those do: a heading kept in a web-side map
280
+ * keyed by the same ids is the drift a single table exists to stop. What is NOT here is anything about how the
281
+ * page draws them — that is the page's, and it changes on its own schedule.
282
+ *
283
+ * ORDER IS REACH, and it is the order of the table itself: jobs nobody picked a model for, then sessions a
284
+ * click of yours starts, then sessions that start without one. Every role belongs to exactly one block and
285
+ * every block keeps the table's order, which is what makes a role added upstairs appear on the page by
286
+ * existing. */
287
+ export type ModelRoleBlockId = "helper" | "pressed" | "unprompted";
219
288
 
220
- /** What this role is, or undefined for an id no build of this table declares. */
221
- export const modelRole = (id: string): ModelRoleSpec | undefined => BY_ID.get(id);
289
+ export interface ModelRoleBlock {
290
+ readonly id: ModelRoleBlockId;
291
+ /** The group's heading on the settings page. */
292
+ readonly label: string;
293
+ /** One line beside it: what the jobs in this block have in common that the others do not. */
294
+ readonly caption: string;
295
+ /** Its roles, in table order. */
296
+ readonly roles: readonly ModelRoleSpec[];
297
+ }
298
+
299
+ const rolesWhere = (match: (role: ModelRoleSpec) => boolean): readonly ModelRoleSpec[] => MODEL_ROLES.filter((role) => match(role));
222
300
 
223
- /** The roles of one kind, in table order: the two blocks the settings page draws. */
224
- export const modelRolesOfKind = (kind: ModelRoleKind): readonly ModelRoleSpec[] => MODEL_ROLES.filter((role) => role.kind === kind);
301
+ export const MODEL_ROLE_BLOCKS: readonly ModelRoleBlock[] = [
302
+ {
303
+ id: "helper",
304
+ label: "Automatic helpers",
305
+ caption: "One prompt, no tools, one answer back.",
306
+ roles: rolesWhere((role) => role.kind === "helper"),
307
+ },
308
+ {
309
+ id: "pressed",
310
+ label: "Runs you start",
311
+ caption: "Whole sessions, begun by a click of yours.",
312
+ roles: rolesWhere((role) => role.kind === "run" && role.trigger === "pressed"),
313
+ },
314
+ {
315
+ id: "unprompted",
316
+ label: "Runs that start themselves",
317
+ caption: "Whole sessions nobody pressed for.",
318
+ roles: rolesWhere((role) => role.kind === "run" && role.trigger === "unprompted"),
319
+ },
320
+ ];
@@ -264,7 +264,9 @@ export type WebchatMessage = z.infer<typeof WebchatMessageSchema>;
264
264
  export const AutomationSchema = z.object({
265
265
  id: entryId.describe("The automation's id."),
266
266
  trigger: TriggerSchema.describe("What sets it off: a schedule, an event in the workspace, a message arriving from outside, or a webhook."),
267
- // Shell command run in the workspace root before waking; exit 0 ⇒ wake, non-zero ⇒ the run is "skipped".
267
+ /* Shell command run in the workspace root before waking; exit 0 ⇒ wake, non-zero ⇒ the run is "skipped".
268
+ * Its environment carries `AUTOMATION_ID` (this automation's own id, so a guard can look up what its past
269
+ * fires did) and, for a trigger that arrived with one, `AUTOMATION_PAYLOAD`. */
268
270
  guard: z
269
271
  .string()
270
272
  .min(1)
@@ -279,7 +281,9 @@ export const AutomationSchema = z.object({
279
281
  // field rather than a shared "public endpoint" bag: the two sources answer different questions (a chat's
280
282
  // greeting and access model, an intake's dedup ceiling and ingest key) and a union of both would be a
281
283
  // schema where most fields are wrong for whichever source is reading it.
282
- issues: IssuesConfigSchema.optional().describe("Settings for the bug reporter, for an automation that takes crash reports from your own sites and apps."),
284
+ issues: IssuesConfigSchema.optional().describe(
285
+ "Settings for the bug reporter, for an automation that takes crash reports from your own sites and apps.",
286
+ ),
283
287
  /* NARROW THIS ONE JOB FURTHER than the persona it runs as, raw tool names, and the escape hatch under the
284
288
  * shelves rather than the way anyone is expected to answer this question.
285
289
  *
@@ -1,14 +1,25 @@
1
1
  // Personas: the named faces a sandbox shows the outside world — which accounts each speaks for, what a
2
2
  // session wearing one may do, and where it works.
3
3
  import { z } from "zod";
4
+ import { type ModelSource, readyChain } from "../model-pins.js";
5
+ import { type ModelPin, ModelPinSchema } from "./agent.js";
4
6
  import { entryId } from "./internal.js";
5
7
  import { SkillDraftSchema, SkillNameSchema, SystemPromptModeSchema } from "./settings.js";
6
8
  /* A NAMED PERSONA THE SANDBOX SHOWS THE OUTSIDE WORLD, "work-reddit", "the studio account", and the layer
7
9
  * that decides which connected accounts a given turn may act through.
8
10
  *
9
- * IT ANSWERS FOUR QUESTIONS AND NO MORE: who it speaks as, what it may do, where it works, and what it is told.
10
- * Making one is then a name, a few accounts, some switches and, only if you want one, a prompt. That is the
11
- * whole of what an owner is deciding, and short enough that they finish.
11
+ * IT ANSWERS FIVE QUESTIONS AND NO MORE: who it speaks as, what it may do, where it works, what it is told, and
12
+ * what it runs on (the repositories its tree holds, the models it runs on). Making one is then a name, a few
13
+ * accounts, some switches and, only if you want one, a prompt. That is the whole of what an owner is deciding,
14
+ * and short enough that they finish.
15
+ *
16
+ * A CARD IS THE SANDBOX'S ONE STATIC DESCRIPTION OF A WORKING POSTURE, and static is the design. Everything a
17
+ * session sees before the user's first word is a function of the card it wears: the accounts, the tool set the
18
+ * powers leave, the kit's prompt and skills, and now the tree it opens on. Two sessions on one card open on
19
+ * the same prefix, which is what lets a provider's prompt cache serve the second. The only per-task decision
20
+ * left is WHICH card, and that is a classification over the owner's own short list (`brief`), once per chat,
21
+ * which a cheap model does reliably; asking a model to compose a context per session instead either picks
22
+ * badly or costs a second strong run and a hand-off of everything it decided.
12
23
  *
13
24
  * NO PUBLISH-OR-DRAFT SWITCH. It read as a lock and was a sentence: it asked the turn to route outward things
14
25
  * through the approvals queue and could not stop it posting. The queue is the mechanism, and a control whose
@@ -122,6 +133,28 @@ export const PersonaWorkspaceSchema = z.object({
122
133
  folders: z.array(z.string().min(1)).max(50).optional().describe("Which folders it may touch at all. Absent means the whole workspace."),
123
134
  });
124
135
  export type PersonaWorkspace = z.infer<typeof PersonaWorkspaceSchema>;
136
+ /* WHAT A CONVERSATION WEARING THIS CARD CARRIES: which repositories its checkout HOLDS, as opposed to
137
+ * `workspace.folders`, which fences what the file tools may TOUCH inside a tree that holds everything.
138
+ *
139
+ * `repos` names NESTED repositories by their workspace-relative dir (the ids repo-discovery reports). The root
140
+ * repository is the workspace itself and is always carried, never listed, so an empty list is the workspace
141
+ * alone. ABSENT `context` is every repository the workspace has, which is what every card meant before this
142
+ * existed and what a card that has never thought about it should get. The sandbox brings a conversation's
143
+ * checkout to this list on every turn (agents/worktrees.ts `selection`): a repository the list names joins,
144
+ * one it stops naming leaves with its work committed to the branch.
145
+ *
146
+ * An OBJECT rather than a bare list, so the next kinds of thing a session can carry (a directory inside a
147
+ * repository through a sparse cone, a documents folder) arrive as fields beside this one rather than as a
148
+ * grammar inside it. */
149
+ export const PersonaContextSchema = z.object({
150
+ repos: z
151
+ .array(z.string().min(1).max(200))
152
+ .max(50)
153
+ .describe(
154
+ "Which nested repositories a conversation wearing this card carries, by workspace-relative path. The workspace itself is always carried; empty means the workspace alone.",
155
+ ),
156
+ });
157
+ export type PersonaContext = z.infer<typeof PersonaContextSchema>;
125
158
  export const PersonaSchema = z.object({
126
159
  id: entryId.describe("The persona's id."),
127
160
  // What the owner calls it in the composer chip. Absent ⇒ surfaces read the id, which is already human-chosen.
@@ -138,28 +171,40 @@ export const PersonaSchema = z.object({
138
171
  .describe(
139
172
  "Which connected accounts are its hands. Named individually rather than by site, because two accounts on one site is the whole problem this solves. Naming one that is not connected yet is not an error: it is a card describing an account this sandbox has still to sign into.",
140
173
  ),
141
- /* Which workspace repos prefer this persona, so a chat opened on a project starts with the right chip already
142
- * selected. A PREFERENCE, not a fence, the owner's chosen chat default is still "every account", and it
143
- * lives on the card rather than in each project's own config so that one account named by three repos stays
144
- * one definition instead of three that drift. */
145
- repos: z
146
- .array(z.string().min(1))
147
- .max(50)
174
+ /* WHAT THIS PERSONA IS FOR, in one line: the sentence a new chat is routed on (the sandbox's
175
+ * agent/persona-router.ts reads one line per card and names one) and the row's subtitle on the Personas
176
+ * page. Written by the owner rather than derived from the card, because a classifier reading "backend work
177
+ * on the api and billing services, never the marketing site" routes better than one reading a list of
178
+ * account ids, and because it is the one sentence a reader scanning the list wants under each name. Absent
179
+ * is a card the router can still name from what else the card says, with less to go on. */
180
+ brief: z
181
+ .string()
182
+ .max(200)
148
183
  .optional()
149
- .describe(
150
- "Which repositories prefer this persona, so a conversation opened on one starts with the right choice already made. A preference rather than a fence.",
151
- ),
184
+ .describe("What this persona is for, in one line. A new chat is routed onto a persona by this sentence, and the Personas page shows it under the name."),
152
185
  // What a session wearing this card may do, and where it works. Both absent ⇒ the full toolbox and the whole
153
186
  // workspace, so a card written before these existed keeps behaving exactly as it did.
154
187
  powers: PersonaPowersSchema.optional().describe(
155
188
  "What a conversation wearing it may do. Absent means the full toolbox, so a card written before this existed behaves exactly as it did.",
156
189
  ),
157
190
  workspace: PersonaWorkspaceSchema.optional().describe("Where it works. Absent means the whole workspace."),
158
- /* WHICH PART OF THE WORKSPACE A SESSION WEARING THIS CARD CARRIES, the id of a context shelf
159
- * (schemas/context.ts). A different question from `workspace.folders`: that one fences what the file tools
160
- * may TOUCH inside a tree that holds everything, this one decides what the tree HOLDS. Absent falls through to
161
- * the sandbox's `contextShelf` setting, and from there to everything. */
162
- context: entryId.optional().describe("Which context shelf a conversation wearing it opens on: the part of the workspace it carries. Absent follows the sandbox setting."),
191
+ context: PersonaContextSchema.optional().describe(
192
+ "Which part of the workspace a conversation wearing it carries: the repositories its checkout holds. Absent means every repository.",
193
+ ),
194
+ /* WHICH MODELS A CONVERSATION WEARING THIS CARD RUNS ON, in order: the ladder shape every role list has
195
+ * (settings.modelRoles, model-pins.ts), for the same reason, a single pin is a single point of failure and
196
+ * the next entry catches an account that is spent today. ABSENT is the caller's own answer: the composer's
197
+ * pick for a chat, the run role's list for a turn a surface started. Present, the composer moves its model
198
+ * pill to the ladder's head when the card is picked (so a chat routed onto a card runs on that card's
199
+ * model as well as in its context), and the sandbox fills an unattended turn's silence from it before the
200
+ * run role's list (agent/turn-resume.ts). A model named on the turn itself always wins. */
201
+ models: z
202
+ .array(ModelPinSchema)
203
+ .max(10)
204
+ .optional()
205
+ .describe(
206
+ "Which models a conversation wearing it runs on, tried in order. Absent means whatever the chat or the job would have run on anyway; a model chosen for the turn itself always wins.",
207
+ ),
163
208
  /* WHICH SYSTEM PROMPT A SESSION WEARING THIS CARD RUNS ON, the same three bases the sandbox chooses
164
209
  * between, asked per card. ABSENT is the fourth answer and the default: follow the sandbox, which is what
165
210
  * every card meant before this field existed and what almost every card will go on meaning.
@@ -179,6 +224,28 @@ export const PersonaSchema = z.object({
179
224
  systemPromptMode: SystemPromptModeSchema.optional(),
180
225
  });
181
226
  export type Persona = z.infer<typeof PersonaSchema>;
227
+ /* WHICH OF A CARD'S MODELS THIS SANDBOX CAN RUN, IN ORDER: the ladder filtered to connected providers and
228
+ * deduplicated — exactly the walk a role list gets, and now the SAME CALL rather than a sibling of one
229
+ * (model-pins.ts readyChain). It used to be "that walk minus the role's floor"; there is no floor any more, so
230
+ * the two lists ask one question. An absent ladder, or one whose every provider is disconnected, is empty
231
+ * here, and empty means the caller's own answer: a card that says nothing about models has not asked for a
232
+ * cheap one, it has left the question to whoever opened the chat. */
233
+ export const personaModels = (card: Pick<Persona, "models">, sources: readonly ModelSource[]): readonly ModelPin[] => readyChain(sources, card.models ?? []);
234
+ /* WHAT A NEW CHAT IS ROUTED ON, and what comes back. The composer asks once per settled draft on a chat that
235
+ * has no turns and no persona pinned by hand (its personaRoute composable), and the sandbox answers with the
236
+ * one card the message belongs to, or none. `folder` and `paths` are the two facts a card's `context` and
237
+ * `workspace.startIn` can be matched against and the words alone cannot supply. */
238
+ export const PersonaRouteAskSchema = z.object({
239
+ prompt: z.string().min(1).max(20000).describe("The message a new chat is about to open with."),
240
+ folder: z.string().max(200).optional().describe("The workspace folder the chat was opened in, when it was opened in one."),
241
+ paths: z.array(z.string().min(1).max(500)).max(50).default([]).describe("Workspace paths the message names: uploads, @-mentions, the editor's own file."),
242
+ });
243
+ export type PersonaRouteAsk = z.infer<typeof PersonaRouteAskSchema>;
244
+ export const PersonaRouteSchema = z.object({
245
+ persona: entryId.optional().describe("The card this message belongs to, or absent when none does and the chat should stay open to everything."),
246
+ reason: z.string().describe("Why, in the one line a chip can show. Present whether or not a card was named."),
247
+ });
248
+ export type PersonaRoute = z.infer<typeof PersonaRouteSchema>;
182
249
  /* THE ONE CARD ID THE PRODUCT NAMES ITSELF, the read-only persona a public web chat answers through.
183
250
  *
184
251
  * Nothing else is stock: a fresh workspace has no personas at all, and every card on the Personas page is one
@@ -307,17 +307,21 @@ export const SandboxSettingsSchema = z.object({
307
307
  "Keep the instructions identical between turns so the provider can cache them, moving anything that varies into the message instead. Cheaper, at the cost of some flexibility.",
308
308
  ),
309
309
  skills: z.array(z.string()).default(["lsp", "fileq"]).describe("Which skills are switched on."),
310
- /* WHICH CONTEXT SHELF A CONVERSATION OPENS ON when nothing closer to it says (schemas/context.ts). A persona
311
- * card's own `context` wins where a turn wears one; this is the sandbox's answer for the turns that do not.
312
- * Empty means no shelf, so a conversation carries every repository the workspace has, which is what every
313
- * conversation did before shelves existed. Empty rather than optional because a settings object is parsed
314
- * from `{}` until the owner first changes something, and every field here has to answer to that. */
315
- contextShelf: z
316
- .string()
317
- .max(60)
318
- .default("")
310
+ /* WHETHER A NEW CHAT IS ROUTED ONTO A PERSONA, and how far the answer goes without a press. The router is
311
+ * the `persona-router` helper role (model-roles.ts): it reads the first message and one line per card and
312
+ * names one card or none, once per chat, before the first turn (the sandbox's agent/persona-router.ts).
313
+ * off — never asked. A chat wears the persona the user picked, or none.
314
+ * suggest — asked, and the answer is a chip on the composer that the user presses to apply. The default:
315
+ * a persona takes accounts and repositories AWAY from a chat, and the first time that happens
316
+ * it should happen because somebody pressed it.
317
+ * auto — the answer is applied when the message is sent, unless the chip was dismissed first.
318
+ * Attended chats only, whatever this says: a wake nobody is watching names its persona on its own form,
319
+ * and routing one onto a card would GRANT it accounts the owner never named for it. */
320
+ personaRouting: z
321
+ .enum(["off", "suggest", "auto"])
322
+ .default("suggest")
319
323
  .describe(
320
- "Which context shelf a conversation opens on when its persona names none: the part of the workspace it carries. Empty means every repository, as before shelves existed.",
324
+ "Whether a new chat is matched to one of your personas from its first message. Suggest shows the match on the composer for you to press; auto applies it when you send unless you dismiss it first. Never applies to unwatched runs, which name their persona themselves.",
321
325
  ),
322
326
  hashlineEdits: z
323
327
  .boolean()
@@ -478,12 +482,13 @@ export const SandboxSettingsSchema = z.object({
478
482
  * that is connected and will not answer today: the account's allowance went on the chat, and one spent
479
483
  * provider takes that job down for hours while the others sit idle.
480
484
  *
481
- * AN ABSENT OR EMPTY LIST IS THE INTERESTING CASE and means the role's declared floor (resolveRoleModels): a
482
- * one-shot helper derives an Auto ladder from whatever is connected right now — so it can never name a
483
- * provider this sandbox has no credential for, and it improves by itself as accounts are added — while a
484
- * whole session falls to the model the owner picked for their own chat, because nothing here can judge what
485
- * a session is worth and a wrong guess is billed whole. Storing resolved ids instead would go stale exactly
486
- * as a pinned model does. */
485
+ * AN ABSENT OR EMPTY LIST IS THE JOB SWITCHED OFF, and nothing is derived to fill it. A one-shot helper
486
+ * used to fall to an "Auto ladder" worked out from whatever was connected, which meant a sandbox nobody
487
+ * had configured still spent an account on every commit subject, every session title and every safety
488
+ * verdict, on a ranking this repo invented and re-ranked whenever an account was added. Not set now means
489
+ * not set: no auto-selection and no recommendation. A one-shot with no list does not run; a whole session
490
+ * with no list opens on the model the owner picked for their own chat, which is a choice they made rather
491
+ * than one this schema guessed. */
487
492
  // `partialRecord`, not `record`: an exhaustive one would make every role a required key, so a settings file
488
493
  // that has never been touched would have to spell out seventeen empty arrays to be valid, and adding a role
489
494
  // would invalidate every settings file in existence. An absent key IS the answer "this role has no list".
@@ -491,7 +496,7 @@ export const SandboxSettingsSchema = z.object({
491
496
  .partialRecord(ModelRoleSchema, z.array(ModelPinSchema).max(10))
492
497
  .default({})
493
498
  .describe(
494
- "Which models do which job, one ordered list per job: commit messages, session titles, the safety judge, pipeline fixes, and every other place this sandbox picks a model for you. Tried in order, so one spent account does not take a job down. A job with no list falls back to its own default: cheapest connected for the one-shot helpers, your own chat model for whole sessions.",
499
+ "Which models do which job, one ordered list per job: commit messages, session titles, the safety judge, pipeline fixes, and every other place this sandbox picks a model for you. Tried in order, so one spent account does not take a job down. Nothing is chosen for you: a one-shot job with no list does not run, and a whole session with no list opens on whatever your own chat is set to.",
495
500
  ),
496
501
  /* WHICH REPOS KEEP A CHANGELOG, the repos whose commits carry a `Release-Note:` trailer, written by the
497
502
  * same quick model that drafts the subject (git/commit-message.ts) and harvested at release time.
@@ -380,7 +380,6 @@ describe(`VERSIONED_STATE_PATHS`, () => {
380
380
  `${STATE_DIR}/config/capability-dismissals.json`,
381
381
  // The context shelves: which repositories a conversation opened on one carries. A list of names, and
382
382
  // the decision about what a session may see, which is what a review is for.
383
- `${STATE_DIR}/config/context/`,
384
383
  /* The two entries the AGENT authors on its own initiative, and the reason `versioned` is not read as
385
384
  * config-only. Both are the sandbox acting outward: a draft publishes words under the owner's name,
386
385
  * a workspace extension is code that runs in the app and can serve HTTP with the workspace under
@@ -738,12 +738,6 @@ const STATE_FILES = [
738
738
  * behaves, it holds no credential, and it belongs in a pull request, which is also what makes it
739
739
  * searchable, since every versioned entry already is. */
740
740
  { path: ".intentic/config/personas/", invalidates: ["personas"], portability: "carry", versioned: true },
741
-
742
- /* THE CONTEXT SHELVES, one JSON file per shelf (schemas/context.ts): which part of the workspace a
743
- * conversation opened on it carries. `versioned` and `carry` on the persona card's own argument, a shelf is
744
- * a list of item ids and holds no credential, and which repositories a session can see is exactly the kind
745
- * of decision that belongs in a pull request. */
746
- { path: ".intentic/config/context/", invalidates: ["context"], portability: "carry", versioned: true },
747
741
  ] as const satisfies readonly WorkspaceStateFile[];
748
742
 
749
743
  export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = STATE_FILES;
@@ -1,30 +0,0 @@
1
- import { z } from "zod";
2
- export declare const CONTEXT_ITEM_KINDS: readonly ["repo"];
3
- export type ContextItemKind = (typeof CONTEXT_ITEM_KINDS)[number];
4
- export declare const ContextItemIdSchema: z.ZodString;
5
- export type ContextItemId = z.infer<typeof ContextItemIdSchema>;
6
- export declare const contextItem: (id: ContextItemId) => {
7
- readonly kind: ContextItemKind;
8
- readonly name: string;
9
- };
10
- export declare const ContextCapsSchema: z.ZodObject<{
11
- repos: z.ZodOptional<z.ZodNumber>;
12
- }, z.core.$strip>;
13
- export type ContextCaps = z.infer<typeof ContextCapsSchema>;
14
- export declare const ContextShelfSchema: z.ZodObject<{
15
- id: z.ZodString;
16
- label: z.ZodOptional<z.ZodString>;
17
- pinned: z.ZodDefault<z.ZodArray<z.ZodString>>;
18
- allowed: z.ZodDefault<z.ZodArray<z.ZodString>>;
19
- denied: z.ZodDefault<z.ZodArray<z.ZodString>>;
20
- caps: z.ZodOptional<z.ZodObject<{
21
- repos: z.ZodOptional<z.ZodNumber>;
22
- }, z.core.$strip>>;
23
- }, z.core.$strip>;
24
- export type ContextShelf = z.infer<typeof ContextShelfSchema>;
25
- export declare const ContextCompositionSchema: z.ZodObject<{
26
- shelf: z.ZodOptional<z.ZodString>;
27
- items: z.ZodArray<z.ZodString>;
28
- }, z.core.$strip>;
29
- export type ContextComposition = z.infer<typeof ContextCompositionSchema>;
30
- //# sourceMappingURL=context.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/schemas/context.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAoBxB,eAAO,MAAM,kBAAkB,YAAI,MAAM,CAAU,CAAC;AACpD,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAKlE,eAAO,MAAM,mBAAmB,aAK0I,CAAC;AAC3K,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAGhE,eAAO,MAAM,WAAW,OAAQ,aAAa,KAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAGtG,CAAC;AAMF,eAAO,MAAM,iBAAiB;;iBAE5B,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAa5D,eAAO,MAAM,kBAAkB;;;;;;;;;iBAW7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAS9D,eAAO,MAAM,wBAAwB;;;iBAGnC,CAAC;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC"}
@@ -1,34 +0,0 @@
1
- import { z } from "zod";
2
- import { entryId } from "./internal.js";
3
- export const CONTEXT_ITEM_KINDS = ["repo"];
4
- const ITEM = /^(repo):[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/;
5
- export const ContextItemIdSchema = z
6
- .string()
7
- .min(1)
8
- .max(200)
9
- .regex(ITEM)
10
- .describe("One thing in the workspace a conversation can carry, as `<kind>:<name>`. Today the kind is `repo` and the name is a repository's workspace-relative path.");
11
- export const contextItem = (id) => {
12
- const at = id.indexOf(":");
13
- return { kind: id.slice(0, at), name: id.slice(at + 1) };
14
- };
15
- export const ContextCapsSchema = z.object({
16
- repos: z.number().int().min(0).optional().describe("How many repositories a composition may hold. Absent means as many as the shelf allows."),
17
- });
18
- export const ContextShelfSchema = z.object({
19
- id: entryId.describe("The shelf's id, which is also its file name."),
20
- label: z.string().max(60).optional().describe("What to call it on screen. Absent falls back to the id."),
21
- pinned: z.array(ContextItemIdSchema).max(200).default([]).describe("Items every composition from this shelf carries."),
22
- allowed: z
23
- .array(ContextItemIdSchema)
24
- .max(500)
25
- .default([])
26
- .describe("Items a composition may carry, in the order they are loaded and shed. The order is the priority; nothing else is."),
27
- denied: z.array(ContextItemIdSchema).max(200).default([]).describe("Items no composition from this shelf carries, whatever else says so."),
28
- caps: ContextCapsSchema.optional().describe("How many of each kind a composition may hold."),
29
- });
30
- export const ContextCompositionSchema = z.object({
31
- shelf: entryId.optional().describe("Which shelf this pick was made from. Absent when the items were set without one."),
32
- items: z.array(ContextItemIdSchema).max(500).describe("What the conversation carries, in shelf order. The root repository is always carried and never listed."),
33
- });
34
- //# sourceMappingURL=context.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"context.js","sourceRoot":"","sources":["../../src/schemas/context.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAmBxC,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,MAAM,CAAU,CAAC;AAKpD,MAAM,IAAI,GAAG,oEAAoE,CAAC;AAClF,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC;KAC/B,MAAM,EAAE;KACR,GAAG,CAAC,CAAC,CAAC;KACN,GAAG,CAAC,GAAG,CAAC;KACR,KAAK,CAAC,IAAI,CAAC;KACX,QAAQ,CAAC,2JAA2J,CAAC,CAAC;AAI3K,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,EAAiB,EAA6D,EAAE;IACxG,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAC3B,OAAO,EAAE,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAoB,EAAE,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,EAAE,CAAC;AAChF,CAAC,CAAC;AAMF,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC;IACtC,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,yFAAyF,CAAC;CAChJ,CAAC,CAAC;AAcH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,EAAE,EAAE,OAAO,CAAC,QAAQ,CAAC,8CAA8C,CAAC;IACpE,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,yDAAyD,CAAC;IACxG,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,mBAAmB,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,kDAAkD,CAAC;IACtH,OAAO,EAAE,CAAC;SACL,KAAK,CAAC,mBAAmB,CAAC;SAC1B,GAAG,CAAC,GAAG,CAAC;SACR,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CAAC,mHAAmH,CAAC;IAClI,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,mBAAmB,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,sEAAsE,CAAC;IAC1I,IAAI,EAAE,iBAAiB,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,+CAA+C,CAAC;CAC/F,CAAC,CAAC;AAUH,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC7C,KAAK,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,kFAAkF,CAAC;IACtH,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,mBAAmB,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,wGAAwG,CAAC;CAClK,CAAC,CAAC"}