@lotics/app-sdk 0.98.5 → 0.100.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.
package/AGENTS.md CHANGED
@@ -17,8 +17,8 @@ signature; open the file.**
17
17
  | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos for the actions whose mechanism is not guessable from the hooks — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
18
18
  | [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. |
19
19
  | [docs/data_fetching.md](./docs/data_fetching.md) | The four read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`/`useCount` — the last for a number with no rows, sharing the `(alias, params, filter)` count key the paginated hook uses, so a list and a badge over one set buy one count; a count is a full scan and stays its OWN request so rows paint without waiting for it, and `rows.length` is never a count since rows truncate at 10,000 — `useQuery` says so with `truncated`), the ROW type (`RowOf` — the alias's projected columns and nothing else, values `unknown`; `__source_record_id`/`__source_table_id` typed but optional), runtime `sort`/`filter` keys AND the `useFieldOptions` map keyed against that same projection (`AppQueryColumns`, `ColumnKeyOf`), cell readers (`row.*` — `row.num` and `row.bool` answer `null` for an EMPTY cell, so none is distinguishable from zero and an unanswered checkbox from a "no" — `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, and `readCreatedAt`/`readUpdatedAt` for the record timestamps every row-level result carries), caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — **realtime push** (a table one of your queries reads changes and that query refetches within about a second, alias-precise, records-only, host-embedded apps only) — and the fourth source, **this app's own successful write**, which re-reads every mounted query immediately rather than waiting on that push (→ [mutations](./docs/mutations.md)), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
