@lotics/app-sdk 0.56.0 → 0.57.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/AGENTS.md CHANGED
@@ -17,10 +17,10 @@ signature; open the file.**
17
17
  | [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
18
18
  | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, data discipline, the search-as-you-type + record-picker patterns. |
19
19
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`. |
20
- | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, preview pairing, filter operators, the server-side delivery bounds. |
20
+ | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
21
21
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
22
22
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
23
- | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). |
23
+ | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). **A `file` input carries its own content** — images/PDFs are perceived natively, Word/Excel/CSV/text are materialized into the run; no reader tool to declare. |
24
24
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen. |
25
25
  | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, `openExternal`/`downloadFile`, geofencing, analytics, `useConfig` (App-Packages installation config), `getAppBinding` (package apps' runtime `F`/`OPT`/`ROLE` resolution via the generated `.lotics/app_fields.ts`), and the publish chain for package contributors. |
26
26
 
@@ -1,3 +1,4 @@
1
+ import { type ImageFidelity } from "./upload/optimize.js";
1
2
  import { type AiContextValue } from "./rpc.js";
2
3
  import { type AgentUIPart, type PendingChoice } from "./agent_stream.js";
3
4
  import type { AppWorkflows, AppWorkflowResults, AppQueries, AppAgents, AppAgentResults } from "./types.js";
@@ -302,8 +303,17 @@ interface FileUploadState {
302
303
  * Upload one file. Resolves to the stored file; pass `UploadedFile.id` into
303
304
  * a `useWorkflow` call to attach it to a record. Rejects on failure — the
304
305
  * file is never partially stored.
306
+ *
307
+ * `fidelity` says how much of the image must survive storage; it defaults to
308
+ * `"high"`, which keeps the text of a photographed document legible. Only
309
+ * photographs are affected — a PDF, Word or Excel file is stored untouched at
310
+ * every step. Pass `"standard"` for bulk visual capture (a forty-photo survey,
311
+ * where the volume is what costs you), or `"original"` when the pixels
312
+ * themselves are the evidence.
305
313
  */
306
- upload: (file: File) => Promise<UploadedFile>;
314
+ upload: (file: File, options?: {
315
+ fidelity?: ImageFidelity;
316
+ }) => Promise<UploadedFile>;
307
317
  /** True while any upload from this hook is in flight. */
308
318
  uploading: boolean;
309
319
  /** Message of the most recent failed upload, cleared when a new one starts. */
@@ -341,7 +351,9 @@ interface AttachmentsState {
341
351
  /** Add picked/pasted/dropped files — each shows its local preview at once and
342
352
  * uploads in the background. Picking is the app's choice: wire a button to
343
353
  * `@lotics/ui` `pickFiles`, or a paste/drop handler, then call this. */
344
- add: (files: File[]) => void;
354
+ add: (files: File[], options?: {
355
+ fidelity?: ImageFidelity;
356
+ }) => void;
345
357
  /** Remove one attachment and revoke its preview URL. */
346
358
  remove: (id: string) => void;
347
359
  /** Remove all attachments and revoke their preview URLs. */
package/dist/src/hooks.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * `useState` — they have nothing to share.
17
17
  */
18
18
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
+ import { DEFAULT_IMAGE_FIDELITY } from "./upload/optimize.js";
19
20
  import useSWR from "swr";
20
21
  import useSWRInfinite from "swr/infinite";
21
22
  import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeHostRefetch, } from "./rpc.js";
@@ -242,12 +243,13 @@ export function usePaginatedQuery(alias, params, opts) {
242
243
  export function useFileUpload() {
243
244
  const [inFlight, setInFlight] = useState(0);
244
245
  const [error, setError] = useState(null);
245
- const upload = useCallback(async (file) => {
246
+ const upload = useCallback(async (file, options) => {
247
+ const fidelity = options?.fidelity ?? DEFAULT_IMAGE_FIDELITY;
246
248
  setInFlight((n) => n + 1);
247
249
  setError(null);
248
250
  try {
249
- const uploaded = await rpc("upload", { file });
250
- captureAppEvent("app_file_uploaded", { mime_type: file.type });
251
+ const uploaded = await rpc("upload", { file, fidelity });
252
+ captureAppEvent("app_file_uploaded", { mime_type: file.type, fidelity });
251
253
  return uploaded;
252
254
  }
253
255
  catch (err) {
@@ -287,7 +289,7 @@ let attachSeq = 0;
287
289
  export function useAttachments() {
288
290
  const { upload } = useFileUpload();
289
291
  const [files, setFiles] = useState([]);
290
- const add = useCallback((incoming) => {
292
+ const add = useCallback((incoming, options) => {
291
293
  for (const file of incoming) {
292
294
  const id = `att_${(attachSeq += 1)}`;
293
295
  const previewUrl = URL.createObjectURL(file);
@@ -295,7 +297,7 @@ export function useAttachments() {
295
297
  ...prev,
296
298
  { id, filename: file.name, mime_type: file.type, preview_url: previewUrl, status: "uploading" },
297
299
  ]);
298
- upload(file)
300
+ upload(file, options)
299
301
  .then((uploaded) => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "ready", file_id: uploaded.id } : f))))
300
302
  .catch(() => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "error" } : f))));
301
303
  }
package/dist/src/rpc.js CHANGED
@@ -525,7 +525,7 @@ function rpcStandalone(op, payload) {
525
525
  case "agentRun.cancel":
526
526
  return standaloneAgentRunCancel(payload);
527
527
  case "upload":
528
- return standaloneUpload(payload.file);
528
+ return standaloneUpload(payload.file, payload.fidelity);
529
529
  case "members":
530
530
  return standaloneMembers(payload);
531
531
  case "context":
@@ -705,7 +705,7 @@ async function standaloneAgentRunCancel(p) {
705
705
  });
706
706
  return { ok: true };
707
707
  }
708
- async function standaloneUpload(file) {
708
+ async function standaloneUpload(file, fidelity) {
709
709
  if (!(file instanceof File)) {
710
710
  throw new Error("upload payload must include a File");
711
711
  }
@@ -713,6 +713,6 @@ async function standaloneUpload(file) {
713
713
  const uploaded = await runUploadPipeline(file, {
714
714
  initUpload: (input) => apiCall("POST", `/v1/apps/${app_id}/files/upload-url`, input, { appId: app_id }),
715
715
  completeUpload: (input) => apiCall("POST", `/v1/apps/${app_id}/files/complete`, input, { appId: app_id }),
716
- });
716
+ }, { fidelity });
717
717
  return uploaded;
718
718
  }
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Browser-side image compression for app uploads.
3
3
  *
4
- * Phone-camera photos are huge (4–10 MB HEIC/JPEG) but documents only need
5
- * legible bytes, not megapixel ones. Resize to ≤1280px on the long edge and
6
- * re-encode as JPEG at q=0.75 before uploading typically 10–20× smaller
7
- * with no visible quality loss for the doc-scan use case.
4
+ * Phone-camera photos are huge (4–10 MB HEIC/JPEG) and no document needs
5
+ * megapixels. Resize to ≤2000px on the long edge — above the standard vision
6
+ * tier's 1568px so a high-resolution reader keeps real detail, but under the
7
+ * limit that rejects requests carrying more than 20 images — and
8
+ * re-encode as JPEG at q=0.9, which keeps small glyphs (container numbers,
9
+ * invoice lines) legible to a reader and to an extraction agent.
8
10
  *
9
11
  * HEIC/HEIF / PNG / WebP are converted to JPEG. Non-image files (PDF, etc.)
10
12
  * pass through unchanged. Falls through gracefully on any browser API gap
@@ -12,10 +14,32 @@
12
14
  *
13
15
  * Ported from `frontend/lib/upload_file_optimization.ts` so public-app forms
14
16
  * get the same mobile-friendly upload behavior as the in-Lotics UI. Kept
15
- * dependency-free (no logger, no UploadIntent abstraction) so the SDK ships
17
+ * dependency-free (no logger, no ImageFidelity abstraction) so the SDK ships
16
18
  * as a single drop-in.
17
19
  */
18
- export type OptimizationReason = "optimized" | "skipped_unsupported_format" | "skipped_environment_unsupported" | "skipped_decode_unavailable" | "skipped_invalid_dimensions" | "skipped_small_dimensions" | "skipped_canvas_unavailable" | "skipped_canvas_type_mismatch";
20
+ /**
21
+ * How faithful the STORED image must be — a closed ordinal scale, not a taxonomy
22
+ * of subjects, so a caller can read the value and know what it costs.
23
+ * `@lotics/shared/image_policy` carries the same scale and the reasoning.
24
+ */
25
+ export type ImageFidelity = "original" | "high" | "standard";
26
+ /**
27
+ * What an upload defaults to when the caller says nothing. MIRRORS
28
+ * `@lotics/shared/image_policy` — this package ships dependency-free and cannot
29
+ * import it — and a test fails when the two drift.
30
+ *
31
+ * `high` because the surfaces that do not think about this mostly carry text; a
32
+ * caller collecting in volume declares `"standard"` and pays a fraction as much.
33
+ */
34
+ export declare const DEFAULT_IMAGE_FIDELITY: ImageFidelity;
35
+ export interface ImagePolicy {
36
+ maxDimensionPx: number;
37
+ jpegQuality: number;
38
+ }
39
+ /** The mirror, exposed so the drift guard can compare it with the shared
40
+ * source of truth. Not part of the package's public surface. */
41
+ export declare const IMAGE_POLICIES_FOR_TEST: Record<"high" | "standard", ImagePolicy>;
42
+ export type OptimizationReason = "optimized" | "skipped_original" | "skipped_unsupported_format" | "skipped_environment_unsupported" | "skipped_decode_unavailable" | "skipped_invalid_dimensions" | "skipped_small_dimensions" | "skipped_canvas_unavailable" | "skipped_canvas_type_mismatch";
19
43
  export interface OptimizationResult {
20
44
  file: File;
21
45
  optimized: boolean;
@@ -27,4 +51,4 @@ export interface OptimizationResult {
27
51
  targetWidth: number;
28
52
  targetHeight: number;
29
53
  }
30
- export declare function optimizeImageForUpload(file: File): Promise<OptimizationResult>;
54
+ export declare function optimizeImageForUpload(file: File, fidelity?: ImageFidelity): Promise<OptimizationResult>;
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Browser-side image compression for app uploads.
3
3
  *
4
- * Phone-camera photos are huge (4–10 MB HEIC/JPEG) but documents only need
5
- * legible bytes, not megapixel ones. Resize to ≤1280px on the long edge and
6
- * re-encode as JPEG at q=0.75 before uploading typically 10–20× smaller
7
- * with no visible quality loss for the doc-scan use case.
4
+ * Phone-camera photos are huge (4–10 MB HEIC/JPEG) and no document needs
5
+ * megapixels. Resize to ≤2000px on the long edge — above the standard vision
6
+ * tier's 1568px so a high-resolution reader keeps real detail, but under the
7
+ * limit that rejects requests carrying more than 20 images — and
8
+ * re-encode as JPEG at q=0.9, which keeps small glyphs (container numbers,
9
+ * invoice lines) legible to a reader and to an extraction agent.
8
10
  *
9
11
  * HEIC/HEIF / PNG / WebP are converted to JPEG. Non-image files (PDF, etc.)
10
12
  * pass through unchanged. Falls through gracefully on any browser API gap
@@ -12,13 +14,36 @@
12
14
  *
13
15
  * Ported from `frontend/lib/upload_file_optimization.ts` so public-app forms
14
16
  * get the same mobile-friendly upload behavior as the in-Lotics UI. Kept
15
- * dependency-free (no logger, no UploadIntent abstraction) so the SDK ships
17
+ * dependency-free (no logger, no ImageFidelity abstraction) so the SDK ships
16
18
  * as a single drop-in.
17
19
  */
18
- const MAX_IMAGE_DIMENSION_PX = 1280;
19
- const JPEG_QUALITY = 0.75;
20
+ /**
21
+ * What an upload defaults to when the caller says nothing. MIRRORS
22
+ * `@lotics/shared/image_policy` — this package ships dependency-free and cannot
23
+ * import it — and a test fails when the two drift.
24
+ *
25
+ * `high` because the surfaces that do not think about this mostly carry text; a
26
+ * caller collecting in volume declares `"standard"` and pays a fraction as much.
27
+ */
28
+ export const DEFAULT_IMAGE_FIDELITY = "high";
29
+ // MIRRORS `@lotics/shared/image_policy` — the SDK ships dependency-free so it
30
+ // cannot import it. Change both together.
31
+ // · standard — bulk visual evidence, NEVER read by a model, so tokens do not
32
+ // enter it: sized purely for upload time and storage across dozens of files.
33
+ // · high — read by a model or a person. Token cost depends on DIMENSION
34
+ // alone, so this buys quality (free in tokens) and keeps the dimension modest.
35
+ const IMAGE_POLICIES = {
36
+ standard: { maxDimensionPx: 1280, jpegQuality: 0.8 },
37
+ high: { maxDimensionPx: 1568, jpegQuality: 0.95 },
38
+ };
39
+ /** The mirror, exposed so the drift guard can compare it with the shared
40
+ * source of truth. Not part of the package's public surface. */
41
+ export const IMAGE_POLICIES_FOR_TEST = IMAGE_POLICIES;
20
42
  const CONVERTIBLE_EXTENSION_PATTERN = /\.(heic|heif|png|webp)$/i;
21
- export async function optimizeImageForUpload(file) {
43
+ export async function optimizeImageForUpload(file, fidelity = DEFAULT_IMAGE_FIDELITY) {
44
+ if (fidelity === "original")
45
+ return unchanged(file, "skipped_original");
46
+ const policy = IMAGE_POLICIES[fidelity];
22
47
  const format = getOptimizableFormat(file);
23
48
  const mimeType = format?.outputMimeType;
24
49
  const unsupportedReason = getUnsupportedReason(file, mimeType);
@@ -34,11 +59,11 @@ export async function optimizeImageForUpload(file) {
34
59
  closeSource(source);
35
60
  return unchanged(file, "skipped_invalid_dimensions");
36
61
  }
37
- if (Math.max(width, height) <= MAX_IMAGE_DIMENSION_PX) {
62
+ if (Math.max(width, height) <= policy.maxDimensionPx) {
38
63
  closeSource(source);
39
64
  return unchanged(file, "skipped_small_dimensions", width, height);
40
65
  }
41
- const { width: targetWidth, height: targetHeight } = scale(width, height);
66
+ const { width: targetWidth, height: targetHeight } = scale(width, height, policy.maxDimensionPx);
42
67
  const canvas = document.createElement("canvas");
43
68
  canvas.width = targetWidth;
44
69
  canvas.height = targetHeight;
@@ -51,7 +76,7 @@ export async function optimizeImageForUpload(file) {
51
76
  ctx.imageSmoothingQuality = "high";
52
77
  ctx.drawImage(source, 0, 0, targetWidth, targetHeight);
53
78
  closeSource(source);
54
- const blob = await canvasToBlob(canvas, mimeType);
79
+ const blob = await canvasToBlob(canvas, mimeType, policy.jpegQuality);
55
80
  if (blob.type !== mimeType) {
56
81
  return unchanged(file, "skipped_canvas_type_mismatch", width, height, targetWidth, targetHeight);
57
82
  }
@@ -128,11 +153,11 @@ function closeSource(source) {
128
153
  if ("close" in source && typeof source.close === "function")
129
154
  source.close();
130
155
  }
131
- function scale(width, height) {
156
+ function scale(width, height, maxDimensionPx) {
132
157
  const largest = Math.max(width, height);
133
- if (largest <= MAX_IMAGE_DIMENSION_PX)
158
+ if (largest <= maxDimensionPx)
134
159
  return { width, height };
135
- const factor = MAX_IMAGE_DIMENSION_PX / largest;
160
+ const factor = maxDimensionPx / largest;
136
161
  return {
137
162
  width: Math.max(1, Math.round(width * factor)),
138
163
  height: Math.max(1, Math.round(height * factor)),
@@ -169,7 +194,7 @@ async function loadImage(file) {
169
194
  image.src = objectUrl;
170
195
  });
171
196
  }
172
- async function canvasToBlob(canvas, mimeType) {
197
+ async function canvasToBlob(canvas, mimeType, jpegQuality) {
173
198
  return new Promise((resolve, reject) => {
174
199
  canvas.toBlob((blob) => {
175
200
  if (!blob) {
@@ -177,6 +202,6 @@ async function canvasToBlob(canvas, mimeType) {
177
202
  return;
178
203
  }
179
204
  resolve(blob);
180
- }, mimeType, JPEG_QUALITY);
205
+ }, mimeType, jpegQuality);
181
206
  });
182
207
  }
@@ -13,6 +13,7 @@
13
13
  * timeouts use platform-internal defaults — same conventions as the
14
14
  * in-Lotics direct-upload pipeline.
15
15
  */
16
+ import { type ImageFidelity } from "./optimize.js";
16
17
  interface UploadInitResponse {
17
18
  upload_url: string;
18
19
  file_id: string;
@@ -47,6 +48,8 @@ export interface UploadRpc {
47
48
  }
48
49
  export interface RunUploadPipelineOptions {
49
50
  signal?: AbortSignal;
51
+ /** What the surface is collecting — selects the stored image resolution. */
52
+ fidelity?: ImageFidelity;
50
53
  }
51
54
  export declare function runUploadPipeline(file: File, rpc: UploadRpc, options?: RunUploadPipelineOptions): Promise<CompleteResponseFile>;
52
55
  export {};
@@ -13,16 +13,16 @@
13
13
  * timeouts use platform-internal defaults — same conventions as the
14
14
  * in-Lotics direct-upload pipeline.
15
15
  */
16
- import { optimizeImageForUpload } from "./optimize.js";
16
+ import { optimizeImageForUpload, DEFAULT_IMAGE_FIDELITY } from "./optimize.js";
17
17
  import { putToStorageWithRetry } from "./transport.js";
18
18
  export async function runUploadPipeline(file, rpc, options = {}) {
19
- const { signal } = options;
19
+ const { signal, fidelity = DEFAULT_IMAGE_FIDELITY } = options;
20
20
  // 1. Compress images. Non-image files return unchanged. Failures here are
21
21
  // intentionally swallowed — the user shouldn't see an upload error
22
22
  // because canvas threw; we just upload the original bytes.
23
23
  let candidate = file;
24
24
  try {
25
- const optimized = await optimizeImageForUpload(file);
25
+ const optimized = await optimizeImageForUpload(file, fidelity);
26
26
  candidate = optimized.file;
27
27
  }
28
28
  catch {
package/docs/ai.md CHANGED
@@ -20,7 +20,7 @@ A declaration carries:
20
20
  | Field | Meaning |
21
21
  |---|---|
22
22
  | `instructions` | System instructions — the task the agent performs per run |
23
- | `tool_names` | The tools the agent may call, resolved against the platform's automation tool registry. **This is the capability boundary** — the run can use nothing else. May be empty for a pure-reasoning agent |
23
+ | `tool_names` | The tools the agent may call, resolved against the platform's automation tool registry. **This is the capability boundary** — the run can use nothing else. May be empty — including for an agent that reads documents, since a [`file` input carries its own content](#file-inputs--what-the-agent-can-actually-see) |
24
24
  | `model_id` | The chat model the agent runs on |
25
25
  | `effort_level` | Optional reasoning depth for adaptive-thinking models |
26
26
  | `inputs` | Optional typed input schema for one run — the same vocabulary as workflow inputs (`text`, `number`, `file`, `member`, `record_link`, `select`, …). The server validates every run payload against it |
@@ -181,9 +181,21 @@ On recovery, `output` adopts the row's output **only when it is an object** (a s
181
181
 
182
182
  Sessions are scoped to the authenticated member who ran them: two members using the same `sessionId` string share nothing, and a member can never read or extend another member's thread.
183
183
 
184
- ### File inputs vision
184
+ ### File inputs what the agent can actually see
185
185
 
186
- A declared `file` input (single or `multi`) is materialized into the model's native perception when the type supports it: **`image/*` becomes a vision part, `application/pdf` a document part** — the agent literally sees the file, no OCR tooling needed. Any other type (docx, xlsx, csv, …) is passed as a `file_id` reference instead; the agent can only open it if a file-reading tool is in its declared `tool_names`. Every file in a `multi` input is materialized — nothing is collapsed to the first. Get the ids from [`useFileUpload` / `useAttachments`](./files.md).
186
+ **A `file` input carries its own content. You do not declare a tool to read it.**
187
+
188
+ | Tier | Types | How the agent gets it |
189
+ |---|---|---|
190
+ | **Perceived natively** | `image/*`, `application/pdf` | A vision / document part — the agent literally sees it |
191
+ | **Materialized** | Word (`.docx`/`.doc`), Excel (`.xlsx`/`.xls`), CSV, and text (`.txt`, `.md`, `.json`, `.eml`, `.html`, `.xml`, `.yaml`) | Read server-side by the same engines `view_files` uses and inlined into the run's message, truncated at 40,000 characters with the agent told when that happened |
192
+ | **Unreadable** | Archives, audio, video | The run fails immediately, naming the file — a fact about the upload, not a gap in your configuration |
193
+
194
+ Every file in a `multi` input is materialized — nothing is collapsed to the first. Get the ids from [`useFileUpload` / `useAttachments`](./files.md).
195
+
196
+ **Why no tool:** the content ends up in the agent's context either way, so making it fetch what the server already holds costs a model round-trip and buys nothing. It also keeps the capability boundary tight — an agent that reads its own input never gains the ability to read *other* files in the workspace.
197
+
198
+ **When to add a tool anyway.** Only to reach *past* what was inlined. A spreadsheet is materialized at 100 rows × 50 columns per sheet and a Word file at 1,000 elements; overflow is reported, never silent. If the agent must read row 4,000 of a large sheet, add `excel_get_range` / `excel_find_cells` (or `view_files` for an ad-hoc second look at a different file).
187
199
 
188
200
  ### Auth, quota, and bounds
189
201
 
package/docs/files.md CHANGED
@@ -14,6 +14,8 @@ discipline in [data fetching](./data_fetching.md).
14
14
  |---|---|---|
15
15
  | Collect bytes from the visitor | `useFileUpload().upload(file)` | `UploadedFile` — a stored, **unattached** file id + presigned serving URLs |
16
16
  | Collect several with live previews | `useAttachments()` | `AttachedFile[]` with instant local previews; `fileIds` when done |
17
+
18
+ `useAttachments().add(files, { fidelity })` takes the same `fidelity` as `upload`.
17
19
  | Attach to a record | a declared workflow with a `{ type: "file" }` input | the workflow writes the id(s) into a `files` field — the **only** write path |
18
20
  | Read back from records | `useQuery` + `readFiles(cell)` | `AppFile[]` — presigned `url`/`thumbnail_url` (24 h) + `size`/`created_at` |
19
21
  | Receive a generated document | `useWorkflow` → `WorkflowResult.files` | presigned files auto-extracted from the run |
@@ -30,17 +32,40 @@ declared workflow (see [mutations](./mutations.md)).
30
32
  const { upload, uploading, error } = useFileUpload();
31
33
  const submit = useWorkflow("submitOrder");
32
34
 
33
- const photo = await upload(file); // File → UploadedFile
34
- await submit({ ...fields, photo_file_id: photo.id });
35
+ const invoiceShot = await upload(file); // high (default)
36
+ const surveyPhoto = await upload(file, { fidelity: "standard" }); // bulk capture
37
+ await submit({ ...fields, invoice_file_id: invoiceShot.id });
35
38
  ```
36
39
 
37
40
  Signature: `dist/src/hooks.d.ts`. Returns `{ upload, uploading, error }`:
38
41
 
39
- - `upload(file: File): Promise<UploadedFile>` — resolves to the stored file; rejects on failure
42
+ - `upload(file: File, options?: { fidelity }): Promise<UploadedFile>` — resolves to the stored file; rejects on failure
40
43
  (the file is never partially stored). `UploadedFile` is
41
44
  `{ id, filename, mime_type, url?, thumbnail_url? }` — `url`/`thumbnail_url` are presigned
42
45
  (24 h) and load directly in the sandboxed iframe, so a just-uploaded image previews without a
43
46
  round-trip.
47
+ - `fidelity` — **how faithful the stored image must be.** An ordinal scale, so the value tells
48
+ you what it costs without looking up a mapping. The platform owns the pixels behind each step
49
+ and re-tunes them centrally as model tiers change.
50
+
51
+ Defaults to `"high"` — enough to resolve the text of a photographed document. Drop to
52
+ `"standard"` when collecting in volume.
53
+
54
+ **This only affects photographs.** A PDF, Word, Excel or CSV file is stored untouched at every
55
+ step, so `"original"` is not what keeps a document intact — it already is. Reach for it when
56
+ the PIXELS are the evidence. Note too that HEIC (what an iPhone camera produces) is transcoded
57
+ to JPEG server-side whatever you choose, because no browser can render it.
58
+
59
+ | `fidelity` | Use it when | Stored at |
60
+ |---|---|---|
61
+ | `"standard"` | The image is only looked AT and archived — site and container surveys, the forty-image drop. Never read by a model, so it is sized purely for upload speed and storage. | 1280 px, q 0.80 |
62
+ | `"high"` *(default)* | The image is READ — invoices, release orders, screenshots of dense tables — by a model or a person. Near-lossless, because a model's token cost depends on dimension alone, so quality is free accuracy. | 1568 px, q 0.95 |
63
+ | `"original"` | The bytes themselves matter: evidence of record, or anything that may be re-read at a higher fidelity later. | unchanged |
64
+
65
+ Getting it wrong is never fatal — too low stores fewer pixels than the reader wanted, it never
66
+ produces a wrong answer. Rule of thumb: **if text on the image has to be legible, or a person
67
+ may zoom, use `"high"`.** Reach for `"original"` only when the file IS the record rather than
68
+ a picture of it — it is the one step whose meaning survives a change to these numbers.
44
69
  - `uploading` — true while **any** upload from this hook is in flight.
45
70
  - `error` — message of the most recent failed upload; cleared when a new one starts.
46
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {