@ego-z/contracts 0.14.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ego-z/contracts",
3
- "version": "0.14.4",
3
+ "version": "0.15.0",
4
4
  "description": "Wire-format type contracts shared between EgoZ backend, SDK, MCP and console. Type-only — no runtime artifacts.",
5
5
  "types": "./src/index.d.ts",
6
6
  "exports": {
@@ -0,0 +1,310 @@
1
+ /**
2
+ * @ego-z/contracts — `POST /egoz/conversations/draft` wire types.
3
+ *
4
+ * The GHOSTED CONVERSATION START. When a tenant surface opens a new chat
5
+ * panel, it calls this before the user has typed anything. EgoZ mints a
6
+ * conversation id, stores the caller's init context against it as a draft,
7
+ * and returns quick suggested prompts — so the panel opens with a real id
8
+ * and something to click, rather than acquiring both mid-stream on the first
9
+ * message.
10
+ *
11
+ * No LLM turn is billed. Nothing is persisted as a user message: the init
12
+ * context is rendered into the system prompt as context, never as something
13
+ * the model answers.
14
+ *
15
+ * ── Two ids, and only one of them is an identity ─────────────────────────
16
+ *
17
+ * `conversationId` is EgoZ-minted and canonical — the only id a follow-up
18
+ * `/ask` ever carries. `draftKey` is a caller-generated dedupe token for one
19
+ * chat panel. They are deliberately separate: the thing that needs
20
+ * uniqueness (a panel's attempt to start a draft) is not the thing that
21
+ * needs identity (a conversation), and conflating them would change which id
22
+ * is canonical for every existing caller.
23
+ *
24
+ * ── Evolution ────────────────────────────────────────────────────────────
25
+ *
26
+ * Additive only. Every `initContext` field is optional, so a caller that
27
+ * hasn't implemented a field yet degrades one feature rather than failing a
28
+ * request. Unknown fields are TOLERATED, not rejected — but they are also
29
+ * not forwarded: the server keeps only what it models, because everything
30
+ * here is rendered into a system prompt and arbitrary caller JSON reaching
31
+ * that position is an injection surface, not a convenience. Callers with
32
+ * something unmodelled to say use `notes`.
33
+ */
34
+
35
+ import type { ThreadStatus } from './thread';
36
+
37
+ // ============================================================================
38
+ // Init context — the snapshot that accompanies a start
39
+ // ============================================================================
40
+
41
+ /** Resolved colour scheme. Never a preference — see `InitContextUi.theme`. */
42
+ export type InitContextTheme = 'light' | 'dark';
43
+
44
+ /** Viewport class. Deliberately not "device" — see `InitContextUi.viewport`. */
45
+ export type InitContextViewport = 'mobile' | 'desktop';
46
+
47
+ /** One open window in the caller's UI. */
48
+ export interface InitContextOpenWindow {
49
+ /** Epoch millis the window was opened. */
50
+ openedAt?: number;
51
+ /**
52
+ * Human-readable window label, e.g. `"Blue Hoodie"`. Safe to render
53
+ * verbatim — unlike the window KEY, which embeds entity ids and is
54
+ * rendered only for the focused window.
55
+ */
56
+ title?: string;
57
+ isFocused?: boolean;
58
+ }
59
+
60
+ /** One visited tab. `visitedAt` gives suggestion ranking a recency tiebreak. */
61
+ export interface InitContextVisitedTab {
62
+ /** Epoch millis the tab was last visited. */
63
+ visitedAt?: number;
64
+ isCurrent?: boolean;
65
+ }
66
+
67
+ /**
68
+ * What the user is looking at.
69
+ *
70
+ * The first four fields are structurally identical to the AppNa console's
71
+ * `UserContext`, so a caller that already models an OS-style window system
72
+ * passes its context straight through with no mapper. They are declared here
73
+ * as EgoZ's own type rather than imported: EgoZ serves callers that are not
74
+ * window systems, and a public contract that mirrors one client's internal
75
+ * type makes every refactor of that client a breaking change here.
76
+ *
77
+ * RANKING USES FOCUS, NOT THE SET. "Has Orders open" is weak signal; "is
78
+ * looking at a product detail" is strong. `openWindows` is a secondary
79
+ * signal and `visitedTabs[].visitedAt` a tiebreak.
80
+ */
81
+ export interface InitContextUi {
82
+ /** Coarse screen the user is on, e.g. `"ecommerce"`, `"orders"`. */
83
+ currentRouteId?: string | null;
84
+ /** Key of the focused window, e.g. `"ecommerce:productDetail:abc"`. */
85
+ focusedWindowId?: string | null;
86
+ openWindows?: Record<string, InitContextOpenWindow>;
87
+ visitedTabs?: Record<string, InitContextVisitedTab>;
88
+ /**
89
+ * The RESOLVED colour scheme — never a preference. A caller whose stored
90
+ * setting is "system" MUST resolve it against `prefers-color-scheme`
91
+ * before sending: `'system'` is a state EgoZ cannot evaluate and has
92
+ * nothing useful to say about, and it would be the value for a large
93
+ * share of users.
94
+ */
95
+ theme?: InitContextTheme | null;
96
+ /**
97
+ * Viewport class — NOT a device. Callers derive it from a width
98
+ * breakpoint, so a narrowed desktop browser window reports `'mobile'`.
99
+ * Named for what it measures on purpose: `device` would invite the agent
100
+ * to say "since you're on your phone…" to someone who dragged their
101
+ * window narrow. A real device signal (touch capability, UA) would be a
102
+ * separate field with a separate source.
103
+ */
104
+ viewport?: InitContextViewport | null;
105
+ }
106
+
107
+ /**
108
+ * Who the user is.
109
+ *
110
+ * `name` is nullable: a phone-only signup genuinely has none, and the
111
+ * greeting has a no-name path rather than producing "Hello, null".
112
+ *
113
+ * ── `roles` is a SET, and it is ADDITIVE-ONLY ────────────────────────────
114
+ *
115
+ * A role gates which actions the agent offers, which makes a client-asserted
116
+ * role a client-asserted permission. It MUST be resolved by whoever
117
+ * authenticated the user; a gateway fills it from the verified session and
118
+ * ignores whatever the browser sent. The vocabulary belongs to the caller's
119
+ * product, so entries are free-form strings.
120
+ *
121
+ * It is an ARRAY, not a single value, because the underlying fact is a set
122
+ * and collapsing a set needs a precedence rule — a precedence rule absent
123
+ * from the contract is one each implementer invents privately, and an owner
124
+ * who also takes appointments then never sees the specialist suggestions.
125
+ *
126
+ * It may only ever WIDEN what the agent offers — add to the capability
127
+ * summary, boost ranking — and may never narrow either. The reason is a
128
+ * property of the source: on a permissions node where present means true
129
+ * and ABSENT MEANS UNKNOWN, `['Specialist']` does not mean "not an owner",
130
+ * it means "specialist is true and the rest is unknown". Every inference of
131
+ * the form *only a specialist, so hide X* is unsound on that data.
132
+ *
133
+ * The asymmetry also matters because the failure is invisible: a wrongly
134
+ * SHOWN suggestion gets reported, a wrongly HIDDEN one never does — the user
135
+ * just sees a less useful assistant and assumes that's what it is. Hard
136
+ * filtering belongs to `tenant.features`, which genuinely can say no.
137
+ *
138
+ * There is no `id` field: the end user is already identified at request
139
+ * level by `externalUserId`. One id, one place.
140
+ */
141
+ export interface InitContextUser {
142
+ name?: string | null;
143
+ roles?: string[];
144
+ }
145
+
146
+ /**
147
+ * Language.
148
+ *
149
+ * TWO DIFFERENT FACTS ABOUT TWO DIFFERENT SUBJECTS — the distinction is
150
+ * load-bearing and was got wrong once already:
151
+ *
152
+ * - `selected` is **which language this person reads**. It is the only input
153
+ * to which language EgoZ replies in. The response echoes the RESOLVED
154
+ * value, so the caller and EgoZ cannot silently disagree about it.
155
+ * - `supported` is **which languages the tenant's CONTENT exists in** — a
156
+ * catalogue of the store's translations, not a list of languages the user
157
+ * understands. It is NEVER consulted when resolving the reply language:
158
+ * doing so gives a French-reading admin at an Arabic-only store Arabic
159
+ * chips, which is the resolver faithfully answering the wrong question.
160
+ *
161
+ * `supported` is still worth sending — it belongs in the prompt, where it
162
+ * makes "translate this product into Hebrew" a sensible suggestion for one
163
+ * tenant and a dead end for another.
164
+ */
165
+ export interface InitContextLocale {
166
+ /** The language the USER reads, e.g. `"ar"`. Drives the reply language. */
167
+ selected?: string | null;
168
+ /**
169
+ * The languages the TENANT'S CONTENT exists in, e.g. `["en","ar","he"]`.
170
+ * Context for the agent; never used to resolve the reply language.
171
+ */
172
+ supported?: string[];
173
+ }
174
+
175
+ /**
176
+ * The END TENANT this conversation belongs to.
177
+ *
178
+ * NOT necessarily the EgoZ tenant. On a gateway integration one EgoZ project
179
+ * fronts many of the gateway's tenants, so EgoZ's own tenant record
180
+ * describes the DEPLOYMENT and knows nothing about tenant "ABC". Which side
181
+ * is authoritative is declared once per EgoZ tenant (`tenantScope`), not
182
+ * inferred per request.
183
+ *
184
+ * `features` is the highest-value field here for suggestion quality: a
185
+ * per-tenant flag set EgoZ has no way to see. Without it the ranker either
186
+ * offers an action the tenant has switched off, or falls back to generic
187
+ * chips — the exact failure the ranking exists to avoid.
188
+ */
189
+ export interface InitContextTenant {
190
+ name?: string | null;
191
+ description?: string | null;
192
+ category?: string | null;
193
+ /** Enabled-feature flags, e.g. `{ products: true, booking: false }`. */
194
+ features?: Record<string, boolean>;
195
+ }
196
+
197
+ /**
198
+ * The full init context. Every field optional — see the file header on
199
+ * additive evolution.
200
+ */
201
+ export interface InitContext {
202
+ user?: InitContextUser;
203
+ locale?: InitContextLocale;
204
+ ui?: InitContextUi;
205
+ tenant?: InitContextTenant;
206
+ /** Anything the contract doesn't model yet. Rendered as untrusted data. */
207
+ notes?: string | null;
208
+ /**
209
+ * Epoch millis the caller took the snapshot. Optional; EgoZ stamps it on
210
+ * arrival when absent.
211
+ *
212
+ * It governs how long the `ui` half may still be rendered into a prompt,
213
+ * which is a SHORTER clock than the draft's own lifetime. A draft resumed
214
+ * after lunch is still the user's conversation, but "you have the
215
+ * Products window open" is by then a confident claim about a screen they
216
+ * closed — so past the snapshot window the UI half is dropped while
217
+ * `user` / `locale` / `tenant` still render.
218
+ */
219
+ capturedAt?: number;
220
+ }
221
+
222
+ // ============================================================================
223
+ // Request / response
224
+ // ============================================================================
225
+
226
+ /** Body of `POST /egoz/conversations/draft`. */
227
+ export interface StartDraftRequestBody {
228
+ /**
229
+ * Idempotency token for ONE chat panel — opaque, caller-generated, stable
230
+ * across remounts of that panel and fresh only for a genuinely new one.
231
+ * A good source is the panel's own window key plus a per-open nonce.
232
+ *
233
+ * Repeating a call with the same key refreshes that draft's init context
234
+ * and returns the SAME `conversationId` rather than minting another row.
235
+ * Callers SHOULD send one: a double-mounting panel that sends none leaks
236
+ * an orphan draft per mount.
237
+ *
238
+ * It is NOT a thread id and can never be used as one — its uniqueness
239
+ * index covers drafts only, and promotion drops it.
240
+ */
241
+ draftKey?: string;
242
+ /**
243
+ * Consumer-supplied conversation id, for callers that already mint their
244
+ * own (Phase 6). Unrelated to idempotency — that is `draftKey`'s job.
245
+ */
246
+ externalThreadId?: string;
247
+ /** The end user this conversation is for, as the caller identifies them. */
248
+ externalUserId?: string;
249
+ initContext?: InitContext;
250
+ }
251
+
252
+ /**
253
+ * Where a suggested prompt came from. Surfaced because the two have
254
+ * different quality and latency characteristics, and a caller measuring chip
255
+ * performance needs to tell an instant catalog hit from a model-generated
256
+ * fallback rather than averaging them together.
257
+ */
258
+ export type SuggestedPromptSource = 'template' | 'llm';
259
+
260
+ /** One suggested prompt chip, ready to render. */
261
+ export interface SuggestedPromptWire {
262
+ /** Stable catalog id — for the caller's own analytics, not for display. */
263
+ id: string;
264
+ /** The prompt text, already in the resolved locale. */
265
+ text: string;
266
+ source: SuggestedPromptSource;
267
+ }
268
+
269
+ /** `data` payload of a successful `POST /egoz/conversations/draft`. */
270
+ export interface StartDraftResponseData {
271
+ /**
272
+ * The canonical conversation id. This is what a follow-up `/ask` carries
273
+ * as `threadId`. A turn carrying it MUST come back with the same id —
274
+ * EgoZ never mints a replacement for a supplied id, and a lapsed draft
275
+ * fails with `DraftExpired` rather than quietly becoming a new
276
+ * conversation.
277
+ */
278
+ conversationId: string;
279
+ /** Always `'draft'` on a successful start; present so callers can assert. */
280
+ status: ThreadStatus;
281
+ /**
282
+ * ISO 8601. When the conversation id stops resolving — the row TTL.
283
+ *
284
+ * One of TWO CLOCKS, deliberately not collapsed into one number: this
285
+ * one governs how long the caller may still resume the conversation.
286
+ */
287
+ expiresAt: string;
288
+ /**
289
+ * ISO 8601, and much sooner than `expiresAt`. When the `ui` half of the
290
+ * init context stops being rendered into the prompt.
291
+ *
292
+ * The second clock. A draft resumed after lunch is still the user's
293
+ * conversation, but "you have the Products window open" is by then a
294
+ * confident claim about a screen they closed. Past this instant the UI
295
+ * half is dropped while `user` / `locale` / `tenant` keep rendering —
296
+ * those don't go stale.
297
+ */
298
+ contextExpiresAt: string;
299
+ /**
300
+ * Quick prompts for the panel to render, in `locale`. May be empty when
301
+ * nothing in the catalog fits and the fallback produced nothing — an
302
+ * empty list is a normal outcome, not an error.
303
+ */
304
+ suggestedPrompts: SuggestedPromptWire[];
305
+ /**
306
+ * The RESOLVED language — what EgoZ actually used — not the requested
307
+ * one. Render the chips in this.
308
+ */
309
+ locale: string;
310
+ }
package/src/index.d.ts CHANGED
@@ -53,8 +53,16 @@
53
53
  * - `thread.d.ts` — `ThreadWire`, `MessageWire`,
54
54
  * `ThreadWithStatsWire`, `ThreadWithMessagesWire`,
55
55
  * `ToolCallWire`, list / get response data,
56
- * vocabularies (`MessageRole`,
56
+ * vocabularies (`MessageRole`, `ThreadStatus`,
57
57
  * `MessageFailureReason`).
58
+ * - `draft-conv.d.ts` — the ghosted conversation start:
59
+ * `InitContext` (+ `InitContextUser`,
60
+ * `InitContextLocale`, `InitContextUi`,
61
+ * `InitContextTenant`, `InitContextOpenWindow`,
62
+ * `InitContextVisitedTab`),
63
+ * `StartDraftRequestBody`, `StartDraftResponseData`,
64
+ * `SuggestedPromptWire`, vocabularies
65
+ * (`InitContextTheme`, `InitContextViewport`).
58
66
  * - `personality.d.ts` — Both surfaces share this file. User-level:
59
67
  * `PersonalityProfileWire`, `PresetDefinitionWire`,
60
68
  * create / update bodies, `ActivePersonalityWire`.
@@ -89,6 +97,7 @@ export * from './mcp-token';
89
97
  export * from './mcp-connection';
90
98
  export * from './tenant';
91
99
  export * from './thread';
100
+ export * from './draft-conv';
92
101
  export * from './personality';
93
102
  export * from './user-memory';
94
103
  export * from './search';
package/src/thread.d.ts CHANGED
@@ -44,13 +44,22 @@ export type MessageFailureReason =
44
44
  * Conversation lifecycle state. Mirrors the CHECK constraint on
45
45
  * `egoz_threads.status` (migration 054).
46
46
  *
47
- * - `'draft'` — minted by the ghosted start (`POST /egoz/conversations/draft`)
48
- * before the user has typed anything. Carries the caller's
49
- * init context, holds a conversation id the caller can keep,
50
- * and is swept after the draft TTL if it never promotes.
51
- * - `'active'` a real conversation. Every thread created by `/ask` is born
52
- * active, and a draft becomes active on its first user
53
- * message. An active thread is never swept.
47
+ * - `'draft'` — minted by the ghosted start
48
+ * (`POST /egoz/conversations/draft`) before the user has
49
+ * typed anything. Carries the caller's init context, holds a
50
+ * conversation id the caller can keep, and lapses after the
51
+ * draft TTL if it never promotes.
52
+ * - `'active'` — a real conversation. Every thread created by `/ask` is born
53
+ * active, and a draft becomes active on its first user
54
+ * message. An active thread is never swept.
55
+ * - `'expired'` — a lapsed draft. A TOMBSTONE, not a conversation: it cannot
56
+ * be resumed and holds no messages. It exists so that a
57
+ * caller returning with a held draft id gets a distinct
58
+ * `DraftExpired` (410) rather than a `NotFound` it cannot
59
+ * tell apart from a garbage id — the contract requires a
60
+ * lapsed draft to fail loudly and specifically, never to
61
+ * silently become a new conversation. Hard-deleted after a
62
+ * grace window.
54
63
  *
55
64
  * ORTHOGONAL TO `isActive`, which means *archived* and predates this field.
56
65
  * A draft is not an archived thread and an archived thread was never a draft;
@@ -58,7 +67,7 @@ export type MessageFailureReason =
58
67
  * draft sweeper's delete predicate would then also match archived user
59
68
  * conversations.
60
69
  */
61
- export type ThreadStatus = 'draft' | 'active';
70
+ export type ThreadStatus = 'draft' | 'active' | 'expired';
62
71
 
63
72
  // ============================================================================
64
73
  // Supporting shapes