20
- | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract (`field_errors` locates a refusal on the control it belongs to, where `message` can only say it at the dialog's scope), typed inputs, the automatic re-read a SUCCESSFUL write triggers over every mounted query (so a screen never waits on the host push to see its own write), diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write). |
21
- | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + explicit `linked()` descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
20
+ | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract (`field_errors` locates a refusal on the control it belongs to, where `message` can only say it at the dialog's scope), typed inputs, the automatic re-read a SUCCESSFUL write triggers over every mounted query (so a screen never waits on the host push to see its own write), diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write), `useRecording` (the HOST records the member — mic, system sound, screen — and files the transcribed recording through a receiving workflow exactly once; `available` gates the control, `state` follows the recording). |
21
+ | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + explicit `linked()` descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the `recording` input a workflow declares to receive a recording, the traps, the bright line, and the verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
22
22
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments` (**the add-queue, and which lifecycle it keeps**: a composer clears, a record passes `landed` and each entry leaves as the stored pile takes it over), `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted; and a LOOKUP of a files field is one list per linked row, which the reader opens itself), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), 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. |
23
23
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions` (keyed by the alias's OWN columns, every key optional), `useViewer`, `useComments` (each comment carries its own resolved `author`, so a thread crossing a role boundary is legible without declaring member access), and the `@lotics/ui` components they feed. |
24
24
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
@@ -386,7 +386,7 @@ export interface FieldOptions {
386
386
  /** The source field's display name — e.g. a picker/section label. */
387
387
  label: string;
388
388
  /**
389
- * Every option of the field — `{ key, label, color }`. Includes options not
389
+ * Every option of the field — `{ key, label, color, mark? }`. Includes options not
390
390
  * present in any current row, so a freshly-added option appears in a picker
391
391
  * without an app change, and a removed one drops out.
392
392
  */
@@ -21,6 +21,9 @@ export type { AttachedFile, AttachmentsOptions, AttachmentsState } from "./attac
21
21
  export { useComments, useCommentCounts } from "./comments.js";
22
22
  export type { AppComment, AppCommentAuthor, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
23
23
  export { useViewer } from "./viewer.js";
24
+ export { useRecording } from "./recording.js";
25
+ export type { UseRecording } from "./recording.js";
26
+ export type { RecordingState, RecordingInputs } from "./recording_state.js";
24
27
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
25
28
  export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
26
29
  export { rpc, isEmbedded } from "./rpc.js";
package/dist/src/index.js CHANGED
@@ -17,6 +17,7 @@ export { mount } from "./mount.js";
17
17
  export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useCount, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
18
18
  export { useComments, useCommentCounts } from "./comments.js";
19
19
  export { useViewer } from "./viewer.js";
20
+ export { useRecording } from "./recording.js";
20
21
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
21
22
  export { rpc, isEmbedded } from "./rpc.js";
22
23
  export { openExternal } from "./open_external.js";
@@ -35,10 +35,14 @@
35
35
  * a static row array cannot, so a master/detail drawer under `?__mock=1` would
36
36
  * otherwise show every parent's children under every parent.
37
37
  *
38
+ * `recordings` hands `useRecording` a canned state per alias, so each state a
39
+ * recording act passes through can be drawn without a host that records.
40
+ *
38
41
  * What's deliberately *not* mocked: `useFileUpload`. Bytes and progress are a
39
42
  * different shape from a request/response pair, and nothing has needed it.
40
43
  */
41
44
  import type { WorkflowResult } from "./hooks.js";
45
+ import type { RecordingState } from "./recording_state.js";
42
46
  /**
43
47
  * What a mocked workflow answers with: a fixed result, or a function of the
44
48
  * inputs it was called with. Return a promise from the function to hold the
@@ -72,6 +76,10 @@ export interface AppFixture {
72
76
  /** Map of workflow alias → the result it resolves with when mock mode is on.
73
77
  * The workflow never executes, so nothing it would have written is written. */
74
78
  workflows?: Record<string, MockWorkflow>;
79
+ /** Map of workflow alias → the state `useRecording(alias)` reports when mock
80
+ * mode is on. It reads as available, and `start`/`stop` resolve without
81
+ * recording anything or changing the state. */
82
+ recordings?: Record<string, RecordingState>;
75
83
  }
76
84
  /**
77
85
  * Called by `mount({ fixture })`. Module-level state because the SDK has no
@@ -105,3 +113,6 @@ export declare function getMockRows(alias: string, call: MockQueryCall): Array<R
105
113
  * app that never calls the workflow pays nothing.
106
114
  */
107
115
  export declare function getMockWorkflow(alias: string): MockWorkflow | null;
116
+ /** The canned recording states when mock mode is active and the fixture names
117
+ * any; otherwise null, and `useRecording` reads the host. */
118
+ export declare function getMockRecordings(): Record<string, RecordingState> | null;
package/dist/src/mock.js CHANGED
@@ -35,6 +35,9 @@
35
35
  * a static row array cannot, so a master/detail drawer under `?__mock=1` would
36
36
  * otherwise show every parent's children under every parent.
37
37
  *
38
+ * `recordings` hands `useRecording` a canned state per alias, so each state a
39
+ * recording act passes through can be drawn without a host that records.
40
+ *
38
41
  * What's deliberately *not* mocked: `useFileUpload`. Bytes and progress are a
39
42
  * different shape from a request/response pair, and nothing has needed it.
40
43
  */
@@ -112,3 +115,10 @@ export function getMockWorkflow(alias) {
112
115
  return null;
113
116
  return registeredFixture?.workflows?.[alias] ?? null;
114
117
  }
118
+ /** The canned recording states when mock mode is active and the fixture names
119
+ * any; otherwise null, and `useRecording` reads the host. */
120
+ export function getMockRecordings() {
121
+ if (!isMockMode())
122
+ return null;
123
+ return registeredFixture?.recordings ?? null;
124
+ }
@@ -0,0 +1,59 @@
1
+ import { type RecordingState } from "./recording_state.js";
2
+ import type { AppWorkflows } from "./types.js";
3
+ /** The platform fills a receiving workflow's `recording` input; the app passes
4
+ * the rest of what the workflow declares. */
5
+ type ActInputsOf<K extends keyof AppWorkflows & string> = AppWorkflows[K] extends Record<string, unknown> ? Omit<AppWorkflows[K], "recording"> : Record<string, unknown>;
6
+ export interface UseRecording<I> {
7
+ /**
8
+ * Whether this surface can record. False standalone, in a host that
9
+ * predates recording, on an instance without transcription, and until the
10
+ * host's context has answered. A control that starts a recording is drawn
11
+ * only when this is true.
12
+ */
13
+ available: boolean;
14
+ /** The most recent recording this app started through this alias. */
15
+ state: RecordingState;
16
+ /** Whether a recording this app started is live, through any alias. The
17
+ * host records one at a time, so a start while this is true is refused. */
18
+ busy: boolean;
19
+ /**
20
+ * Ask the host to record. The host opens its own capture dialog, and nothing
21
+ * records until the member presses Start there. Resolves `{ started: true }`
22
+ * once capture runs, `{ started: false }` when the member closed the dialog;
23
+ * rejects, with the reason, when the host refuses (another recording is
24
+ * running, the inputs fail the workflow's contract, recording is unavailable).
25
+ * When the recording is transcribed, the host runs the alias's workflow with
26
+ * these inputs plus `recording`.
27
+ */
28
+ start: {} extends I ? (inputs?: I) => Promise<{
29
+ started: boolean;
30
+ }> : (inputs: I) => Promise<{
31
+ started: boolean;
32
+ }>;
33
+ /** Stop this alias's live recording. Resolves whether or not one was live. */
34
+ stop: () => Promise<void>;
35
+ }
36
+ /**
37
+ * Record a call, a visit, a meeting — and file it through a workflow.
38
+ *
39
+ * `alias` names a workflow that declares the platform's `recording` input; the
40
+ * host records as the signed-in member and, once the transcript is ready, runs
41
+ * that workflow once with the app's inputs and the recording. Its write reaches
42
+ * the screen like any workflow's.
43
+ *
44
+ * ```tsx
45
+ * const visit = useRecording("log_visit");
46
+ * if (!visit.available) return null;
47
+ * if (visit.state.phase === "live" && visit.state.inputs.site === siteId) {
48
+ * return <Button onPress={() => visit.stop()}>Stop</Button>;
49
+ * }
50
+ * return (
51
+ * <Button disabled={visit.busy} onPress={() => visit.start({ site: siteId })}>
52
+ * Record the visit
53
+ * </Button>
54
+ * );
55
+ * ```
56
+ */
57
+ export declare function useRecording<K extends keyof AppWorkflows & string>(alias: K): UseRecording<ActInputsOf<K>>;
58
+ export declare function useRecording(alias: string): UseRecording<Record<string, unknown>>;
59
+ export {};
@@ -0,0 +1,30 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+ import { rpc } from "./rpc.js";
3
+ import { getMockRecordings } from "./mock.js";
4
+ import { useAppContext } from "./viewer.js";
5
+ import { anyRecordingLive, recordingStateOf, subscribeRecordings, } from "./recording_state.js";
6
+ const NO_MOCKS = {};
7
+ export function useRecording(alias) {
8
+ const { recordingEnabled } = useAppContext();
9
+ const mocks = getMockRecordings() ?? NO_MOCKS;
10
+ const mocked = mocks[alias];
11
+ const hostState = useSyncExternalStore(subscribeRecordings, () => recordingStateOf(alias));
12
+ const hostBusy = useSyncExternalStore(subscribeRecordings, anyRecordingLive);
13
+ const start = useCallback(async (inputs) => {
14
+ if (getMockRecordings()?.[alias])
15
+ return { started: true };
16
+ return rpc("recording.start", { alias, inputs: inputs ?? {} });
17
+ }, [alias]);
18
+ const stop = useCallback(async () => {
19
+ if (getMockRecordings()?.[alias])
20
+ return;
21
+ await rpc("recording.stop", { alias });
22
+ }, [alias]);
23
+ return {
24
+ available: mocked !== undefined || recordingEnabled,
25
+ state: mocked ?? hostState,
26
+ busy: hostBusy || Object.values(mocks).some((state) => state.phase === "live"),
27
+ start,
28
+ stop,
29
+ };
30
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * What the host says about the recordings THIS app started, one per workflow
3
+ * alias: the most recently started recording through that alias.
4
+ *
5
+ * A leaf module, because two sides write it and one reads it: the bridge
6
+ * listener in `rpc.ts` applies the host's `recordingChanged` push, the context
7
+ * fetch in `viewer.ts` applies the snapshot a newly loaded frame starts from,
8
+ * and `useRecording` reads it. Both writes arrive on the one postMessage channel
9
+ * in the order the host sent them, so the later arrival is always the newer
10
+ * state.
11
+ */
12
+ /** The act's own inputs the recording was started with — what `start` was
13
+ * passed, echoed back so a screen drawing one act per row can tell which row
14
+ * the recording is about. */
15
+ export type RecordingInputs = Record<string, unknown>;
16
+ /**
17
+ * One recording, from the host's side. `idle`: nothing of this alias's is
18
+ * running or waiting. `live`: capturing. `interrupted`: the tab closed mid-call
19
+ * and the member has not yet resumed or finished it from the recording bar.
20
+ * `processing`: stopped, the transcript is on its way. `filed`: the workflow
21
+ * ran and succeeded. `failed`: transcription failed, the platform refused to
22
+ * file (the member lost access, the alias or its declaration changed), or the
23
+ * run failed; the recording is kept, and the member retries from the recording
24
+ * bar.
25
+ */
26
+ export type RecordingState = {
27
+ phase: "idle";
28
+ } | {
29
+ phase: "live";
30
+ elapsed_seconds: number;
31
+ inputs: RecordingInputs;
32
+ } | {
33
+ phase: "interrupted";
34
+ elapsed_seconds: number;
35
+ inputs: RecordingInputs;
36
+ } | {
37
+ phase: "processing";
38
+ inputs: RecordingInputs;
39
+ } | {
40
+ phase: "filed";
41
+ execution_id: string;
42
+ inputs: RecordingInputs;
43
+ } | {
44
+ phase: "failed";
45
+ message: string;
46
+ inputs: RecordingInputs;
47
+ };
48
+ export declare const IDLE_RECORDING: RecordingState;
49
+ export declare function subscribeRecordings(listener: () => void): () => void;
50
+ export declare function recordingStateOf(alias: string): RecordingState;
51
+ export declare function anyRecordingLive(): boolean;
52
+ /** The host's push for one alias. */
53
+ export declare function applyRecordingChange(alias: string, state: RecordingState): void;
54
+ /** The snapshot `context` carries: every alias it omits is idle. */
55
+ export declare function replaceRecordings(snapshot: Record<string, RecordingState>): void;
56
+ /** A host message's `state`, or null when it is not one. */
57
+ export declare function readRecordingState(raw: unknown): RecordingState | null;
58
+ /** A context's `recordings`, keeping each well-formed entry. */
59
+ export declare function readRecordingSnapshot(raw: unknown): Record<string, RecordingState>;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * What the host says about the recordings THIS app started, one per workflow
3
+ * alias: the most recently started recording through that alias.
4
+ *
5
+ * A leaf module, because two sides write it and one reads it: the bridge
6
+ * listener in `rpc.ts` applies the host's `recordingChanged` push, the context
7
+ * fetch in `viewer.ts` applies the snapshot a newly loaded frame starts from,
8
+ * and `useRecording` reads it. Both writes arrive on the one postMessage channel
9
+ * in the order the host sent them, so the later arrival is always the newer
10
+ * state.
11
+ */
12
+ export const IDLE_RECORDING = { phase: "idle" };
13
+ const states = new Map();
14
+ const listeners = new Set();
15
+ function emit() {
16
+ for (const listener of listeners)
17
+ listener();
18
+ }
19
+ export function subscribeRecordings(listener) {
20
+ listeners.add(listener);
21
+ return () => {
22
+ listeners.delete(listener);
23
+ };
24
+ }
25
+ export function recordingStateOf(alias) {
26
+ return states.get(alias) ?? IDLE_RECORDING;
27
+ }
28
+ export function anyRecordingLive() {
29
+ for (const state of states.values())
30
+ if (state.phase === "live")
31
+ return true;
32
+ return false;
33
+ }
34
+ /** The host's push for one alias. */
35
+ export function applyRecordingChange(alias, state) {
36
+ if (state.phase === "idle")
37
+ states.delete(alias);
38
+ else
39
+ states.set(alias, state);
40
+ emit();
41
+ }
42
+ /** The snapshot `context` carries: every alias it omits is idle. */
43
+ export function replaceRecordings(snapshot) {
44
+ states.clear();
45
+ for (const [alias, state] of Object.entries(snapshot)) {
46
+ if (state.phase !== "idle")
47
+ states.set(alias, state);
48
+ }
49
+ emit();
50
+ }
51
+ function isRecord(value) {
52
+ return value !== null && typeof value === "object" && !Array.isArray(value);
53
+ }
54
+ /** A host message's `state`, or null when it is not one. */
55
+ export function readRecordingState(raw) {
56
+ if (!isRecord(raw))
57
+ return null;
58
+ if (raw.phase === "idle")
59
+ return IDLE_RECORDING;
60
+ const inputs = raw.inputs;
61
+ if (!isRecord(inputs))
62
+ return null;
63
+ switch (raw.phase) {
64
+ case "live":
65
+ case "interrupted":
66
+ return typeof raw.elapsed_seconds === "number"
67
+ ? { phase: raw.phase, elapsed_seconds: raw.elapsed_seconds, inputs }
68
+ : null;
69
+ case "processing":
70
+ return { phase: "processing", inputs };
71
+ case "filed":
72
+ return typeof raw.execution_id === "string"
73
+ ? { phase: "filed", execution_id: raw.execution_id, inputs }
74
+ : null;
75
+ case "failed":
76
+ return typeof raw.message === "string" ? { phase: "failed", message: raw.message, inputs } : null;
77
+ default:
78
+ return null;
79
+ }
80
+ }
81
+ /** A context's `recordings`, keeping each well-formed entry. */
82
+ export function readRecordingSnapshot(raw) {
83
+ if (!isRecord(raw))
84
+ return {};
85
+ const entries = Object.entries(raw).flatMap(([alias, value]) => {
86
+ const state = readRecordingState(value);
87
+ if (state === null) {
88
+ console.error(`The host's context carried a malformed recording for "${alias}"; it reads as idle.`, value);
89
+ return [];
90
+ }
91
+ return [[alias, state]];
92
+ });
93
+ return Object.fromEntries(entries);
94
+ }
package/dist/src/rpc.d.ts CHANGED
@@ -21,7 +21,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
21
21
  * app → host: { id, op, payload }
