lemma-sdk 0.2.9 → 0.2.10

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/README.md CHANGED
@@ -47,6 +47,64 @@ const supportAssistant = await client.assistants.get("support_assistant");
47
47
  - Ergonomic type aliases exported at top level: `Agent`, `Assistant`, `Conversation`, `Task`, `TaskMessage`, `CreateAgentInput`, `CreateAssistantInput`, etc.
48
48
  - `client.withPod(podId)` returns a pod-scoped client that shares auth state with the parent client.
49
49
 
50
+ ## Table Access Grants (`accessible_tables`)
51
+
52
+ For function, agent, and assistant payloads, `accessible_tables` must be an array of objects:
53
+
54
+ - `table_name`: target table
55
+ - `mode`: `READ` or `WRITE`
56
+
57
+ `accessible_tables: ["table_name"]` is no longer valid.
58
+
59
+ You do not pass a datastore name in SDK calls. Table and file operations are pod-scoped (`client.tables`, `client.records`, `client.files`) and take table/file identifiers directly.
60
+
61
+ Examples:
62
+
63
+ ```ts
64
+ import {
65
+ TableAccessMode,
66
+ type CreateFunctionRequest,
67
+ type CreateAgentInput,
68
+ type CreateAssistantInput,
69
+ } from "lemma-sdk";
70
+
71
+ const functionPayload: CreateFunctionRequest = {
72
+ name: "expense_summary",
73
+ code: "def handler(ctx):\n return {'ok': True}",
74
+ config: {},
75
+ accessible_tables: [
76
+ { table_name: "expenses", mode: TableAccessMode.READ },
77
+ { table_name: "expense_summaries", mode: TableAccessMode.WRITE },
78
+ ],
79
+ accessible_folders: ["/reports"],
80
+ accessible_applications: [],
81
+ };
82
+
83
+ const agentPayload: CreateAgentInput = {
84
+ name: "expense-summarizer",
85
+ instruction: "Summarize expenses without mutating data.",
86
+ tool_sets: [],
87
+ accessible_tables: [
88
+ { table_name: "expenses", mode: TableAccessMode.READ },
89
+ { table_name: "expense_notes", mode: TableAccessMode.WRITE },
90
+ ],
91
+ accessible_folders: [],
92
+ accessible_applications: [],
93
+ };
94
+
95
+ const assistantPayload: CreateAssistantInput = {
96
+ name: "expense_assistant",
97
+ instruction: "Answer expense questions and save approved notes.",
98
+ tool_sets: [],
99
+ accessible_tables: [
100
+ { table_name: "expenses", mode: TableAccessMode.READ },
101
+ { table_name: "expense_notes", mode: TableAccessMode.WRITE },
102
+ ],
103
+ accessible_folders: ["/notes"],
104
+ accessible_applications: [],
105
+ };
106
+ ```
107
+
50
108
  ## Auth Helpers
51
109
 
