@lotics/app-sdk 0.100.1 → 0.101.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/AGENTS.md +32 -47
  2. package/dist/agent_stream.d.ts +131 -0
  3. package/dist/ask_ai.d.ts +27 -0
  4. package/dist/attachments.d.ts +58 -0
  5. package/dist/chunk-ARV5FAU5.js +1132 -0
  6. package/dist/comments.d.ts +89 -0
  7. package/dist/error_report.d.ts +9 -0
  8. package/dist/folder_pick.d.ts +8 -0
  9. package/dist/geolocation.d.ts +42 -0
  10. package/dist/hooks.d.ts +251 -0
  11. package/dist/{src/index.d.ts → index.d.ts} +13 -22
  12. package/dist/index.js +31331 -0
  13. package/dist/index.js.LEGAL.txt +11 -0
  14. package/dist/members.d.ts +32 -0
  15. package/dist/mock.d.ts +37 -0
  16. package/dist/mount.d.ts +19 -0
  17. package/dist/new_record.d.ts +37 -0
  18. package/dist/open_app.d.ts +12 -0
  19. package/dist/open_external.d.ts +10 -0
  20. package/dist/overlay.d.ts +25 -0
  21. package/dist/queries.d.ts +231 -0
  22. package/dist/recording.d.ts +47 -0
  23. package/dist/recording_state.d.ts +43 -0
  24. package/dist/rename_file.d.ts +13 -0
  25. package/dist/router.d.ts +10 -0
  26. package/dist/router.js +97 -0
  27. package/dist/row.d.ts +87 -0
  28. package/dist/rpc.d.ts +114 -0
  29. package/dist/select.d.ts +24 -0
  30. package/dist/shared_types.d.ts +8 -0
  31. package/dist/store.d.ts +43 -0
  32. package/dist/types.d.ts +36 -0
  33. package/dist/upload/optimize.d.ts +30 -0
  34. package/dist/upload/pipeline.d.ts +36 -0
  35. package/dist/upload/transport.d.ts +19 -0
  36. package/dist/url_params.d.ts +55 -0
  37. package/dist/use_recents.d.ts +15 -0
  38. package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
  39. package/dist/viewer.d.ts +41 -0
  40. package/dist/written.d.ts +79 -0
  41. package/docs/ai.md +74 -133
  42. package/docs/data_fetching.md +209 -290
  43. package/docs/files.md +61 -51
  44. package/docs/members_and_options.md +92 -62
  45. package/docs/mutations.md +136 -205
  46. package/docs/navigation_and_state.md +26 -35
  47. package/docs/queries.md +144 -207
  48. package/docs/recipes.md +21 -45
  49. package/docs/runtime.md +74 -137
  50. package/docs/security.md +8 -11
  51. package/docs/workflows.md +189 -174
  52. package/package.json +27 -28
  53. package/dist/src/agent_stream.d.ts +0 -200
  54. package/dist/src/agent_stream.js +0 -314
  55. package/dist/src/ask_ai.d.ts +0 -40
  56. package/dist/src/ask_ai.js +0 -35
  57. package/dist/src/attachments.d.ts +0 -68
  58. package/dist/src/attachments.js +0 -93
  59. package/dist/src/comments.d.ts +0 -127
  60. package/dist/src/comments.js +0 -192
  61. package/dist/src/download.js +0 -54
  62. package/dist/src/geolocation.d.ts +0 -64
  63. package/dist/src/geolocation.js +0 -96
  64. package/dist/src/hooks.d.ts +0 -781
  65. package/dist/src/hooks.js +0 -860
  66. package/dist/src/index.js +0 -34
  67. package/dist/src/members.d.ts +0 -105
  68. package/dist/src/members.js +0 -62
  69. package/dist/src/mock.d.ts +0 -118
  70. package/dist/src/mock.js +0 -124
  71. package/dist/src/mount.d.ts +0 -47
  72. package/dist/src/mount.js +0 -34
  73. package/dist/src/new_record.d.ts +0 -74
  74. package/dist/src/new_record.js +0 -117
  75. package/dist/src/open_app.d.ts +0 -15
  76. package/dist/src/open_app.js +0 -18
  77. package/dist/src/open_external.d.ts +0 -16
  78. package/dist/src/open_external.js +0 -19
  79. package/dist/src/recording.d.ts +0 -59
  80. package/dist/src/recording.js +0 -30
  81. package/dist/src/recording_state.d.ts +0 -59
  82. package/dist/src/recording_state.js +0 -94
  83. package/dist/src/router.d.ts +0 -17
  84. package/dist/src/router.js +0 -144
  85. package/dist/src/row.d.ts +0 -159
  86. package/dist/src/row.js +0 -254
  87. package/dist/src/rpc.d.ts +0 -207
  88. package/dist/src/rpc.js +0 -904
  89. package/dist/src/select.d.ts +0 -48
  90. package/dist/src/select.js +0 -40
  91. package/dist/src/types.d.ts +0 -115
  92. package/dist/src/types.js +0 -1
  93. package/dist/src/upload/optimize.d.ts +0 -54
  94. package/dist/src/upload/optimize.js +0 -207
  95. package/dist/src/upload/pipeline.d.ts +0 -55
  96. package/dist/src/upload/pipeline.js +0 -52
  97. package/dist/src/upload/transport.d.ts +0 -42
  98. package/dist/src/upload/transport.js +0 -128
  99. package/dist/src/url_params.d.ts +0 -93
  100. package/dist/src/url_params.js +0 -215
  101. package/dist/src/use_optimistic.d.ts +0 -27
  102. package/dist/src/use_optimistic.js +0 -27
  103. package/dist/src/use_recents.d.ts +0 -19
  104. package/dist/src/use_recents.js +0 -71
  105. package/dist/src/use_url_state.js +0 -73
  106. package/dist/src/viewer.d.ts +0 -26
  107. package/dist/src/viewer.js +0 -47
  108. /package/dist/{src/download.d.ts → download.d.ts} +0 -0