22
22
  * host → app: { id, type: "result", data } | { id, type: "error", message }
23
23
  */
24
- export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
24
+ export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts" | "recording.start" | "recording.stop";
25
25
  /** Payload for starting a streaming agent run. */
26
26
  export interface AgentRunPayload {
27
27
  alias: string;
@@ -82,6 +82,15 @@ export interface AppContext {
82
82
  * app that didn't opt in, and (vacuously) for standalone visitors.
83
83
  */
84
84
  comments_enabled: boolean;
85
+ /**
86
+ * Whether this host records for the app: true only from a host that has the
87
+ * `recording.*` ops, for a signed-in member, on an instance that transcribes.
88
+ * A host that predates recording omits it, which reads as false.
89
+ */
90
+ recording_enabled?: boolean;
91
+ /** The recordings this app started that the host still tracks, by alias —
92
+ * the state a newly loaded frame starts from. */
93
+ recordings?: Record<string, unknown>;
85
94
  }
86
95
  /**
87
96
  * Whether the app is running embedded in a Lotics host (vs. standalone at its
package/dist/src/rpc.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { runUploadPipeline } from "./upload/pipeline.js";
2
+ import { applyRecordingChange, readRecordingState, recordingStateOf } from "./recording_state.js";
2
3
  import { parseSearch, serializeMerge, } from "./url_params.js";
3
4
  /**
4
5
  * The embedding Lotics host's origin — present iff the app is bridged.
@@ -192,6 +193,24 @@ function ensureListener() {
192
193
  cb(aliases);
193
194
  return;
194
195
  }
196
+ // Broadcast (no id): a recording THIS app started moved — the host sends
197
+ // no other app's and not the chat's.
198
+ if (msg.type === "recordingChanged") {
199
+ const state = readRecordingState(msg.state);
200
+ if (typeof msg.alias !== "string" || state === null) {
201
+ console.error("The host sent a malformed recordingChanged push; it is ignored.", msg);
202
+ return;
203
+ }
204
+ const before = recordingStateOf(msg.alias);
205
+ applyRecordingChange(msg.alias, state);
206
+ // A filed recording is this app's own successful write, so the screen
207
+ // re-reads now, as after any workflow, not on the realtime push.
208
+ const newlyFiled = state.phase === "filed" &&
209
+ !(before.phase === "filed" && before.execution_id === state.execution_id);
210
+ if (newlyFiled)
211
+ notifyLocalWrite();
212
+ return;
213
+ }
195
214
  if (typeof msg.id !== "number")
196
215
  return;
197
216
  // Single-response ops.
@@ -738,6 +757,11 @@ function rpcStandalone(op, payload) {
738
757
  case "comments.delete":
739
758
  case "comments.counts":
740
759
  return rejectCommentsStandalone();
760
+ case "recording.start":
761
+ case "recording.stop":
762
+ // The host records, as the signed-in member; a standalone visitor is
763
+ // anonymous and has no host to capture in.
764
+ return Promise.reject(new Error("Recording needs the Lotics host and a signed-in member. Gate the control on useRecording's `available`."));
741
765
  }
742
766
  }
743
767
  /**
@@ -794,6 +818,7 @@ async function standaloneContext() {
794
818
  // comments are unavailable regardless of the capability flag.
795
819
  member_id: null,
796
820
  comments_enabled: info.comments_enabled,
821
+ recording_enabled: false,
797
822
  };
798
823
  }
799
824
  /**
@@ -22,6 +22,20 @@ export interface ResolvedOption {
22
22
  * degrades a missing/unknown token to a neutral badge.
23
23
  */
24
24
  color?: string;
25
+ /**
26
+ * The option's own mark, where its field gives one to every option — the
27
+ * channel it IS (`brand`, a `BrandMark` name) or a glyph (`icon`, a kit icon
28
+ * name). Populated by `useFieldOptions` like `color`, absent on a cell. Pass
29
+ * the option to `@lotics/ui`'s `Status`, which draws it in the dot's place and
30
+ * degrades a name its build does not draw to the dot.
31
+ */
32
+ mark?: {
33
+ kind: "brand";
34
+ name: string;
35
+ } | {
36
+ kind: "icon";
37
+ name: string;
38
+ };
25
39
  }