52
110
  ```ts
@@ -2,6 +2,7 @@ import type { ConversationMessage } from "./types.js";
2
2
  export interface ParsedAssistantStreamEvent {
3
3
  message?: ConversationMessage;
4
4
  status?: string;
5
+ token?: string;
5
6
  }
6
7
  export declare function parseAssistantStreamEvent(value: unknown): ParsedAssistantStreamEvent;
7
8
  export declare function upsertConversationMessage(messages: ConversationMessage[], incoming: ConversationMessage): ConversationMessage[];
@@ -51,6 +51,9 @@ export function parseAssistantStreamEvent(value) {
51
51
  }
52
52
  const eventType = typeof value.type === "string" ? value.type.toLowerCase() : "";
53
53
  const payload = extractPayload(value);
54
+ if (eventType === "token" && typeof payload === "string") {
55
+ return { token: payload };
56
+ }
54
57
  if (eventType === "message" || eventType === "message_added") {
55
58
  const message = toConversationMessage(payload);
56
59
  return message ? { message } : {};
@@ -13,6 +13,8 @@ export interface UseAssistantSessionOptions {
13
13
  organizationId?: string;
14
14
  conversationId?: string | null;
15
15
  autoLoad?: boolean;
16
+ autoResume?: boolean;
17
+ syncOnTurnEnd?: boolean;
16
18
  onEvent?: (event: SseRawEvent, payload: unknown | null) => void;
17
19
  onStatus?: (status: string) => void;
18
20
  onMessage?: (message: ConversationMessage) => void;
@@ -30,12 +32,22 @@ export interface SendAssistantMessageOptions {
30
32
  conversationId?: string | null;
31
33
  createIfMissing?: boolean;
32
34
  createConversation?: CreateConversationInput;
35
+ syncOnTurnEnd?: boolean;
36
+ }
37
+ export interface ResumeAssistantOptions {
38
+ conversationId?: string | null;
39
+ /**
40
+ * When true, skips resume unless conversation status is currently RUNNING.
41
+ */
42
+ onlyIfRunning?: boolean;
43
+ syncOnTurnEnd?: boolean;
33
44
  }
34
45
  export interface UseAssistantSessionResult {
35
46
  conversationId: string | null;
36
47
  conversation: Conversation | null;
37
48
  status?: string;
38
49
  messages: ConversationMessage[];
50
+ streamingText: string;
39
51
  isStreaming: boolean;
40
52
  error: Error | null;
41
53
  setConversationId: (conversationId: string | null) => void;
@@ -52,7 +64,8 @@ export interface UseAssistantSessionResult {
52
64
  pageToken?: string;
53
65
  }) => Promise<CursorPage<ConversationMessage>>;
54
66
  sendMessage: (content: string, options?: SendAssistantMessageOptions) => Promise<Conversation>;
55
- resume: (conversationId?: string | null) => Promise<void>;
67
+ resume: (conversationId?: string | null | ResumeAssistantOptions) => Promise<void>;
68
+ resumeIfRunning: (conversationId?: string | null) => Promise<boolean>;
56
69
  stop: (conversationId?: string | null) => Promise<void>;
57
70
  cancel: () => void;
58
71
  clearMessages: () => void;
@@ -29,26 +29,89 @@ function normalizeScope(client, defaults, override) {
29
29
  organizationId: override?.organizationId ?? defaults.organizationId ?? null,
30
30
  };
31
31
  }
32
+ function normalizeConversationStatus(status) {
33
+ if (typeof status !== "string")
34
+ return undefined;
35
+ const normalized = status.trim().toUpperCase();
36
+ return normalized.length > 0 ? normalized : undefined;
37
+ }
38
+ function isConversationRunningStatus(status) {
39
+ const normalized = normalizeConversationStatus(status);
40
+ if (!normalized)
41
+ return false;
42
+ return normalized === "RUNNING" || normalized === "IN_PROGRESS" || normalized === "PROCESSING";
43
+ }
44
+ function resolveResumeInput(input) {
45
+ if (typeof input === "string" || input === null) {
46
+ return { conversationId: input };
47
+ }
48
+ return input ?? {};
49
+ }
32
50
  export function useAssistantSession(options) {
33
- const { client, podId: defaultPodId, assistantId: defaultAssistantId, organizationId: defaultOrganizationId, conversationId: externalConversationId = null, autoLoad = true, onEvent, onStatus, onMessage, onError, } = options;
51
+ const { client, podId: defaultPodId, assistantId: defaultAssistantId, organizationId: defaultOrganizationId, conversationId: externalConversationId = null, autoLoad = true, autoResume = false, syncOnTurnEnd = false, onEvent, onStatus, onMessage, onError, } = options;
34
52
  const [conversationId, setConversationIdState] = useState(externalConversationId);
35
53
  const [conversation, setConversation] = useState(null);
36
54
  const [status, setStatus] = useState(undefined);
37
55
  const [messages, setMessages] = useState([]);
56
+ const [streamingText, setStreamingText] = useState("");
38
57
  const [isStreaming, setIsStreaming] = useState(false);
39
58
  const [error, setError] = useState(null);
40
59
  const abortRef = useRef(null);
60
+ const statusRef = useRef(undefined);
61
+ const streamingTextRef = useRef("");
62
+ const autoResumedKeyRef = useRef(null);
63
+ const onEventRef = useRef(onEvent);
64
+ const onStatusRef = useRef(onStatus);
65
+ const onMessageRef = useRef(onMessage);
66
+ const onErrorRef = useRef(onError);
41
67
  const setConversationId = useCallback((nextConversationId) => {
42
68
  setConversationIdState(nextConversationId);
69
+ autoResumedKeyRef.current = null;
70
+ streamingTextRef.current = "";
71
+ setStreamingText("");
43
72
  if (!nextConversationId) {
44
73
  setConversation(null);
45
74
  setStatus(undefined);
75
+ statusRef.current = undefined;
46
76
  setMessages([]);
47
77
  }
48
78
  }, []);
49
79
  useEffect(() => {
50
80
  setConversationIdState(externalConversationId);
51
81
  }, [externalConversationId]);
82
+ useEffect(() => {
83
+ onEventRef.current = onEvent;
84
+ }, [onEvent]);
85
+ useEffect(() => {
86
+ onStatusRef.current = onStatus;
87
+ }, [onStatus]);
88
+ useEffect(() => {
89
+ onMessageRef.current = onMessage;
90
+ }, [onMessage]);
91
+ useEffect(() => {
92
+ onErrorRef.current = onError;
93
+ }, [onError]);
94
+ useEffect(() => {
95
+ statusRef.current = status;
96
+ }, [status]);
97
+ const setConversationStatus = useCallback((nextStatus) => {
98
+ const normalized = normalizeConversationStatus(nextStatus);
99
+ setStatus(normalized);
100
+ statusRef.current = normalized;
101
+ if (normalized) {
102
+ onStatusRef.current?.(normalized);
103
+ }
104
+ }, []);
105
+ const clearStreamingText = useCallback(() => {
106
+ streamingTextRef.current = "";
107
+ setStreamingText("");
108
+ }, []);
109
+ const appendStreamingToken = useCallback((token) => {
110
+ if (!token)
111
+ return;
112
+ streamingTextRef.current += token;
113
+ setStreamingText(streamingTextRef.current);
114
+ }, []);
52
115
  const cancel = useCallback(() => {
53
116
  abortRef.current?.abort();
54
117
  abortRef.current = null;
@@ -78,14 +141,14 @@ export function useAssistantSession(options) {
78
141
  catch (listError) {
79
142
  const normalized = normalizeError(listError, "Failed to list conversations.");
80
143
  setError(normalized);
81
- onError?.(listError);
144
+ onErrorRef.current?.(listError);
82
145
  return {
83
146
  items: [],
84
147
  limit: input.limit ?? 20,
85
148
  next_page_token: null,
86
149
  };
87
150
  }
88
- }, [client, defaultScope, onError]);
151
+ }, [client, defaultScope]);
89
152
  const createConversation = useCallback(async (input = {}) => {
90
153
  applyPodScope(client, input.podId ?? defaultPodId ?? null);
91
154
  const payload = {
@@ -101,11 +164,13 @@ export function useAssistantSession(options) {
101
164
  if (input.setActive !== false) {
102
165
  setConversationIdState(created.id);
103
166
  setConversation(created);
104
- setStatus(typeof created.status === "string" ? created.status.toUpperCase() : undefined);
167
+ setConversationStatus(created.status);
105
168
  setMessages([]);
169
+ clearStreamingText();
170
+ autoResumedKeyRef.current = null;
106
171
  }
107
172
  return created;
108
- }, [client, defaultAssistantId, defaultOrganizationId, defaultPodId]);
173
+ }, [clearStreamingText, client, defaultAssistantId, defaultOrganizationId, defaultPodId, setConversationStatus]);
109
174
  const refreshConversation = useCallback(async (explicitConversationId) => {
110
175
  const id = explicitConversationId ?? conversationId;
111
176
  if (!id)
@@ -118,36 +183,30 @@ export function useAssistantSession(options) {
118
183
  });
119
184
  setConversation(nextConversation);
120
185
  const nextStatus = typeof nextConversation.status === "string"
121
- ? nextConversation.status.toUpperCase()
186
+ ? nextConversation.status
122
187
  : undefined;
123
- setStatus(nextStatus);
124
- if (nextStatus) {
125
- onStatus?.(nextStatus);
126
- }
188
+ setConversationStatus(nextStatus);
127
189
  return nextConversation;
128
190
  }
129
191
  catch (refreshError) {
130
192
  const normalized = normalizeError(refreshError, "Failed to fetch conversation.");
131
193
  setError(normalized);
132
- onError?.(refreshError);
194
+ onErrorRef.current?.(refreshError);
133
195
  return null;
134
196
  }
135
- }, [client, conversationId, defaultScope, onError, onStatus]);
197
+ }, [client, conversationId, defaultScope, setConversationStatus]);
136
198
  const loadMessages = useCallback(async (input = {}) => {
137
199
  const id = input.conversationId ?? conversationId;
138
200
  if (!id) {
139
201
  return { items: [], limit: input.limit ?? 20, next_page_token: null };
140
202
  }
141
203
  try {
142
- const scope = normalizeScope(client, defaultScope);
143
- applyPodScope(client, scope.podId);
144
204
  const response = await client.conversations.messages.list(id, {
145
- pod_id: scope.podId ?? undefined,
146
205
  limit: input.limit,
147
206
  page_token: input.pageToken,
148
207
  });
149
208
  const nextMessages = response.items ?? [];
150
- setMessages(nextMessages);
209
+ setMessages((previous) => nextMessages.reduce((accumulator, message) => upsertConversationMessage(accumulator, message), previous));
151
210
  return {
152
211
  items: nextMessages,
153
212
  limit: response.limit ?? input.limit ?? 20,
@@ -157,32 +216,58 @@ export function useAssistantSession(options) {
157
216
  catch (messageError) {
158
217
  const normalized = normalizeError(messageError, "Failed to fetch conversation messages.");
159
218
  setError(normalized);
160
- onError?.(messageError);
219
+ onErrorRef.current?.(messageError);
161
220
  return {
162
221
  items: [],
163
222
  limit: input.limit ?? 20,
164
223
  next_page_token: null,
165
224
  };
166
225
  }
167
- }, [client, conversationId, defaultScope, onError]);
168
- const consume = useCallback(async (stream, controller) => {
226
+ }, [clearStreamingText, client, conversationId, defaultScope, setConversationStatus]);
227
+ const consume = useCallback(async ({ stream, controller, streamConversationId, syncAfterStream, }) => {
169
228
  setIsStreaming(true);
170
229
  setError(null);
230
+ clearStreamingText();
231
+ let sawTerminalStatus = false;
171
232
  try {
172
233
  for await (const event of readSSE(stream)) {
173
234
  if (controller.signal.aborted) {
174
235
  break;
175
236
  }
176
237
  const payload = parseSSEJson(event);
177
- onEvent?.(event, payload);
238
+ onEventRef.current?.(event, payload);
178
239
  const parsed = parseAssistantStreamEvent(payload);
240
+ if (parsed.token) {
241
+ appendStreamingToken(parsed.token);
242
+ }
179
243
  if (parsed.message) {
180
244
  setMessages((previous) => upsertConversationMessage(previous, parsed.message));
181
- onMessage?.(parsed.message);
245
+ onMessageRef.current?.(parsed.message);
246
+ const role = typeof parsed.message.role === "string"
247
+ ? parsed.message.role.toLowerCase()
248
+ : "";
249
+ if (role === "assistant" || role === "tool") {
250
+ clearStreamingText();
251
+ }
182
252
  }
183
253
  if (parsed.status) {
184
- setStatus(parsed.status);
185
- onStatus?.(parsed.status);
254
+ setConversationStatus(parsed.status);
255
+ if (!isConversationRunningStatus(parsed.status)) {
256
+ sawTerminalStatus = true;
257
+ clearStreamingText();
258
+ }
259
+ }
260
+ }
261
+ if (!controller.signal.aborted) {
262
+ if (!sawTerminalStatus && isConversationRunningStatus(statusRef.current)) {
263
+ setConversationStatus("WAITING");
264
+ }
265
+ clearStreamingText();
266
+ const shouldSync = syncAfterStream ?? syncOnTurnEnd;
267
+ const syncConversationId = streamConversationId ?? conversationId;
268
+ if (shouldSync && syncConversationId) {
269
+ await refreshConversation(syncConversationId);
270
+ await loadMessages({ conversationId: syncConversationId, limit: 100 });
186
271
  }
187
272
  }
188
273
  }
@@ -190,7 +275,7 @@ export function useAssistantSession(options) {
190
275
  if (!(streamError instanceof Error && streamError.name === "AbortError")) {
191
276
  const normalized = normalizeError(streamError, "Failed to stream assistant run.");
192
277
  setError(normalized);
193
- onError?.(streamError);
278
+ onErrorRef.current?.(streamError);
194
279
  }
195
280
  }
196
281
  finally {
@@ -199,7 +284,15 @@ export function useAssistantSession(options) {
199
284
  }
200
285
  setIsStreaming(false);
201
286
  }
202
- }, [onError, onEvent, onMessage, onStatus]);
287
+ }, [
288
+ appendStreamingToken,
289
+ clearStreamingText,
290
+ conversationId,
291
+ loadMessages,
292
+ refreshConversation,
293
+ setConversationStatus,
294
+ syncOnTurnEnd,
295
+ ]);
203
296
  const ensureConversation = useCallback(async (overrideConversationId, options) => {
204
297
  const existingId = overrideConversationId ?? conversationId;
205
298
  if (existingId) {
@@ -232,11 +325,21 @@ export function useAssistantSession(options) {
232
325
  pod_id: scope.podId ?? undefined,
233
326
  signal: controller.signal,
234
327
  });
235
- await consume(stream, controller);
328
+ setConversationStatus("RUNNING");
329
+ await consume({
330
+ stream,
331
+ controller,
332
+ streamConversationId: resolvedConversationId,
333
+ syncAfterStream: input.syncOnTurnEnd,
334
+ });
236
335
  return resolvedConversation;
237
- }, [cancel, client, consume, defaultScope, ensureConversation]);
238
- const resume = useCallback(async (explicitConversationId) => {
239
- const id = requireConversationId(explicitConversationId ?? conversationId);
336
+ }, [cancel, client, consume, defaultScope, ensureConversation, setConversationStatus]);
337
+ const resume = useCallback(async (input) => {
338
+ const resumeInput = resolveResumeInput(input);
339
+ const id = requireConversationId(resumeInput.conversationId ?? conversationId);
340
+ if (resumeInput.onlyIfRunning && !isConversationRunningStatus(statusRef.current)) {
341
+ return;
342
+ }
240
343
  cancel();
241
344
  const controller = new AbortController();
242
345
  abortRef.current = controller;
@@ -246,8 +349,39 @@ export function useAssistantSession(options) {
246
349
  pod_id: scope.podId ?? undefined,
247
350
  signal: controller.signal,
248
351
  });
249
- await consume(stream, controller);
250
- }, [cancel, client, consume, conversationId, defaultScope]);
352
+ setConversationStatus("RUNNING");
353
+ await consume({
354
+ stream,
355
+ controller,
356
+ streamConversationId: id,
357
+ syncAfterStream: resumeInput.syncOnTurnEnd,
358
+ });
359
+ }, [cancel, client, consume, conversationId, defaultScope, setConversationStatus]);
360
+ const resumeIfRunning = useCallback(async (explicitConversationId) => {
361
+ const id = explicitConversationId ?? conversationId;
362
+ if (!id)
363
+ return false;
364
+ if (isStreaming)
365
+ return false;
366
+ const statusKey = normalizeConversationStatus(statusRef.current);
367
+ const resumeKey = `${id}:${statusKey ?? "UNKNOWN"}`;
368
+ if (autoResumedKeyRef.current === resumeKey) {
369
+ return false;
370
+ }
371
+ const knownRunning = isConversationRunningStatus(statusRef.current);
372
+ if (!knownRunning) {
373
+ const latestConversation = await refreshConversation(id);
374
+ if (!latestConversation || !isConversationRunningStatus(latestConversation.status)) {
375
+ return false;
376
+ }
377
+ }
378
+ autoResumedKeyRef.current = resumeKey;
379
+ await resume({
380
+ conversationId: id,
381
+ onlyIfRunning: true,
382
+ });
383
+ return true;
384
+ }, [conversationId, isStreaming, refreshConversation, resume]);
251
385
  const stop = useCallback(async (explicitConversationId) => {
252
386
  const id = requireConversationId(explicitConversationId ?? conversationId);
253
387
  const scope = normalizeScope(client, defaultScope);
@@ -255,22 +389,50 @@ export function useAssistantSession(options) {
255
389
  await client.conversations.stopRun(id, {
256
390
  pod_id: scope.podId ?? undefined,
257
391
  });
392
+ setConversationStatus("WAITING");
393
+ clearStreamingText();
258
394
  }, [client, conversationId, defaultScope]);
259
395
  const clearMessages = useCallback(() => {
260
396
  setMessages([]);
261
397
  }, []);
398
+ useEffect(() => {
399
+ autoResumedKeyRef.current = null;
400
+ }, [conversationId]);
401
+ useEffect(() => {
402
+ if (!isConversationRunningStatus(status)) {
403
+ autoResumedKeyRef.current = null;
404
+ }
405
+ }, [status]);
262
406
  useEffect(() => {
263
407
  if (!autoLoad || !conversationId) {
264
408
  return;
265
409
  }
266
- void refreshConversation(conversationId);
267
- void loadMessages({ conversationId });
268
- }, [autoLoad, conversationId, loadMessages, refreshConversation]);
410
+ let cancelled = false;
411
+ const bootstrapConversation = async () => {
412
+ const latestConversation = await refreshConversation(conversationId);
413
+ if (cancelled)
414
+ return;
415
+ await loadMessages({ conversationId, limit: 100 });
416
+ if (cancelled)
417
+ return;
418
+ if (!autoResume)
419
+ return;
420
+ const latestStatus = normalizeConversationStatus(latestConversation?.status) ?? normalizeConversationStatus(statusRef.current);
421
+ if (!isConversationRunningStatus(latestStatus))
422
+ return;
423
+ await resumeIfRunning(conversationId);
424
+ };
425
+ void bootstrapConversation();
426
+ return () => {
427
+ cancelled = true;
428
+ };
429
+ }, [autoLoad, autoResume, conversationId, loadMessages, refreshConversation, resumeIfRunning]);
269
430
  return {
270
431
  conversationId,
271
432
  conversation,
272
433
  status,
273
434
  messages,
435
+ streamingText,
274
436
  isStreaming,
275
437
  error,
276
438
  setConversationId,
@@ -280,6 +442,7 @@ export function useAssistantSession(options) {
280
442
  loadMessages,
281
443
  sendMessage,
282
444
  resume,
445
+ resumeIfRunning,
283
446
  stop,
284
447
  cancel,
285
448
  clearMessages,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemma-sdk",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Official TypeScript SDK for Lemma pod-scoped APIs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",