package/package.json CHANGED
@@ -1,57 +1,56 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.100.1",
4
- "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
3
+ "version": "0.101.1",
4
+ "description": "The SDK a Lotics custom-code app reads and writes through \u2014 typed hooks over the host bridge, cell readers, mount() and AppRouter",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
8
- "types": "./dist/src/index.d.ts",
9
- "default": "./dist/src/index.js"
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
10
  },
11
11
  "./router": {
12
- "types": "./dist/src/router.d.ts",
13
- "default": "./dist/src/router.js"
12
+ "types": "./dist/router.d.ts",
13
+ "default": "./dist/router.js"
14
14
  }
15
15
  },
16
- "types": "./dist/src/index.d.ts",
16
+ "types": "./dist/index.d.ts",
17
17
  "files": [
18
18
  "dist",
19
19
  "AGENTS.md",
20
20
  "docs"
21
21
  ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
22
25
  "scripts": {
23
- "build": "tsgo -p tsconfig.build.json",
24
- "typecheck": "tsgo --noEmit",
26
+ "build": "tsgo -p tsconfig.build.json && node scripts/build_sdk.mjs",
27
+ "lint": "oxlint",
25
28
  "test": "vitest run",
26
- "prepublishOnly": "npm run build"
29
+ "typecheck": "tsgo --noEmit",
30
+ "prepack": "npm run build"
27
31
  },
28
32
  "dependencies": {
29
- "ai": "^7.0.30",
30
- "swr": "^2.4.1"
33
+ "ai": "^7.0.30"
31
34
  },
32
35
  "peerDependencies": {
33
36
  "react": "^19.2.0",
34
37
  "react-dom": "^19.2.0",
35
38
  "react-router": "^7.0.0 || ^8.0.0"
36
39
  },
37
- "peerDependenciesMeta": {
38
- "react-router": {
39
- "optional": true
40
- }
41
- },
42
40
  "devDependencies": {
41
+ "@lotics/shared": "*",
42
+ "@testing-library/react": "^16.3.0",
43
43
  "@types/react": "^19.2.17",
44
- "@types/react-dom": "^19.0.0",
45
- "react": "19.2.3",
46
- "react-dom": "19.2.3",
47
- "react-router": "7.18.2"
44
+ "@types/react-dom": "~19.2.2",
45
+ "@vitejs/plugin-react": "^4.3.4",
46
+ "esbuild": "^0.28.1",
47
+ "jsdom": "^27.0.0",
48
+ "react": "^19.2.0",
49
+ "react-dom": "^19.2.0",
50
+ "react-router": "^7.18.2",
51
+ "typescript": "~6.0.3",
52
+ "vite": "^7.2.4"
48
53
  },
49
- "keywords": [
50
- "lotics",
51
- "app-sdk",
52
- "react",
53
- "iframe"
54
- ],
55
54
  "engines": {
56
55
  "node": ">=18"
57
56
  },
@@ -59,6 +58,6 @@
59
58
  "repository": {
60
59
  "type": "git",
61
60
  "url": "https://github.com/lotics/lotics.git",
62
- "directory": "packages/app-sdk"
61
+ "directory": "packages/app_sdk"
63
62
  }
64
63
  }
