@camox/api-contract 0.71.0 → 0.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camox/api-contract",
3
- "version": "0.71.0",
3
+ "version": "0.74.1",
4
4
  "license": "MIT",
5
5
  "files": [
6
6
  "src",
@@ -44,9 +44,9 @@
44
44
  "types": "./dist/locales.d.ts",
45
45
  "default": "./dist/locales.js"
46
46
  },
47
- "./patrol": {
48
- "types": "./dist/patrol.d.ts",
49
- "default": "./dist/patrol.js"
47
+ "./specialists": {
48
+ "types": "./dist/specialists.d.ts",
49
+ "default": "./dist/specialists.js"
50
50
  },
51
51
  "./router": {
52
52
  "types": "./dist/router.d.ts",
@@ -55,6 +55,10 @@
55
55
  "./layout-routes": {
56
56
  "types": "./dist/layout-routes.d.ts",
57
57
  "default": "./dist/layout-routes.js"
58
+ },
59
+ "./claim-links": {
60
+ "types": "./dist/claim-links.d.ts",
61
+ "default": "./dist/claim-links.js"
58
62
  }
59
63
  },
60
64
  "dependencies": {
@@ -0,0 +1,23 @@
1
+ import { projectWebsiteUrl } from "./projects";
2
+
3
+ /** URL search param carrying a Claim Link's token on the website it links to. */
4
+ export const CLAIM_LINK_SEARCH_PARAM = "camox-claim";
5
+
6
+ /**
7
+ * Where a Claim Link stands: `open` until it's claimed, revoked by an operator, or blocked, when a
8
+ * claim was refused because someone who isn't an operator joined its organization (ADR 0041).
9
+ */
10
+ export type ClaimLinkState = "open" | "claimed" | "revoked" | "blocked";
11
+
12
+ /**
13
+ * A Claim Link: the project's address, its Custom Domain if it has one, else its `camox.site`
14
+ * subdomain, with the link's token.
15
+ */
16
+ export function claimLinkUrl(
17
+ project: { slug: string; customDomain: string | null },
18
+ token: string,
19
+ ) {
20
+ const url = new URL("/", projectWebsiteUrl(project));
21
+ url.searchParams.set(CLAIM_LINK_SEARCH_PARAM, token);
22
+ return url.toString();
23
+ }
package/src/drafts.ts CHANGED
@@ -50,9 +50,9 @@ export const LOOK_LOCALE_PARAM = "locale";
50
50
 
51
51
  /**
52
52
  * The look document's Block Definition mode: instead of a page, the block of the Block Definition
53
- * `LOOK_BLOCK_DEFINITION_PARAM` names, alone in the site's shell, with its default content and
54
- * Block Settings and the overrides `LOOK_CONTENT_PARAM` and `LOOK_SETTINGS_PARAM` give as JSON, in
55
- * the shape the content tools take.
53
+ * `LOOK_BLOCK_DEFINITION_PARAM` names, alone in the site's shell, with empty fields and the
54
+ * content and Block Settings `LOOK_CONTENT_PARAM` and `LOOK_SETTINGS_PARAM` give as JSON merged
55
+ * on, in the shape the content tools take.
56
56
  */
57
57
  export const LOOK_BLOCK_DEFINITION_PARAM = "blockDefinition";
58
58
  export const LOOK_CONTENT_PARAM = "content";
@@ -60,8 +60,8 @@ export const LOOK_SETTINGS_PARAM = "settings";
60
60
 
61
61
  /**
62
62
  * The look document's Layout Definition mode: instead of a page, the curated Layout
63
- * `LOOK_LAYOUT_DEFINITION_PARAM` names, alone in the site's shell, its blocks with their default
64
- * content and Block Settings and the overrides `LOOK_BLOCKS_PARAM` gives as JSON, by Block
63
+ * `LOOK_LAYOUT_DEFINITION_PARAM` names, alone in the site's shell, its blocks with empty fields
64
+ * and the content and Block Settings `LOOK_BLOCKS_PARAM` gives as JSON merged on, by Block
65
65
  * Definition name (`{ navbar: { content, settings } }`), around a labelled placeholder where a
66
66
  * page's content goes, as tall as `LOOK_PLACEHOLDER_PARAM` says: one of `LOOK_PLACEHOLDER_HEIGHTS`,
67
67
  * the first without.
@@ -164,12 +164,6 @@ export type EndedDraft = {
164
164
  */
165
165
  export const DRAFT_ENDED_ERROR_CODE = "DRAFT_ENDED";
166
166
 
167
- /**
168
- * oRPC error code for a publish whose token no longer matches Live: Live changed since the
169
- * preview, so nothing was applied and the caller reviews the new preview.
170
- */
171
- export const CONFLICTS_CHANGED_ERROR = "CONFLICTS_CHANGED";
172
-
173
167
  /**
174
168
  * oRPC error code for a publish of a draft with code whose revision isn't the one the review
175
169
  * showed: the agent left a newer one, or the draft is already publishing. The caller reviews
@@ -179,15 +173,16 @@ export const REVISION_CHANGED_ERROR = "REVISION_CHANGED";
179
173
 
180
174
  /**
181
175
  * Why a publish with code went back to the review instead of landing: the agent left a revision
182
- * after the one the review showed, Live's conflicts changed, the draft got a blocker, Live's code
183
- * moved and the draft's merge of it clashed (the "doesn't merge with Live's" blocker, which
184
- * Camox's turn fixes), Live's code moved and the draft took too long to merge it, or the publish
185
- * failed on Camox's side.
176
+ * after the one the review showed, the draft got a blocker, Live's code moved and the draft's
177
+ * merge of it clashed (the "doesn't merge with Live's" blocker, which Camox's turn fixes), Live's
178
+ * code moved and the draft took too long to merge it, or the publish failed on Camox's side.
179
+ * `conflictsChanged` is only stored by publishes before ADR 0043, which aborted when Live changed.
186
180
  */
187
181
  export const PUBLISH_ABORT_REASONS = [
188
182
  "revisionChanged",
189
183
  "conflictsChanged",
190
184
  "blocked",
185
+ "codeDoesNotBuild",
191
186
  "codeDoesNotMerge",
192
187
  "liveCodeMoved",
193
188
  "failed",
@@ -198,36 +193,13 @@ export type PublishAbortReason = (typeof PUBLISH_ABORT_REASONS)[number];
198
193
  /** oRPC error code for a publish refused because the draft still has blockers. */
199
194
  export const PUBLISH_BLOCKED_ERROR = "PUBLISH_BLOCKED";
200
195
 
201
- /**
202
- * oRPC error code for a publish refused because the draft still has pending translations: every
203
- * locale goes live together, and there is no publishing anyway (ADR 0033).
204
- */
205
- export const TRANSLATIONS_PENDING_ERROR = "TRANSLATIONS_PENDING";
206
-
207
- /**
208
- * oRPC error code for a publish refused because the draft has open findings, each to fix or for a
209
- * member to dismiss: there is no publishing past them (ADR 0033).
210
- */
211
- export const FINDINGS_OPEN_ERROR = "FINDINGS_OPEN";
212
-
213
- /**
214
- * oRPC error code for a publish refused because a patroller's check at publish hasn't read the
215
- * draft's latest changes yet: publishing waits for it (ADR 0033).
216
- */
217
- export const CHECKS_RUNNING_ERROR = "CHECKS_RUNNING";
218
-
219
196
  /**
220
197
  * Why the publish tool published nothing of a draft with changes, and returned a fresh preview to
221
- * review again, by the error code of each: Live changed since the preview, the draft has blockers,
222
- * translations are still running, the draft has open findings, a check at publish is still
223
- * running, or the draft's code changed since the preview.
198
+ * review again, by the error code of each: the draft has blockers, or the draft's code changed
199
+ * since the preview.
224
200
  */
225
201
  export const NOT_PUBLISHED_CODES = {
226
- conflictsChanged: CONFLICTS_CHANGED_ERROR,
227
202
  blocked: PUBLISH_BLOCKED_ERROR,
228
- translating: TRANSLATIONS_PENDING_ERROR,
229
- findings: FINDINGS_OPEN_ERROR,
230
- checking: CHECKS_RUNNING_ERROR,
231
203
  revisionChanged: REVISION_CHANGED_ERROR,
232
204
  } as const;
233
205
 
@@ -339,7 +311,7 @@ const LOCALE_WRITES: Record<keyof Router["locales"], boolean> = {
339
311
 
340
312
  // Applying a finding's fix edits content as the member would; dismissing one is the project's,
341
313
  // never content (ADR 0034).
342
- const PATROL_WRITES: Record<keyof Router["patrol"], boolean> = {
314
+ const SPECIALISTS_WRITES: Record<keyof Router["specialists"], boolean> = {
343
315
  get: false,
344
316
  applyFix: true,
345
317
  dismissFinding: false,
@@ -352,7 +324,9 @@ const CONTENT_WRITES: Partial<Record<keyof Router, Record<string, boolean>>> = {
352
324
  collectionDefinitions: COLLECTION_WRITES,
353
325
  files: FILE_WRITES,
354
326
  pages: PAGE_WRITES,
355
- patrol: PATROL_WRITES,
327
+ specialists: SPECIALISTS_WRITES,
328
+ // The old name of `specialists`, for studios not yet upgraded (ADR 0019).
329
+ patrol: SPECIALISTS_WRITES,
356
330
  repeatableItems: ITEM_WRITES,
357
331
  };
358
332
 
@@ -0,0 +1,52 @@
1
+ /**
2
+ * What a field holds before anyone writes it: the empty value of its kind. Fields declare no
3
+ * default, so content missing a field, or holding a value of the wrong kind, reads as this.
4
+ * Shared by the SDK (rendering, new repeatable items, authoring forms) and the API (creating
5
+ * content, conforming it on read), so both agree on what an unset field is.
6
+ */
7
+
8
+ /** A link that goes nowhere yet. */
9
+ export const EMPTY_LINK = { type: "external", text: "", href: "", newTab: false } as const;
10
+
11
+ function isObject(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+
15
+ /**
16
+ * The empty value of a field, from its stored JSON Schema (`fieldType` and, for an enum, its
17
+ * options): `""` for text, a link to nowhere, `false`, an enum's first option, `null` for one
18
+ * asset or record, and `[]` for lists and repeaters. A fresh copy each call.
19
+ */
20
+ export function emptyFieldValue(fieldSchema: unknown): unknown {
21
+ const schema = isObject(fieldSchema) ? fieldSchema : {};
22
+ switch (schema.fieldType) {
23
+ case "Link":
24
+ return { ...EMPTY_LINK };
25
+ case "Boolean":
26
+ return false;
27
+ case "Enum": {
28
+ const options = Array.isArray(schema.enum) ? schema.enum : [];
29
+ const first = options.find((option) => typeof option === "string");
30
+ return first ?? "";
31
+ }
32
+ case "Image":
33
+ case "File":
34
+ case "Reference":
35
+ return null;
36
+ case "ImageList":
37
+ case "FileList":
38
+ case "ReferenceList":
39
+ case "Repeater":
40
+ return [];
41
+ default:
42
+ return "";
43
+ }
44
+ }
45
+
46
+ /** The empty value of every field of a content or settings schema, by name. */
47
+ export function emptyFieldValues(schema: unknown): Record<string, unknown> {
48
+ const properties = isObject(schema) && isObject(schema.properties) ? schema.properties : {};
49
+ return Object.fromEntries(
50
+ Object.entries(properties).map(([name, field]) => [name, emptyFieldValue(field)]),
51
+ );
52
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./artifact.ts";
2
+ export * from "./empty-values.ts";
2
3
  export * from "./icons.ts";
3
4
  export * from "./ids.ts";
4
5
  export * from "./record-templates.ts";
package/src/query-keys.ts CHANGED
@@ -59,7 +59,11 @@ export const queryKeys = {
59
59
  // The draft a viewer is in, scoped to it like content.
60
60
  get: ["camox", "drafts", "get"],
61
61
  },
62
- // The Patrol of a scope: its reports and its patrollers' pending work.
62
+ // The Specialists of a scope: their reports and pending work.
63
+ specialists: {
64
+ get: ["camox", "specialists"],
65
+ },
66
+ // Where studios not yet upgraded to the specialists cache them (ADR 0019).
63
67
  patrol: {
64
68
  get: ["camox", "patrol"],
65
69
  },
@@ -1,14 +1,14 @@
1
1
  /**
2
- * The Patrol (ADR 0024): every patroller of a project, the work each did as reports, as the
2
+ * The Specialists (ADR 0024): every specialist of a project, the work each did as reports, as the
3
3
  * studio shows it at the top of the Agent Sidebar. The API reads it for one scope, a draft or
4
4
  * Live, and names each report's subject, so the studio needs no entity loaded to show it.
5
5
  */
6
6
 
7
- /** Every patroller's stable id. The registry in the API lists them all. */
8
- export const PATROLLER_IDS = ["translator", "seoManager", "linkChecker"] as const;
7
+ /** Every specialist's stable id. The registry in the API lists them all. */
8
+ export const SPECIALIST_IDS = ["translator", "seoManager", "linkChecker"] as const;
9
9
 
10
- /** A patroller's stable id. */
11
- export type PatrollerId = (typeof PATROLLER_IDS)[number];
10
+ /** A specialist's stable id. */
11
+ export type SpecialistId = (typeof SPECIALIST_IDS)[number];
12
12
 
13
13
  /**
14
14
  * What a structured report says was done. Each has a failed form, by the report's outcome. A
@@ -32,7 +32,7 @@ export type ReportEntity =
32
32
  | "record"
33
33
  | "file";
34
34
 
35
- /** A report's subject, named as the Canvas names it, read when the Patrol is. */
35
+ /** A report's subject, named as the Canvas names it, read when the Specialists is. */
36
36
  export type ReportSubject = { entity: ReportEntity; id: string; name: string };
37
37
 
38
38
  /**
@@ -57,10 +57,10 @@ export const reportLocale = (
57
57
  export type ReportOutcome = "done" | "failed";
58
58
 
59
59
  /**
60
- * Where a patroller is on duty, in drafts or on Live: it works as you go, checks when you
61
- * publish, or is off there (ADR 0034).
60
+ * Where a specialist is on duty, in drafts or on Live: it works as you go, or is off there (ADR
61
+ * 0034). None checks when you publish: publishing never waits on Camox (ADR 0043).
62
62
  */
63
- export type PatrollerDuty = "asYouGo" | "atPublish" | "off";
63
+ export type SpecialistDuty = "asYouGo" | "off";
64
64
 
65
65
  /**
66
66
  * Where the studio shows a report's subject: a page on the Canvas, with the block and repeatable
@@ -72,7 +72,7 @@ export type ReportPlace =
72
72
  | { kind: "file"; fileId: string };
73
73
 
74
74
  export type ReportView = {
75
- /** Unique among the patroller's reports in the view. */
75
+ /** Unique among the specialist's reports in the view. */
76
76
  id: string;
77
77
  summary: ReportSummary;
78
78
  outcome: ReportOutcome;
@@ -91,12 +91,14 @@ export type ReportView = {
91
91
  * What a finding is about (ADR 0034), by its kind:
92
92
  * - `missingPage`: a link to a page that doesn't exist, the page's name as it was last known.
93
93
  * - `brokenLink`: an external link that failed, and how.
94
+ * - `missingMetaTitle`: a page whose meta title is written by hand, and empty.
94
95
  * A link's `url` is as content stores it: a page link's is `camox:page:<id>`, and `text` is what
95
96
  * the link reads.
96
97
  */
97
98
  export type FindingAbout =
98
99
  | { kind: "missingPage"; url: string; text: string; page: string | null }
99
- | { kind: "brokenLink"; url: string; text: string; failure: LinkFailure };
100
+ | { kind: "brokenLink"; url: string; text: string; failure: LinkFailure }
101
+ | { kind: "missingMetaTitle" };
100
102
 
101
103
  /** How a link failed: an HTTP error status, no connection (DNS included), or no answer in time. */
102
104
  export type LinkFailure =
@@ -126,14 +128,17 @@ export type FindingView = {
126
128
  fieldKind: string | null;
127
129
  /** When it was first raised, in milliseconds since the epoch. */
128
130
  raisedAt: number;
129
- /** Whether it's Live's. Live's are fixed in the Patrol Draft. */
131
+ /**
132
+ * Whether it's Live's. A draft shows Live's findings on the content it still holds, and fixes
133
+ * them there.
134
+ */
130
135
  live: boolean;
131
136
  /** Where its content shows, as for reports. */
132
137
  place: ReportPlace | null;
133
138
  };
134
139
 
135
- /** What a finding is about, after where it is: "links to About, a page that no longer exists". */
136
- function aboutSentence(about: FindingAbout): string {
140
+ /** What a link finding is about, after where it is: "links to About, a page that no longer exists". */
141
+ function linkSentence(about: Exclude<FindingAbout, { kind: "missingMetaTitle" }>): string {
137
142
  switch (about.kind) {
138
143
  case "missingPage":
139
144
  return about.page === null
@@ -154,10 +159,12 @@ function aboutSentence(about: FindingAbout): string {
154
159
 
155
160
  /**
156
161
  * A finding as one sentence, as the studio, emails, Briefs and refusals word it: "“About us” in
157
- * Footer links to About, a page that no longer exists".
162
+ * Footer links to About, a page that no longer exists", or "Pricing has no meta title".
158
163
  */
159
164
  export function findingSentence(finding: Pick<FindingView, "about" | "subject">): string {
160
- return `“${finding.about.text}” in ${finding.subject.name} ${aboutSentence(finding.about)}`;
165
+ const { about, subject } = finding;
166
+ if (about.kind === "missingMetaTitle") return `${subject.name} has no meta title`;
167
+ return `“${about.text}” in ${subject.name} ${linkSentence(about)}`;
161
168
  }
162
169
 
163
170
  /**
@@ -165,7 +172,9 @@ export function findingSentence(finding: Pick<FindingView, "about" | "subject">)
165
172
  * by its field: "“About us” links to About, a page that no longer exists".
166
173
  */
167
174
  export function findingSentenceInPlace(finding: Pick<FindingView, "about">): string {
168
- return `“${finding.about.text}” ${aboutSentence(finding.about)}`;
175
+ const { about } = finding;
176
+ if (about.kind === "missingMetaTitle") return "The page has no meta title";
177
+ return `“${about.text}” ${linkSentence(about)}`;
169
178
  }
170
179
 
171
180
  /** A member's dismissal of what a finding was about: no finding like it is raised again. */
@@ -180,26 +189,26 @@ export type DismissalView = {
180
189
  /** What applying a finding's fix did: applied it, or found its content changed and rechecked. */
181
190
  export type ApplyResult = { applied: boolean };
182
191
 
183
- /** How many subjects a patroller is on in one locale, a locale's tag. */
192
+ /** How many subjects a specialist is on in one locale, a locale's tag. */
184
193
  export type LocaleCount = { locale: string; count: number };
185
194
 
186
195
  /**
187
- * What a patroller is on: the one subject when there's one, and how many subjects in each
196
+ * What a specialist is on: the one subject when there's one, and how many subjects in each
188
197
  * locale, the most first. A subject in several locales counts in each.
189
198
  */
190
- export type PatrollerWorking = {
199
+ export type SpecialistWorking = {
191
200
  subject: ReportSubject | null;
192
201
  locales: LocaleCount[];
193
202
  };
194
203
 
195
- export type PatrollerView = {
196
- id: PatrollerId;
204
+ export type SpecialistView = {
205
+ id: SpecialistId;
197
206
  /** Its role, as editors know it: "Translator", "SEO manager". */
198
207
  role: string;
199
208
  /** Its duty in the scope viewed. Off there, it still has its reports. */
200
- duty: PatrollerDuty;
209
+ duty: SpecialistDuty;
201
210
  /** Null when it isn't working in the scope. */
202
- working: PatrollerWorking | null;
211
+ working: SpecialistWorking | null;
203
212
  /**
204
213
  * When it last finished a check in the scope, whether it found anything or not, or null when it
205
214
  * never checked there.
@@ -220,14 +229,14 @@ export type PatrollerView = {
220
229
  dismissals: DismissalView[];
221
230
  };
222
231
 
232
+ /** Every specialist of the registry, the latest activity first; never-worked ones last. */
233
+ export type SpecialistsView = { specialists: SpecialistView[] };
234
+
223
235
  /**
224
- * Every patroller of the registry, the latest activity first; never-worked ones last. With the
225
- * project's open Patrol Draft, where Live's findings are fixed, if any.
236
+ * URL search param the studio opens a specialist's findings from, by its id, as the email about
237
+ * Live's new findings links to them. The studio drops it once opened.
226
238
  */
227
- export type PatrolView = {
228
- patrollers: PatrollerView[];
229
- patrolDraft: { id: string; name: string | null } | null;
230
- };
239
+ export const SPECIALIST_SEARCH_PARAM = "camox-specialist";
231
240
 
232
- /** How many reports the Patrol shows of each patroller; the rest are counted. */
241
+ /** How many reports the Specialists shows of each specialist; the rest are counted. */
233
242
  export const REPORTS_SHOWN = 5;
package/src/threads.ts CHANGED
@@ -44,9 +44,8 @@ export const RECONCILE_ENTRY = "camox.reconcile";
44
44
 
45
45
  /**
46
46
  * The kind of the transcript entry opening a draft's thread a Live thread handed a request off
47
- * to: the Brief, which the model reads and editors see as from Camox, ahead of the editor's
48
- * messages. In a draft Camox opened, such as its Patrol Draft, Camox's own messages are Briefs
49
- * too, with their `operationId` as data, and start the agent's run, which acts for Camox.
47
+ * to: the Brief, which the model reads ahead of the editor's messages, and editors see collapsed
48
+ * after them, so the request reads on as they sent it.
50
49
  */
51
50
  export const BRIEF_ENTRY = "camox.brief";
52
51
 
@@ -60,8 +59,8 @@ export const DRAFT_ENDED_ENTRY = "camox.draft-ended";
60
59
  /**
61
60
  * The kind of the transcript entry a Live thread starts afresh from once its agent handed a
62
61
  * request off to a draft (ADR 0036): a reset of Pi's context, with a `startedDraftCardSchema` as
63
- * data. Editors see it as the draft's card, alone, and the model reads a note naming the draft.
64
- * Everything before it is left out of both.
62
+ * data. Editors see an empty thread, and the model reads a note naming the draft. Everything
63
+ * before it is left out of both.
65
64
  */
66
65
  export const DRAFT_CARD_ENTRY = "camox.draft-card";
67
66
 
@@ -127,6 +126,30 @@ export const codeRequestDataSchema = artifactRequestDataSchema.extend({
127
126
  });
128
127
  export type CodeRequestData = z.infer<typeof codeRequestDataSchema>;
129
128
 
129
+ /**
130
+ * The draft thread's agent's tool asking the Connected Agents following its turn questions, which
131
+ * their waiting calls come back with as the agent works on (ADR 0042). The model calls it itself,
132
+ * never from a script.
133
+ */
134
+ export const ASK_TOOL = "askConnectedAgent";
135
+
136
+ /**
137
+ * The kind of the transcript entry recording questions the agent asked the Connected Agents
138
+ * following its turn, with a `questionsDataSchema` as data. Neither the model nor editors read
139
+ * it: questions are never a thread's messages, and never end its turn.
140
+ */
141
+ export const QUESTIONS_ENTRY = "camox.questions";
142
+
143
+ /**
144
+ * What a questions entry records: the questions, in the order the agent asked them, and the
145
+ * Connected Agents they were asked of, each by its member's id and client id.
146
+ */
147
+ export const questionsDataSchema = z.object({
148
+ questions: z.array(z.string()).min(1),
149
+ to: z.array(z.object({ memberId: z.string(), clientId: z.string() })),
150
+ });
151
+ export type QuestionsData = z.infer<typeof questionsDataSchema>;
152
+
130
153
  /**
131
154
  * The agent's tools handing work to an agent of its own, in a conversation beside the thread's,
132
155
  * whose answer they return: editors see what that agent does as the agent's.
@@ -210,13 +233,14 @@ export const connectedAgentClientSchema = z.object({ name: z.string(), verified:
210
233
  export type ConnectedAgentClient = z.infer<typeof connectedAgentClientSchema>;
211
234
 
212
235
  /**
213
- * What a Connected Agent's message entry records: the member it acts as, its client, and the
214
- * operation id it was sent with.
236
+ * What a Connected Agent's message entry records: the member it acts as, its client, the
237
+ * operation id it was sent with, and whether it answers the agent's questions (ADR 0042).
215
238
  */
216
239
  export const connectedAgentMessageDataSchema = z.object({
217
240
  member: threadSenderSchema,
218
241
  client: connectedAgentClientSchema,
219
242
  operationId: z.string().optional(),
243
+ answer: z.boolean().optional(),
220
244
  });
221
245
  export type ConnectedAgentMessageData = z.infer<typeof connectedAgentMessageDataSchema>;
222
246
 
@@ -291,7 +315,9 @@ export type ThreadMessage =
291
315
  | { id: string; from: "editor"; sender: ThreadSender; text: string }
292
316
  /**
293
317
  * A member's Connected Agent's message: the member's own AI, in its client, writing as itself.
294
- * Never shown as the member's own words.
318
+ * Never shown as the member's own words. An answer to the agent's questions shows as the
319
+ * context the draft was built on, collapsed, as a Brief does, rather than as a request
320
+ * (ADR 0042).
295
321
  */
296
322
  | {
297
323
  id: string;
@@ -299,9 +325,15 @@ export type ThreadMessage =
299
325
  member: ThreadSender;
300
326
  client: ConnectedAgentClient;
301
327
  text: string;
328
+ answer: boolean;
302
329
  }
303
330
  /** Camox's own message, starting a turn it began itself: why it did. */
304
331
  | { id: string; from: "camox"; text: string }
332
+ /**
333
+ * The Brief the agent on Live wrote as it handed the request off to the draft, shown after the
334
+ * editor's messages it opened the thread with, though the model reads it before them.
335
+ */
336
+ | { id: string; from: "brief"; text: string }
305
337
  /** The agent's final message of a turn, in Markdown. */
306
338
  | { id: string; from: "agent"; text: string }
307
339
  /**
@@ -403,9 +435,17 @@ export const EMPTY_THREAD_STATE: ThreadState = { steps: [], running: false };
403
435
  /** One transcript entry, as far as the studio's view is concerned. */
404
436
  type ThreadStep = { id: string } & (
405
437
  | { type: "editor"; sender: ThreadSender; text: string }
406
- | { type: "connectedAgent"; member: ThreadSender; client: ConnectedAgentClient; text: string }
438
+ | {
439
+ type: "connectedAgent";
440
+ member: ThreadSender;
441
+ client: ConnectedAgentClient;
442
+ text: string;
443
+ answer: boolean;
444
+ }
407
445
  /** Camox starting a turn of its own, saying why. */
408
446
  | { type: "camox"; text: string }
447
+ /** The Brief opening a draft's thread a Live thread handed a request off to. */
448
+ | { type: "brief"; text: string }
409
449
  | { type: "answer"; text: string }
410
450
  /** A round of tool calls, or their results: the run goes on after it. */
411
451
  | { type: "tools" }
@@ -560,11 +600,8 @@ function threadStepOf(entry: EntryRecord): ThreadStep | undefined {
560
600
  const push = pushDataSchema.safeParse(entry.data).data;
561
601
  return push && { id, type: "push", ...push };
562
602
  }
563
- // The model reads a note: editors see the card it starts the Live thread afresh with.
564
- if (entry.kind === DRAFT_CARD_ENTRY) {
565
- const card = startedDraftCardSchema.safeParse(entry.data).data;
566
- return card && { id, type: "draft", ...card };
567
- }
603
+ // Only the model reads it, as a note naming the draft: editors see the Live thread start empty.
604
+ if (entry.kind === DRAFT_CARD_ENTRY) return undefined;
568
605
  const message = entry.model?.find((candidate) => candidate.role !== "system");
569
606
  if (!message) return undefined;
570
607
  if (message.role === "user") {
@@ -580,10 +617,16 @@ function threadStepOf(entry: EntryRecord): ThreadStep | undefined {
580
617
  member: senderOf(data.member)!,
581
618
  client: { name: data.client.name, verified: data.client.verified },
582
619
  text: editorTextOf(message.content),
620
+ answer: data.answer ?? false,
583
621
  }
584
622
  );
585
623
  }
586
- if (entry.kind === BRIEF_ENTRY) return { id, type: "camox", text: textOf(message.content) };
624
+ if (entry.kind === BRIEF_ENTRY) {
625
+ // Only Camox's own messages, in a draft it opened, carry an operation id: a hand-off's
626
+ // Brief is written ahead of the editor's messages, which start the run.
627
+ const own = z.object({ operationId: z.string() }).safeParse(entry.data).success;
628
+ return { id, type: own ? "camox" : "brief", text: textOf(message.content) };
629
+ }
587
630
  if (entry.kind === RECONCILE_ENTRY) {
588
631
  const reconcile = reconcileDataSchema.safeParse(entry.data).data;
589
632
  return reconcile && { id, type: "camox", text: reconcile.message };
@@ -648,9 +691,19 @@ export function threadViewOf({
648
691
  if (waiting !== undefined) messages.push({ id: `${waiting}:failed`, from: "failed" });
649
692
  endTurn();
650
693
  };
694
+ /** A hand-off's Brief, shown once the editors' messages it opened the thread with are. */
695
+ let brief: Extract<ThreadMessage, { from: "brief" }> | undefined;
696
+ const showBrief = () => {
697
+ if (brief) messages.push(brief);
698
+ brief = undefined;
699
+ };
651
700
 
652
701
  for (const step of steps) {
702
+ if (step.type !== "editor" && step.type !== "connectedAgent") showBrief();
653
703
  switch (step.type) {
704
+ case "brief":
705
+ brief = { id: step.id, from: "brief", text: step.text };
706
+ break;
654
707
  case "editor":
655
708
  // Pi places a steering message only once a model call is settled: after a round of tool
656
709
  // calls, or with the answer ending the run. Never between a failed call and its retry, so
@@ -668,6 +721,7 @@ export function threadViewOf({
668
721
  member: step.member,
669
722
  client: step.client,
670
723
  text: step.text,
724
+ answer: step.answer,
671
725
  });
672
726
  waiting = step.id;
673
727
  break;
@@ -738,6 +792,7 @@ export function threadViewOf({
738
792
  break;
739
793
  }
740
794
  }
795
+ showBrief();
741
796
  if (!running) fail();
742
797
  if (!running) return { messages, running };
743
798
  return {
@@ -798,7 +853,7 @@ const callsLook = (block: AssistantMessage["content"][number]) =>
798
853
 
799
854
  /**
800
855
  * Adds an entry to the thread's state as Pi places it. An entry heading a new context, such as
801
- * the card a Live thread starts afresh from, leaves out every step before its head, as Pi's
856
+ * the one a Live thread starts afresh from, leaves out every step before its head, as Pi's
802
857
  * snapshots do.
803
858
  */
804
859
  const append = (state: ThreadState, entry: EntryRecord | undefined): ThreadState => {