lemma-sdk 0.2.8 → 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,10 +284,22 @@ 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) {
299
+ // Avoid a network roundtrip on every send when we already have this conversation in state.
300
+ if (conversation?.id === existingId) {
301
+ return conversation;
302
+ }
206
303
  const existing = await refreshConversation(existingId);
207
304
  if (existing)
208
305
  return existing;
@@ -215,7 +312,7 @@ export function useAssistantSession(options) {
215
312
  ...(options.createConversation ?? {}),
216
313
  setActive: true,
217
314
  });
218
- }, [conversationId, createConversation, refreshConversation]);
315
+ }, [conversation, conversationId, createConversation, refreshConversation]);
219
316
  const sendMessage = useCallback(async (content, input = {}) => {
220
317
  const resolvedConversation = await ensureConversation(input.conversationId, input);
221
318
  const resolvedConversationId = requireConversationId(resolvedConversation.id);
@@ -228,11 +325,21 @@ export function useAssistantSession(options) {
228
325
  pod_id: scope.podId ?? undefined,
229
326
  signal: controller.signal,
230
327
  });
231
- await consume(stream, controller);
328
+ setConversationStatus("RUNNING");
329
+ await consume({
330
+ stream,
331
+ controller,
332
+ streamConversationId: resolvedConversationId,
333
+ syncAfterStream: input.syncOnTurnEnd,
334
+ });
232
335
  return resolvedConversation;
233
- }, [cancel, client, consume, defaultScope, ensureConversation]);
234
- const resume = useCallback(async (explicitConversationId) => {
235
- 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
+ }
236
343
  cancel();
237
344
  const controller = new AbortController();
238
345
  abortRef.current = controller;
@@ -242,8 +349,39 @@ export function useAssistantSession(options) {
242
349
  pod_id: scope.podId ?? undefined,
243
350
  signal: controller.signal,
244
351
  });
245
- await consume(stream, controller);
246
- }, [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]);
247
385
  const stop = useCallback(async (explicitConversationId) => {
248
386
  const id = requireConversationId(explicitConversationId ?? conversationId);
249
387
  const scope = normalizeScope(client, defaultScope);
@@ -251,22 +389,50 @@ export function useAssistantSession(options) {
251
389
  await client.conversations.stopRun(id, {
252
390
  pod_id: scope.podId ?? undefined,
253
391
  });
392
+ setConversationStatus("WAITING");
393
+ clearStreamingText();
254
394
  }, [client, conversationId, defaultScope]);
255
395
  const clearMessages = useCallback(() => {
256
396
  setMessages([]);
257
397
  }, []);
398
+ useEffect(() => {
399
+ autoResumedKeyRef.current = null;
400
+ }, [conversationId]);
401
+ useEffect(() => {
402
+ if (!isConversationRunningStatus(status)) {
403
+ autoResumedKeyRef.current = null;
404
+ }
405
+ }, [status]);
258
406
  useEffect(() => {
259
407
  if (!autoLoad || !conversationId) {
260
408
  return;
261
409
  }
262
- void refreshConversation(conversationId);
263
- void loadMessages({ conversationId });
264
- }, [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]);
265
430
  return {
266
431
  conversationId,
267
432
  conversation,
268
433
  status,
269
434
  messages,
435
+ streamingText,
270
436
  isStreaming,
271
437
  error,
272
438
  setConversationId,
@@ -276,6 +442,7 @@ export function useAssistantSession(options) {
276
442
  loadMessages,
277
443
  sendMessage,
278
444
  resume,
445
+ resumeIfRunning,
279
446
  stop,
280
447
  cancel,
281
448
  clearMessages,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemma-sdk",
3
- "version": "0.2.8",
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",