@@ -1,200 +0,0 @@
1
- /**
2
- * Parse a backend app-agent run stream into the app's agent-run model.
3
- *
4
- * The backend streams the AI-SDK "UI message" SSE protocol (the same wire format
5
- * the chat uses) — `data: <json>\n\n` frames, each a typed chunk, terminated by
6
- * `data: [DONE]`. We fold the chunk types that matter into an ordered `parts` array
7
- * — the SAME ai-sdk `UIMessagePart[]` shape `@lotics/ui`'s `AgentRun` renders, so
8
- * `<AgentRun parts={run.parts} />` needs no adapter. `ai` is a TYPE-ONLY import here
9
- * (tool calls are hand-built as concrete `dynamic-tool` parts); NO `ai` runtime
10
- * enters the sandboxed app bundle. Unknown chunk types are ignored, so a newer
11
- * AI-SDK never breaks the SDK.
12
- *
13
- * An agent can be either kind: a STRUCTURED agent (declares `outputs`) emits its
14
- * result as the input of the injected `submit_result` tool → `output` (NOT a part);
15
- * a FREE-TEXT agent's result is its prose → a text part. `output` is NEVER the
16
- * free-text (see `AgentRunState.output`). Pure + reducer-shaped so it is fully
17
- * unit-testable against recorded frames — no live model needed.
18
- */
19
- import type { UIMessagePart, UIDataTypes, UITools } from "ai";
20
- /** An ai-sdk message part — the render model, tool-set-agnostic. */
21
- export type AgentUIPart = UIMessagePart<UIDataTypes, UITools>;
22
- /**
23
- * The MANDATORY interactive tools every app-agent run carries (mirrors the
24
- * server's `INTERACTIVE_APP_TOOLS`). A call to one PARKS the run awaiting the
25
- * user's answer: its part stays `input-available` through `finish`, the state
26
- * reads `awaiting_input`, and `useAgentRun().answerChoice` continues the run.
27
- */
28
- export declare const INTERACTIVE_TOOLS: ReadonlySet<string>;
29
- export interface AgentRunState {
30
- status: "streaming" | "awaiting_input" | "completed" | "error";
31
- /** The ordered transcript as ai-sdk `parts` — prose interleaved with the tool
32
- * calls (`dynamic-tool` parts), as it streamed. The single source of truth for
33
- * the feed; `useAgentRun` derives `text` / `steps` from it. */
34
- parts: AgentUIPart[];
35
- /** The structured result — the `submit_result` tool's input — or `undefined`.
36
- * NEVER the free-text answer: a free-text agent (or a structured one that
37
- * finished without submitting) has no `output`; its answer is the transcript's
38
- * text. Coercing text here would hand a consumer reading `output.<field>` a bare
39
- * string, crashing it ("... is not iterable"). */
40
- output?: unknown;
41
- error?: string;
42
- }
43
- /** A settled run row, as the poll endpoint returns it. */
44
- export interface SettledAgentRun {
45
- status: string;
46
- output?: unknown;
47
- error_message?: string | null;
48
- /** Present on a polled `awaiting_input` row: the pending ask derived from the
49
- * server-side transcript, so a client that lost the stream (or reloaded) can
50
- * rebuild the question without ever having received the part. */
51
- pending_interactive?: {
52
- tool_call_id: string;
53
- tool_name: string;
54
- input: unknown;
55
- } | null;
56
- }
57
- /**
58
- * Fold a POLLED settled run row into the stream-accumulated state — the shared
59
- * adoption step for BOTH recovery paths (a dropped stream, and a stream that
60
- * ended cleanly WITHOUT a `finish` frame — an edge can close a long SSE
61
- * gracefully mid-run, which looks like completion but is a truncation).
62
- *
63
- * The row is the source of truth for status and the STRUCTURED output. A
64
- * free-text run's row `output` is its prose string — that never enters
65
- * `state.output` (a structured consumer reads `output.<field>`; a stray string
66
- * would crash it). The free-text answer stays in the transcript text.
67
- */
68
- export declare function adoptSettledRun(state: AgentRunState, settled: SettledAgentRun): AgentRunState;
69
- export type ChoiceOption = {
70
- label: string;
71
- description: string;
72
- };
73
- export type ChoiceQuestion = {
74
- question: string;
75
- options: ChoiceOption[];
76
- allow_custom?: boolean;
77
- };
78
- export type AskUserChoiceAnswer = {
79
- type: "option";
80
- question_index: number;
81
- question_number: number;
82
- question_text: string;
83
- option_index: number;
84
- option_letter: string;
85
- option_label: string;
86
- option_description: string;
87
- } | {
88
- type: "custom";
89
- question_index: number;
90
- question_number: number;
91
- question_text: string;
92
- text: string;
93
- } | {
94
- type: "skipped";
95
- question_index: number;
96
- question_number: number;
97
- question_text: string;
98
- };
99
- export type AskUserChoiceOutput = {
100
- answers: AskUserChoiceAnswer[];
101
- skipped_by_user?: boolean;
102
- };
103
- export interface PendingChoice {
104
- toolCallId: string;
105
- questions: ChoiceQuestion[];
106
- }
107
- export declare function pendingInteractiveCall(state: AgentRunState): PendingChoice | null;
108
- /**
109
- * Fold the user's picks into the tool's output shape — `answers` aligns to
110
- * `questions` by index (the `ClarifyWizard` contract: `{ value, custom }` per
111
- * question, value = the picked option's label or the free text). A missing or
112
- * empty entry is a skipped question.
113
- */
114
- export declare function buildChoiceOutput(questions: ChoiceQuestion[], answers: {
115
- value: string;
116
- custom: boolean;
117
- }[]): AskUserChoiceOutput;
118
- /** The user answered: settle the pending part (the answer rides its `output` for
119
- * the feed's on-demand reveal) and put the run back into `streaming` for the
120
- * continuation leg's chunks. */
121
- export declare function applyInteractiveAnswer(state: AgentRunState, toolCallId: string, output: AskUserChoiceOutput): AgentRunState;
122
- export declare function initialAgentRunState(): AgentRunState;
123
- /** A parsed UI-message chunk — only the fields we read, all optional. */
124
- interface Chunk {
125
- type?: string;
126
- delta?: string;
127
- /** Partial tool-argument text, streamed while a tool call's input is generated
128
- * (`tool-input-delta`) — distinct from `delta` (answer/thinking prose). */
129
- inputTextDelta?: string;
130
- toolName?: string;
131
- toolCallId?: string;
132
- input?: unknown;
133
- output?: unknown;
134
- errorText?: string;
135
- finishReason?: string;
136
- }
137
- /** Fold one chunk into the run state. Returns a new state (immutable). */
138
- export declare function reduceAgentChunk(state: AgentRunState, chunk: Chunk): AgentRunState;
139
- /**
140
- * Incremental SSE frame splitter. Feed it raw text as it arrives; it returns the
141
- * complete chunks parsed so far and the leftover partial frame to carry forward.
142
- * `[DONE]` is dropped (the `finish` chunk already settles the state).
143
- */
144
- export declare function parseSseChunks(buffer: string): {
145
- chunks: Chunk[];
146
- rest: string;
147
- };
148
- /**
149
- * How one run LEG ended — the whole answer, in the resolved value.
150
- *
151
- * A leg used to resolve `TOutput | undefined`, and `undefined` meant three
152
- * different things: the run parked on a question, the run died, or the client
153
- * stopped listening. Telling them apart needed the hook's state, which has not
154
- * committed yet when the promise resolves — so every consumer raced, and none
155
- * could win. Minh Tín's app lost live `awaiting_input` runs to exactly that: the
156
- * dialog closed over a healthy, answerable row and the run expired on its TTL.
157
- *
158
- * Discriminating here makes the race unrepresentable rather than guarded. A run
159
- * FAILURE is data (`failed`) — the caller needs no try/catch for it.
160
- *
161
- * The promise still REJECTS for the two cases that are not run outcomes at all:
162
- * API misuse (`answerChoice` with nothing pending), and a refused answer (400
163
- * invalid / 409 raced cancel), where the run stays PARKED and answerable — a
164
- * landing there would report `parked` and swallow the reason the answer bounced.
165
- */
166
- export type AgentRunLanding<TOutput> =
167
- /** The leg finished. `output` is the structured result (absent for a free-text
168
- * agent, where `text` is the result). */
169
- {
170
- kind: "settled";
171
- output?: TOutput;
172
- text: string;
173
- }
174
- /** Parked on a question. `pendingChoice` carries it and `answerChoice`
175
- * continues — there is nothing for the caller to do here, which is the point:
176
- * it is no longer indistinguishable from a dead run. */
177
- | {
178
- kind: "parked";
179
- }
180
- /** The run failed and its error is user-facing. */
181
- | {
182
- kind: "failed";
183
- error: string;
184
- }
185
- /** The CLIENT stopped listening (`abort`, unmount, a newer run replacing this
186
- * one). The run itself keeps executing server-side and lands in the session
187
- * history — never surface this as a failure. */
188
- | {
189
- kind: "aborted";
190
- };
191
- /** The client stopped listening; the run itself continues server-side. Shared so
192
- * the four exit points cannot drift, and so the literal keeps its type. */
193
- export declare const ABORTED: AgentRunLanding<never>;
194
- /** All prose parts concatenated — a free-text agent's actual result. Shared so
195
- * the hook's `text` and a landing's can never disagree about what was said. */
196
- export declare function proseOf(parts: readonly AgentUIPart[]): string;
197
- /** The landing a settled accumulator describes. One place decides, so `run` and
198
- * `answerChoice` can never disagree about what an outcome was called. */
199
- export declare function landingOf(state: AgentRunState): AgentRunLanding<unknown>;
200
- export {};
@@ -1,314 +0,0 @@
1
- /**
2
- * Parse a backend app-agent run stream into the app's agent-run model.
3
- *
4
- * The backend streams the AI-SDK "UI message" SSE protocol (the same wire format
5
- * the chat uses) — `data: <json>\n\n` frames, each a typed chunk, terminated by
6
- * `data: [DONE]`. We fold the chunk types that matter into an ordered `parts` array
7
- * — the SAME ai-sdk `UIMessagePart[]` shape `@lotics/ui`'s `AgentRun` renders, so
8
- * `<AgentRun parts={run.parts} />` needs no adapter. `ai` is a TYPE-ONLY import here
9
- * (tool calls are hand-built as concrete `dynamic-tool` parts); NO `ai` runtime
10
- * enters the sandboxed app bundle. Unknown chunk types are ignored, so a newer
11
- * AI-SDK never breaks the SDK.
12
- *
13
- * An agent can be either kind: a STRUCTURED agent (declares `outputs`) emits its
14
- * result as the input of the injected `submit_result` tool → `output` (NOT a part);
15
- * a FREE-TEXT agent's result is its prose → a text part. `output` is NEVER the
16
- * free-text (see `AgentRunState.output`). Pure + reducer-shaped so it is fully
17
- * unit-testable against recorded frames — no live model needed.
18
- */
19
- /**
20
- * The MANDATORY interactive tools every app-agent run carries (mirrors the
21
- * server's `INTERACTIVE_APP_TOOLS`). A call to one PARKS the run awaiting the
22
- * user's answer: its part stays `input-available` through `finish`, the state
23
- * reads `awaiting_input`, and `useAgentRun().answerChoice` continues the run.
24
- */
25
- export const INTERACTIVE_TOOLS = new Set(["ask_user_choice"]);
26
- /** The tool the backend injects to carry a typed structured result. */
27
- const SUBMIT_TOOL = "submit_result";
28
- /**
29
- * Fold a POLLED settled run row into the stream-accumulated state — the shared
30
- * adoption step for BOTH recovery paths (a dropped stream, and a stream that
31
- * ended cleanly WITHOUT a `finish` frame — an edge can close a long SSE
32
- * gracefully mid-run, which looks like completion but is a truncation).
33
- *
34
- * The row is the source of truth for status and the STRUCTURED output. A
35
- * free-text run's row `output` is its prose string — that never enters
36
- * `state.output` (a structured consumer reads `output.<field>`; a stray string
37
- * would crash it). The free-text answer stays in the transcript text.
38
- */
39
- export function adoptSettledRun(state, settled) {
40
- if (settled.status === "completed") {
41
- const structured = settled.output !== null && typeof settled.output === "object" ? settled.output : undefined;
42
- return { ...state, status: "completed", output: structured ?? state.output };
43
- }
44
- // A PARKED row is a live, resumable state — never coerce it into an error.
45
- // The invariant the hook layer relies on: `awaiting_input` always yields an
46
- // answerable pending part. When the stream died before the ask part arrived,
47
- // rebuild it from the row's derived `pending_interactive`.
48
- if (settled.status === "awaiting_input") {
49
- const next = { ...state, status: "awaiting_input" };
50
- if (pendingInteractiveCall(next))
51
- return next;
52
- const p = settled.pending_interactive;
53
- const alreadyPresent = p && state.parts.some((part) => part.type === "dynamic-tool" && part.toolCallId === p.tool_call_id);
54
- if (p && INTERACTIVE_TOOLS.has(p.tool_name) && !alreadyPresent) {
55
- return {
56
- ...next,
57
- parts: [
58
- ...state.parts,
59
- { type: "dynamic-tool", toolName: p.tool_name, toolCallId: p.tool_call_id, state: "input-available", input: p.input },
60
- ],
61
- };
62
- }
63
- return next;
64
- }
65
- return { ...state, status: "error", error: settled.error_message ?? "The run was stopped." };
66
- }
67
- /** The run's pending `ask_user_choice`, parsed for rendering (a `ClarifyWizard`
68
- * maps 1:1) — non-null exactly while the run is parked awaiting the answer. */
69
- /** Untrusted model output — a non-JSON string is data, not an exception. */
70
- function decodeJson(raw) {
71
- try {
72
- return JSON.parse(raw);
73
- }
74
- catch {
75
- return null;
76
- }
77
- }
78
- export function pendingInteractiveCall(state) {
79
- if (state.status !== "awaiting_input")
80
- return null;
81
- for (let i = state.parts.length - 1; i >= 0; i--) {
82
- const p = state.parts[i];
83
- if (p.type !== "dynamic-tool" || !INTERACTIVE_TOOLS.has(p.toolName))
84
- continue;
85
- if (p.state !== "input-available" && p.state !== "input-streaming")
86
- continue;
87
- const questions = parseChoiceQuestions(p.input);
88
- return questions.length > 0 ? { toolCallId: p.toolCallId, questions } : null;
89
- }
90
- return null;
91
- }
92
- function parseChoiceQuestions(input) {
93
- // A model may hand the tool its arguments as a JSON STRING rather than an
94
- // object. The SERVER accepts either and parks the run, so refusing the string
95
- // here strands a live `awaiting_input` run: the question exists, the run is
96
- // answerable, and the UI reports it as unrecoverable. Parse before validating
97
- // — a string that isn't JSON falls through to the same empty result as any
98
- // other malformed input, which `adoptGuarded` surfaces loudly and retryably.
99
- const decoded = typeof input === "string" ? decodeJson(input) : input;
100
- if (!decoded || typeof decoded !== "object")
101
- return [];
102
- const record = decoded;
103
- if (!Array.isArray(record.questions))
104
- return [];
105
- const questions = [];
106
- for (const raw of record.questions) {
107
- if (!raw || typeof raw !== "object")
108
- continue;
109
- const q = raw;
110
- if (typeof q.question !== "string" || !Array.isArray(q.options))
111
- continue;
112
- const options = [];
113
- for (const o of q.options) {
114
- if (o && typeof o === "object" && typeof o.label === "string") {
115
- const opt = o;
116
- options.push({ label: opt.label, description: typeof opt.description === "string" ? opt.description : "" });
117
- }
118
- }
119
- questions.push({ question: q.question, options, allow_custom: q.allow_custom === true });
120
- }
121
- return questions;
122
- }
123
- /**
124
- * Fold the user's picks into the tool's output shape — `answers` aligns to
125
- * `questions` by index (the `ClarifyWizard` contract: `{ value, custom }` per
126
- * question, value = the picked option's label or the free text). A missing or
127
- * empty entry is a skipped question.
128
- */
129
- export function buildChoiceOutput(questions, answers) {
130
- const toLetter = (i) => String.fromCharCode("A".charCodeAt(0) + i);
131
- const built = questions.map((question, index) => {
132
- const questionNumber = index + 1;
133
- const answer = answers[index];
134
- const value = answer?.value.trim() ?? "";
135
- if (!answer || value.length === 0) {
136
- return { type: "skipped", question_index: index, question_number: questionNumber, question_text: question.question };
137
- }
138
- const optionIndex = answer.custom ? -1 : question.options.findIndex((o) => o.label === value);
139
- if (optionIndex >= 0) {
140
- const option = question.options[optionIndex];
141
- return {
142
- type: "option",
143
- question_index: index,
144
- question_number: questionNumber,
145
- question_text: question.question,
146
- option_index: optionIndex,
147
- option_letter: toLetter(optionIndex),
148
- option_label: option.label,
149
- option_description: option.description,
150
- };
151
- }
152
- return { type: "custom", question_index: index, question_number: questionNumber, question_text: question.question, text: value };
153
- });
154
- const answeredCount = built.filter((a) => a.type !== "skipped").length;
155
- return { answers: built, skipped_by_user: answeredCount === 0 };
156
- }
157
- /** The user answered: settle the pending part (the answer rides its `output` for
158
- * the feed's on-demand reveal) and put the run back into `streaming` for the
159
- * continuation leg's chunks. */
160
- export function applyInteractiveAnswer(state, toolCallId, output) {
161
- const parts = state.parts.map((p) => p.type === "dynamic-tool" && p.toolCallId === toolCallId
162
- ? { type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-available", input: p.input, output }
163
- : p);
164
- return { ...state, status: "streaming", parts };
165
- }
166
- export function initialAgentRunState() {
167
- return { status: "streaming", parts: [] };
168
- }
169
- /** Grow the trailing prose part of the given kind, or open a new one (after a tool
170
- * ran, or when the kind flips text↔reasoning) — so the transcript interleaves
171
- * answer prose, thinking, and tools in the order they streamed. */
172
- function appendProse(parts, kind, piece) {
173
- const last = parts[parts.length - 1];
174
- if (last && last.type === kind) {
175
- return [...parts.slice(0, -1), { ...last, text: last.text + piece }];
176
- }
177
- const opened = kind === "text" ? { type: "text", text: piece, state: "streaming" } : { type: "reasoning", text: piece, state: "streaming" };
178
- return [...parts, opened];
179
- }
180
- /** Apply an update to the `dynamic-tool` part with the given call id (by identity),
181
- * leaving the rest untouched. A missing id (older frame) or no match is a no-op. */
182
- function updateTool(parts, toolCallId, fn) {
183
- if (!toolCallId)
184
- return parts;
185
- return parts.map((p) => (p.type === "dynamic-tool" && p.toolCallId === toolCallId ? fn(p) : p));
186
- }
187
- /** Fold one chunk into the run state. Returns a new state (immutable). */
188
- export function reduceAgentChunk(state, chunk) {
189
- switch (chunk.type) {
190
- case "text-delta": {
191
- const piece = chunk.delta ?? "";
192
- return piece ? { ...state, parts: appendProse(state.parts, "text", piece) } : state;
193
- }
194
- case "reasoning-delta": {
195
- // Thinking is its OWN part (not folded into the answer prose) so the UI can
196
- // keep it collapsed / revealed-on-demand rather than inline in the answer.
197
- const piece = chunk.delta ?? "";
198
- return piece ? { ...state, parts: appendProse(state.parts, "reasoning", piece) } : state;
199
- }
200
- case "tool-input-start":
201
- case "tool-input-available": {
202
- const name = chunk.toolName ?? "tool";
203
- if (name === SUBMIT_TOOL) {
204
- // The agent emitted its structured result — routed to `output`, not a part.
205
- return { ...state, output: chunk.input };
206
- }
207
- const id = chunk.toolCallId ?? `${name}-${state.parts.length}`;
208
- // Upsert by call id: the tool part opens "input-available" (a running step in
209
- // the feed) the moment the call fires and settles when its output arrives
210
- // (below), so the feed shows a live tool, not an instantly-done one.
211
- const existing = state.parts.some((p) => p.type === "dynamic-tool" && p.toolCallId === id);
212
- if (existing) {
213
- return { ...state, parts: updateTool(state.parts, id, (p) => ({ type: "dynamic-tool", toolName: p.toolName || name, toolCallId: id, state: "input-available", input: chunk.input ?? p.input })) };
214
- }
215
- return {
216
- ...state,
217
- parts: [...state.parts, { type: "dynamic-tool", toolName: name, toolCallId: id, state: "input-available", input: chunk.input }],
218
- };
219
- }
220
- case "tool-input-delta":
221
- // A tool's arguments streaming in. The ai-parts model carries no live size, so
222
- // this is a no-op — the part already reads as running.
223
- return state;
224
- case "tool-output-available":
225
- return { ...state, parts: updateTool(state.parts, chunk.toolCallId, (p) => ({ type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-available", input: p.input, output: chunk.output })) };
226
- case "tool-output-error":
227
- return { ...state, parts: updateTool(state.parts, chunk.toolCallId, (p) => ({ type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-error", input: p.input, errorText: chunk.errorText ?? "The tool failed." })) };
228
- case "error":
229
- return { ...state, status: "error", error: chunk.errorText ?? "The run failed." };
230
- case "abort":
231
- return { ...state, status: state.status === "streaming" ? "error" : state.status, error: state.error ?? "The run was stopped." };
232
- case "finish": {
233
- // Settle the status, and settle anything still streaming so nothing reads as
234
- // in-progress on a completed run: a tool with no output frame → output-available
235
- // (its output never came); a text/reasoning part still "streaming" → "done" (the
236
- // stream's `text-end`/`reasoning-end` markers aren't tracked per-part). `output`
237
- // is whatever `submit_result` set (else undefined) — NEVER the accumulated text:
238
- // a structured-output consumer reads `output.<field>`, so a stray free-text
239
- // string there would crash it. The free-text answer is always in a text part.
240
- // ONE exemption: a pending INTERACTIVE call (`ask_user_choice`) is the run
241
- // PARKING, not finishing — its part stays `input-available` and the state
242
- // reads `awaiting_input` so the app renders the question and continues.
243
- const awaiting = state.parts.some((p) => p.type === "dynamic-tool" && INTERACTIVE_TOOLS.has(p.toolName) && (p.state === "input-available" || p.state === "input-streaming"));
244
- const parts = state.parts.map((p) => {
245
- if (p.type === "dynamic-tool" &&
246
- (p.state === "input-available" || p.state === "input-streaming") &&
247
- !INTERACTIVE_TOOLS.has(p.toolName)) {
248
- return { type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-available", input: p.input, output: undefined };
249
- }
250
- if ((p.type === "text" || p.type === "reasoning") && p.state === "streaming") {
251
- return { ...p, state: "done" };
252
- }
253
- return p;
254
- });
255
- const status = state.status === "error" ? "error" : awaiting ? "awaiting_input" : "completed";
256
- return { ...state, status, parts };
257
- }
258
- default:
259
- return state;
260
- }
261
- }
262
- /**
263
- * Incremental SSE frame splitter. Feed it raw text as it arrives; it returns the
264
- * complete chunks parsed so far and the leftover partial frame to carry forward.
265
- * `[DONE]` is dropped (the `finish` chunk already settles the state).
266
- */
267
- export function parseSseChunks(buffer) {
268
- const chunks = [];
269
- const frames = buffer.split("\n\n");
270
- const rest = frames.pop() ?? ""; // last element is the incomplete frame
271
- for (const frame of frames) {
272
- for (const line of frame.split("\n")) {
273
- const trimmed = line.startsWith("data:") ? line.slice(5).trim() : "";
274
- if (!trimmed || trimmed === "[DONE]")
275
- continue;
276
- try {
277
- chunks.push(JSON.parse(trimmed));
278
- }
279
- catch {
280
- // a non-JSON data line — skip it, never throw on the stream
281
- }
282
- }
283
- }
284
- return { chunks, rest };
285
- }
286
- /** The client stopped listening; the run itself continues server-side. Shared so
287
- * the four exit points cannot drift, and so the literal keeps its type. */
288
- export const ABORTED = { kind: "aborted" };
289
- /** All prose parts concatenated — a free-text agent's actual result. Shared so
290
- * the hook's `text` and a landing's can never disagree about what was said. */
291
- export function proseOf(parts) {
292
- return parts.reduce((acc, p) => (p.type === "text" ? acc + p.text : acc), "");
293
- }
294
- /** The landing a settled accumulator describes. One place decides, so `run` and
295
- * `answerChoice` can never disagree about what an outcome was called. */
296
- export function landingOf(state) {
297
- switch (state.status) {
298
- case "awaiting_input":
299
- return { kind: "parked" };
300
- case "error":
301
- return { kind: "failed", error: state.error ?? "The run failed." };
302
- case "completed":
303
- return { kind: "settled", output: state.output, text: proseOf(state.parts) };
304
- case "streaming":
305
- // A leg that is still streaming has not landed. Every internal caller has
306
- // already resolved this state — by polling the persisted row — before
307
- // asking, so this branch is unreachable there. It is named rather than
308
- // left to a fallthrough because the fallthrough answered "settled", which
309
- // would report a live run as finished and hand a consumer a partial
310
- // `output` as if it were the result. Unconfirmed is the truthful answer,
311
- // and it matches what the poll path says for the same situation.
312
- return { kind: "failed", error: "The run has not finished — its result cannot be confirmed yet." };
313
- }
314
- }
@@ -1,40 +0,0 @@
1
- export interface AskAiArgs {
2
- /** Prefills the chat composer — the user sees, edits, and sends it
3
- * themselves. Nothing runs until they do. */
4
- prompt?: string;
5
- /** Files to attach to the chat (by id, from `readFiles(cell)`). The host
6
- * resolves each id itself and opens the file preview beside the chat. */
7
- file_ids?: string[];
8
- /** Records the chat should know about (ids from query rows). The host
9
- * resolves them to table context — the agent can then read and act on
10
- * them under the signed-in user's authority. */
11
- record_ids?: string[];
12
- /** Free-text grounding for the agent ("Shipment SGN-2481, customer …").
13
- * The host stamps the app's identity alongside it automatically. */
14
- context?: string;
15
- }
16
- /**
17
- * Hand off to the Lotics chat agent — opens the messenger on a FRESH chat
18
- * seeded with the given files, records, and prompt.
19
- *
20
- * Use this for dialogue-shaped work: iterating on a document ("edit this
21
- * invoice"), drafting from record context, open-ended questions. For
22
- * structured judgment that commits back into the app's own data
23
- * (extract/check/match/rank), use `useAgentRun` with a review surface
24
- * instead — the outcome of `askAi` lands in chat, not in your workflows.
25
- *
26
- * The user stays in control: the prompt is only prefilled, attachments are
27
- * visible in the composer, and nothing is sent until they press send.
28
- *
29
- * ```tsx
30
- * import { askAi } from "@lotics/app-sdk";
31
- * await askAi({
32
- * file_ids: [file.id],
33
- * record_ids: [row.id],
34
- * prompt: "Update the header of this invoice to match our letterhead.",
35
- * });
36
- * ```
37
- *
38
- * Only available embedded in Lotics — rejects in standalone/dev mode.
39
- */
40
- export declare function askAi(args: AskAiArgs): Promise<void>;