@konneal/engine 0.2.32 → 0.2.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/apicalls.d.ts +72 -0
  2. package/dist/ask-BDD5TFNC.js +12 -0
  3. package/dist/{chunk-JGKSUSF5.js → chunk-4GJGBGJK.js} +3 -3
  4. package/dist/{chunk-F66NYFUK.js → chunk-DADOI4Q6.js} +44 -8
  5. package/dist/{chunk-TDKNQPA2.js → chunk-MHA2OFWE.js} +305 -11
  6. package/dist/{chunk-S7NLWPNX.js → chunk-MNNE7OIM.js} +4 -4
  7. package/dist/{chunk-2RKYO3OC.js → chunk-R2V3X6SQ.js} +1 -1
  8. package/dist/{chunk-OMXAE27N.js → chunk-V46XM2GU.js} +4 -0
  9. package/dist/config.d.ts +1 -0
  10. package/dist/context.d.ts +37 -0
  11. package/dist/drafts.d.ts +5 -0
  12. package/dist/env.d.ts +2 -0
  13. package/dist/quota.d.ts +1 -0
  14. package/dist/requestScope.d.ts +10 -2
  15. package/dist/search-EADF54TY.js +12 -0
  16. package/dist/worker_mcp/src/index.js +2 -2
  17. package/dist/worker_public/src/config.js +1 -1
  18. package/dist/worker_public/src/index.js +12 -9
  19. package/dist/worker_public/src/requestScope.js +2 -2
  20. package/docs/API.md +83 -0
  21. package/package.json +1 -1
  22. package/workers/worker_public/migrations/0015_ask_retries.sql +4 -0
  23. package/workers/worker_public/src/apicalls.ts +447 -0
  24. package/workers/worker_public/src/ask.ts +57 -7
  25. package/workers/worker_public/src/config.ts +4 -0
  26. package/workers/worker_public/src/context.ts +89 -4
  27. package/workers/worker_public/src/drafts.ts +10 -0
  28. package/workers/worker_public/src/env.ts +5 -1
  29. package/workers/worker_public/src/index.ts +4 -1
  30. package/workers/worker_public/src/ports/cloudflare/adapters.ts +8 -0
  31. package/workers/worker_public/src/quota.ts +3 -3
  32. package/workers/worker_public/src/requestScope.ts +9 -3
  33. package/dist/ask-43C2WZZO.js +0 -12
  34. package/dist/search-NHTT5DDI.js +0 -12