26
40
  /**
27
41
  * Parse a `useQuery` cell value into `ResolvedOption[]`. Returns `[]` for
@@ -6,6 +6,7 @@
6
6
  export declare function useAppContext(): {
7
7
  memberId: string | null;
8
8
  commentsEnabled: boolean;
9
+ recordingEnabled: boolean;
9
10
  resolved: boolean;
10
11
  };
11
12
  /**
@@ -1,12 +1,23 @@
1
1
  import useSWR from "swr";
2
2
  import { rpc } from "./rpc.js";
3
+ import { readRecordingSnapshot, replaceRecordings } from "./recording_state.js";
4
+ /**
5
+ * The context, with its recording snapshot applied in the same task the
6
+ * answer arrived in — before any later host push is handled, so the snapshot
7
+ * can never overwrite a newer push.
8
+ */
9
+ async function fetchContext() {
10
+ const context = await rpc("context", {});
11
+ replaceRecordings(readRecordingSnapshot(context.recordings));
12
+ return context;
13
+ }
3
14
  /**
4
15
  * Read the app's context once, shared across every hook via a stable SWR key.
5
16
  * The host (the product iframe, or `lotics app dev`) supplies the signed-in
6
17
  * member and the app's declared capabilities.
7
18
  */
8
19
  export function useAppContext() {
9
- const { data } = useSWR("app-context", () => rpc("context", {}), {
20
+ const { data } = useSWR("app-context", fetchContext, {
10
21
  revalidateOnFocus: false,
11
22
  revalidateIfStale: false,
12
23
  revalidateOnReconnect: false,
@@ -15,6 +26,7 @@ export function useAppContext() {
15
26
  return {
16
27
  memberId: data?.member_id ?? null,
17
28
  commentsEnabled: data?.comments_enabled ?? false,
29
+ recordingEnabled: data?.recording_enabled === true,
18
30
  resolved: data !== undefined,
19
31
  };
20
32
  }
@@ -77,7 +77,7 @@ rendering with no options; and every key is optional, so the read is through `?.
77
77
  | Property | Meaning |
78
78
  | --- | --- |
79
79
  | `label` | The source field's display name — a ready picker/section label |
80
- | `options` | Every option of the field — `{ key, label, color }` — in field-config order, **including options not present in any current row** |
80
+ | `options` | Every option of the field — `{ key, label, color, mark? }` (the option's own brand or glyph, where the field marks every option) — in field-config order, **including options not present in any current row** |
81
81
  | `byKey(key)` | Resolve one option by key; `undefined` for an unknown key (option removed after the cell was written) |
82
82
 
83
83
  - `color` is a named palette token (e.g. `"blue"`, `"emerald"`). Pass the option straight to
package/docs/mutations.md CHANGED
@@ -4,8 +4,8 @@ Everything about writing data from an app: `useWorkflow` (the only write path),
4
4
  `WorkflowResult` contract and its resolve-never-throw failure model, declaring typed workflow
5
5
  inputs (file/member/optional inputs), returning structured data with `return({ data })`,
6
6
  the automatic re-read a successful write triggers, the diff-before-update discipline, locked records
7
- (`readLocked` + `request_locked_record_change`), and optimistic reconciliation
8
- (`useOptimistic`). Read this before building any screen that creates, updates, or deletes
7
+ (`readLocked` + `request_locked_record_change`), optimistic reconciliation
8
+ (`useOptimistic`), and `useRecording` — a recording the host files through a workflow. Read this before building any screen that creates, updates, or deletes
9
9
  records. What may be written **inside** the workflow body — the JS subset, steps, helpers,
10
10
  traps — is [workflows](./workflows.md). Reading data is [queries](./queries.md) +
11
11
  [data_fetching](./data_fetching.md); uploads are [files](./files.md); who a write runs as is
@@ -686,3 +686,71 @@ workflow cannot be used to clobber another record by guessing its id.
686
686
 
687
687
  Use `newRecordId()` directly if you need the id outside a hook (routing to the surface before
688
688
  mounting it, say). It mints the same `rec_*` shape the server validates.
689
+
690
+ ## Recording into a workflow: `useRecording`
691
+
692
+ A recording act — record a site visit, a call, a meeting, and file it on the row it is about —
693
+ is a workflow whose run waits for the transcript. Exact signature: `dist/src/recording.d.ts`.
694
+
695
+ ```tsx
696
+ import { useRecording } from "@lotics/app-sdk";
697
+
698
+ const visit = useRecording("log_visit");
699
+ if (!visit.available) return null; // draw nothing that cannot record
700
+ const mine = visit.state.phase !== "idle" && visit.state.inputs.site === siteId;
701
+
702
+ const { started } = await visit.start({ site: siteId }); // false: the member closed the dialog
703
+ await visit.stop();
704
+ ```
705
+
706
+ - **The alias names a receiving workflow** — one that declares the platform's `recording` input
707
+ ([workflows](./workflows.md#a-workflow-that-receives-a-recording)). `start` takes the rest of
708
+ its declared inputs, typed from the declaration without `recording`; passing `recording` is a
709
+ `tsc` error and a refusal. Such a workflow runs only from a recording: `useWorkflow` on its
710
+ alias is refused, because the platform alone fills `recording`.
711
+ - **The host records, never the app.** `start` asks the Lotics host, which opens its own capture
712
+ dialog — microphone, and where the browser supports it system sound and the screen — named
713
+ with the app's name. Nothing records until the member presses Start there. The app touches no
714
+ media device and needs no permission. The recording belongs to the tab, not the frame: the
715
+ member may leave the app, and a bar in the product carries the recording until it is filed.
716
+ - **`start` resolves `{ started: true }`** once capture runs and **`{ started: false }`** when the
717
+ member closed the dialog. It **rejects with the host's reason** when refused: a recording is
718
+ already running (one per tab, across every app and the chat), the member has three recordings
719
+ still open, the inputs fail the workflow's declaration (a deleted record is refused before
720
+ anything is recorded, not after), or recording is unavailable. Show the reason.
721
+ - **`stop`** stops this alias's live recording, and resolves whether or not one was live. The
722
+ member can also stop from the product's bar.
723
+ - **Filing.** Once transcribed, the host runs the workflow **once** with `{ ...inputs,
724
+ recording }`, under the app owner's authority with the recording member as
725
+ `runtime.triggered_by_member_id` ([security](./security.md)). A silent recording files with
726
+ `transcript: ""`. Filing is re-checked at run time: the member still holds the app, the alias
727
+ is still bound, and its declaration still receives recordings. A refused or failed filing is
728
+ never retried by itself and the recording is never lost: the member retries from the
729
+ product's bar, and a failed run also sends the app's owner the failed-run notice.
730
+ - **`available`** is what the host states — false standalone, in `lotics app dev`, in a host
731
+ that predates recording, on an instance without transcription, and until the context has
732
+ answered. A standalone app has no member to record as; both calls reject there.
733
+
734
+ `state` is the most recent recording this app started through the alias (a second recording
735
+ replaces the first's state):
736
+
737
+ | `phase` | Carries | Means |
738
+ |---|---|---|
739
+ | `idle` | — | nothing through this alias is running or waiting |
740
+ | `live` | `elapsed_seconds`, `inputs` | capturing; the host updates it each second |
741
+ | `interrupted` | `elapsed_seconds`, `inputs` | the tab closed mid-recording; the member resumes or finishes it from the product's bar |
742
+ | `processing` | `inputs` | stopped; the transcript is on its way |
743
+ | `filed` | `execution_id`, `inputs` | the workflow ran and succeeded |
744
+ | `failed` | `message`, `inputs` | transcription failed, or filing was refused or its run failed |
745
+
746
+ `inputs` is what `start` was passed, so a screen with one act per row can tell which row the
747
+ recording is about. **`busy`** is true while any recording this app started is live, through
748
+ any alias: stand the other acts down, since the host would refuse them. A recording in another
749
+ app or the chat is invisible to this app, so `start` can still be refused while `busy` is false.
750
+
751
+ **Filed means written.** When `state` becomes `filed`, every mounted query re-reads, exactly as
752
+ after a successful `useWorkflow` call ([refetch](#refetch-after-a-mutation)). Draw the filed row's
753
+ files and transcript with `@lotics/ui/recording`.
754
+
755
+ A mock fixture's `recordings` map (alias → `state`) draws each phase without a host
756
+ ([runtime](./runtime.md#the-mock-harness-optionsfixture--__mock1)).
package/docs/runtime.md CHANGED
@@ -66,7 +66,7 @@ Activation is a **two-step gate** — both must hold, so demo data shipping in t
66
66
  bundle never leaks into normal traffic:
67
67
 
68
68
  1. A fixture is registered via `mount({ fixture })` (`AppFixture` type:
69
- `dist/src/mock.d.ts` — `{ queries?, workflows? }`).
69
+ `dist/src/mock.d.ts` — `{ queries?, workflows?, recordings? }`).
70
70
  2. The page URL carries `?__mock=1` (exactly `1`). Without the flag the fixture
71
71
  is completely inert.
72
72
 
@@ -99,6 +99,10 @@ transport. Calling `mount` again (HMR) replaces the registration last-write-wins
99
99
  whose only AI surface is a workflow calling `agent(...)` otherwise has no
100
100
  non-billing path to its own thinking / done / error screens, which is how
101
101
  those three ship unreviewed.
102
+ - **A mocked recording is a canned state.** `recordings: { log_visit: { phase:
103
+ "live", elapsed_seconds: 754, inputs: { site: "rec_1" } } }` makes
104
+ `useRecording("log_visit")` available with that `state`; `start` and `stop`
105
+ resolve without recording anything or moving the state.
102
106
  - **Not mocked:** uploads, `useFieldOptions`, members, comments, agent runs. In
103
107
  mock mode those still hit the real transport.
104
108
  - **Analytics is disabled** whenever `?__mock=1` is present, fixture or not — a
@@ -202,6 +206,7 @@ surfaces:
202
206
  | `agentRuns` (session history) | **no** — the product host doesn't implement it either (`"Unknown RPC op: agentRuns"`) | **no** | yes |
203
207
  | `askAi` | yes | **no** — `"Unknown RPC op: askAi"` | rejects — `"askAi is only available when the app runs inside Lotics"` |
204
208
  | `openApp` | yes — routes to the sibling in the same tab | yes — opens the sibling on the web app in a new tab (one app is served locally) | rejects — `"openApp needs the Lotics host …"` |
209
+ | `recording.start`, `recording.stop` | yes, when the context states `recording_enabled` | **no** — `"Unknown RPC op: …"`; the context omits `recording_enabled` | rejects — `"Recording needs the Lotics host and a signed-in member …"` |
205
210
 
206
211
  **Limitation:** Don't build a session-log UI on `useAgentRuns`; keep the log in app
207
212
  state from live `useAgentRun` results (see [ai](./ai.md)). Server-side *cancel*
@@ -250,13 +255,15 @@ semantics are documented:
250
255
  | `agentRun.cancel` | `{ run_id }` | `{ ok: true }` | [ai](./ai.md) |
251
256
  | `urlState.get` / `urlState.set` | — / `{ params }` | `UrlParams` / `void` | [navigation & state](./navigation_and_state.md) |
252
257
  | `comments.list/create/update/delete/counts` | comment ops | comment payloads | [members & options](./members_and_options.md) |
258
+ | `recording.start` / `recording.stop` | `{ alias, inputs }` / `{ alias }` | `{ started }` / `void` | [mutations](./mutations.md#recording-into-a-workflow-userecording) |
253
259
 
254
260
  The **streaming** agent-run op is *not* reachable through `rpc()` — its
255
261
  response is a chunk stream, not a single value; it's internal to `useAgentRun`.
256
262
 
257
- `rpc("context", {})` resolves the viewer: `{ member_id, comments_enabled }`.
258
- `member_id` is the signed-in member when embedded, `null` standalone — read it
259
- through `useViewer()` rather than this op. **Limitation:** the context type is
263
+ `rpc("context", {})` resolves the viewer: `{ member_id, comments_enabled,
264
+ recording_enabled?, recordings? }`. `member_id` is the signed-in member when
265
+ embedded, `null` standalone — read it through `useViewer()` rather than this op;
266
+ the recording fields are `useRecording`'s. **Limitation:** the context type is
260
267
  not exported from the package root, so type the result yourself via the `rpc<T>`
261
268
  generic.
262
269
 
package/docs/workflows.md CHANGED
@@ -861,6 +861,52 @@ refuses, up front, exactly the lists a live run refuses (over 10 000 items). The
861
861
  is the one `live_reads: true` makes reachable: a loop body that reads issues one real query per
862
862
  iteration.
863
863
 
864
+ ## A workflow that receives a recording
865
+
866
+ A workflow that files a recording ([mutations](./mutations.md#recording-into-a-workflow-userecording))
867
+ declares the platform's `recording` input in exactly this shape, beside its own inputs:
868
+
869
+ ```jsonc
870
+ // package.json#lotics.workflows.log_visit
871
+ "inputs": {
872
+ "site": { "type": "record_link", "table_id": "tbl_sites" },
873
+ "recording": { "type": "object", "fields": {
874
+ "session_id": { "type": "text" },
875
+ "audio": { "type": "file" },
876
+ "video": { "type": "file", "required": false },
877
+ "transcript": { "type": "text" },
878
+ "duration_seconds": { "type": "number" },
879
+ "recorded_at": { "type": "datetime" }
880
+ } }
881
+ }
882
+ ```
883
+
884
+ A declaration that differs in any field, type or `required` is not a receiver: the host refuses
885
+ to start a recording for it, naming the first difference. The platform alone fills `recording`,
886
+ so a call through `useWorkflow` is refused.
887
+
888
+ - `audio` and `video` are file ids in the workspace. `video` is absent when the screen was not
889
+ recorded or its video failed to assemble. Both carry the same sound, so file **one**: the
890
+ video when present, else the audio.
891
+ - `transcript` is the verbatim text, one `Speaker A: …` line per turn, and `""` for a silent
892
+ recording. A text field holds it whole, with no length limit: three hours is about 200,000
893
+ characters.
894
+ - `recorded_at` is when capture started; `duration_seconds` is how long it ran.
895
+
896
+ ```js
897
+ const i = trigger.app_workflow.inputs;
898
+ const r = i.recording;
899
+ await create_records({
900
+ table_id: "tbl_visits",
901
+ records: [{
902
+ fld_site: [i.site],
903
+ fld_recording: [r.video ?? r.audio],
904
+ fld_transcript: r.transcript,
905
+ fld_visited_at: r.recorded_at,
906
+ }],
907
+ });
908
+ ```
909
+
864
910
  ## A worked body
865
911
 
866
912
  An app action that creates an order after checking for a duplicate, then returns the new id.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.98.5",
3
+ "version": "0.100.1",
4
4
  "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {