@stina/extension-api 1.0.0 → 1.6.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/src/runtime.ts CHANGED
@@ -105,6 +105,13 @@ const messagePort = getMessagePort()
105
105
  let extensionModule: ExtensionModule | null = null
106
106
  let extensionDisposable: Disposable | null = null
107
107
  let extensionContext: ExtensionContext | null = null
108
+ /**
109
+ * What this extension was granted, kept from activation.
110
+ *
111
+ * A request-scoped context is built long after `handleActivate` has returned, and
112
+ * has to know the same things it did.
113
+ */
114
+ let grantedPermissions: string[] = []
108
115
  let backgroundTaskManager: WorkerBackgroundTaskManager | null = null
109
116
 
110
117
  const pendingRequests = new Map<string, PendingRequest>()
@@ -284,6 +291,7 @@ async function handleActivate(payload: {
284
291
  settings: Record<string, unknown>
285
292
  }): Promise<void> {
286
293
  const { extensionId, extensionVersion, storagePath, permissions } = payload
294
+ grantedPermissions = permissions
287
295
 
288
296
  // Build the context based on permissions
289
297
  extensionContext = buildContext(extensionId, extensionVersion, storagePath, permissions)
@@ -346,7 +354,12 @@ function handleSettingsChanged(key: string, value: unknown): void {
346
354
  // ExecutionContext builder is in runtime/executionContext.ts
347
355
 
348
356
  async function handleSchedulerFire(payload: SchedulerFirePayload): Promise<void> {
349
- const execContext = createExecutionContext(sendRequest, extensionContext!, payload.userId)
357
+ const execContext = createExecutionContext(
358
+ sendRequest,
359
+ extensionContext!,
360
+ payload.userId,
361
+ grantedPermissions.includes('attachments.read')
362
+ )
350
363
 
351
364
  // Run callbacks concurrently to avoid blocking
352
365
  const results = await Promise.allSettled(
@@ -543,7 +556,12 @@ async function handleToolExecuteRequest(
543
556
  }
544
557
 
545
558
  try {
546
- const execContext = createExecutionContext(sendRequest, extensionContext!, payload.userId)
559
+ const execContext = createExecutionContext(
560
+ sendRequest,
561
+ extensionContext!,
562
+ payload.userId,
563
+ grantedPermissions.includes('attachments.read')
564
+ )
547
565
 
548
566
  const result = await tool.execute(payload.params, execContext)
549
567
 
@@ -586,7 +604,12 @@ async function handleActionExecuteRequest(
586
604
  }
587
605
 
588
606
  try {
589
- const execContext = createExecutionContext(sendRequest, extensionContext!, payload.userId)
607
+ const execContext = createExecutionContext(
608
+ sendRequest,
609
+ extensionContext!,
610
+ payload.userId,
611
+ grantedPermissions.includes('attachments.read')
612
+ )
590
613
 
591
614
  const result = await action.execute(payload.params, execContext)
592
615
 
@@ -648,11 +671,36 @@ function buildContext(
648
671
  if (permissions.some((p) => p.startsWith('network:'))) {
649
672
  const networkApi: NetworkAPI = {
650
673
  async fetch(url: string, options?: RequestInit): Promise<Response> {
651
- const result = await sendRequest<{ status: number; statusText: string; headers: Record<string, string>; body: string }>('network.fetch', { url, options })
652
- return new Response(result.body, {
674
+ const result = await sendRequest<{
675
+ status: number
676
+ statusText: string
677
+ headers: Record<string, string>
678
+ body: string
679
+ bodyEncoding?: 'base64'
680
+ }>('network.fetch', { url, options })
681
+ // The host sends a body that is not text as base64, since the bridge is
682
+ // JSON. Turn it back into bytes here so `response.arrayBuffer()` gives
683
+ // the extension what the server sent and `text()` still works.
684
+ const isBase64 = result.bodyEncoding === 'base64'
685
+ const body = isBase64
686
+ ? Uint8Array.from(atob(result.body), (char) => char.charCodeAt(0))
687
+ : result.body
688
+
689
+ // The host says the same thing in a header, for extensions whose bundled
690
+ // copy of this runtime is older than the encoding and would hand the
691
+ // base64 on as text. This runtime just decoded it, so the header would be
692
+ // a lie by the time the extension reads it: take it back out.
693
+ const headers = { ...result.headers }
694
+ if (isBase64) {
695
+ for (const name of Object.keys(headers)) {
696
+ if (name.toLowerCase() === 'x-stina-body-encoding') delete headers[name]
697
+ }
698
+ }
699
+
700
+ return new Response(body, {
653
701
  status: result.status,
654
702
  statusText: result.statusText,
655
- headers: result.headers,
703
+ headers,
656
704
  })
657
705
  },
658
706
 
@@ -996,6 +1044,7 @@ export type {
996
1044
  Tool,
997
1045
  ToolDefinition,
998
1046
  ToolResult,
1047
+ ToolAttachment,
999
1048
  ToolCall,
1000
1049
  Action,
1001
1050
  ActionResult,
@@ -197,6 +197,14 @@ export const LabelPropsSchema = z
197
197
  .passthrough()
198
198
  .describe('Label component')
199
199
 
200
+ export const ClockPropsSchema = z
201
+ .object({
202
+ component: z.literal('Clock'),
203
+ style: ExtensionComponentStyleSchema.optional(),
204
+ })
205
+ .passthrough()
206
+ .describe('Clock component: the current date and time where the user is')
207
+
200
208
  export const ParagraphPropsSchema = z
201
209
  .object({
202
210
  component: z.literal('Paragraph'),
@@ -820,6 +828,7 @@ export type ChartSeries = z.infer<typeof ChartSeriesSchema>
820
828
  export type ChartProps = z.infer<typeof ChartPropsSchema>
821
829
  export type StatTrend = z.infer<typeof StatTrendSchema>
822
830
  export type StatTileProps = z.infer<typeof StatTilePropsSchema>
831
+ export type ClockProps = z.infer<typeof ClockPropsSchema>
823
832
  export type ProgressShape = z.infer<typeof ProgressShapeSchema>
824
833
  export type ProgressColor = z.infer<typeof ProgressColorSchema>
825
834
  export type ProgressBarProps = z.infer<typeof ProgressBarPropsSchema>
@@ -96,6 +96,7 @@ export {
96
96
  PanelActionSchema,
97
97
  HeaderPropsSchema,
98
98
  LabelPropsSchema,
99
+ ClockPropsSchema,
99
100
  ParagraphPropsSchema,
100
101
  ButtonPropsSchema,
101
102
  TextInputPropsSchema,
@@ -155,6 +156,7 @@ export {
155
156
  type PanelAction,
156
157
  type HeaderProps,
157
158
  type LabelProps,
159
+ type ClockProps,
158
160
  type ParagraphProps,
159
161
  type ButtonProps,
160
162
  type TextInputProps,
@@ -20,6 +20,7 @@ export const VALID_PERMISSIONS = [
20
20
  'user.location.read',
21
21
  'chat.history.read',
22
22
  'chat.current.read',
23
+ 'attachments.read',
23
24
  'chat.message.write',
24
25
  'provider.register',
25
26
  'tools.register',
@@ -75,7 +76,14 @@ const StoragePermissionSchema = z.enum(['storage.collections', 'secrets.manage']
75
76
  * User data permission schema
76
77
  */
77
78
  const UserDataPermissionSchema = z
78
- .enum(['user.profile.read', 'user.list', 'user.location.read', 'chat.history.read', 'chat.current.read'])
79
+ .enum([
80
+ 'user.profile.read',
81
+ 'user.list',
82
+ 'user.location.read',
83
+ 'chat.history.read',
84
+ 'chat.current.read',
85
+ 'attachments.read',
86
+ ])
79
87
  .describe('User data access permission')
80
88
 
81
89
  /**
@@ -251,6 +251,19 @@ export interface LabelProps extends ExtensionComponentData {
251
251
  text: string
252
252
  }
253
253
 
254
+ /**
255
+ * The extension API properties for the Clock component.
256
+ *
257
+ * The odd one out among the display components: it takes no facts, because the
258
+ * fact it shows is what time it is, and that is not something an action can
259
+ * hand over once. It reads the clock and the timezone from the host and keeps
260
+ * itself current, so a card carrying one stays right while the window is left
261
+ * open.
262
+ */
263
+ export interface ClockProps extends ExtensionComponentData {
264
+ component: 'Clock'
265
+ }
266
+
254
267
  /** The extension API properties for the paragraph component. */
255
268
  export interface ParagraphProps extends ExtensionComponentData {
256
269
  component: 'Paragraph'
@@ -85,6 +85,49 @@ export interface ExecutionContext {
85
85
 
86
86
  /** User-scoped secrets */
87
87
  readonly userSecrets: SecretsAPI
88
+
89
+ /**
90
+ * The files attached to this user's conversations.
91
+ *
92
+ * Present only for an extension holding `attachments.read`, and only on a request
93
+ * that knows whose it is. Absent otherwise, so a tool that wants files has to say
94
+ * so in its manifest and check before reaching for them.
95
+ */
96
+ readonly attachments?: AttachmentsAPI
97
+ }
98
+
99
+ /**
100
+ * Reading a file that is already in a conversation.
101
+ *
102
+ * For handing one on: mailing back the PDF she was just shown, printing it, putting
103
+ * it somewhere. Not for finding out what it says — `core_read_attachment` does that
104
+ * without an extension, and getting the text is nearly always what she actually
105
+ * wants.
106
+ */
107
+ export interface AttachmentsAPI {
108
+ /**
109
+ * Read one attachment by id.
110
+ *
111
+ * The id comes from Stina, which is the whole point: she gets it from the message
112
+ * a file arrived on, or from the result of the tool that handed it over, and
113
+ * passes it to a tool as a parameter.
114
+ *
115
+ * Resolves `null` when no such attachment belongs to this user — deleted, or
116
+ * never theirs. The two are the same answer on purpose.
117
+ */
118
+ read(attachmentId: string): Promise<AttachmentContent | null>
119
+ }
120
+
121
+ /** One attachment's bytes, with what is known about them. */
122
+ export interface AttachmentContent {
123
+ id: string
124
+ /** `image/jpeg`, `image/png`, `application/pdf` or `text/plain`, read from the bytes. */
125
+ mime: string
126
+ /** base64, no data-URI prefix. */
127
+ data: string
128
+ byteSize: number
129
+ /** The name it was stored under, when it had one. */
130
+ name?: string
88
131
  }
89
132
 
90
133
  /**
@@ -28,6 +28,13 @@ export type UserDataPermission =
28
28
  | 'user.location.read'
29
29
  | 'chat.history.read'
30
30
  | 'chat.current.read'
31
+ /**
32
+ * Read the bytes of a file attached to one of this user's conversations, given
33
+ * its id. For handing a file on — mailing the PDF she was shown, printing it —
34
+ * not for reading what it says, which `core_read_attachment` does without an
35
+ * extension.
36
+ */
37
+ | 'attachments.read'
31
38
 
32
39
  /** Capability permissions */
33
40
  export type CapabilityPermission =
@@ -89,9 +89,23 @@ export interface ModelCapabilities {
89
89
  * Report this per model *and* per auth mode, like `voiceDuplex`: the same
90
90
  * provider often serves both a vision model and a text-only one, and a picture
91
91
  * sent to the latter is at best ignored and at worst an error mid-conversation.
92
- * Stina uses it to decide whether the paperclip is offered at all.
93
92
  */
94
93
  vision?: boolean
94
+
95
+ /**
96
+ * The model can be given files to read alongside the text of a message, and
97
+ * the provider folds {@link ChatMessage.files} into whatever its API calls them.
98
+ *
99
+ * Separate from `vision` because the two come apart in both directions: a model
100
+ * that reads a PDF natively is not necessarily one that looks at a photograph,
101
+ * and an OpenAI-compatible server with a vision model behind it may take images
102
+ * and nothing else. Report it per model and per auth mode for the same reason
103
+ * `vision` is reported that way.
104
+ *
105
+ * A provider that says nothing here keeps behaving exactly as before: it is
106
+ * handed the text, and `files` is simply a field it does not read.
107
+ */
108
+ documents?: boolean
95
109
  }
96
110
 
97
111
  /**
@@ -189,6 +203,18 @@ export interface ChatMessage {
189
203
  * Only ever `image/jpeg` or `image/png` — see `ChatAttachmentDTO` for why.
190
204
  */
191
205
  images?: ChatImage[]
206
+ /**
207
+ * Files the user attached for the model to read: PDFs and plain text.
208
+ *
209
+ * Additive in the same way `images` is, and split from it for the same reason
210
+ * the two capabilities are separate — a provider folds a document into a
211
+ * different content block than a picture, and many can do one and not the other.
212
+ * A provider that ignores the field behaves exactly as it did before.
213
+ *
214
+ * Only present on user messages, because that is where both hosted providers
215
+ * require a document to sit.
216
+ */
217
+ files?: ChatFile[]
192
218
  /** For assistant messages: tool calls made by the model */
193
219
  tool_calls?: ToolCall[]
194
220
  /** For tool messages: the ID of the tool call this is a response to */
@@ -210,6 +236,30 @@ export interface ChatImage {
210
236
  data: string
211
237
  }
212
238
 
239
+ /**
240
+ * One file the user attached for the model to read, rather than to look at.
241
+ *
242
+ * `application/pdf` and `text/plain` are what the host stores, so those are what
243
+ * arrive. Both hosted providers read a PDF natively and want it as its own content
244
+ * block; plain text needs no such thing and can simply be put in the prompt, which
245
+ * is why a provider with no document support at all can still do something useful
246
+ * with a `text/plain` file if it chooses to.
247
+ */
248
+ export interface ChatFile {
249
+ /** `application/pdf` or `text/plain`. */
250
+ mime: string
251
+ /** The file itself, base64 with no data-URI prefix. */
252
+ data: string
253
+ /**
254
+ * The name the file arrived under, when it had one.
255
+ *
256
+ * Worth passing on rather than dropping: `faktura-1042.pdf` is most of what is
257
+ * known about a file before it is opened, and both providers have somewhere to
258
+ * put it — a file name on the one, a document title on the other.
259
+ */
260
+ name?: string
261
+ }
262
+
213
263
  /**
214
264
  * A tool call made by the model
215
265
  */
@@ -73,6 +73,48 @@ export interface ToolResult {
73
73
  * would send her after a component that does not exist.
74
74
  */
75
75
  cardSuggestion?: string
76
+
77
+ /**
78
+ * Files to put in front of the user, in the conversation where the tool ran.
79
+ *
80
+ * For what a card cannot hold and the model cannot reproduce: a generated image,
81
+ * a photo fetched on the user's behalf, the PDF that came attached to a mail.
82
+ * Like {@link ToolResult.display}, this is for the user — it is lifted out before
83
+ * the result reaches the model, which would have nothing to do with the bytes but
84
+ * spend tokens on them. Say in `data` that a file was attached, so she can talk
85
+ * about it without reciting it.
86
+ *
87
+ * The host stores each one as an attachment of the conversation, exactly as a
88
+ * file the user sends is stored, so it is served to every client and can be saved
89
+ * or shared from there. The store's own rules apply: JPEG and PNG, PDF, and plain
90
+ * text, up to 20 MB (1 MB for text). What the file *is* is read from the bytes,
91
+ * not from `name`, so a PDF mislabelled `.png` still lands as a PDF. One that
92
+ * fails the rules is dropped with a warning rather than half-shown.
93
+ *
94
+ * Attaching a file does not read it to the model. What comes back in the result
95
+ * the model sees is a reference apiece — `{ id, mime, name? }` under this same
96
+ * key, in place of the bytes — and she reads one with `core_read_attachment` if
97
+ * she decides to. That is the intended flow for a mail's attachment: hand it over
98
+ * here, and let her choose whether to open it.
99
+ */
100
+ attachments?: ToolAttachment[]
101
+ }
102
+
103
+ /**
104
+ * One file a tool wants to put in the conversation. See {@link ToolResult.attachments}.
105
+ */
106
+ export interface ToolAttachment {
107
+ /** The bytes, base64 encoded. What they are is read from them, not from `name`. */
108
+ data: string
109
+ /**
110
+ * A file name to offer when the user saves it, e.g. `friday.png` or
111
+ * `faktura-1042.pdf`.
112
+ *
113
+ * Worth sending for a picture and close to required for a document: it is the
114
+ * whole label the user sees in the conversation, and it is what Stina has to go
115
+ * on when she decides whether to read it.
116
+ */
117
+ name?: string
76
118
  }
77
119
 
78
120
  /**
package/src/types.ts CHANGED
@@ -59,6 +59,7 @@ export type {
59
59
  ModelCapabilities,
60
60
  ChatMessage,
61
61
  ChatImage,
62
+ ChatFile,
62
63
  ToolCall,
63
64
  ChatOptions,
64
65
  GetModelsOptions,
@@ -69,7 +70,7 @@ export type {
69
70
  } from './types.provider.js'
70
71
 
71
72
  // Tools and Actions
72
- export type { Tool, ToolResult, Action, ActionResult } from './types.tools.js'
73
+ export type { Tool, ToolResult, ToolAttachment, Action, ActionResult } from './types.tools.js'
73
74
 
74
75
  // Context and APIs
75
76
  export type {
@@ -100,6 +101,8 @@ export type {
100
101
  BackgroundTaskCallback,
101
102
  BackgroundTaskHealth,
102
103
  BackgroundWorkersAPI,
104
+ AttachmentsAPI,
105
+ AttachmentContent,
103
106
  } from './types.context.js'
104
107
 
105
108
  // Storage and Secrets