@@ -0,0 +1,447 @@
1
+ // The api_call draft act (TODO.ai-platform/09) — the operations
2
+ // assistant's proposal grammar. One platform operation, prepared as a
3
+ // draft; the panel pre-flights it against the platform's assembled
4
+ // specification (unknown method+path pairs refuse there), the act class
5
+ // decides the arc (preference rides the standing grant's seam; record
6
+ // confirms per act and executes as the user), and the platform's own
7
+ // gates judge the write. This module, like drafts.ts, is STRUCTURALLY
8
+ // write-free: it reads the operation directory (D1) and calls the
9
+ // pick model; there is no fetch to the platform here.
10
+ //
11
+ // The invariants, and how this module keeps them:
12
+ //
13
+ // - THE DIRECTORY IS THE CLOSED WORLD. A draft's call names an
14
+ // operation the deployment's operation directory (the D1 `api_ops`
15
+ // table, loaded from the publisher's OpenAPI plane at ingest)
16
+ // actually carries — resolved by operation id AFTER the model
17
+ // proposes. An id the directory does not hold is no draft, ever;
18
+ // the model cannot invent a path because it never writes one.
19
+ //
20
+ // - THE MACHINE FACET BOUNDS THE MACHINE ACTS. An entity-context ask
21
+ // whose machine facet offers acts may propose only from that set
22
+ // (machineOffers); an empty set is the honest "the user can read
23
+ // but not act" — the answer says so and no draft rides. The facet
24
+ // is advisory; the platform re-judges authoritatively.
25
+ //
26
+ // - THE NEVER-OFFER LIST IS PROFILE DATA. The publisher declares the
27
+ // operations the assistant never proposes (the auth family — signout
28
+ // is the user's own, forever — and the grant's own mint/revoke:
29
+ // delegation is granted by the user's hand on the settings page,
30
+ // never by a chat message). The list filters the pick prompt AND
31
+ // re-checks the picked operation (belt and braces).
32
+ //
33
+ // - BODIES TRACE. A preference draft's body carries only values the
34
+ // user stated (drafts.ts's traceability primitive); an untraceable
35
+ // body refuses honestly instead of guessing settings.
36
+ //
37
+ // - TITLES SAY WHAT THE ACT DOES. "Save the filter", never
38
+ // "Execute PUT /api/…" — a title that leaks the wire shape is
39
+ // rejected and falls back to the operation's own summary.
40
+
41
+ import { P } from "./profile.ts";
42
+ import { machineOffers, type DeclaredContext, type MachineContext } from "./context.ts";
43
+ import { valueTracesToUser } from "./drafts.ts";
44
+ import { portModelRunner, portStore } from "./env.ts";
45
+ import type { ModelRunner } from "./ports/model.ts";
46
+ import type { StoreQuery } from "./ports/store.ts";
47
+
48
+ // ── the wire shape (the estate contract — the panel and the platform
49
+ // mirror it; the deployment's docs carry the reference) ────────────
50
+
51
+ export interface ApiCall {
52
+ method: "POST" | "PUT" | "PATCH" | "DELETE" | "GET";
53
+ path: string;
54
+ body?: unknown;
55
+ }
56
+
57
+ export interface ApiCallDraft {
58
+ kind: "draft";
59
+ act: "api_call";
60
+ version: 1;
61
+ title: string;
62
+ prepared_at: string;
63
+ /** ALWAYS true — the card's tap (or the standing grant, for the
64
+ * preference family) is the only authority that executes. */
65
+ requires_confirmation: true;
66
+ call: ApiCall;
67
+ notes?: string[];
68
+ }
69
+
70
+ export type ApiCallRefusalReason =
71
+ | "sign_in_required"
72
+ | "machine_read_only"
73
+ | "no_matching_act"
74
+ | "entity_unresolved"
75
+ | "operation_unknown"
76
+ | "operation_never_offered"
77
+ | "body_untraced"
78
+ | "pick_failed";
79
+
80
+ export type ApiCallVerdict =
81
+ | { status: "draft"; draft: ApiCallDraft; answer: string }
82
+ | { status: "refused"; reason: ApiCallRefusalReason; answer: string };
83
+
84
+ // ── the profile's acts facet (publisher data — the engine ships none) ─
85
+
86
+ interface ActsProfile {
87
+ /** the generic entity write the machine acts ride (path carries the
88
+ * {store}/{id} template exactly as the plane declares it) */
89
+ entity_write?: { method: string; path: string };
90
+ /** the entity pages' route patterns → the store the entity lives in */
91
+ entity_routes?: Array<{ pattern: string; store: string }>;
92
+ /** path prefixes the assistant never proposes (the auth family, the
93
+ * grant's own mint/revoke) */
94
+ never_offer?: string[];
95
+ }
96
+
97
+ function actsProfile(): ActsProfile | null {
98
+ const acts = (P() as any).publisher?.acts;
99
+ return acts && typeof acts === "object" ? acts : null;
100
+ }
101
+
102
+ /** A target the assistant never proposes: the never-offer prefixes match
103
+ * the operation's declared (templated) path. */
104
+ export function neverOffered(path: string): boolean {
105
+ const prefixes = actsProfile()?.never_offer ?? [];
106
+ return prefixes.some((p) => typeof p === "string" && path.startsWith(p));
107
+ }
108
+
109
+ // ── the operation directory (D1, loaded from the plane at ingest) ────
110
+
111
+ export interface ApiOp {
112
+ operation_id: string;
113
+ method: string;
114
+ path: string;
115
+ act_class: string | null;
116
+ summary: string | null;
117
+ tag: string | null;
118
+ }
119
+
120
+ async function operationById(store: StoreQuery, id: string): Promise<ApiOp | null> {
121
+ try {
122
+ return (await store.prepare("SELECT operation_id, method, path, act_class, summary, tag FROM api_ops WHERE operation_id = ?1").bind(id).first()) as ApiOp | null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ async function operationByMethodPath(store: StoreQuery, method: string, path: string): Promise<ApiOp | null> {
129
+ try {
130
+ return (await store.prepare("SELECT operation_id, method, path, act_class, summary, tag FROM api_ops WHERE method = ?1 AND path = ?2").bind(method.toLowerCase(), path).first()) as ApiOp | null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /** The preference family (the standing grant's closed world), for the
137
+ * pick prompt — bounded; a deployment whose family outgrows the cap
138
+ * truncates honestly (the directory read is ordered, never random). */
139
+ async function preferenceOps(store: StoreQuery): Promise<ApiOp[]> {
140
+ try {
141
+ const rows = await store.prepare("SELECT operation_id, method, path, act_class, summary, tag FROM api_ops WHERE act_class = 'preference' ORDER BY operation_id LIMIT 32").bind().all();
142
+ return ((rows as any).results ?? []) as ApiOp[];
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
147
+
148
+ // ── the entity resolution (the route pattern table is profile data) ──
149
+
150
+ /** Parse the declared context's route against the profile's entity-route
151
+ * patterns (`/a/b/:id` segments; the LAST parameter captures the entity
152
+ * id). Returns the store + id the entity write addresses, or null — a
153
+ * route the table does not cover is an honest unresolved, never a guess. */
154
+ export function resolveEntityRoute(route: string | undefined): { store: string; id: string } | null {
155
+ const patterns = actsProfile()?.entity_routes ?? [];
156
+ if (!route) return null;
157
+ const segs = route.split("?")[0]!.split("/").filter(Boolean);
158
+ for (const p of patterns) {
159
+ const psegs = String(p.pattern ?? "").split("/").filter(Boolean);
160
+ if (psegs.length !== segs.length) continue;
161
+ let id: string | null = null;
162
+ let ok = true;
163
+ for (let i = 0; i < psegs.length; i++) {
164
+ const ps = psegs[i]!;
165
+ if (ps.startsWith(":")) {
166
+ id = decodeURIComponent(segs[i]!);
167
+ } else if (ps !== segs[i]) {
168
+ ok = false;
169
+ break;
170
+ }
171
+ }
172
+ // the id is a routing token, bounded and charset-checked — it lands
173
+ // in a URL path, never in prose
174
+ if (ok && id && id.length <= 120 && /^[A-Za-z0-9._~-]+$/.test(id)) return { store: String(p.store), id };
175
+ }
176
+ return null;
177
+ }
178
+
179
+ // ── the intent detection (conservative — a false positive would offer
180
+ // an act the user never asked for) ─────────────────────────────────
181
+
182
+ /** The machine-act intent: an entity declaration with a machine facet,
183
+ * plus the user's own act verb aimed at the entity. */
184
+ const MACHINE_INTENT_RE =
185
+ /\b(accept|reject|submit|resubmit|withdraw|suspend|reinstate|renew|revise|transfer|issue|register|log|record|dispatch|complete|sign|approve|verify)\b[\s\S]{0,40}\b(this|that|the|it)\b|\b(this|that|the|it)\b[\s\S]{0,40}\b(accept|reject|submit|resubmit|withdraw|suspend|reinstate|renew|revise|transfer|issue|register|log|dispatch|complete|sign|approve|verify)\b/i;
186
+
187
+ /** The preference intent: the user's own settings vocabulary. */
188
+ const PREFERENCE_INTENT_RE =
189
+ /\b(mute|unmute|unsubscribe|subscribe|notification prefs?|notification rules?|email prefs?|email preferences|saved? filters?|save (?:this|the|that) filter|notify me|stop notifying|inbox)\b/i;
190
+
191
+ export type ApiCallIntent = "machine_act" | "preference";
192
+
193
+ /** A question ABOUT a concept is never an act request ("What is a saved
194
+ * filter?" asks for knowledge; "save this filter" acts). Conservative
195
+ * by design: the question openers steer to the corpus path. */
196
+ const QUESTION_OPENER_RE = /^\s*(?:what|how|why|where|when|which|who|whose|explain|describe|define|is|are|does|do)\b/i;
197
+
198
+ /** Which api_call family the ask aims at, or null. The machine facet
199
+ * decides the machine family (no facet = no machine act, whatever the
200
+ * verbs); the preference family needs no facet. */
201
+ export function detectApiCallIntent(query: string, declared: DeclaredContext | null): ApiCallIntent | null {
202
+ if (QUESTION_OPENER_RE.test(query)) return null;
203
+ if (declared?.kind === "entity" && declared.machine && MACHINE_INTENT_RE.test(query)) return "machine_act";
204
+ if (PREFERENCE_INTENT_RE.test(query)) return "preference";
205
+ return null;
206
+ }
207
+
208
+ // ── the pick (the model proposes; the directory and the facet dispose) ─
209
+
210
+ interface Pick {
211
+ /** machine family: the offered action id; preference family: the
212
+ * operation id — null is the honest "nothing here matches" */
213
+ target: string | null;
214
+ title?: string;
215
+ body?: unknown;
216
+ }
217
+
218
+ function pickPrompt(kind: ApiCallIntent, candidates: string[]): { system: string; max: number } {
219
+ if (kind === "machine_act") {
220
+ return {
221
+ system: `You map the user's request to one machine act from the OFFERED list, or to none.
222
+
223
+ Rules:
224
+ - Output ONLY a JSON object — no prose, no code fence: {"target": "<action id or null>", "title": "<what the act does, plain words>"}.
225
+ - The target must be one of the offered action ids, copied exactly, or null when the request matches none.
226
+ - The title names what the act DOES for the user ("Accept the application"), never the wire (no method, no path, no "/api/").
227
+ - Never invent an act that is not listed.
228
+
229
+ Offered acts (action id — lands the entity on):
230
+ ${candidates.join("\n")}`,
231
+ max: 300,
232
+ };
233
+ }
234
+ return {
235
+ system: `You map the user's request to one operation from the PREFERENCE list (the user's own settings), or to none.
236
+
237
+ Rules:
238
+ - Output ONLY a JSON object — no prose, no code fence: {"target": "<operation id or null>", "title": "<what the act does, plain words>", "body": {...}}.
239
+ - The target must be one of the listed operation ids, copied exactly, or null when the request matches none.
240
+ - "body" carries only values the user stated in their own words; omit it when the request names no values.
241
+ - The title names what the act DOES for the user ("Save the filter", "Mute these emails"), never the wire (no method, no path, no "/api/").
242
+ - Never invent an operation that is not listed.
243
+
244
+ Preference operations (id — method path — summary):
245
+ ${candidates.join("\n")}`,
246
+ max: 800,
247
+ };
248
+ }
249
+
250
+ async function pickTarget(runner: ModelRunner, model: string, kind: ApiCallIntent, candidates: string[], userTurns: string[]): Promise<Pick | null> {
251
+ const { system, max } = pickPrompt(kind, candidates);
252
+ const transcript = userTurns.map((t, i) => `${i + 1}. ${t}`).join("\n").slice(0, 8000);
253
+ try {
254
+ const res = await runner.run({
255
+ model,
256
+ messages: [
257
+ { role: "system", content: system },
258
+ { role: "user", content: `The user's messages, oldest first:\n${transcript}` },
259
+ ],
260
+ maxTokens: max,
261
+ effort: "low",
262
+ temperature: 0.1,
263
+ });
264
+ const text = res.text;
265
+ if (typeof text !== "string") return null;
266
+ const start = text.indexOf("{");
267
+ const end = text.lastIndexOf("}");
268
+ if (start < 0 || end <= start) return null;
269
+ const parsed = JSON.parse(text.slice(start, end + 1));
270
+ if (!parsed || typeof parsed !== "object") return null;
271
+ return {
272
+ target: typeof parsed.target === "string" && parsed.target.trim() ? parsed.target.trim().slice(0, 120) : null,
273
+ title: typeof parsed.title === "string" && parsed.title.trim() ? parsed.title.trim().slice(0, 120) : undefined,
274
+ body: parsed.body && typeof parsed.body === "object" ? parsed.body : undefined,
275
+ };
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+
281
+ /** The title guard: a title that leaks the wire shape is no title. */
282
+ function honestTitle(proposed: string | undefined, fallback: string): string {
283
+ const t = (proposed ?? "").trim();
284
+ if (!t) return fallback;
285
+ if (/\b(GET|POST|PUT|PATCH|DELETE)\b/.test(t) || t.includes("/api/") || t.includes("{")) return fallback;
286
+ return t.slice(0, 120);
287
+ }
288
+
289
+ /** The body guard: every string value traces to the user's own words
290
+ * (the never-invents doctrine, drafts.ts's primitive). Scalars the user
291
+ * toggles (booleans, numbers) pass — they state no fact. */
292
+ export function bodyTraces(body: unknown, userTurns: string[]): boolean {
293
+ if (body == null) return true;
294
+ if (typeof body === "boolean" || typeof body === "number") return true;
295
+ if (typeof body === "string") return valueTracesToUser(body, userTurns);
296
+ if (Array.isArray(body)) return body.every((v) => bodyTraces(v, userTurns));
297
+ if (typeof body === "object") return Object.values(body as Record<string, unknown>).every((v) => bodyTraces(v, userTurns));
298
+ return false;
299
+ }
300
+
301
+ // ── the composition ──────────────────────────────────────────────────
302
+
303
+ export interface ApiCallPrepareOpts {
304
+ intent: ApiCallIntent;
305
+ query: string;
306
+ history: Array<{ role: string; content: string }>;
307
+ member: { sub: string } | null;
308
+ declared: DeclaredContext | null;
309
+ /** the intent-mapping model (the profile's pick — the operations
310
+ * deployment runs the stronger reasoning tier here) */
311
+ model: string;
312
+ }
313
+
314
+ function refusal(reason: ApiCallRefusalReason, answer: string): ApiCallVerdict {
315
+ return { status: "refused", reason, answer };
316
+ }
317
+
318
+ export async function prepareApiCall(env: any, opts: ApiCallPrepareOpts): Promise<ApiCallVerdict> {
319
+ if (!opts.member) {
320
+ return refusal(
321
+ "sign_in_required",
322
+ "Preparing an act starts from your own account — sign in and ask again. " +
323
+ "The draft would still be yours alone: the card asks for your confirmation, and only your own tap executes it — I never hold a write credential.",
324
+ );
325
+ }
326
+ const acts = actsProfile();
327
+ const store = portStore(env);
328
+ const runner = portModelRunner(env);
329
+ const userTurns = [...opts.history.filter((h) => h.role === "user").map((h) => h.content), opts.query];
330
+
331
+ // ── the machine family: the offered set bounds the proposal ──
332
+ if (opts.intent === "machine_act") {
333
+ const machine = opts.declared?.machine as MachineContext | undefined;
334
+ const label = opts.declared?.label || "this record";
335
+ if (!machine) {
336
+ return refusal("no_matching_act", "This page did not declare the record's lifecycle state, so I cannot propose an act on it honestly. Open the record itself and ask again, or act from the platform's own controls — they always show exactly what your role may do.");
337
+ }
338
+ if (machine.acts.length === 0) {
339
+ // the honest "read but not act": say so, propose nothing
340
+ return refusal(
341
+ "machine_read_only",
342
+ `The record's lifecycle is at ${machine.state}, and the machine offers your role no acts there — you can read this record but not act on it. ` +
343
+ "Nothing was drafted. If that looks wrong, an officer of your organization can check the role your account carries.",
344
+ );
345
+ }
346
+ const entity = resolveEntityRoute(opts.declared?.route);
347
+ if (!entity) {
348
+ return refusal("entity_unresolved", "I cannot tell which record the act would land on from this page, so nothing was drafted. The platform's own controls on the record always act on the right one.");
349
+ }
350
+ const candidates = machine.acts.map((a) => `${a.action} — lands on ${a.to}${a.guard ? ` (requires ${a.guard})` : ""}`);
351
+ const pick = await pickTarget(runner, opts.model, "machine_act", candidates, userTurns);
352
+ if (!pick) {
353
+ return refusal("pick_failed", "I could not map your request to an act reliably just now — nothing was drafted. Ask again in a moment, or act from the platform's own controls.");
354
+ }
355
+ if (!pick.target || !machineOffers(machine, pick.target)) {
356
+ const offered = machine.acts.map((a) => a.action).join(", ");
357
+ return refusal(
358
+ "no_matching_act",
359
+ `None of the acts the machine offers your role at ${machine.state} matches that request — the offered set is exactly: ${offered}. ` +
360
+ "I never propose an act outside it. Nothing was drafted.",
361
+ );
362
+ }
363
+ const act = machine.acts.find((a) => a.action === pick.target)!;
364
+ const write = acts?.entity_write;
365
+ if (!write?.method || !write?.path) {
366
+ return refusal("operation_unknown", "This deployment has not wired the entity write operation, so I cannot prepare the act here.");
367
+ }
368
+ // the directory is the closed world: the entity write must exist in
369
+ // the plane, and the never-offer list re-checks even it
370
+ const op = await operationByMethodPath(store, write.method, write.path);
371
+ if (!op) {
372
+ return refusal("operation_unknown", "The act's operation is not in this deployment's API directory, so I cannot prepare it honestly — nothing was drafted.");
373
+ }
374
+ if (neverOffered(op.path)) {
375
+ return refusal("operation_never_offered", "That act is not one the assistant may propose — nothing was drafted.");
376
+ }
377
+ const path = write.path.replace("{store}", entity.store).replace("{id}", entity.id);
378
+ const title = honestTitle(pick.title, `${act.action} — ${label}`.slice(0, 120));
379
+ const notes = [
380
+ `The machine act ${act.action} moves the record from ${machine.state} to ${act.to}; the platform re-judges the act against the machine and your role when you confirm.`,
381
+ ...(act.guard ? [`This act declares the ${act.guard} input — the confirmation card collects it; the draft carries none.`] : []),
382
+ ];
383
+ const draft: ApiCallDraft = {
384
+ kind: "draft",
385
+ act: "api_call",
386
+ version: 1,
387
+ title,
388
+ prepared_at: new Date().toISOString(),
389
+ requires_confirmation: true,
390
+ call: { method: write.method as ApiCall["method"], path, body: { status: act.to } },
391
+ notes,
392
+ };
393
+ const answer =
394
+ `I've prepared the act "${title}" for ${label}: the machine act ${act.action}, from ${machine.state} to ${act.to}.\n\n` +
395
+ `The confirmation card carries it — a record act, so your own confirmation signs it and the platform's gates judge the write as you. ` +
396
+ `I never hold a write credential, and I never propose an act the machine does not offer your role.`;
397
+ return { status: "draft", draft, answer };
398
+ }
399
+
400
+ // ── the preference family: the standing grant's closed world ──
401
+ const family = (await preferenceOps(store)).filter((o) => !neverOffered(o.path));
402
+ if (!family.length) {
403
+ return refusal("operation_unknown", "This deployment's API directory lists no preference operations, so I cannot prepare that here — nothing was drafted.");
404
+ }
405
+ const candidates = family.map((o) => `${o.operation_id} — ${o.method.toUpperCase()} ${o.path} — ${o.summary ?? ""}`.slice(0, 300));
406
+ const pick = await pickTarget(runner, opts.model, "preference", candidates, userTurns);
407
+ if (!pick) {
408
+ return refusal("pick_failed", "I could not map your request to a preference act reliably just now — nothing was drafted. Ask again in a moment, or change the setting on your account page directly.");
409
+ }
410
+ if (!pick.target) {
411
+ return refusal("no_matching_act", "None of the preference acts I may propose matches that request — nothing was drafted. Your account's settings page carries the full set.");
412
+ }
413
+ const op = await operationById(store, pick.target);
414
+ if (!op || op.act_class !== "preference") {
415
+ // the picked id resolves to nothing, or to a record act — either way
416
+ // the draft never rides (the model proposed outside the closed world)
417
+ return refusal("operation_unknown", "I cannot match that request to a preference act the platform declares — nothing was drafted.");
418
+ }
419
+ if (neverOffered(op.path)) {
420
+ return refusal("operation_never_offered", "That act is not one the assistant may propose — delegation and sign-in acts stay in your own hands on the settings page. Nothing was drafted.");
421
+ }
422
+ if (pick.body !== undefined && !bodyTraces(pick.body, userTurns)) {
423
+ return refusal(
424
+ "body_untraced",
425
+ "I can't prepare that change because the values I would set are not ones you stated — I never invent settings. " +
426
+ "Tell me exactly what to set, in your own words, and I'll prepare the draft.",
427
+ );
428
+ }
429
+ const title = honestTitle(pick.title, (op.summary ?? op.operation_id).slice(0, 120));
430
+ const draft: ApiCallDraft = {
431
+ kind: "draft",
432
+ act: "api_call",
433
+ version: 1,
434
+ title,
435
+ prepared_at: new Date().toISOString(),
436
+ requires_confirmation: true,
437
+ call: { method: op.method.toUpperCase() as ApiCall["method"], path: op.path, ...(pick.body !== undefined ? { body: pick.body } : {}) },
438
+ notes: [
439
+ "A preference act: with your standing grant the card executes it straight away; without one, your own tap on the card confirms it. The grant is yours on the settings page — granted or revoked by your hand, never by a message.",
440
+ ],
441
+ };
442
+ const answer =
443
+ `I've prepared the act "${title}" — a preference act on your own settings.\n\n` +
444
+ "With your standing grant it executes under the grant's authority; without one, the card asks once and your tap confirms it. " +
445
+ "The platform re-checks the act against its own specification either way, and the grant never covers record acts.";
446
+ return { status: "draft", draft, answer };
447
+ }
@@ -26,8 +26,9 @@ import { evaluateConditionSets, quantitiesIn, type ConditionVerdict } from "./co
26
26
  import { evaluateAggregation, type AggregationVerdict } from "./aggregation";
27
27
  import { matchLicensedTopic, boundaryNoteText } from "./boundary";
28
28
  import { detectDraftIntent, prepareDraft } from "./drafts";
29
+ import { detectApiCallIntent, prepareApiCall } from "./apicalls";
29
30
  import { memoryNote } from "./memories";
30
- import { entitlementScope, resolveRequestScope, requestSalt } from "./requestScope";
31
+ import { entitlementScope, resolveRequestScope, requestSalt, standardKeysFrom } from "./requestScope";
31
32
  import { rawSessionToken } from "./session";
32
33
  import { cacheKeyMaterial, corpusGen, exactCacheKey, freshRequested, semanticCacheKey } from "./answercache";
33
34
  import type { Env } from "./env";
@@ -237,7 +238,7 @@ async function handleAsk(
237
238
  // the latency program's clock: every telemetry write below reports the
238
239
  // wall time from request entry to its own exit
239
240
  const tStart = Date.now();
240
- const telemetryMeta = () => ({ durationMs: Date.now() - tStart, keyId: key?.id ?? null });
241
+ const telemetryMeta = () => ({ durationMs: Date.now() - tStart, keyId: key?.id ?? null, retries: generateRetries });
241
242
  // the latency anatomy, surfaced as standard Server-Timing headers on
242
243
  // the JSON response — the reduction program's per-stage data
243
244
  const stageTiming: Record<string, number> = {};
@@ -277,6 +278,13 @@ async function handleAsk(
277
278
  // ask bypasses both answer caches (read AND write) exactly as a
278
279
  // declared-context ask does.
279
280
  const draftAct = P().publisher.features?.drafts ? detectDraftIntent(q.query) : null;
281
+ // The api_call draft (TODO.ai-platform/09): the operations deployment's
282
+ // proposal grammar — one platform operation, bounded by the affordance
283
+ // channel's machine facet (machine acts) or the preference family's
284
+ // closed world (the operation directory). Same cache posture as the
285
+ // prefill draft: the answer depends on the conversation and the
286
+ // account's standing, never on the query alone.
287
+ const apiCallIntent = !draftAct && P().publisher.features?.api_call_drafts ? detectApiCallIntent(q.query, declaredCtx) : null;
280
288
 
281
289
  const member = tier === "member" ? await sessionFrom(req, env as any) : null;
282
290
  // resolved before the quota check: the effort choice prices the ask
@@ -301,15 +309,20 @@ async function handleAsk(
301
309
  // Members get federated retrieval (OIML + ISO/IEC) merged into the same
302
310
  // pipeline via the service binding; rag-public never touches the
303
311
  // internal index itself, and generation/rerank stay in ONE pipeline.
312
+ // The key-tier licensed federation (the LIVE_MEMBER_TOKEN retirement):
313
+ // an API-key caller with a VALIDATED entitlement set rides the same
314
+ // lane — the key is re-verified server-side by the internal worker and
315
+ // the entitlement set is the licensed scope the hard cut honors.
304
316
  const service = env.INTERNAL_SERVICE;
305
317
  const fedAuth = {
306
318
  cookie: req.headers.get("cookie") ?? "",
307
319
  authorization: req.headers.get("authorization") ?? "",
308
320
  };
321
+ const keyLicensed = tier === "key" && standardKeysFrom(body).size > 0;
309
322
  // Dataset scope + memory selection (MECE: the derivation lives in
310
323
  // ./requestScope; this path only wires it). A request that explicitly
311
324
  // disables every dataset is a user error.
312
- const scope = resolveRequestScope(body, member);
325
+ const scope = resolveRequestScope(body, member, { keyWithEntitlements: keyLicensed });
313
326
  if ("error" in scope) return err(400, "invalid_input", "datasets: at least one dataset must stay enabled");
314
327
  const { corpora, narrowed, isoOn } = scope;
315
328
  // The license entitlement set (TODO.external-refs/08): request-scoped,
@@ -327,7 +340,7 @@ async function handleAsk(
327
340
  // per-request depth toggle (⚡ fast / 🧠 thorough): effort changes the
328
341
  // answer, so it salts the cache with the selections above
329
342
  const salt = requestSaltStr ? `${requestSaltStr}|effort:${effort}` : `effort:${effort}`;
330
- const federate = member && service && isoOn
343
+ const federate = (member || keyLicensed) && service && isoOn
331
344
  ? (q2: string) => retrieveInternal(service, fedAuth, q2)
332
345
  : undefined;
333
346
 
@@ -362,7 +375,7 @@ async function handleAsk(
362
375
  // answer caches; corpus surgery bumps it (scripts/invalidate_answer_
363
376
  // cache.py) and old-generation entries miss (oimlsmart/rag#72)
364
377
  const gen = await corpusGen(env.CACHE);
365
- const cached = fresh || contextual || declaredCtx || draftAct || userImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
378
+ const cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || userImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
366
379
  const wantsStream = body?.stream === true || (tier === "anon" && body?.stream !== false);
367
380
 
368
381
  if (cached) {
@@ -409,7 +422,7 @@ async function handleAsk(
409
422
  // node's answer for another (observed run-to-run across the golden
410
423
  // model legs). Node-scoped queries use the exact cache only.
411
424
  const nodeScoped = !!modelNodeRefIn(q.query) || !!modelNodeRefIn(declaredCtx?.label);
412
- if (!cached && !nodeScoped && !contextual && !declaredCtx && !draftAct && !q.lang && !userImage && !fresh) {
425
+ if (!cached && !nodeScoped && !contextual && !declaredCtx && !draftAct && !apiCallIntent && !q.lang && !userImage && !fresh) {
413
426
  const wv0 = (await warmEmbed) ?? null;
414
427
  if (wv0) {
415
428
  const sc0 = await semanticCacheGet(env, gen, wv0, salt);
@@ -553,7 +566,7 @@ async function handleAsk(
553
566
  // for standalone knowledge questions; contextual turns, declared-context
554
567
  // asks and image asks always run live; fresh regenerates, bypassing
555
568
  // this cache too)
556
- if (understanding?.intent !== "conversational" && !nodeScoped && !contextual && !declaredCtx && !draftAct && !userImage && !fresh) {
569
+ if (understanding?.intent !== "conversational" && !nodeScoped && !contextual && !declaredCtx && !draftAct && !apiCallIntent && !userImage && !fresh) {
557
570
  const warmVec = (await warmEmbed) ?? null;
558
571
  if (warmVec) {
559
572
  const sc = await semanticCacheGet(env, gen, warmVec, salt);
@@ -681,6 +694,43 @@ async function handleAsk(
681
694
  return json({ answer: verdict.answer, citations, model, query_hash: queryHash, follow_ups: [], context_applied: draftCtxApplied, read: readAs(), ...(draftPayload ? { draft: draftPayload } : {}), quota, });
682
695
  }
683
696
 
697
+ // ── The api_call draft (TODO.ai-platform/09) — the operations
698
+ // assistant's proposal grammar (apicalls.ts): one platform operation,
699
+ // prepared as a draft, bounded by the affordance channel's machine
700
+ // facet or the preference family's closed world. THE SERVICE NEVER
701
+ // WRITES here either: the draft rides the response's `draft` field;
702
+ // the panel pre-flights it against the platform's specification and
703
+ // the platform's own gates judge the execution. Branched where the
704
+ // prefill draft branches — the draft grounds in the conversation +
705
+ // the operation directory, not the corpus passages.
706
+ if (apiCallIntent) {
707
+ const callCtxApplied = declaredCtx ? appliedContext(declaredCtx, null) : NO_CONTEXT;
708
+ const queryHash = await sha256Hex(q.query);
709
+ const verdict = await prepareApiCall(env, {
710
+ intent: apiCallIntent,
711
+ query: q.query,
712
+ history: keptHistory,
713
+ member,
714
+ declared: declaredCtx,
715
+ model: roleModel(env, "acts"),
716
+ });
717
+ console.log("api_call draft:", apiCallIntent, "→", verdict.status === "draft" ? `draft (${verdict.draft.call.method} ${verdict.draft.call.path})` : `refused (${verdict.reason})`);
718
+ const draftPayload = verdict.status === "draft" ? verdict.draft : undefined;
719
+ telemetry(env, ctx, tier, "ask", model, true, verdict.answer.length, queryHash, q.lang, undefined, telemetryMeta());
720
+ if (wantsStream) {
721
+ return sseResponse(
722
+ [
723
+ ...(readAs() ? [{ type: "read", read: readAs() }] : []),
724
+ { type: "citations", citations: [], context_applied: callCtxApplied, ...(draftPayload ? { draft: draftPayload } : {}), quota, },
725
+ { type: "token", v: verdict.answer },
726
+ { type: "done", model, query_hash: queryHash, context_applied: callCtxApplied, read: readAs() },
727
+ ],
728
+ corsHeaders(req),
729
+ );
730
+ }
731
+ return json({ answer: verdict.answer, citations: [], model, query_hash: queryHash, follow_ups: [], context_applied: callCtxApplied, read: readAs(), ...(draftPayload ? { draft: draftPayload } : {}), quota, });
732
+ }
733
+
684
734
  // (declared before the retrieval try: the account block, the refusal
685
735
  // gate, the prompt and the response all read them)
686
736
  let liveRecords: LiveRecord[] | undefined;
@@ -9,6 +9,10 @@ export const MODELS = {
9
9
  member: "@cf/zai-org/glm-5.3-flash",
10
10
  // hot-path understanding/summarize stays on the cheap Qwen (cost-first lane)
11
11
  understand: "@cf/qwen/qwen3-30b-a3b-fp8",
12
+ // the api_call act's intent-to-act mapping (TODO.ai-platform/09) — the
13
+ // operations deployment points ACTS_MODEL at the stronger reasoning
14
+ // tier; the floor is the understand lane
15
+ acts: "@cf/qwen/qwen3-30b-a3b-fp8",
12
16
  // generation fallback when the answer model is unavailable
13
17
  fallback: "@cf/qwen/qwen3-30b-a3b-fp8",
14
18
  grader: "@cf/deepseek-ai/deepseek-v4-flash-0731", // CRAG grader + judges (unsuffixed slug was retired → silent 5018s)