@paigy/mcp 0.40.11 → 0.40.15
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 +35 -9
- package/dist/{chunk-FQVK7SO4.js → chunk-37FS33UP.js} +56 -5
- package/dist/{chunk-H5GGWCPS.js → chunk-PHTQITTC.js} +835 -498
- package/dist/{chunk-I5QINUN6.js → chunk-SE2JCKDI.js} +593 -454
- package/dist/{chunk-XIHVHYDU.js → chunk-X6G7XXCP.js} +1 -1
- package/dist/dist-DKP3DJQJ.js +110 -0
- package/dist/enable.js +9 -4
- package/dist/index.js +29 -5
- package/dist/listen.js +48 -16
- package/dist/onboard.js +4 -4
- package/dist/slot.js +1 -1
- package/dist/stalled.js +35 -0
- package/dist/statusline.js +1 -1
- package/package.json +3 -2
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
// ../../packages/schema/dist/index.js
|
|
2
|
+
import { z as z4 } from "zod";
|
|
3
|
+
import { z } from "zod";
|
|
2
4
|
import { z as z3 } from "zod";
|
|
3
|
-
import { z as z2 } from "zod";
|
|
4
5
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
+
import { z as z2 } from "zod";
|
|
6
7
|
var OPTIONS_MIN = 2;
|
|
7
8
|
var OPTIONS_MAX = 6;
|
|
9
|
+
var OptionSchema = z.object({
|
|
10
|
+
id: z.string(),
|
|
11
|
+
label: z.string(),
|
|
12
|
+
hint: z.string().max(500).describe("Optional short projection of consequence or action if this option is chosen (e.g. 'Reruns test suite', 'Merges to main').").optional(),
|
|
13
|
+
// .describe() flows into the MCP contact JSON schema (zodToJsonSchema), so
|
|
14
|
+
// the constraints below are what an agent reads when deciding to use these.
|
|
15
|
+
html: z.string().max(16384).describe(
|
|
16
|
+
"Optional sandboxed HTML/CSS preview for a visual 'pick one' (shown in the option card). Untrusted-sandboxed: NO JavaScript, NO external network or images \u2014 inline CSS and data: URIs only; <=16KB. Rendered edge-to-edge in a responsive card that is 200pt tall (about 320pt wide on a phone, with the next option peeking beside it); make your HTML fit that viewport. Use for layout/CSS mockups, tables, diffs. For a hosted image use `image` instead."
|
|
17
|
+
).optional(),
|
|
18
|
+
image: z.string().url().describe(
|
|
19
|
+
"Optional image URL rendered as the option's preview (plain image, not sandboxed). For agent-generated HTML/CSS mockups, use `html` instead."
|
|
20
|
+
).optional()
|
|
21
|
+
});
|
|
22
|
+
var OptionInputSchema = OptionSchema.omit({ id: true }).extend({
|
|
23
|
+
label: z.string().trim().min(1).max(1e3)
|
|
24
|
+
}).strict();
|
|
8
25
|
var NIGHT = { from: 23, to: 7 };
|
|
9
26
|
function draft2020(node) {
|
|
10
27
|
if (Array.isArray(node)) return node.map(draft2020);
|
|
@@ -35,39 +52,30 @@ function mcpInputSchema(s) {
|
|
|
35
52
|
delete schema.$schema;
|
|
36
53
|
return draft2020(schema);
|
|
37
54
|
}
|
|
38
|
-
var AskInputSchema =
|
|
39
|
-
id:
|
|
40
|
-
parentId:
|
|
41
|
-
repo:
|
|
42
|
-
ask:
|
|
43
|
-
"
|
|
55
|
+
var AskInputSchema = z2.object({
|
|
56
|
+
id: z2.string().optional().describe("Optional idempotency key or client-side ID for this specific ask."),
|
|
57
|
+
parentId: z2.string().uuid().optional().describe("The Goal this question is about \u2014 usually the one you are working on. The question goes onto that Goal and its answer comes back there. Omit it and Paigy places the question in the tree itself."),
|
|
58
|
+
repo: z2.string().optional().describe("Optional repository context."),
|
|
59
|
+
ask: z2.string().trim().min(1).max(1e4).describe(
|
|
60
|
+
"ONE question, and only what is needed to answer it. Several questions are several asks in the array, one each: an answer settles the one ask it was given in the shape that ask declares, so a person who answers the part of a bundled ask that interested them settles nothing and is asked again. Paigy reads every ask once and takes a bundled one apart into its own cards anyway \u2014 parts of one piece of work under one Goal, separate things as separate Goals \u2014 but the words it splits are its reading, not yours. News, progress and findings are their own contact; a call contact JOINS a call already happening, so several arrive as one call."
|
|
44
61
|
),
|
|
45
|
-
options:
|
|
46
|
-
label: z.string().trim().min(1).max(1e3),
|
|
47
|
-
hint: z.string().trim().max(500).describe("Optional short projection of consequence or action if this option is chosen.").optional(),
|
|
48
|
-
image: z.string().url().optional(),
|
|
49
|
-
html: z.string().max(16384).describe("Optional sandboxed HTML/CSS preview. No JavaScript or network; inline CSS and data: URIs only. It renders edge-to-edge in a responsive card 200pt tall (about 320pt wide on a phone, with the next option peeking beside it), so fit the HTML to that viewport.").optional()
|
|
50
|
-
}).strict()).min(2).max(6).optional()
|
|
62
|
+
options: z2.array(OptionInputSchema).min(2).max(6).optional()
|
|
51
63
|
}).strict();
|
|
52
|
-
var StartContactSchema =
|
|
53
|
-
asks:
|
|
54
|
-
waiting:
|
|
55
|
-
channel:
|
|
64
|
+
var StartContactSchema = z2.object({
|
|
65
|
+
asks: z2.array(AskInputSchema).min(1).describe("The questions to pose, one per object. An ask with a parentId is filed on that Goal as it is; only an ask naming no Goal is placed in the tree by Paigy."),
|
|
66
|
+
waiting: z2.enum(["none", "hard"]).default("none"),
|
|
67
|
+
channel: z2.enum(["notification", "call"]).default("notification")
|
|
56
68
|
}).strict();
|
|
57
|
-
var ContactSchema =
|
|
69
|
+
var ContactSchema = z2.union([StartContactSchema, z2.object({ deliveryId: z2.string().uuid() }).strict()]);
|
|
58
70
|
var CONTACT_SCHEMA = { type: "object", ...mcpInputSchema(ContactSchema) };
|
|
59
|
-
var CONTACT_DESCRIPTION = "Contact the user with one or more asks/questions. Pass an array of asks (each with 'ask', optional 'options', 'id', 'parentId', 'repo'), plus channel:'notification'|'call', and waiting:'none'|'hard'. An ask that names a Goal (parentId) goes onto that Goal; one that names none is placed in the person's tree by Paigy. Notification returns immediately; collect durable answers with check_replies. On stdio, a Call holds one cancellable ~45s window; continue with ONLY {deliveryId}.
|
|
60
|
-
var CreateGoalSchema =
|
|
61
|
-
outcome:
|
|
71
|
+
var CONTACT_DESCRIPTION = "Contact the user with one or more asks/questions. Pass an array of asks (each with 'ask', optional 'options', 'id', 'parentId', 'repo'), plus channel:'notification'|'call', and waiting:'none'|'hard'. An ask that names a Goal (parentId) goes onto that Goal; one that names none is placed in the person's tree by Paigy. Notification returns immediately; collect durable answers with check_replies. On stdio, a Call holds one cancellable ~45s window; continue with ONLY {deliveryId}. One question per 'ask'; several questions are several objects in 'asks'. A bundled ask is taken apart into its own cards by Paigy's one intake read \u2014 parts of one piece of work under one Goal, separate things as separate Goals.";
|
|
72
|
+
var CreateGoalSchema = z3.object({
|
|
73
|
+
outcome: z3.string().trim().min(1).max(1e4),
|
|
62
74
|
/** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
|
|
63
75
|
* rings"). Omit it and the brain writes one at admission from the outcome. */
|
|
64
|
-
title:
|
|
65
|
-
ownerParticipant:
|
|
66
|
-
idempotencyKey:
|
|
67
|
-
/** A past conversation this Goal should be read against — History's "new session from this"
|
|
68
|
-
* (owner, on the call of 2026-09-14: "let's do the reference with the threading"). A
|
|
69
|
-
* reference only: the owner reads it through `get_thread`, which does its own scoping, and
|
|
70
|
-
* the writer refuses a thread belonging to another account. */
|
|
76
|
+
title: z3.string().trim().min(1).max(80).optional(),
|
|
77
|
+
ownerParticipant: z3.string().trim().min(1).optional(),
|
|
78
|
+
idempotencyKey: z3.string().trim().min(1).max(200),
|
|
71
79
|
/** THE GOAL THIS ONE BELONGS UNDER (owner, 2026-09-15: "the ask I gave for the design doc
|
|
72
80
|
* didn't get created as a child goal of the voice UI goal, which is how it should've
|
|
73
81
|
* worked"). It could not have been: this door took no parent, so the only route was
|
|
@@ -75,55 +83,45 @@ var CreateGoalSchema = z2.object({
|
|
|
75
83
|
* took the short one. The hierarchy has been modelled since Goals existed and had been used
|
|
76
84
|
* ZERO times in 2,031 of them. Absent, the server judges it against the caller's open Goals
|
|
77
85
|
* (`apps/api/src/goal/intake.ts`). The writer refuses a Goal belonging to another account. */
|
|
78
|
-
parentGoalId:
|
|
86
|
+
parentGoalId: z3.string().uuid().optional(),
|
|
79
87
|
/** The repository or project identifier this Goal belongs to (#2280) — e.g. "owner/repo" or
|
|
80
88
|
* repo name. Delegated work inherits this from its parent Goal when omitted. */
|
|
81
|
-
repo:
|
|
89
|
+
repo: z3.string().trim().min(1).max(200).optional()
|
|
82
90
|
});
|
|
83
91
|
var CreateGoalToolSchema = CreateGoalSchema.extend({
|
|
84
92
|
idempotencyKey: CreateGoalSchema.shape.idempotencyKey.optional().describe("Optional. One is minted per call; pass your own only so a retry lands on the same Goal.")
|
|
85
93
|
}).strict();
|
|
86
94
|
var CREATE_GOAL_DESCRIPTION = 'Create a durable Goal for an outcome. Without parentGoalId it is placed against your open Goals: if one already IS this work, that Goal comes back (existing: true) and nothing new is created \u2014 continue it; if the work belongs under one, it is created there (parentGoalId in the receipt); otherwise it is a root. Pass parentGoalId yourself to put it under a specific Goal. Pass repo to anchor the work to a specific repository ("owner/repo" or repo name); delegated children inherit it. Pass title to name it in one to five words, as a person would refer to it out loud ("the night rings") \u2014 it heads every list and is spoken on a call; without one the brain writes it. Admission only: the owner must claim it before doing work, then update it as it advances. Returns an admission receipt with goalId, current state, revision, ownerParticipant, and the next step; no Goal content or execution lease.';
|
|
87
|
-
var UpdateGoalSchema =
|
|
88
|
-
revision:
|
|
89
|
-
changes:
|
|
90
|
-
outcome:
|
|
95
|
+
var UpdateGoalSchema = z3.object({
|
|
96
|
+
revision: z3.number().int().positive(),
|
|
97
|
+
changes: z3.object({
|
|
98
|
+
outcome: z3.string().trim().min(1).max(1e4).optional(),
|
|
91
99
|
/** The work's NAME (#2115) — one to five words, how a person refers to it out loud. The
|
|
92
100
|
* brain writes one at admission; this is the owner saying it better. Null clears it. */
|
|
93
|
-
title:
|
|
94
|
-
ownerParticipant:
|
|
95
|
-
parentGoalId:
|
|
96
|
-
dependencies:
|
|
97
|
-
children:
|
|
98
|
-
state:
|
|
99
|
-
progress:
|
|
100
|
-
reviewed:
|
|
101
|
-
dueAt:
|
|
101
|
+
title: z3.string().trim().min(1).max(80).nullable().optional(),
|
|
102
|
+
ownerParticipant: z3.string().trim().min(1).optional(),
|
|
103
|
+
parentGoalId: z3.string().uuid().nullable().optional(),
|
|
104
|
+
dependencies: z3.array(z3.object({ goalId: z3.string().uuid(), gate: z3.enum(["start", "finish"]) }).strict()).optional(),
|
|
105
|
+
children: z3.array(z3.object({ outcome: z3.string().trim().min(1).max(1e4), ownerParticipant: z3.string().trim().min(1), gate: z3.enum(["start", "finish"]).optional() }).strict()).optional(),
|
|
106
|
+
state: z3.enum(["active", "done", "cancelled"]).optional(),
|
|
107
|
+
progress: z3.string().trim().min(1).max(1e4).optional(),
|
|
108
|
+
reviewed: z3.literal(true).optional(),
|
|
109
|
+
dueAt: z3.string().datetime({ offset: true }).nullable().optional()
|
|
102
110
|
}).strict().refine((v) => Object.keys(v).length > 0),
|
|
103
|
-
reason:
|
|
104
|
-
operationId:
|
|
111
|
+
reason: z3.string().trim().min(1).max(2e3),
|
|
112
|
+
operationId: z3.string().uuid().optional()
|
|
105
113
|
}).strict();
|
|
106
|
-
var UpdateGoalToolSchema = UpdateGoalSchema.omit({ operationId: true }).extend({ goalId:
|
|
107
|
-
var ClaimGoalSchema =
|
|
108
|
-
var GetGoalSchema =
|
|
114
|
+
var UpdateGoalToolSchema = UpdateGoalSchema.omit({ operationId: true }).extend({ goalId: z3.string().uuid() }).strict();
|
|
115
|
+
var ClaimGoalSchema = z3.object({ goalId: z3.string().uuid().optional() }).strict();
|
|
116
|
+
var GetGoalSchema = z3.object({ goalId: z3.string().uuid() }).strict();
|
|
109
117
|
var GET_GOAL_DESCRIPTION = "Read one Goal without claiming it: its outcome, state, revision, progress, blockers, the conversation on it (each question with its options and what was decided), and `next`, the one step to take. Foreign or sibling-owned Goals are not disclosed.";
|
|
110
118
|
var UPDATE_GOAL_DESCRIPTION = "Update an owned Goal at an exact revision. State, ownership, dependencies, children, progress, title, and review acknowledgement are explicit; stale revisions are rejected. title is the work's name in one to five words, as a person would refer to it out loud (it is spoken on a call and heads every list); null clears it. reviewed: true acknowledges new evidence and closes the Deliveries addressed to you on that Goal, never over an open decision. dueAt (an ISO instant, or null) makes the Goal wait until then; when it passes you are woken for it \u2014 use it for a promise to follow up later. Returns the Goal as get_goal reads it, at its new revision.";
|
|
111
|
-
var CLAIM_GOAL_DESCRIPTION = "Claim the oldest runnable or review-pending Goal you own, or pass goalId to claim that Goal. Returns the Goal as get_goal reads it, and creates or renews the execution lease.";
|
|
119
|
+
var CLAIM_GOAL_DESCRIPTION = "Claim the oldest runnable or review-pending Goal you own, or pass goalId to claim that Goal. Another agent's Goal that has gone quiet for 3 days (check_replies lists them as stalledOthers) is taken over when you claim it, and becomes yours to finish or cancel. Returns the Goal as get_goal reads it, and creates or renews the execution lease.";
|
|
112
120
|
var CHECK_REPLIES_DESCRIPTION = "Your open Deliveries: every Notification or Call currently addressed to you \u2014 a request the user started toward you, an answer relayed to something you asked, a handoff \u2014 one row each: its Goals, how many decisions are still open, and the newest words in brief. A pure read with no arguments: nothing is consumed, acknowledged or claimed by reading it, so call it on startup, after a long wait, or whenever you want to know what is outstanding. To act on one, claim its Goal (claim_goal) or reread it in full with contact({deliveryId}). Once you have acted on what arrived, update_goal with reviewed: true closes the Deliveries addressed to you on that Goal. Your runnable and review-pending Goals come from claim_goal, not from here.";
|
|
113
|
-
var CheckRepliesSchema =
|
|
114
|
-
var GetThreadSchema = z2.object({
|
|
115
|
-
parentId: z2.string().describe("The Thread to read \u2014 the parentId of a search_threads hit.")
|
|
116
|
-
}).strict();
|
|
117
|
-
var GET_THREAD_DESCRIPTION = "Read the authorized durable Entries on one conversation Thread \u2014 what you wrote there and what was delivered to you, oldest first. Use claim_goal to find the work to resume and get_goal for the conversation on a Goal; use this only to rehydrate a Thread that a search hit named.";
|
|
118
|
-
var SearchThreadsSchema = z2.object({
|
|
119
|
-
q: z2.string().describe("What to look for \u2014 plain words or a phrase (e.g. 'the livekit timeout', 'deploy to prod').")
|
|
120
|
-
}).strict();
|
|
121
|
-
var SEARCH_THREADS_DESCRIPTION = `Search your PAST conversations before asking \u2014 "have we discussed this before?". Full-text over your own threads (the asks you sent + the user's answers); returns ranked threads with highlighted snippets, NOT rows: { hits: [{ parentId, at, agentLabel, matches: [{ notificationId, role, snippet }] }] }. The loop this exists for: search first \u2192 get_thread the best hit to rehydrate it \u2192 THEN continue or contact, so you answer with receipts ("last week you said ship it") instead of re-asking. Read-only, safe to call anytime; scoped to your own account's threads.`;
|
|
121
|
+
var CheckRepliesSchema = z3.object({}).strict();
|
|
122
122
|
var AGENT_TOOLS = [
|
|
123
123
|
{ name: "contact", description: CONTACT_DESCRIPTION, inputSchema: CONTACT_SCHEMA },
|
|
124
124
|
{ name: "check_replies", description: CHECK_REPLIES_DESCRIPTION, inputSchema: mcpInputSchema(CheckRepliesSchema) },
|
|
125
|
-
{ name: "get_thread", description: GET_THREAD_DESCRIPTION, inputSchema: mcpInputSchema(GetThreadSchema) },
|
|
126
|
-
{ name: "search_threads", description: SEARCH_THREADS_DESCRIPTION, inputSchema: mcpInputSchema(SearchThreadsSchema) },
|
|
127
125
|
{ name: "create_goal", description: CREATE_GOAL_DESCRIPTION, inputSchema: mcpInputSchema(CreateGoalToolSchema) },
|
|
128
126
|
{ name: "claim_goal", description: CLAIM_GOAL_DESCRIPTION, inputSchema: mcpInputSchema(ClaimGoalSchema) },
|
|
129
127
|
{ name: "get_goal", description: GET_GOAL_DESCRIPTION, inputSchema: mcpInputSchema(GetGoalSchema) },
|
|
@@ -156,17 +154,17 @@ function entryWords(entry) {
|
|
|
156
154
|
return entry.sources.map((source) => source.text).join("\n");
|
|
157
155
|
}
|
|
158
156
|
var LIVE_MS = 3 * 6e4;
|
|
159
|
-
var ContextSchema =
|
|
160
|
-
title:
|
|
161
|
-
description:
|
|
157
|
+
var ContextSchema = z4.object({
|
|
158
|
+
title: z4.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
|
|
159
|
+
description: z4.array(z4.string().min(1)).describe(
|
|
162
160
|
"Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand. MAY BE EMPTY: a claim whose whole content is its heading \u2014 a single sentence \u2014 has no body, and saying so beats repeating the heading underneath itself. That repeat is what `min(1)` used to force, at 2x the storage, with every reader subtracting it back out at render time."
|
|
163
161
|
)
|
|
164
162
|
});
|
|
165
|
-
var ParticipantSchema =
|
|
166
|
-
kind:
|
|
167
|
-
id:
|
|
163
|
+
var ParticipantSchema = z4.object({
|
|
164
|
+
kind: z4.enum(["human", "agent"]),
|
|
165
|
+
id: z4.string()
|
|
168
166
|
});
|
|
169
|
-
var TransformSchema =
|
|
167
|
+
var TransformSchema = z4.enum([
|
|
170
168
|
"structure",
|
|
171
169
|
// shape intent into an answer contract; pick channel/urgency — broker `ask`, `select` shapes, `points`
|
|
172
170
|
"request_more",
|
|
@@ -182,26 +180,13 @@ var TransformSchema = z3.enum([
|
|
|
182
180
|
"summarize"
|
|
183
181
|
// reduce volume, keep decision value — 30-turn cap, spoken briefing
|
|
184
182
|
]);
|
|
185
|
-
var
|
|
186
|
-
|
|
187
|
-
label:
|
|
188
|
-
hint: z3.string().max(500).describe("Optional short projection of consequence or action if this option is chosen (e.g. 'Reruns test suite', 'Merges to main').").optional(),
|
|
189
|
-
// .describe() flows into the MCP contact JSON schema (zodToJsonSchema), so
|
|
190
|
-
// the constraints below are what an agent reads when deciding to use these.
|
|
191
|
-
html: z3.string().max(16384).describe(
|
|
192
|
-
"Optional sandboxed HTML/CSS preview for a visual 'pick one' (shown in the option card). Untrusted-sandboxed: NO JavaScript, NO external network or images \u2014 inline CSS and data: URIs only; <=16KB. Rendered edge-to-edge in a responsive card that is 200pt tall (about 320pt wide on a phone, with the next option peeking beside it); make your HTML fit that viewport. Use for layout/CSS mockups, tables, diffs. For a hosted image use `image` instead."
|
|
193
|
-
).optional(),
|
|
194
|
-
image: z3.string().url().describe(
|
|
195
|
-
"Optional image URL rendered as the option's preview (plain image, not sandboxed). For agent-generated HTML/CSS mockups, use `html` instead."
|
|
196
|
-
).optional()
|
|
197
|
-
});
|
|
198
|
-
var VisualSchema = z3.object({
|
|
199
|
-
url: z3.string().url(),
|
|
200
|
-
label: z3.string().optional()
|
|
183
|
+
var VisualSchema = z4.object({
|
|
184
|
+
url: z4.string().url(),
|
|
185
|
+
label: z4.string().optional()
|
|
201
186
|
});
|
|
202
|
-
var NotifyLevelSchema =
|
|
203
|
-
var SelectShapeSchema =
|
|
204
|
-
var ReceiptEventSchema =
|
|
187
|
+
var NotifyLevelSchema = z4.enum(["inbox", "push", "banner", "call"]);
|
|
188
|
+
var SelectShapeSchema = z4.enum(["one", "many", "rank", "confirm", "text"]);
|
|
189
|
+
var ReceiptEventSchema = z4.enum([
|
|
205
190
|
"delivered",
|
|
206
191
|
// the bundle reached the recipient at some level
|
|
207
192
|
"seen",
|
|
@@ -231,47 +216,47 @@ var ReceiptEventSchema = z3.enum([
|
|
|
231
216
|
// be rewound by a writer that forgot to advance it.
|
|
232
217
|
"restarted"
|
|
233
218
|
]);
|
|
234
|
-
var AttentionSchema =
|
|
219
|
+
var AttentionSchema = z4.object({
|
|
235
220
|
urgency: NotifyLevelSchema,
|
|
236
221
|
/** The required answer shape, or null for a plain notify that asks nothing back. */
|
|
237
222
|
select: SelectShapeSchema.nullable(),
|
|
238
223
|
/** Coverage contract (#396) — points the answer must address; null = none declared. */
|
|
239
|
-
points:
|
|
224
|
+
points: z4.array(z4.string()).nullable(),
|
|
240
225
|
/** Whether the ask blocks the sender — what lets arbitration escalate it on silence. */
|
|
241
|
-
blocking:
|
|
226
|
+
blocking: z4.boolean(),
|
|
242
227
|
/** Reserved (MODEL.md lists it): a response deadline. No row column yet — a later Phase 2
|
|
243
228
|
* slice wires it; optional so today's rows/callers project cleanly. */
|
|
244
|
-
deadline:
|
|
229
|
+
deadline: z4.string().datetime().nullable().optional()
|
|
245
230
|
});
|
|
246
|
-
var NotifyRequestFields =
|
|
231
|
+
var NotifyRequestFields = z4.object({
|
|
247
232
|
/** Plaintext message content. Present on the plaintext path (today's shape);
|
|
248
233
|
* ABSENT on the E2EE path, where the sealed `envelope` below carries it. The
|
|
249
234
|
* superRefine at the bottom enforces exactly one of the two. */
|
|
250
235
|
context: ContextSchema.optional(),
|
|
251
|
-
options:
|
|
236
|
+
options: z4.array(OptionInputSchema).min(OPTIONS_MIN).max(OPTIONS_MAX).optional().describe(
|
|
252
237
|
"The choices, in order \u2014 required when select is 'one'/'many'/'rank', omitted otherwise. Ids are assigned automatically by position ('1', '2', \u2026); the user's answer references them as optionId(s)."
|
|
253
238
|
),
|
|
254
|
-
points:
|
|
239
|
+
points: z4.array(z4.string().min(1)).optional().describe(
|
|
255
240
|
"The distinct things you need answered, each a short phrase \u2014 on a call the broker keeps the conversation going until each is addressed, and the reply reports which were covered, so a half-answer is never silently returned as final. Omit for single-part asks."
|
|
256
241
|
),
|
|
257
|
-
visuals:
|
|
242
|
+
visuals: z4.array(VisualSchema).optional().describe(
|
|
258
243
|
"Images attached to the message itself \u2014 context for the whole question (a screenshot, a chart). For a preview on one selectable choice, use that option's `html`/`image` instead."
|
|
259
244
|
),
|
|
260
245
|
/** Git repo the agent is working in ("owner/name"). Local MCP fills this from the checkout — omit unless overriding. */
|
|
261
|
-
repo:
|
|
246
|
+
repo: z4.string().optional(),
|
|
262
247
|
/** Git branch the agent is on. Local MCP fills this from the checkout — omit unless overriding. */
|
|
263
|
-
branch:
|
|
248
|
+
branch: z4.string().optional(),
|
|
264
249
|
/** Continue an existing conversation — the id of any notification in it (its root
|
|
265
250
|
* is the conversation's identity). Omitted = start a new conversation. Renamed
|
|
266
251
|
* from `parentId` (2026-08-03): one linkage system, the parent; the API edge
|
|
267
252
|
* still accepts the old name from older clients. */
|
|
268
|
-
parentId:
|
|
253
|
+
parentId: z4.string().uuid().optional(),
|
|
269
254
|
/** The durable outcome this contact advances. Optional during the notification-to-Work
|
|
270
255
|
* migration; when present, a blocking ask creates a DecisionNeed for this Work. */
|
|
271
|
-
workId:
|
|
256
|
+
workId: z4.string().uuid().optional(),
|
|
272
257
|
/** Target Goal scope. During staged migration this is accepted by the shared contract but
|
|
273
258
|
* target delivery activation remains model-gated; workId and goalId are mutually exclusive. */
|
|
274
|
-
goalId:
|
|
259
|
+
goalId: z4.string().uuid().optional(),
|
|
275
260
|
urgency: NotifyLevelSchema.default("inbox").describe(
|
|
276
261
|
"The level you're requesting \u2014 the user's account permissions + session mode can lower it. 'inbox' (default) = sits silently in the inbox for the user to get to. 'push' = a quiet passive push (lands in Notification Center, no sound) \u2014 a gentle heads-up. 'banner' = a time-sensitive banner/lock-screen push with sound (a 'paige') they tap to open \u2014 use when you need them soon-ish but it's not worth ringing them. 'call' = rings the user's phone now (a CallKit voice call) \u2014 use only when you genuinely need them in the moment (blocked and waiting, time-sensitive). context.title is what they see on the banner/ring, so make it specific."
|
|
277
262
|
),
|
|
@@ -279,7 +264,7 @@ var NotifyRequestFields = z3.object({
|
|
|
279
264
|
* visible and marks it needs_input. Renamed from the old `parentId` (2026-08-03)
|
|
280
265
|
* when `parentId` became the conversation handle: `parentId` says WHERE, this
|
|
281
266
|
* says HOW. */
|
|
282
|
-
clarifies:
|
|
267
|
+
clarifies: z4.string().optional(),
|
|
283
268
|
select: SelectShapeSchema.optional().describe(
|
|
284
269
|
"How the user answers \u2014 required on the fully-shaped form, pick the shape that fits the question: 'one' = pick one option, 'many' = pick several, 'rank' = pick & order (each needs `options`); 'confirm' = yes/no or approve/deny; 'text' = free-form reply only (status updates, open questions). 'confirm' and 'text' take no options. Omit only when sending the simplified `ask` form \u2014 the broker picks the shape."
|
|
285
270
|
),
|
|
@@ -293,20 +278,20 @@ var NotifyRequestFields = z3.object({
|
|
|
293
278
|
// (broker/agenda-design.md) — not by a wire cap the agent has to pre-summarize under.
|
|
294
279
|
// Owner, 2026-07-28: "our actual limitation on how long something is to the user should
|
|
295
280
|
// come from the broker splitting and summarizing." The cap that remains is a size guard.
|
|
296
|
-
ask:
|
|
281
|
+
ask: z4.string().min(1).max(1e4).optional().describe(
|
|
297
282
|
'SIMPLIFIED FORM \u2014 state in plain prose what you need to learn from the user and why it matters now (e.g. "I need to know whether to deploy the auth fix \u2014 tests are green, staging verified"). Write as much as the situation needs (up to 10k characters) \u2014 Paigy breaks it into topics and reads it back a few sentences at a time; do NOT pre-summarize it into one line. Paigy derives the title, answer shape, options, and delivery channel for you. Mutually exclusive with context/select/options \u2014 send one form or the other.'
|
|
298
283
|
),
|
|
299
|
-
needs:
|
|
284
|
+
needs: z4.array(z4.string().min(1)).optional().describe(
|
|
300
285
|
"With `ask` only: the distinct things you need answered when the ask is multi-part \u2014 becomes the coverage contract (`points`), so a half-answer is never silently final."
|
|
301
286
|
),
|
|
302
|
-
urgencyHint:
|
|
287
|
+
urgencyHint: z4.enum(["whenever", "soon", "now"]).optional().describe(
|
|
303
288
|
"With `ask` only: how urgently you need the answer \u2014 'whenever' (inbox), 'soon' (worth a heads-up), 'now' (you're blocked this minute). A hint, not a command: the user's settings still have the final word."
|
|
304
289
|
),
|
|
305
290
|
/** #575: the ONE self-report that replaces urgencyHint + blocking — what happens
|
|
306
291
|
* to the agent's work while it waits. Normalized server-side into those two
|
|
307
292
|
* fields (normalizeWaiting) so everything downstream is untouched; explicit
|
|
308
293
|
* urgencyHint/blocking win when both are sent. */
|
|
309
|
-
waiting:
|
|
294
|
+
waiting: z4.enum(["none", "soft", "hard"]).optional().describe(
|
|
310
295
|
"With `ask`: what happens to your work while you wait. 'none' = you're just informing the user. 'soft' = you'd like an answer but can keep working. 'hard' = you are stopped until they answer (reaches them urgently and escalates to a real phone call if unanswered). Replaces urgencyHint + blocking \u2014 send this one field."
|
|
311
296
|
),
|
|
312
297
|
/** Δ9b (#895): HOLD this claim so the sender can correct the plan before anyone is
|
|
@@ -314,98 +299,98 @@ var NotifyRequestFields = z3.object({
|
|
|
314
299
|
* holding by default would charge every quiet claim that minute before any agent could
|
|
315
300
|
* correct anything. Ignored for `waiting: 'hard'`: a blocking ask rings on what we have,
|
|
316
301
|
* and the enrichment can still land mid-call (#781 re-plans the unspoken tail). */
|
|
317
|
-
confirm:
|
|
302
|
+
confirm: z4.boolean().optional().describe(
|
|
318
303
|
"Hold this one so you can correct the plan before the user is interrupted. The response comes back with `held: true` and the plan; POST the confirm route to release it (with options/visuals/urgency corrections, or nothing at all). If you never do, it is announced anyway a couple of minutes later. Ignored when waiting is 'hard'."
|
|
319
304
|
),
|
|
320
305
|
/** #575: a RELAY of the user's explicitly stated preference, never the agent's
|
|
321
306
|
* choice. Outranks waiting in both directions: 'call' rings even for a
|
|
322
307
|
* waiting:'none' "call me when it's done"; 'message' never rings even for
|
|
323
308
|
* waiting:'hard'. */
|
|
324
|
-
channel:
|
|
309
|
+
channel: z4.enum(["call", "message"]).optional().describe(
|
|
325
310
|
"Only if the user explicitly said how to reach them \u2014 'call me' \u2192 'call', 'just message/text me' \u2192 'message'. Omit otherwise; Paigy picks."
|
|
326
311
|
),
|
|
327
|
-
confirmStyle:
|
|
312
|
+
confirmStyle: z4.enum(["yesno", "approve"]).default("yesno").describe(
|
|
328
313
|
"Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
|
|
329
314
|
),
|
|
330
|
-
blocking:
|
|
315
|
+
blocking: z4.boolean().default(false).describe(
|
|
331
316
|
"Set true when real downstream work is stuck behind this specific decision \u2014 you can't make meaningful progress until it's answered. This is the real signal for how urgently the user should be reached; it's what the premier use case (an agent that stays unblocked instead of going idle) depends on. Independent of `urgency`: a `banner`-level question can still be `blocking` (something IS stuck, just not time-critical enough to ring for immediately) \u2014 if it goes unanswered a while, Paigy escalates it to a real call using this flag rather than guessing from how many other things happen to be pending. Leave false for anything you could work around, defer, or where other useful work exists meanwhile."
|
|
332
317
|
)
|
|
333
318
|
});
|
|
334
319
|
var NotifyRequestSchema = NotifyRequestFields.superRefine((r, ctx) => {
|
|
335
|
-
if (r.workId && r.goalId) ctx.addIssue({ code:
|
|
320
|
+
if (r.workId && r.goalId) ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["goalId"], message: "pass goalId or workId, not both" });
|
|
336
321
|
if (r.ask !== void 0) {
|
|
337
322
|
for (const f of ["context", "select", "points"]) {
|
|
338
323
|
if (r[f] !== void 0)
|
|
339
|
-
ctx.addIssue({ code:
|
|
324
|
+
ctx.addIssue({ code: z4.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives the answer shape from your prose. Drop ${f} and say it in \`ask\` instead ("should I\u2026" for approve/deny, "which of these\u2026" for a pick), passing \`options\` when you're offering concrete alternatives.` });
|
|
340
325
|
}
|
|
341
326
|
return;
|
|
342
327
|
}
|
|
343
328
|
if (r.needs !== void 0 || r.urgencyHint !== void 0)
|
|
344
|
-
ctx.addIssue({ code:
|
|
329
|
+
ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["needs"], message: "needs/urgencyHint belong to the simplified `ask` form \u2014 with a shaped request use points/urgency" });
|
|
345
330
|
if (!r.context)
|
|
346
|
-
ctx.addIssue({ code:
|
|
331
|
+
ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["context"], message: "context is required (plaintext path)" });
|
|
347
332
|
if (!r.select)
|
|
348
|
-
ctx.addIssue({ code:
|
|
333
|
+
ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["select"], message: "select is required on the shaped form" });
|
|
349
334
|
const needsOptions = r.select === "one" || r.select === "many" || r.select === "rank";
|
|
350
335
|
if (needsOptions && !r.options?.length)
|
|
351
|
-
ctx.addIssue({ code:
|
|
336
|
+
ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' requires options` });
|
|
352
337
|
if (!needsOptions && r.options?.length)
|
|
353
|
-
ctx.addIssue({ code:
|
|
338
|
+
ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' takes no options` });
|
|
354
339
|
});
|
|
355
|
-
var NotifyStatusSchema =
|
|
356
|
-
var AgentStateSchema =
|
|
357
|
-
var TurnSchema =
|
|
358
|
-
prompt:
|
|
359
|
-
reply:
|
|
340
|
+
var NotifyStatusSchema = z4.enum(["pending", "answered", "ignored"]);
|
|
341
|
+
var AgentStateSchema = z4.enum(["idle", "in_progress", "completed", "needs_input"]);
|
|
342
|
+
var TurnSchema = z4.object({
|
|
343
|
+
prompt: z4.string(),
|
|
344
|
+
reply: z4.string()
|
|
360
345
|
});
|
|
361
|
-
var UserAnswerSchema =
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
346
|
+
var UserAnswerSchema = z4.discriminatedUnion("kind", [
|
|
347
|
+
z4.object({ kind: z4.literal("option"), optionId: z4.string(), label: z4.string().optional() }),
|
|
348
|
+
z4.object({ kind: z4.literal("text"), text: z4.string() }),
|
|
349
|
+
z4.object({ kind: z4.literal("ignored") }),
|
|
350
|
+
z4.object({ kind: z4.literal("multi"), optionIds: z4.array(z4.string()), labels: z4.array(z4.string()).optional() }),
|
|
351
|
+
z4.object({ kind: z4.literal("ranked"), optionIds: z4.array(z4.string()), labels: z4.array(z4.string()).optional() }),
|
|
352
|
+
z4.object({ kind: z4.literal("clarify"), chunks: z4.array(z4.string()).min(1) }),
|
|
353
|
+
z4.object({ kind: z4.literal("confirm"), approved: z4.boolean() }),
|
|
354
|
+
z4.object({ kind: z4.literal("turns"), turns: z4.array(TurnSchema).min(1) }),
|
|
370
355
|
/** An auto-answer derived from the user's PAST decisions (broker/precedent-design.md §2):
|
|
371
356
|
* delivered through the same settle/await path as a human answer, carrying the judge's
|
|
372
357
|
* derivation and the precedent ids it grew from. Always paired with a visible trail
|
|
373
358
|
* card the user can reply to — the broker never overrides the user. */
|
|
374
|
-
|
|
359
|
+
z4.object({ kind: z4.literal("precedent"), answer: z4.string(), derivation: z4.string(), sources: z4.array(z4.string()).min(1) })
|
|
375
360
|
]);
|
|
376
|
-
var IntentSchema =
|
|
361
|
+
var IntentSchema = z4.object({
|
|
377
362
|
// The full vocabulary the bot's mapper emits (mapper.INTENT_KINDS) — the schema lagged
|
|
378
363
|
// it by two ("detail", "feedback"), and because the settle handler parsed the array
|
|
379
364
|
// all-or-nothing, ONE feedback act silently dropped EVERY intent on the call,
|
|
380
365
|
// questions included. Found auditing five calls' stored feedback, 2026-08-01.
|
|
381
|
-
kind:
|
|
382
|
-
detail:
|
|
366
|
+
kind: z4.enum(["defer", "delegate", "channel", "question", "detail", "feedback", "command", "control"]),
|
|
367
|
+
detail: z4.string(),
|
|
383
368
|
/** Defer only: seconds until the callback the caller asked for, when something upstream
|
|
384
369
|
* already read the time. Nothing sets it today (#397 documented an MCP parser that was
|
|
385
370
|
* never written) — the API reads the defer's `detail` itself with `notes/when.ts`
|
|
386
371
|
* (`parseDelay`, #1292), and a value here simply wins over that reading. */
|
|
387
|
-
dueInSeconds:
|
|
372
|
+
dueInSeconds: z4.number().int().positive().optional(),
|
|
388
373
|
/** Feedback only (#812): WHICH failure the complaint names — typed by the mapper that
|
|
389
374
|
* already read the utterance, so `feedback_from_call.kind` stops defaulting to
|
|
390
375
|
* 'other' on every row. A table that records that something was wrong and nothing
|
|
391
376
|
* about what cannot answer "is the bot looping less this week?". */
|
|
392
|
-
fault:
|
|
377
|
+
fault: z4.enum(["loop", "unanswered", "overridden", "misheard", "slow", "other"]).optional()
|
|
393
378
|
});
|
|
394
|
-
var RideAlongSchema =
|
|
379
|
+
var RideAlongSchema = z4.object({
|
|
395
380
|
/** The note this came from — assign/clarify/close it through /api/notes/:id. */
|
|
396
|
-
noteId:
|
|
381
|
+
noteId: z4.string(),
|
|
397
382
|
/** What to do, in the owner's own words (the note's headline). Never model-rewritten. */
|
|
398
|
-
text:
|
|
383
|
+
text: z4.string(),
|
|
399
384
|
/** The thread to report back on, when the note was dispatched over the request rail. */
|
|
400
|
-
parentId:
|
|
385
|
+
parentId: z4.string().nullable()
|
|
401
386
|
});
|
|
402
|
-
var AwaitItemSchema =
|
|
403
|
-
|
|
404
|
-
type:
|
|
405
|
-
parentId:
|
|
406
|
-
notificationId:
|
|
407
|
-
workId:
|
|
408
|
-
decisionId:
|
|
387
|
+
var AwaitItemSchema = z4.discriminatedUnion("type", [
|
|
388
|
+
z4.object({
|
|
389
|
+
type: z4.literal("reply"),
|
|
390
|
+
parentId: z4.string(),
|
|
391
|
+
notificationId: z4.string(),
|
|
392
|
+
workId: z4.string().uuid().optional(),
|
|
393
|
+
decisionId: z4.string().uuid().optional(),
|
|
409
394
|
answer: UserAnswerSchema,
|
|
410
395
|
/** WHAT THE AGENT CANNOT KNOW FROM THE FIELDS BESIDE IT (owner, 2026-09-04, issue
|
|
411
396
|
* #1537). One line, built from the record: the ask and the caller's reply VERBATIM,
|
|
@@ -415,161 +400,131 @@ var AwaitItemSchema = z3.discriminatedUnion("type", [
|
|
|
415
400
|
* "call me back after you merge" in their own words decides for itself what to do,
|
|
416
401
|
* and now knows exactly which call to make. Absent when either half is missing —
|
|
417
402
|
* a sentence with a hole in it is worse than no sentence. */
|
|
418
|
-
note:
|
|
403
|
+
note: z4.string().optional(),
|
|
419
404
|
/** The call record rendered for THIS agent (`voice/record-design.md`): the words the
|
|
420
405
|
* shaped answer was mapped from, filtered to its own claims. There is no second list
|
|
421
406
|
* of labels beside it — the acts went 2026-09-04 and `intents` went with them (owner,
|
|
422
407
|
* 2026-09-04): the agent reads the sentence and decides. */
|
|
423
|
-
transcript:
|
|
408
|
+
transcript: z4.string().optional(),
|
|
424
409
|
/** Coverage report (#396), when the ask declared `points`: which of them this
|
|
425
410
|
* answer addressed. Missing points = re-ask or proceed knowingly partial. */
|
|
426
|
-
covered:
|
|
411
|
+
covered: z4.array(z4.string()).optional(),
|
|
427
412
|
/** Ride-alongs (RideAlongSchema) — pending work for you, attached to the moment you
|
|
428
413
|
* became free. Only `reply` and `idle` carry it: those are the two outcomes that
|
|
429
414
|
* END a wait. `remind`, `superseded` and `turn` are mid-flight, and handing an
|
|
430
415
|
* agent a side-quest while it is still holding the line is how the main thing gets
|
|
431
416
|
* dropped. Absent/empty = nothing owed. */
|
|
432
|
-
also:
|
|
417
|
+
also: z4.array(RideAlongSchema).optional()
|
|
433
418
|
}),
|
|
434
|
-
|
|
435
|
-
type:
|
|
436
|
-
parentId:
|
|
437
|
-
notificationId:
|
|
438
|
-
remindAt:
|
|
419
|
+
z4.object({
|
|
420
|
+
type: z4.literal("remind"),
|
|
421
|
+
parentId: z4.string(),
|
|
422
|
+
notificationId: z4.string(),
|
|
423
|
+
remindAt: z4.string().datetime({ offset: true }),
|
|
439
424
|
/** Seconds until remindAt, server-computed — pass straight to ScheduleWakeup. */
|
|
440
|
-
remindInSeconds:
|
|
425
|
+
remindInSeconds: z4.number()
|
|
441
426
|
}),
|
|
442
427
|
/** The awaited ask was REPLACED by a newer notification on its thread (e.g. a
|
|
443
428
|
* post-feedback revision, #633) — the user will never answer this id. Stop
|
|
444
429
|
* awaiting it; the live ask is the thread's newest turn (await that one, or
|
|
445
|
-
* re-orient via
|
|
446
|
-
|
|
447
|
-
type:
|
|
448
|
-
parentId:
|
|
449
|
-
notificationId:
|
|
430
|
+
* re-orient via check_replies). */
|
|
431
|
+
z4.object({
|
|
432
|
+
type: z4.literal("superseded"),
|
|
433
|
+
parentId: z4.string(),
|
|
434
|
+
notificationId: z4.string()
|
|
450
435
|
}),
|
|
451
436
|
/** A LIVE call's turn, streamed as it lands (#783). PROVISIONAL: the user can still
|
|
452
437
|
* revise any of these until the final reply arrives — partial = intelligence,
|
|
453
438
|
* settled = authorization. Use it to PREPARE (fetch, draft, warm), never to act
|
|
454
439
|
* irreversibly. If `acts` carries a question aimed at you and you know the answer,
|
|
455
440
|
* contact on the same thread right away — the caller hears it on the same call. */
|
|
456
|
-
|
|
457
|
-
type:
|
|
458
|
-
notificationId:
|
|
459
|
-
inFlight:
|
|
460
|
-
turn:
|
|
461
|
-
idx:
|
|
462
|
-
prompt:
|
|
463
|
-
reply:
|
|
464
|
-
acts:
|
|
441
|
+
z4.object({
|
|
442
|
+
type: z4.literal("partial"),
|
|
443
|
+
notificationId: z4.string(),
|
|
444
|
+
inFlight: z4.literal(true),
|
|
445
|
+
turn: z4.object({
|
|
446
|
+
idx: z4.number(),
|
|
447
|
+
prompt: z4.string(),
|
|
448
|
+
reply: z4.string(),
|
|
449
|
+
acts: z4.array(IntentSchema).nullable().optional()
|
|
465
450
|
})
|
|
466
451
|
}),
|
|
467
|
-
|
|
468
|
-
type:
|
|
469
|
-
also:
|
|
452
|
+
z4.object({
|
|
453
|
+
type: z4.literal("idle"),
|
|
454
|
+
also: z4.array(RideAlongSchema).optional(),
|
|
470
455
|
/** Is a call live for this agent's user right now? The SDK polls the partial stream
|
|
471
456
|
* (#783) between idle ticks ONLY while this is not `false` — a partial can only exist
|
|
472
457
|
* during a live call, and polling for one on a banner/message was a wasted HTTP call +
|
|
473
458
|
* 3 queries on every idle tick of every waiting agent (~80% of all traffic at scale).
|
|
474
459
|
* Absent = an older API → the SDK keeps polling, exactly as before. */
|
|
475
|
-
inFlight:
|
|
460
|
+
inFlight: z4.boolean().optional()
|
|
476
461
|
})
|
|
477
462
|
]);
|
|
478
|
-
var VoiceKeySchema =
|
|
479
|
-
var AgendaTurnSchema =
|
|
463
|
+
var VoiceKeySchema = z4.enum(["rachel", "george", "jessica", "brian", "lily"]);
|
|
464
|
+
var AgendaTurnSchema = z4.object({
|
|
480
465
|
/** THE TURN'S IDENTITY (the first-sentence stream, 2026-09-09): the brain call that wrote
|
|
481
466
|
* it and its place in that reply — `<brainCallId>:<index>`, with `:p` on the first
|
|
482
467
|
* sentence a re-plan publishes ahead of the rest. A turn is spoken once, by this id: the
|
|
483
468
|
* completion of a streamed re-plan carries the published sentence again, and the walk
|
|
484
469
|
* drops what it already said by identity, never by the API's guess of what was polled.
|
|
485
470
|
* Absent on plans nothing streams (a ring plan, a floor). */
|
|
486
|
-
id:
|
|
471
|
+
id: z4.string().optional(),
|
|
487
472
|
/** Twin coverage (#1089): sibling claim ids this asking turn's answer ALSO settles —
|
|
488
473
|
* the planner declares duplicates instead of asking them twice. */
|
|
489
|
-
coveredIds:
|
|
474
|
+
coveredIds: z4.array(z4.string()).optional(),
|
|
490
475
|
/** At most three short spoken sentences. Capped because a turn is a breath: a 1031-char
|
|
491
476
|
* line went out on 2026-07-28 and the caller could not answer it at all. */
|
|
492
|
-
info:
|
|
493
|
-
question:
|
|
477
|
+
info: z4.array(z4.string().min(1)).max(3).default([]),
|
|
478
|
+
question: z4.string().min(1).nullable(),
|
|
494
479
|
/** True on the one turn carrying the agent's own declared question. */
|
|
495
|
-
asks:
|
|
480
|
+
asks: z4.boolean().optional(),
|
|
496
481
|
/** The claim this turn belongs to (#781) — the RETURN identity: answers route by it.
|
|
497
482
|
* Absent on a single-claim plan (the session's own claim) and on shared context turns,
|
|
498
483
|
* which route nothing. */
|
|
499
|
-
claimId:
|
|
484
|
+
claimId: z4.string().optional(),
|
|
500
485
|
/** The claim's voice key (#462) — the OUTBOUND identity, audible who-is-asking. */
|
|
501
|
-
voice:
|
|
486
|
+
voice: z4.string().optional(),
|
|
502
487
|
/** The claim's AGENT NAME (#838) — the spoken identity. A voice alone doesn't say
|
|
503
488
|
* whose request this is: an item that folded in from another agent arrived as a bare
|
|
504
489
|
* non-sequitur ("First real production sign-in is yours to make whenever you want.")
|
|
505
490
|
* and the owner answered "What?". The bot names the agent before its first turn. */
|
|
506
|
-
agent:
|
|
491
|
+
agent: z4.string().optional(),
|
|
507
492
|
/** The claim's agent by ID — the pairing's connection id (`notifications.token_id`), the
|
|
508
493
|
* same id a face is minted from. A name is not an identity: two pairings may be called
|
|
509
494
|
* "Claude", and a name cannot be joined on. The record's entries carry it (`agent_id`)
|
|
510
495
|
* so "who said that" survives the call, and it rides PER TURN because a coalesced call
|
|
511
496
|
* speaks for several agents — the turn is the only place that knows which. */
|
|
512
|
-
agentId:
|
|
497
|
+
agentId: z4.string().optional(),
|
|
513
498
|
select: SelectShapeSchema.optional(),
|
|
514
|
-
options:
|
|
499
|
+
options: z4.array(OptionSchema.omit({ id: true })).optional(),
|
|
515
500
|
/** Pacing (#826, owner 2026-08-03: "how fast we move through them ... are parameters"):
|
|
516
501
|
* seconds the floor stays open after this turn speaks. Absent = the bot's defaults
|
|
517
502
|
* (the beat for context, the answer window for asks). Clamped bot-side. */
|
|
518
|
-
pace:
|
|
503
|
+
pace: z4.number().positive().optional(),
|
|
519
504
|
/** Whether the walk WAITS for an answer before moving on. Absent = derived as today
|
|
520
505
|
* (a question blocks, context flows). blocking:false on a question = ask and move
|
|
521
506
|
* on, the claim stays pending; blocking:true on context = hold for a reply. */
|
|
522
|
-
blocking:
|
|
507
|
+
blocking: z4.boolean().optional()
|
|
523
508
|
});
|
|
524
509
|
var CLAIM_STALE_MS = 30 * 6e4;
|
|
525
|
-
var InboxItemSchema =
|
|
526
|
-
id:
|
|
527
|
-
|
|
528
|
-
* detail — they power History's "Continue" / "New session from this" (#57/#251). */
|
|
529
|
-
parentId: z3.string().optional(),
|
|
530
|
-
/** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
|
|
531
|
-
* arrival and its units are N rows of it, so this — not `parentId` — is what makes a
|
|
532
|
-
* multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
|
|
533
|
-
* accumulates every message an agent ever sent, so grouping by it renders a day of
|
|
534
|
-
* unrelated updates as a single "12-part request". Absent on rows written before the
|
|
535
|
-
* `asks` table, and on anything that never went through `notify` — both fall back to the
|
|
536
|
-
* thread, which is what the client did for all rows until now. */
|
|
537
|
-
askId: z3.string().optional(),
|
|
538
|
-
/** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
|
|
539
|
-
* shares one `created_at` to the microsecond, so without it the author's order is
|
|
540
|
-
* unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
|
|
541
|
-
* (live 2026-08-10, D35). The API already orders by it; this lets a reader that
|
|
542
|
-
* re-sorts (grouping, filtering) put an arrival back in the order it was written. */
|
|
543
|
-
seq: z3.number().int().optional(),
|
|
544
|
-
/** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
|
|
545
|
-
* `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
|
|
546
|
-
* counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
|
|
547
|
-
* screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
|
|
548
|
-
* only evidence of itself. How big an arrival is, is a fact about the arrival, so the
|
|
549
|
-
* server that can still see every row states it. Absent on any row with no `askId`: a
|
|
550
|
-
* unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
|
|
551
|
-
* the one part of its own arrival a single row cannot answer. */
|
|
552
|
-
units: z3.number().int().positive().optional(),
|
|
553
|
-
tokenId: z3.string().optional(),
|
|
510
|
+
var InboxItemSchema = z4.object({
|
|
511
|
+
id: z4.string(),
|
|
512
|
+
tokenId: z4.string().optional(),
|
|
554
513
|
status: NotifyStatusSchema,
|
|
555
514
|
context: ContextSchema,
|
|
556
|
-
options:
|
|
515
|
+
options: z4.array(OptionSchema).optional(),
|
|
557
516
|
/** The ask's declared coverage points (#396), when the agent sent them. */
|
|
558
|
-
points:
|
|
559
|
-
/** The call's AGENDA (broker/agenda-design.md): the ordered turns it is made of, built at
|
|
560
|
-
* ring/enqueue time. Replaces the condensed line + index-aligned phrased points, which
|
|
561
|
-
* between them could not express a call as a sequence. `question: null` is a real turn —
|
|
562
|
-
* a status update stays a statement instead of being shaped into a yes/no. */
|
|
517
|
+
points: z4.array(z4.string()).optional(),
|
|
563
518
|
/** Does this claim want an ANSWER, or is it telling you something? Written per row from
|
|
564
519
|
* `requestAsks` — the agent's own declaration, not a guess. `false` is what earns a card
|
|
565
520
|
* its acknowledge affordance: without it a status update offers a text box and a dismiss,
|
|
566
521
|
* and neither of those is "got it" (owner, 2026-08-10). */
|
|
567
|
-
asks:
|
|
522
|
+
asks: z4.boolean().optional(),
|
|
568
523
|
/** When a live process last pulsed for this row's agent — the liveness input for
|
|
569
524
|
* "working requires a pulse" (#928): the list said "Working…" from agent_state alone
|
|
570
525
|
* while the party called the same dead claim stalled. Absent = no token/no data,
|
|
571
526
|
* which must never CLAIM stalled. */
|
|
572
|
-
lastSeenAt:
|
|
527
|
+
lastSeenAt: z4.string().optional(),
|
|
573
528
|
/** WHEN THE AGENT LAST SAID ANYTHING ABOUT THIS CLAIM — the newest `agent_state` row in
|
|
574
529
|
* the `notification_events` ledger (trigger-written since 20260621010000, so every row a
|
|
575
530
|
* user can see has one). The age input for `CLAIM_STALE_MS`, and it has to be this rather
|
|
@@ -579,53 +534,91 @@ var InboxItemSchema = z3.object({
|
|
|
579
534
|
* work. Reading the row's birth as the claim's age brands that "No update in 8h" the
|
|
580
535
|
* instant the agent picks it up (#997). Absent = pre-trigger row; fall back to
|
|
581
536
|
* `createdAt`. */
|
|
582
|
-
agentStateAt:
|
|
583
|
-
agenda
|
|
584
|
-
|
|
537
|
+
agentStateAt: z4.string().datetime().optional(),
|
|
538
|
+
/** THE QUESTIONS A CALL CARRIES — the call screen's agenda spine (walk/design.md §11, owner
|
|
539
|
+
* 2026-09-22). One per DecisionNeed on the Call, in the Call's order, answered or open (a
|
|
540
|
+
* superseded or cancelled need is no longer a question anyone is asked). Present only on a
|
|
541
|
+
* Call's cards, and every card of that Call carries the same list: the call screen reads it
|
|
542
|
+
* once, off the one read it already makes (`GET /api/inbox/:callId`).
|
|
543
|
+
*
|
|
544
|
+
* It counts DECISIONS, not agenda turns: turns include context-only lines and are re-planned
|
|
545
|
+
* every cycle, so a spine drawn from them would change length under the caller mid-call.
|
|
546
|
+
*
|
|
547
|
+
* `entryId` is the request Entry — the id the bot calls a CLAIM, and the one it names on the
|
|
548
|
+
* `turn` topic (`asking`, `settled`), because the bot never sees a DecisionNeed id. `title` is
|
|
549
|
+
* the card's own concise heading; `answer` the accepted answer in words, null while open. It
|
|
550
|
+
* REPLACED `agenda` (turns), which nothing ever filled. */
|
|
551
|
+
questions: z4.array(z4.object({
|
|
552
|
+
id: z4.string(),
|
|
553
|
+
entryId: z4.string(),
|
|
554
|
+
title: z4.string(),
|
|
555
|
+
state: z4.enum(["open", "answered"]),
|
|
556
|
+
answer: z4.string().nullable(),
|
|
557
|
+
/** WHO ASKED IT (owner, 2026-09-23, Goal a345e906: each agenda row wears its agent's face) — the
|
|
558
|
+
* request Entry's author, as the same three facts the item's own `tokenId`/`name`/`voice`
|
|
559
|
+
* carry for the call's one agent, so the phone draws it with the same seed. Absent when the
|
|
560
|
+
* author is not an agent this account holds (unpaired since, or a person). */
|
|
561
|
+
agent: z4.object({ tokenId: z4.string(), name: z4.string(), voice: VoiceKeySchema.optional() }).optional()
|
|
562
|
+
})).optional(),
|
|
563
|
+
visuals: z4.array(VisualSchema).optional(),
|
|
585
564
|
/** The connected agent's name (the single pairing name — user-typed, or the
|
|
586
565
|
* agent's suggestion, or a default silly name). */
|
|
587
|
-
name:
|
|
566
|
+
name: z4.string(),
|
|
588
567
|
/** The pairing's assigned voice (#462); absent = the default voice. */
|
|
589
568
|
voice: VoiceKeySchema.optional(),
|
|
590
|
-
repo:
|
|
591
|
-
branch:
|
|
592
|
-
createdAt:
|
|
593
|
-
snoozedUntil:
|
|
569
|
+
repo: z4.string().optional(),
|
|
570
|
+
branch: z4.string().optional(),
|
|
571
|
+
createdAt: z4.string().datetime(),
|
|
572
|
+
snoozedUntil: z4.string().datetime().optional(),
|
|
594
573
|
agentState: AgentStateSchema.default("idle"),
|
|
595
574
|
/** Whose action the item is waiting on: "you" = an agent asked you (the default,
|
|
596
575
|
* every agent→user notification); "agent" = you sent a request and it's awaiting the
|
|
597
576
|
* agent (held in the inbox until the agent replies on the thread). */
|
|
598
|
-
turn:
|
|
577
|
+
turn: z4.enum(["you", "agent"]).default("you"),
|
|
599
578
|
/** Hard error reason on an awaiting request (turn="agent") — the wake failed to reach
|
|
600
|
-
* the agent (provider-agnostic; set server-side). Absent = no hard error
|
|
601
|
-
*
|
|
602
|
-
error:
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
579
|
+
* the agent (provider-agnostic; set server-side). Absent = no hard error. Drives the inbox
|
|
580
|
+
* error badge + Retry. */
|
|
581
|
+
error: z4.string().optional(),
|
|
582
|
+
/** WHEN THIS AGENT WORK WENT QUIET (turn="agent"), by the one rule (`coldSince`: three days
|
|
583
|
+
* with nothing said), or absent while it is not stalled. The inbox's stalled badge reads
|
|
584
|
+
* this and nothing else (2026-09-23: a 3-minute age rule badged every live Goal stalled,
|
|
585
|
+
* and "dismiss the stalled ones" cancelled 37 pieces of live work). */
|
|
586
|
+
cold: z4.string().datetime().optional(),
|
|
587
|
+
clarifies: z4.string().optional(),
|
|
588
|
+
/** THE RING, ON THE ITEM (walk/design.md §12 §17, #2251): the last ring on this card was
|
|
589
|
+
* declined, and what the ladder will do next — read off the cron's own row, never computed
|
|
590
|
+
* on the phone. Present only while a `declined` receipt stands on the card's last Call.
|
|
591
|
+
* The ladder is ACCOUNT-WIDE (#2259): `anchorAt` and `step` are the account's position;
|
|
592
|
+
* `nextRingAt` is this card's armed instant (`deliveries.next_ring_at`), null once the cron
|
|
593
|
+
* has disarmed it — the curve's end, `inbox`/`dismiss`, or a setting that said no more rings.
|
|
594
|
+
* It replaced `gaveUp` (deleted 2026-09-22): "the ladder spent" was a boolean the projection
|
|
595
|
+
* never set, and it is `nextRingAt === null` here — the party's *Missed you* (`party/dress.ts`)
|
|
596
|
+
* and the roster's `unreached` read `declinedAt`, and stand while it does. */
|
|
597
|
+
ring: z4.object({
|
|
598
|
+
declinedAt: z4.string().datetime(),
|
|
599
|
+
anchorAt: z4.string().datetime(),
|
|
600
|
+
nextRingAt: z4.string().datetime().nullable(),
|
|
601
|
+
step: z4.number().int()
|
|
602
|
+
}).optional(),
|
|
610
603
|
/** Why this arrived the way it did, read back off the delivery receipt (`notify/why.ts`).
|
|
611
604
|
* Absent for anything never delivered through a push, and for older rows written before
|
|
612
605
|
* the reason was recorded. Deliberately a debug affordance, shown small (owner,
|
|
613
606
|
* 2026-08-07) — its real job is to give "this didn't need a call" something to be
|
|
614
607
|
* feedback ABOUT. */
|
|
615
|
-
why:
|
|
608
|
+
why: z4.object({
|
|
616
609
|
asked: NotifyLevelSchema,
|
|
617
610
|
got: NotifyLevelSchema,
|
|
618
|
-
because:
|
|
619
|
-
line:
|
|
611
|
+
because: z4.enum(["unresponsive", "dismissed", "not_permitted", "silent", "coalesced", "agent_capped", "unplanned", "learned_raise"]).optional(),
|
|
612
|
+
line: z4.string()
|
|
620
613
|
}).optional(),
|
|
621
|
-
select:
|
|
622
|
-
confirmStyle:
|
|
614
|
+
select: z4.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
|
|
615
|
+
confirmStyle: z4.enum(["yesno", "approve"]).default("yesno").describe(
|
|
623
616
|
"Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
|
|
624
617
|
),
|
|
625
618
|
/** Real downstream work is stuck behind this one — set by the agent, independent of
|
|
626
619
|
* urgency (see the main README's "premier use case" + notify/states.md). Drives the
|
|
627
620
|
* inbox's blocking badge and the extra confirm step before dismissing it. */
|
|
628
|
-
blocking:
|
|
621
|
+
blocking: z4.boolean().default(false),
|
|
629
622
|
/** The user's locked-in answer; present only for replied items (GET /api/replied/:id). */
|
|
630
623
|
answer: UserAnswerSchema.optional(),
|
|
631
624
|
/** THE TARGET FACTS A CARD RENDERS (#1796 point 5, 2026-09-11): the Delivery it is a view of,
|
|
@@ -633,35 +626,38 @@ var InboxItemSchema = z3.object({
|
|
|
633
626
|
* for a request that asks nothing), whether its content is sealed, and that Goal's state. The
|
|
634
627
|
* answer writer (`POST /api/entries`) and the disposition (`close_delivery`) take their ids from
|
|
635
628
|
* here. The server projects it (`apps/api/src/inbox/project.ts`); a client never builds it. */
|
|
636
|
-
communication:
|
|
637
|
-
deliveryId:
|
|
638
|
-
kind:
|
|
639
|
-
entryId:
|
|
640
|
-
goalIds:
|
|
641
|
-
decisionNeedId:
|
|
642
|
-
sealed:
|
|
643
|
-
goalState:
|
|
629
|
+
communication: z4.object({
|
|
630
|
+
deliveryId: z4.string(),
|
|
631
|
+
kind: z4.enum(["notification", "call"]),
|
|
632
|
+
entryId: z4.string(),
|
|
633
|
+
goalIds: z4.array(z4.string()),
|
|
634
|
+
decisionNeedId: z4.string().optional(),
|
|
635
|
+
sealed: z4.boolean(),
|
|
636
|
+
goalState: z4.string().optional(),
|
|
637
|
+
/** THAT GOAL'S NAME (#2416) — what Activity's row is headed by, since a row there is one Goal
|
|
638
|
+
* and the cards it holds sit behind it. Stamped by the same read as `goalState`. */
|
|
639
|
+
goalTitle: z4.string().optional()
|
|
644
640
|
}).optional()
|
|
645
641
|
});
|
|
646
642
|
var APNS_TOKEN_RE = /^[0-9a-fA-F]{64}$/;
|
|
647
|
-
var PushTokenSchema =
|
|
648
|
-
voipToken:
|
|
649
|
-
alertToken:
|
|
650
|
-
fcmToken:
|
|
651
|
-
platform:
|
|
643
|
+
var PushTokenSchema = z4.object({
|
|
644
|
+
voipToken: z4.string().min(1).optional(),
|
|
645
|
+
alertToken: z4.string().min(1).optional(),
|
|
646
|
+
fcmToken: z4.string().min(1).optional(),
|
|
647
|
+
platform: z4.enum(["ios", "android"])
|
|
652
648
|
}).superRefine((v, ctx) => {
|
|
653
649
|
if (v.platform !== "ios") return;
|
|
654
650
|
for (const field of ["voipToken", "alertToken"]) {
|
|
655
651
|
const token = v[field];
|
|
656
652
|
if (token === void 0 || APNS_TOKEN_RE.test(token)) continue;
|
|
657
653
|
ctx.addIssue({
|
|
658
|
-
code:
|
|
654
|
+
code: z4.ZodIssueCode.custom,
|
|
659
655
|
path: [field],
|
|
660
656
|
message: `not an APNs device token (want 64 hex chars, got ${token.length})`
|
|
661
657
|
});
|
|
662
658
|
}
|
|
663
659
|
});
|
|
664
|
-
var MissedCallSchema =
|
|
660
|
+
var MissedCallSchema = z4.enum([
|
|
665
661
|
"retry_10m",
|
|
666
662
|
"retry_30m",
|
|
667
663
|
"retry_60m",
|
|
@@ -673,32 +669,32 @@ var MissedCallSchema = z3.enum([
|
|
|
673
669
|
]);
|
|
674
670
|
var clock = (h) => h === 0 ? "midnight" : h === 12 ? "noon" : h < 12 ? `${h} am` : `${h - 12} pm`;
|
|
675
671
|
var QUIET = ` Nothing rings from ${clock(NIGHT.from)} to ${clock(NIGHT.to)} your time; the count waits for morning.`;
|
|
676
|
-
var BrokerTuningSchema =
|
|
672
|
+
var BrokerTuningSchema = z4.object({
|
|
677
673
|
/** 'none' = skip the spoken ack after a mapped answer (power users find it slow). */
|
|
678
|
-
ackVerbosity:
|
|
674
|
+
ackVerbosity: z4.enum(["normal", "none"]).optional(),
|
|
679
675
|
/** How readily the mapper asks its one clarification: 'low' = only when truly
|
|
680
676
|
* uninterpretable, 'high' = whenever not fully certain. */
|
|
681
|
-
clarifyEagerness:
|
|
677
|
+
clarifyEagerness: z4.enum(["low", "normal", "high"]).optional(),
|
|
682
678
|
/** The user's own shorthand: when they say `say`, they mean `mean`. */
|
|
683
|
-
phrasebook:
|
|
679
|
+
phrasebook: z4.array(z4.object({ say: z4.string().min(1).max(60), mean: z4.string().min(1).max(120) })).max(24).optional(),
|
|
684
680
|
/** The language calls are PLANNED in, when the account has chosen one (#1272). Absent —
|
|
685
681
|
* which is every account today — means the agent's own words decide, per ask: a call
|
|
686
682
|
* about an English ask opens in English. This is the only thing that overrides that,
|
|
687
683
|
* and a live caller who switches language mid-call still outranks it (broker/lang.ts).
|
|
688
684
|
* Set per user (no UI yet), like `voiceTuning`. */
|
|
689
|
-
language:
|
|
685
|
+
language: z4.enum(["en", "es"]).optional()
|
|
690
686
|
});
|
|
691
|
-
var UserSettingsSchema =
|
|
692
|
-
permissions:
|
|
693
|
-
call:
|
|
694
|
-
banner:
|
|
695
|
-
push:
|
|
687
|
+
var UserSettingsSchema = z4.object({
|
|
688
|
+
permissions: z4.object({
|
|
689
|
+
call: z4.boolean(),
|
|
690
|
+
banner: z4.boolean(),
|
|
691
|
+
push: z4.boolean()
|
|
696
692
|
}),
|
|
697
|
-
sessionMode:
|
|
698
|
-
silentPush:
|
|
699
|
-
autoCallback:
|
|
693
|
+
sessionMode: z4.enum(["default", "all_calls", "silent"]),
|
|
694
|
+
silentPush: z4.boolean(),
|
|
695
|
+
autoCallback: z4.boolean(),
|
|
700
696
|
/** Opt-in (default false) to using your content to improve Paigy and train models. */
|
|
701
|
-
improveConsent:
|
|
697
|
+
improveConsent: z4.boolean(),
|
|
702
698
|
missedCall: MissedCallSchema.default("backoff_standard"),
|
|
703
699
|
/** Where voice audio is processed. 'hosted' (default) = Paigy's voice services
|
|
704
700
|
* (ElevenLabs TTS, faster-whisper STT, the call bot); 'on_device' = the phone
|
|
@@ -706,7 +702,13 @@ var UserSettingsSchema = z3.object({
|
|
|
706
702
|
* Optional, NOT defaulted: a stale client PATCHing the full settings object
|
|
707
703
|
* must not silently reset this privacy choice. Absent = leave unchanged on
|
|
708
704
|
* write, 'hosted' on read (see store.ts). */
|
|
709
|
-
voiceMode:
|
|
705
|
+
voiceMode: z4.enum(["hosted", "on_device"]).optional(),
|
|
706
|
+
/** Talk — after you answer, the next step is read aloud (walk/design.md §6). ALWAYS ON until
|
|
707
|
+
* turned off (owner, 2026-09-18, #2249): a setting, not a per-walk toggle. Optional, NOT
|
|
708
|
+
* defaulted, for the same reason `voiceMode` is: a stale client PATCHing the full settings
|
|
709
|
+
* object must not silently turn it back on. Absent = leave unchanged on write, true on
|
|
710
|
+
* read (see store.ts). */
|
|
711
|
+
talk: z4.boolean().optional(),
|
|
710
712
|
/** Per-user ring budget (#603): calls per rolling day before further calls
|
|
711
713
|
* degrade to banner. Absent = the global default (25). A number, never a
|
|
712
714
|
* bypass — every account keeps a ceiling. No UI; set per user for testing. */
|
|
@@ -714,10 +716,10 @@ var UserSettingsSchema = z3.object({
|
|
|
714
716
|
* payload['tuning'] (e.g. { silence_s: 3.5 } — a longer pause window for a
|
|
715
717
|
* slower speaker). No API-side semantics; the bot resolves each key with its
|
|
716
718
|
* own defaults. Set per user (no UI yet); absent = bot defaults. */
|
|
717
|
-
voiceTuning:
|
|
719
|
+
voiceTuning: z4.record(z4.string(), z4.union([z4.number(), z4.string()])).optional(),
|
|
718
720
|
/** Opt-in to real-phone (PSTN) calls when the app can't ring. Optional, not
|
|
719
721
|
* defaulted — an older client PATCHing the full object must not clobber it. */
|
|
720
|
-
pstnCalls:
|
|
722
|
+
pstnCalls: z4.boolean().optional(),
|
|
721
723
|
/** The user's IANA timezone (e.g. "America/Bogota"), recorded by the app — it is the
|
|
722
724
|
* only party that knows it. REMINDERS are why it exists: "remind me at ten" becomes
|
|
723
725
|
* an absolute `due_at` only if we know whose ten. Optional and never defaulted, for
|
|
@@ -726,52 +728,51 @@ var UserSettingsSchema = z3.object({
|
|
|
726
728
|
* that failure reads as the reminder rail being unreliable rather than as a missing
|
|
727
729
|
* setting. Absent = a spoken time can't be landed, so the reminder rides the next
|
|
728
730
|
* call — honest about what we know. */
|
|
729
|
-
timezone:
|
|
731
|
+
timezone: z4.string().min(1).max(64).optional(),
|
|
730
732
|
/** Rung-2 broker tuning (#381). Optional and NOT defaulted, same stale-client
|
|
731
733
|
* clobber guard as voiceMode: absent = leave unchanged on write. */
|
|
732
734
|
broker: BrokerTuningSchema.optional()
|
|
733
735
|
});
|
|
734
|
-
var HistoryItemSchema =
|
|
735
|
-
id:
|
|
736
|
-
parentId: z3.string(),
|
|
736
|
+
var HistoryItemSchema = z4.object({
|
|
737
|
+
id: z4.string(),
|
|
737
738
|
/** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
|
|
738
|
-
initiator:
|
|
739
|
-
title:
|
|
739
|
+
initiator: z4.enum(["user", "agent"]),
|
|
740
|
+
title: z4.string(),
|
|
740
741
|
/** The agent on the other end (its name). */
|
|
741
|
-
name:
|
|
742
|
-
createdAt:
|
|
742
|
+
name: z4.string(),
|
|
743
|
+
createdAt: z4.string(),
|
|
743
744
|
/** When the agent fetched your request (user→agent only). */
|
|
744
|
-
agentAckedAt:
|
|
745
|
+
agentAckedAt: z4.string().nullable(),
|
|
745
746
|
/** When you answered the agent's notification (agent→user only). */
|
|
746
|
-
humanAckedAt:
|
|
747
|
+
humanAckedAt: z4.string().nullable()
|
|
747
748
|
});
|
|
748
749
|
var ACTIVITY_LINES = 2;
|
|
749
750
|
var ACTIVITY_LINE_MAX = 80;
|
|
750
|
-
var AgentActivitySchema =
|
|
751
|
+
var AgentActivitySchema = z4.object({
|
|
751
752
|
/** Oldest first, so the newest line is last — the one that replaces in place. */
|
|
752
|
-
lines:
|
|
753
|
+
lines: z4.array(z4.string().max(ACTIVITY_LINE_MAX)).max(ACTIVITY_LINES),
|
|
753
754
|
/** When the harness observed this tail. Its own timestamp, not the heartbeat's: a beat
|
|
754
755
|
* that carries an UNCHANGED tail must not make a stalled agent look like it just moved. */
|
|
755
|
-
at:
|
|
756
|
+
at: z4.string().datetime()
|
|
756
757
|
});
|
|
757
|
-
var ConnectionSummarySchema =
|
|
758
|
+
var ConnectionSummarySchema = z4.object({
|
|
758
759
|
/** The connection = the agent's token id (used to address a request). */
|
|
759
|
-
id:
|
|
760
|
+
id: z4.string(),
|
|
760
761
|
/** The credential kind: "device" = a paired machine (mint-only — it hosts and mints, it
|
|
761
762
|
* never talks); "agent" = an identity that sends. The roster and devices surfaces split
|
|
762
763
|
* on this. Optional/absent reads as "agent" (a row predating the kind column). See
|
|
763
764
|
* apps/api/src/tokens/devices-vs-agents-design.md. */
|
|
764
|
-
kind:
|
|
765
|
+
kind: z4.enum(["device", "agent"]).optional(),
|
|
765
766
|
/** For an agent, the token id of the DEVICE that minted it — so agents group under their
|
|
766
767
|
* machine, and revoking a device cascades to them. Null on devices, and on unlinked
|
|
767
768
|
* agents (phone-launched, provider-managed, or minted before the link existed). */
|
|
768
|
-
mintedByDevice:
|
|
769
|
-
device:
|
|
769
|
+
mintedByDevice: z4.string().nullable().optional(),
|
|
770
|
+
device: z4.string().nullable(),
|
|
770
771
|
/** The agent's display name (the single pairing name). */
|
|
771
|
-
name:
|
|
772
|
+
name: z4.string(),
|
|
772
773
|
/** For a managed connection, the provider key (e.g. "cma") that agentOrigin maps to a
|
|
773
774
|
* label; null for a local connection. Sourced from the token's provider, not the name. */
|
|
774
|
-
provider:
|
|
775
|
+
provider: z4.string().nullable(),
|
|
775
776
|
/** The pairing's assigned voice (#462); null = the default voice. */
|
|
776
777
|
voice: VoiceKeySchema.nullable(),
|
|
777
778
|
/** The LOUDEST this agent may ever reach you — a ceiling on `NOTIFY_LADDER`, set by the
|
|
@@ -782,22 +783,22 @@ var ConnectionSummarySchema = z3.object({
|
|
|
782
783
|
* every surface at once and outranks even `sessionMode: all_calls` — a mode the user
|
|
783
784
|
* set once must not overrule a rule they set about one agent. */
|
|
784
785
|
reach: NotifyLevelSchema.nullable().optional(),
|
|
785
|
-
createdAt:
|
|
786
|
+
createdAt: z4.string().datetime(),
|
|
786
787
|
/** Most recent notification on this connection, either direction. Null = no contact yet.
|
|
787
788
|
* Drives the agents-page recency grouping (Today / This week / …). */
|
|
788
|
-
lastContactAt:
|
|
789
|
+
lastContactAt: z4.string().datetime().nullable(),
|
|
789
790
|
/** Last presence heartbeat from a running agent process (POST /api/presence) — the
|
|
790
791
|
* desktop app while open. Null = never seen; stale = offline. */
|
|
791
|
-
lastSeenAt:
|
|
792
|
+
lastSeenAt: z4.string().datetime().nullable().optional(),
|
|
792
793
|
/** What a live desktop can run (companion.md §2.2), advertised on its heartbeat:
|
|
793
794
|
* harness availabilities + granted workspaces — the option set the phone's
|
|
794
795
|
* "new session" sheet offers. Absent for ordinary MCP agents. */
|
|
795
|
-
runtime:
|
|
796
|
+
runtime: z4.object({
|
|
796
797
|
/** The @paigy/harness this host is running — a machine the self-update has not reached
|
|
797
798
|
* shows its age here (`apps/desktop/src/update.ts`). */
|
|
798
|
-
version:
|
|
799
|
-
harnesses:
|
|
800
|
-
workspaces:
|
|
799
|
+
version: z4.string().optional(),
|
|
800
|
+
harnesses: z4.array(z4.object({ name: z4.string(), label: z4.string(), status: z4.string() })).optional(),
|
|
801
|
+
workspaces: z4.array(z4.string()).optional()
|
|
801
802
|
}).optional(),
|
|
802
803
|
/** The tail of this agent's working log, when a harness is driving it — the agent page's
|
|
803
804
|
* live strip. Absent for anything the desktop harness isn't running (a hatched identity
|
|
@@ -806,148 +807,178 @@ var ConnectionSummarySchema = z3.object({
|
|
|
806
807
|
activity: AgentActivitySchema.optional(),
|
|
807
808
|
/** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
|
|
808
809
|
* false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
|
|
809
|
-
managed:
|
|
810
|
+
managed: z4.boolean()
|
|
810
811
|
});
|
|
811
|
-
var LedgerItemSchema =
|
|
812
|
-
var AgentLedgerSchema =
|
|
813
|
-
agent:
|
|
812
|
+
var LedgerItemSchema = z4.object({ id: z4.string(), parentId: z4.string(), title: z4.string(), createdAt: z4.string() });
|
|
813
|
+
var AgentLedgerSchema = z4.object({
|
|
814
|
+
agent: z4.object({ id: z4.string(), name: z4.string(), revokedAt: z4.string().nullable() }),
|
|
814
815
|
/** Its own questions you have not answered. */
|
|
815
|
-
asks:
|
|
816
|
+
asks: z4.array(LedgerItemSchema),
|
|
816
817
|
/** Its questions you answered that nobody acted on — still owed to somebody. */
|
|
817
|
-
answered:
|
|
818
|
+
answered: z4.array(LedgerItemSchema),
|
|
818
819
|
/** Requests you sent it that it never took. */
|
|
819
|
-
requests:
|
|
820
|
-
goals:
|
|
821
|
-
callbacks:
|
|
820
|
+
requests: z4.array(LedgerItemSchema),
|
|
821
|
+
goals: z4.array(z4.object({ id: z4.string(), outcome: z4.string(), state: z4.string() })),
|
|
822
|
+
callbacks: z4.array(z4.object({ id: z4.string(), parentId: z4.string(), trigger: z4.string(), note: z4.string(), dueAt: z4.string().nullable() }))
|
|
822
823
|
});
|
|
823
|
-
var ReassignResultSchema =
|
|
824
|
-
moved:
|
|
825
|
-
parentId:
|
|
824
|
+
var ReassignResultSchema = z4.object({
|
|
825
|
+
moved: z4.object({ asks: z4.number(), answered: z4.number(), requests: z4.number(), goals: z4.number(), callbacks: z4.number() }),
|
|
826
|
+
parentId: z4.string().nullable()
|
|
826
827
|
});
|
|
827
|
-
var MoveRingSchema =
|
|
828
|
-
var MoveSchema =
|
|
829
|
-
id:
|
|
828
|
+
var MoveRingSchema = z4.enum(["home", "travels", "retired", "quarantined"]);
|
|
829
|
+
var MoveSchema = z4.object({
|
|
830
|
+
id: z4.string(),
|
|
830
831
|
/** The reusable question, as distill normalized it. */
|
|
831
|
-
question:
|
|
832
|
+
question: z4.string(),
|
|
832
833
|
/** The operative ruling. Editable by the user (PATCH) — which resets the ledger. */
|
|
833
|
-
answer:
|
|
834
|
+
answer: z4.string(),
|
|
834
835
|
/** The user's stated reason, when they gave one. Null = inherently narrow: the judge is
|
|
835
836
|
* told so, and the ruling only derives essentially the same question in the same scope. */
|
|
836
|
-
rationale:
|
|
837
|
+
rationale: z4.string().nullable(),
|
|
837
838
|
/** Where the ruling lives: a repo/workspace, or 'global'. */
|
|
838
|
-
scope:
|
|
839
|
+
scope: z4.string(),
|
|
839
840
|
ring: MoveRingSchema,
|
|
840
841
|
/** True = the user pinned it with `always` (travel granted by hand, not by evidence). */
|
|
841
|
-
pinned:
|
|
842
|
+
pinned: z4.boolean(),
|
|
842
843
|
/** True = a pin the user placed was BROKEN by later counter-evidence. Surfaced so the
|
|
843
844
|
* break is visible instead of a pin silently disappearing. */
|
|
844
|
-
pinBroken:
|
|
845
|
+
pinBroken: z4.boolean(),
|
|
845
846
|
/** When the ruling was distilled. */
|
|
846
|
-
learnedAt:
|
|
847
|
+
learnedAt: z4.string(),
|
|
847
848
|
/** Last time it answered an ask. Null = never fired. */
|
|
848
|
-
lastUsedAt:
|
|
849
|
+
lastUsedAt: z4.string().nullable(),
|
|
849
850
|
/** How many asks it has answered. Instrumentation — deliberately NOT an input to the
|
|
850
851
|
* evidence curve: firing says the question keeps arising, not that the ruling is right. */
|
|
851
|
-
usedCount:
|
|
852
|
+
usedCount: z4.number(),
|
|
852
853
|
/** Ledger: outcomes that said it held up. Saturating — the tenth is worth almost nothing. */
|
|
853
|
-
confirms:
|
|
854
|
+
confirms: z4.number(),
|
|
854
855
|
/** Ledger: contradictions, in signal units (a full override = 1, weaker signals less).
|
|
855
856
|
* Linear and priced above the entire confirmation budget, so any full counter wins. */
|
|
856
|
-
counters:
|
|
857
|
+
counters: z4.number(),
|
|
857
858
|
/** The agent that asked the question this move came from, when known. Null for a move
|
|
858
859
|
* distilled from a clarify ruling (those carry no agent) or one whose source rows are gone. */
|
|
859
|
-
learnedFrom:
|
|
860
|
+
learnedFrom: z4.object({ id: z4.string(), name: z4.string() }).nullable()
|
|
860
861
|
});
|
|
861
|
-
var QueueQuestionSchema =
|
|
862
|
+
var QueueQuestionSchema = z4.object({
|
|
862
863
|
/** The decision need's id — what an answer is accepted against. */
|
|
863
|
-
id:
|
|
864
|
+
id: z4.string(),
|
|
864
865
|
/** The words that were asked, from the request Entry that asked them. */
|
|
865
|
-
question:
|
|
866
|
+
question: z4.string(),
|
|
866
867
|
/** Where it was asked — which is where the ruling goes (`POST /api/entries`). Null only
|
|
867
868
|
* for a need whose request Entry is carried by no interactive Delivery, which nothing
|
|
868
869
|
* can answer. */
|
|
869
|
-
deliveryId:
|
|
870
|
+
deliveryId: z4.string().nullable().default(null),
|
|
870
871
|
/** The Entry the ruling is about. */
|
|
871
|
-
aboutId:
|
|
872
|
+
aboutId: z4.string().nullable().default(null),
|
|
872
873
|
/** Empty for a free-text question. */
|
|
873
|
-
options:
|
|
874
|
-
select:
|
|
875
|
-
askedAt:
|
|
874
|
+
options: z4.array(OptionSchema).default([]),
|
|
875
|
+
select: z4.enum(["one", "many", "rank", "confirm", "text"]).default("text"),
|
|
876
|
+
askedAt: z4.string(),
|
|
876
877
|
/** Null while the question is open — which is how the page tells the two apart. */
|
|
877
|
-
answeredAt:
|
|
878
|
+
answeredAt: z4.string().nullable().default(null),
|
|
878
879
|
/** The ruling in the person's own words, from the contribution that replied — not the
|
|
879
880
|
* option id, which is not something anyone reads back. Null while it is open, and null
|
|
880
881
|
* for a settled question whose reply carried nothing readable. */
|
|
881
|
-
answer:
|
|
882
|
+
answer: z4.string().nullable().default(null),
|
|
882
883
|
/** The Goal this question belongs to — a step knows its Goal on its own, not only through
|
|
883
884
|
* an `InboxItem`'s `communication.goalIds[0]` (walk/design.md §12 item 3).
|
|
884
885
|
* READ BY `apps/client/src/walk/order.ts`, which stamps it onto every `WalkStep`: the walk's
|
|
885
886
|
* order, its route, home's trees and the list of steps all take a step's Goal from here, so
|
|
886
887
|
* this is the field they agree through rather than each re-deriving it from the row it
|
|
887
888
|
* arrived under. Required because the API projects it on every need it sends. */
|
|
888
|
-
goalId:
|
|
889
|
+
goalId: z4.string(),
|
|
889
890
|
/** True only while an unmet START gate holds the Goal — a Goal that merely waits to
|
|
890
891
|
* *finish* does not stop a person from answering (owner, 2026-09-16: "per need gate from
|
|
891
892
|
* the API"; §4's dashed node). Not the same fact as `QueueItem.blocked`, which counts any
|
|
892
893
|
* gate at all. */
|
|
893
|
-
blocked:
|
|
894
|
+
blocked: z4.boolean().default(false)
|
|
895
|
+
});
|
|
896
|
+
var QueueReplySchema = z4.object({
|
|
897
|
+
/** The card this note was (`deliveryId:requestEntryId`, minted by the server like every card
|
|
898
|
+
* id) — so the phone can tell a reply it just sent from one the queue already carries, and the
|
|
899
|
+
* walk can name it in its zoom. */
|
|
900
|
+
id: z4.string(),
|
|
901
|
+
/** The Goal the note is on. */
|
|
902
|
+
goalId: z4.string(),
|
|
903
|
+
/** What the note said. */
|
|
904
|
+
note: z4.string(),
|
|
905
|
+
/** Where it was carried — where a second reply goes (`POST /api/entries`, #2252). */
|
|
906
|
+
deliveryId: z4.string(),
|
|
907
|
+
requestEntryId: z4.string(),
|
|
908
|
+
askedAt: z4.string(),
|
|
909
|
+
/** When the person last replied — the window's start. */
|
|
910
|
+
repliedAt: z4.string(),
|
|
911
|
+
/** The person's latest words about it; null when there is nothing readable in them. */
|
|
912
|
+
reply: z4.string().nullable()
|
|
894
913
|
});
|
|
895
|
-
var QueueItemSchema =
|
|
896
|
-
id:
|
|
914
|
+
var QueueItemSchema = z4.object({
|
|
915
|
+
id: z4.string(),
|
|
897
916
|
/** One-line headline — the first sentence of the outcome. */
|
|
898
|
-
title:
|
|
917
|
+
title: z4.string(),
|
|
899
918
|
/** The outcome in full, verbatim: the person's own words are what an assignee sees. */
|
|
900
|
-
intent:
|
|
919
|
+
intent: z4.string(),
|
|
901
920
|
/** `ready` | `active` | `waiting` | `done` | `cancelled`, straight off the Goal. */
|
|
902
|
-
state:
|
|
921
|
+
state: z4.string(),
|
|
903
922
|
/** Who holds it (a participant ref); null when nobody does yet. */
|
|
904
|
-
assignee:
|
|
923
|
+
assignee: z4.string().nullable().default(null),
|
|
905
924
|
/** What the agent last said it was doing; null if it has said nothing. */
|
|
906
|
-
progress:
|
|
907
|
-
|
|
908
|
-
|
|
925
|
+
progress: z4.string().nullable().default(null),
|
|
926
|
+
/** HOME'S LINE FOR THAT NOTE (owner, 2026-09-23): a few plain words one read wrote from `progress`,
|
|
927
|
+
* served only while it was written for the current note. Null means show the Goal's name. */
|
|
928
|
+
progressLine: z4.string().nullable().optional(),
|
|
929
|
+
reviewPending: z4.boolean().default(false),
|
|
930
|
+
dueAt: z4.string().nullable().default(null),
|
|
909
931
|
/** The Goal this one was opened under; null at the root. */
|
|
910
|
-
parentGoalId:
|
|
932
|
+
parentGoalId: z4.string().nullable().default(null),
|
|
911
933
|
/** Goals opened under this one — only those the same list holds. */
|
|
912
|
-
childGoalIds:
|
|
934
|
+
childGoalIds: z4.array(z4.string()).default([]),
|
|
913
935
|
/** Goals this one waits on (start or finish gates). */
|
|
914
|
-
dependencyGoalIds:
|
|
936
|
+
dependencyGoalIds: z4.array(z4.string()).default([]),
|
|
915
937
|
/** True while any gate is on a Goal that is not done — the walk draws it dashed. */
|
|
916
|
-
blocked:
|
|
938
|
+
blocked: z4.boolean().default(false),
|
|
917
939
|
/** Every decision need on it, open or settled — the page decides which to show. */
|
|
918
|
-
questions:
|
|
940
|
+
questions: z4.array(QueueQuestionSchema).default([]),
|
|
941
|
+
/** Every note on it the person replied to (`QueueReplySchema`) — the page decides which to show.
|
|
942
|
+
* Optional, not defaulted: absent is none, and every hand-built queue (fixtures, the demo) need
|
|
943
|
+
* not spell an empty list. */
|
|
944
|
+
replies: z4.array(QueueReplySchema).optional(),
|
|
919
945
|
/** The repository or project identifier this Goal belongs to (#2280), null if untracked. */
|
|
920
|
-
repo:
|
|
921
|
-
createdAt:
|
|
922
|
-
updatedAt:
|
|
946
|
+
repo: z4.string().nullable().optional(),
|
|
947
|
+
createdAt: z4.string(),
|
|
948
|
+
updatedAt: z4.string().nullable().default(null),
|
|
949
|
+
/** When its owner last SAID something about it (`goals.last_progress_at`, written by every
|
|
950
|
+
* `update_goal` that changes `progress`). `updatedAt` moves for reasons nobody chose — a
|
|
951
|
+
* state recomputed, a review flag — so it cannot tell work in hand from work gone quiet. */
|
|
952
|
+
lastProgressAt: z4.string().nullable().optional()
|
|
923
953
|
});
|
|
924
|
-
var
|
|
925
|
-
var
|
|
926
|
-
var
|
|
927
|
-
var
|
|
928
|
-
|
|
954
|
+
var COLD_AFTER_MS = 3 * 24 * 60 * 60 * 1e3;
|
|
955
|
+
var NoteSourceSchema = z4.enum(["app", "call"]);
|
|
956
|
+
var NoteStatusSchema = z4.enum(["open", "assigned", "in_progress", "done"]);
|
|
957
|
+
var NoteRepeatSchema = z4.enum(["once", "until_done"]);
|
|
958
|
+
var DecisionSchema = z4.object({
|
|
959
|
+
id: z4.string(),
|
|
929
960
|
/** The note this decision refines; null = recorded on a bare thread (the
|
|
930
961
|
* extensibility seam — any conversation can accrue decisions). */
|
|
931
|
-
noteId:
|
|
962
|
+
noteId: z4.string().nullable(),
|
|
932
963
|
/** What was ambiguous — the broker's (or the user's own) question. */
|
|
933
|
-
question:
|
|
964
|
+
question: z4.string(),
|
|
934
965
|
/** The user's ruling; null while the question is open. */
|
|
935
|
-
answer:
|
|
936
|
-
decidedAt:
|
|
937
|
-
createdAt:
|
|
966
|
+
answer: z4.string().nullable(),
|
|
967
|
+
decidedAt: z4.string().nullable(),
|
|
968
|
+
createdAt: z4.string()
|
|
938
969
|
});
|
|
939
|
-
var NoteSchema =
|
|
940
|
-
id:
|
|
970
|
+
var NoteSchema = z4.object({
|
|
971
|
+
id: z4.string(),
|
|
941
972
|
/** One-line headline (broker-titled; deterministic floor). */
|
|
942
|
-
title:
|
|
973
|
+
title: z4.string(),
|
|
943
974
|
/** The original intent, verbatim — assignees always see the user's own words. */
|
|
944
|
-
intent:
|
|
975
|
+
intent: z4.string(),
|
|
945
976
|
source: NoteSourceSchema,
|
|
946
977
|
status: NoteStatusSchema,
|
|
947
978
|
/** Who it was assigned to (a participant ref, 'agent:<tokenId>'); null = unassigned. */
|
|
948
|
-
assignee:
|
|
979
|
+
assignee: z4.string().nullable(),
|
|
949
980
|
/** The request thread minted at assignment; null until assigned. */
|
|
950
|
-
parentId:
|
|
981
|
+
parentId: z4.string().nullable(),
|
|
951
982
|
/** REMINDERS (reminders-design.md): the NOT-BEFORE this becomes eligible to ride a
|
|
952
983
|
* call — never a deadline. It only ever comes from the user's own words, so when it
|
|
953
984
|
* passes Paigy rings ONCE (#1293, owner 2026-08-26: a time said out loud is consent to
|
|
@@ -956,131 +987,131 @@ var NoteSchema = z3.object({
|
|
|
956
987
|
// Defaulted, not required: a Note from an API deploy older than the reminders
|
|
957
988
|
// migration has none of these, and the defaults ARE what it means — no not-before,
|
|
958
989
|
// one ride, never ridden. Parsing must not fail across a rolling deploy.
|
|
959
|
-
dueAt:
|
|
990
|
+
dueAt: z4.string().nullable().default(null),
|
|
960
991
|
repeat: NoteRepeatSchema.default("once"),
|
|
961
992
|
/** How many calls have already carried it — the fatigue cap counts rides, not days. */
|
|
962
|
-
rides:
|
|
963
|
-
lastRideAt:
|
|
964
|
-
createdAt:
|
|
993
|
+
rides: z4.number().int().default(0),
|
|
994
|
+
lastRideAt: z4.string().nullable().default(null),
|
|
995
|
+
createdAt: z4.string()
|
|
965
996
|
});
|
|
966
|
-
var TriageItemSchema =
|
|
967
|
-
noteId:
|
|
997
|
+
var TriageItemSchema = z4.object({
|
|
998
|
+
noteId: z4.string(),
|
|
968
999
|
/** The note's headline at run time. */
|
|
969
|
-
title:
|
|
1000
|
+
title: z4.string(),
|
|
970
1001
|
/** WHY, in one short human line, evidence first — this is read on a phone underneath
|
|
971
1002
|
* the note's title: "no movement in 34 days", "worked 3 notes in this repo this week".
|
|
972
1003
|
* Never a model's reasoning transcript, never an id. */
|
|
973
|
-
why:
|
|
1004
|
+
why: z4.string()
|
|
974
1005
|
});
|
|
975
|
-
var TriageAssignmentSchema =
|
|
1006
|
+
var TriageAssignmentSchema = z4.object({
|
|
976
1007
|
/** The agent's token id — what `dispatchNote` resolves and what a request is addressed to. */
|
|
977
|
-
agent:
|
|
1008
|
+
agent: z4.string(),
|
|
978
1009
|
/** Its display name at run time (the name on the hatchling's card). Denormalized for the
|
|
979
1010
|
* same reason as `title`: the card must render from the proposal alone. */
|
|
980
|
-
agentName:
|
|
981
|
-
notes:
|
|
1011
|
+
agentName: z4.string(),
|
|
1012
|
+
notes: z4.array(TriageItemSchema)
|
|
982
1013
|
});
|
|
983
|
-
var TriageStatusSchema =
|
|
984
|
-
var SubmitTriageSchema =
|
|
1014
|
+
var TriageStatusSchema = z4.enum(["open", "superseded", "dismissed"]);
|
|
1015
|
+
var SubmitTriageSchema = z4.object({
|
|
985
1016
|
/** Which runtime judged: "ollama" (inference never left the machine) or a harness the
|
|
986
1017
|
* user already runs under their own credentials ("claude" / "codex" / "agy"). Recorded
|
|
987
1018
|
* so the phone can say where the content went — an unattributed privacy claim is worth
|
|
988
1019
|
* nothing, and #1106's promise is precisely "Paigy's servers never see this". */
|
|
989
|
-
provider:
|
|
1020
|
+
provider: z4.string().min(1).max(60),
|
|
990
1021
|
/** The concrete model when the provider names one (an ollama tag); null otherwise. */
|
|
991
|
-
model:
|
|
1022
|
+
model: z4.string().max(200).nullable().optional(),
|
|
992
1023
|
/** How many open notes the run actually looked at — the denominator on the phone
|
|
993
1024
|
* ("6 of 50"), and the honest answer to "did it read the whole queue?". */
|
|
994
|
-
reviewed:
|
|
995
|
-
close:
|
|
996
|
-
stale:
|
|
997
|
-
assign:
|
|
1025
|
+
reviewed: z4.number().int().min(0).max(1e4).default(0),
|
|
1026
|
+
close: z4.array(TriageItemSchema).max(200).default([]),
|
|
1027
|
+
stale: z4.array(TriageItemSchema).max(200).default([]),
|
|
1028
|
+
assign: z4.array(TriageAssignmentSchema).max(50).default([])
|
|
998
1029
|
});
|
|
999
1030
|
var TriageProposalSchema = SubmitTriageSchema.extend({
|
|
1000
|
-
id:
|
|
1001
|
-
runAt:
|
|
1031
|
+
id: z4.string(),
|
|
1032
|
+
runAt: z4.string(),
|
|
1002
1033
|
status: TriageStatusSchema,
|
|
1003
|
-
model:
|
|
1034
|
+
model: z4.string().nullable().default(null)
|
|
1004
1035
|
});
|
|
1005
|
-
var AcceptTriageSchema =
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
group:
|
|
1010
|
-
agent:
|
|
1011
|
-
noteIds:
|
|
1036
|
+
var AcceptTriageSchema = z4.discriminatedUnion("group", [
|
|
1037
|
+
z4.object({ group: z4.literal("close"), noteIds: z4.array(z4.string()).max(200).optional() }),
|
|
1038
|
+
z4.object({ group: z4.literal("stale"), noteIds: z4.array(z4.string()).max(200).optional() }),
|
|
1039
|
+
z4.object({
|
|
1040
|
+
group: z4.literal("assign"),
|
|
1041
|
+
agent: z4.string().min(1),
|
|
1042
|
+
noteIds: z4.array(z4.string()).max(200).optional()
|
|
1012
1043
|
})
|
|
1013
1044
|
]);
|
|
1014
|
-
var AcceptTriageResultSchema =
|
|
1015
|
-
accepted:
|
|
1016
|
-
failed:
|
|
1045
|
+
var AcceptTriageResultSchema = z4.object({
|
|
1046
|
+
accepted: z4.array(z4.string()),
|
|
1047
|
+
failed: z4.array(z4.object({ noteId: z4.string(), reason: z4.string() }))
|
|
1017
1048
|
});
|
|
1018
|
-
var DeliveryModeSchema =
|
|
1019
|
-
var RegisterDeliverySchema =
|
|
1020
|
-
var OAuthStartSchema =
|
|
1021
|
-
provider:
|
|
1022
|
-
returnTo:
|
|
1049
|
+
var DeliveryModeSchema = z4.enum(["poll", "self_hosted"]);
|
|
1050
|
+
var RegisterDeliverySchema = z4.object({ mode: DeliveryModeSchema });
|
|
1051
|
+
var OAuthStartSchema = z4.object({
|
|
1052
|
+
provider: z4.enum(["cma"]),
|
|
1053
|
+
returnTo: z4.string().min(1)
|
|
1023
1054
|
});
|
|
1024
|
-
var DeliveryConfigSchema =
|
|
1025
|
-
tokenId:
|
|
1055
|
+
var DeliveryConfigSchema = z4.object({
|
|
1056
|
+
tokenId: z4.string(),
|
|
1026
1057
|
mode: DeliveryModeSchema,
|
|
1027
1058
|
/** null when the deployment has no anon key configured. `self_hosted` is then REFUSED
|
|
1028
1059
|
* (503 `self_hosted_unavailable`) rather than registered, so a self_hosted config always
|
|
1029
1060
|
* carries credentials; only a `poll` registration can come back with null here. */
|
|
1030
|
-
realtime:
|
|
1061
|
+
realtime: z4.object({ url: z4.string(), anonKey: z4.string() }).nullable()
|
|
1031
1062
|
});
|
|
1032
|
-
var WakeNudgeSchema =
|
|
1033
|
-
kind:
|
|
1034
|
-
notificationId:
|
|
1035
|
-
parentId:
|
|
1063
|
+
var WakeNudgeSchema = z4.object({
|
|
1064
|
+
kind: z4.enum(["reply", "request", "callback"]),
|
|
1065
|
+
notificationId: z4.string().optional(),
|
|
1066
|
+
parentId: z4.string()
|
|
1036
1067
|
});
|
|
1037
|
-
var PairingStatusSchema =
|
|
1038
|
-
var DeviceCodeSchema =
|
|
1039
|
-
device_code:
|
|
1040
|
-
user_code:
|
|
1041
|
-
verification_uri:
|
|
1042
|
-
verification_uri_complete:
|
|
1043
|
-
interval:
|
|
1044
|
-
expires_in:
|
|
1068
|
+
var PairingStatusSchema = z4.enum(["pending", "approved", "denied", "expired"]);
|
|
1069
|
+
var DeviceCodeSchema = z4.object({
|
|
1070
|
+
device_code: z4.string(),
|
|
1071
|
+
user_code: z4.string(),
|
|
1072
|
+
verification_uri: z4.string().url(),
|
|
1073
|
+
verification_uri_complete: z4.string().url(),
|
|
1074
|
+
interval: z4.number(),
|
|
1075
|
+
expires_in: z4.number()
|
|
1045
1076
|
});
|
|
1046
|
-
var DeviceInfoSchema =
|
|
1047
|
-
code:
|
|
1077
|
+
var DeviceInfoSchema = z4.object({
|
|
1078
|
+
code: z4.string(),
|
|
1048
1079
|
/** The agent's suggested name (from /device/code) — shown on the approval screen,
|
|
1049
1080
|
* pre-filling the name field the human can edit. */
|
|
1050
|
-
name:
|
|
1081
|
+
name: z4.string(),
|
|
1051
1082
|
/** @deprecated Legacy alias of `name` for the pre-#531 embedded bundle in App Store
|
|
1052
1083
|
* build 35, whose DeviceFlow renders `info.agent.slice(0, 2)` — without this a FRESH
|
|
1053
1084
|
* install crashes on the pairing screen on first launch, before the OTA lands
|
|
1054
1085
|
* (seen live: PAIGY-5T, 2026-07-21). Remove once a newer binary is the floor. */
|
|
1055
|
-
agent:
|
|
1056
|
-
device:
|
|
1086
|
+
agent: z4.string().optional(),
|
|
1087
|
+
device: z4.string().nullable(),
|
|
1057
1088
|
status: PairingStatusSchema
|
|
1058
1089
|
});
|
|
1059
|
-
var DeviceTokenSchema =
|
|
1060
|
-
access_token:
|
|
1090
|
+
var DeviceTokenSchema = z4.object({
|
|
1091
|
+
access_token: z4.string(),
|
|
1061
1092
|
/** The pairing's single name (user-typed at approval, the agent's suggestion, or
|
|
1062
1093
|
* a default silly name). */
|
|
1063
|
-
name:
|
|
1064
|
-
device:
|
|
1094
|
+
name: z4.string(),
|
|
1095
|
+
device: z4.string().nullable(),
|
|
1065
1096
|
/** The pairing's assigned voice, cached so the desktop can seed the SAME face the phone
|
|
1066
1097
|
* draws — voice is the third ingredient of a hatchling's build (party/traits.ts). */
|
|
1067
|
-
voice:
|
|
1098
|
+
voice: z4.string().nullable().optional(),
|
|
1068
1099
|
/** The token's server-side id — the face's COLOUR anchor, and the only seed ingredient
|
|
1069
1100
|
* that survives a rename. Cached by the host's identity beat. */
|
|
1070
|
-
token_id:
|
|
1101
|
+
token_id: z4.string().nullable().optional(),
|
|
1071
1102
|
/** WHERE this identity works — the folder a wake should land it in. Written by the host
|
|
1072
1103
|
* at spawn and by `paigy-harness handoff` from a live terminal. Without it every wake
|
|
1073
1104
|
* landed in the FIRST granted workspace and the agent rediscovered its own repo from
|
|
1074
1105
|
* the thread each time (host.ts, live catch 2026-08-06 — prompt-papered until now). */
|
|
1075
|
-
workspace:
|
|
1076
|
-
uik_pub:
|
|
1106
|
+
workspace: z4.string().nullable().optional(),
|
|
1107
|
+
uik_pub: z4.string().nullable().optional()
|
|
1077
1108
|
});
|
|
1078
|
-
var SupportRequestSchema =
|
|
1079
|
-
email:
|
|
1080
|
-
message:
|
|
1081
|
-
name:
|
|
1109
|
+
var SupportRequestSchema = z4.object({
|
|
1110
|
+
email: z4.string().email().max(320),
|
|
1111
|
+
message: z4.string().trim().min(1).max(5e3),
|
|
1112
|
+
name: z4.string().trim().max(120).optional()
|
|
1082
1113
|
});
|
|
1083
|
-
var NotificationFeedbackKindSchema =
|
|
1114
|
+
var NotificationFeedbackKindSchema = z4.enum([
|
|
1084
1115
|
"break_down",
|
|
1085
1116
|
// "This should be more than one ask — break it down."
|
|
1086
1117
|
"regenerate_options",
|
|
@@ -1095,9 +1126,117 @@ var NotificationFeedbackKindSchema = z3.enum([
|
|
|
1095
1126
|
// anything else — the note carries it.
|
|
1096
1127
|
]);
|
|
1097
1128
|
|
|
1129
|
+
// src/listening.ts
|
|
1130
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
1131
|
+
import { homedir } from "os";
|
|
1132
|
+
import { dirname, join } from "path";
|
|
1133
|
+
var HARNESS_MARKS = ["PAIGY_HARNESS", "PAIGY_SESSION_ID"];
|
|
1134
|
+
function harnessDriven(env) {
|
|
1135
|
+
return HARNESS_MARKS.some((mark) => !!env[mark]);
|
|
1136
|
+
}
|
|
1137
|
+
function sessionStartHook() {
|
|
1138
|
+
return `#!/bin/sh
|
|
1139
|
+
# Paigy \u2014 written by \`paigy-enable-tools\`. Prints one line into a starting session.
|
|
1140
|
+
# Never edits, never calls the network, and exits 0 whatever happens: a hook that fails
|
|
1141
|
+
# is a session that fails to start.
|
|
1142
|
+
[ -n "${HARNESS_MARKS.map((m) => `\${${m}}`).join("")}" ] && exit 0 # harness-spawned (either mark): its pump already delivers
|
|
1143
|
+
[ -f "\${HOME}/.paigy/token.json" ] || exit 0 # not paired on this machine: nothing to listen as
|
|
1144
|
+
cat <<'PAIGY'
|
|
1145
|
+
Paigy: nothing pushes answers into a session started in a terminal \u2014 an answer waits for your
|
|
1146
|
+
next check_replies. Start your own listener now, as a BACKGROUND task whose output you watch
|
|
1147
|
+
(your harness's monitor/background-task tool), and leave it running for this session:
|
|
1148
|
+
|
|
1149
|
+
npx -y -p @paigy/mcp@latest paigy-listen
|
|
1150
|
+
|
|
1151
|
+
Each line it prints is a Delivery waiting for you: read the Goal, and reply with contact when
|
|
1152
|
+
the answer needs a decision. Start it once; if it is already running, do nothing.
|
|
1153
|
+
PAIGY
|
|
1154
|
+
exit 0
|
|
1155
|
+
`;
|
|
1156
|
+
}
|
|
1157
|
+
function withSessionStartHook(existing, command) {
|
|
1158
|
+
let config = {};
|
|
1159
|
+
if (existing) {
|
|
1160
|
+
try {
|
|
1161
|
+
config = JSON.parse(existing);
|
|
1162
|
+
} catch {
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
const hooks = config.hooks ??= {};
|
|
1167
|
+
const starts = hooks.SessionStart ??= [];
|
|
1168
|
+
const already = starts.some((group) => (group.hooks ?? []).some((h) => h.command === command));
|
|
1169
|
+
if (!already) starts.push({ hooks: [{ type: "command", command }] });
|
|
1170
|
+
return JSON.stringify(config, null, 2);
|
|
1171
|
+
}
|
|
1172
|
+
var word = (s) => `'${s.replace(/'/g, "'\\''")}'`;
|
|
1173
|
+
function decideListen(f) {
|
|
1174
|
+
if (harnessDriven(f.env)) {
|
|
1175
|
+
return {
|
|
1176
|
+
status: "listening",
|
|
1177
|
+
via: "harness",
|
|
1178
|
+
message: "Already listening: this session was started by the Paigy harness, whose pump delivers every answer into it as it lands. Nothing to start."
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
if (!f.token) {
|
|
1182
|
+
return {
|
|
1183
|
+
status: "unpaired",
|
|
1184
|
+
message: "Not paired: no token in this session's slot, so there is no identity to listen as \u2014 call onboard first."
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
if (f.pidAlive && f.pid !== void 0) {
|
|
1188
|
+
return { status: "listening", via: "daemon", pid: f.pid, message: `Already listening (paigy-listen pid ${f.pid}). Do nothing.` };
|
|
1189
|
+
}
|
|
1190
|
+
return {
|
|
1191
|
+
status: "start",
|
|
1192
|
+
command: `PAIGY_AGENT=${word(f.slot)} PAIGY_SESSION_ID=${word(f.session)} ${word(f.execPath)} ${word(f.listenJs)} --brief`,
|
|
1193
|
+
how: "Run this command NOW as a watched background task (your harness's background-task/monitor tool), and leave it running for this session. Each line it prints is a Delivery waiting for you: claim_goal the Goal it names, read it, and answer with contact. Start it once."
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
function listenMark(slot, home = homedir()) {
|
|
1197
|
+
return join(home, ".paigy", "listen", `${slot.replace(/[^\w.-]/g, "_")}.pid`);
|
|
1198
|
+
}
|
|
1199
|
+
function writeListenMark(slot, pid, home = homedir()) {
|
|
1200
|
+
const path = listenMark(slot, home);
|
|
1201
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1202
|
+
writeFileSync(path, `${pid}
|
|
1203
|
+
`, { mode: 384 });
|
|
1204
|
+
chmodSync(path, 384);
|
|
1205
|
+
}
|
|
1206
|
+
function removeListenMark(slot, home = homedir()) {
|
|
1207
|
+
rmSync(listenMark(slot, home), { force: true });
|
|
1208
|
+
}
|
|
1209
|
+
function listenerAlive(slot, home = homedir()) {
|
|
1210
|
+
const path = listenMark(slot, home);
|
|
1211
|
+
let pid;
|
|
1212
|
+
try {
|
|
1213
|
+
pid = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
|
|
1214
|
+
} catch {
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
1218
|
+
rmSync(path, { force: true });
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1221
|
+
try {
|
|
1222
|
+
process.kill(pid, 0);
|
|
1223
|
+
return pid;
|
|
1224
|
+
} catch (e) {
|
|
1225
|
+
if (e.code === "EPERM") return pid;
|
|
1226
|
+
rmSync(path, { force: true });
|
|
1227
|
+
return null;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1098
1231
|
export {
|
|
1099
1232
|
mcpInputSchema,
|
|
1100
1233
|
AGENT_TOOLS,
|
|
1101
1234
|
serverInstructions,
|
|
1102
|
-
entryWords
|
|
1235
|
+
entryWords,
|
|
1236
|
+
sessionStartHook,
|
|
1237
|
+
withSessionStartHook,
|
|
1238
|
+
decideListen,
|
|
1239
|
+
writeListenMark,
|
|
1240
|
+
removeListenMark,
|
|
1241
|
+
listenerAlive
|
|
1103
1242
|
};
|