@odyn-app/api 0.2.0 → 0.3.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/README.md +69 -0
- package/cli/build.mjs +18 -3
- package/cli/github.mjs +2 -3
- package/dist/index.d.ts +343 -11
- package/dist/index.js +342 -8
- package/dist/ui/button.d.ts +1 -1
- package/dist/ui/orb/orb-frame-clock.d.ts +9 -1
- package/dist/ui/orb/orb-frame-clock.js +26 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,8 +29,77 @@ defineExtension({
|
|
|
29
29
|
- `@odyn-app/api/ui`: Odyn's `Button`, `Skeleton`, `SettingRow`, `Orb` and `cn`, for extension pages.
|
|
30
30
|
- `@odyn-app/api/theme.css`: Odyn's colours, fonts and light/dark themes. Add `@import "@odyn-app/api/theme.css";` to a page's stylesheet.
|
|
31
31
|
|
|
32
|
+
## A sidebar button, a full-window page and a dashboard card (kit version 5)
|
|
33
|
+
|
|
34
|
+
With `apiVersion: 5`, any extension can ask for these. The user approves each one at install.
|
|
35
|
+
|
|
36
|
+
- `ui:sidebar` + `sidebarItem: { title, icon, command }` — a button under Marketplace. A click runs `command`. `odyn.ui.setBadge(3)` shows a number on it; `null` or `0` clears it.
|
|
37
|
+
- `ui:dialog` + a page in `surfaces` with `placement: 'dialog'` — a full-window page. `odyn.ui.openDialog()` opens it, but only while handling a click on your button or card.
|
|
38
|
+
- `ui:dashboard` + `dashboardCard: { command }` — one card on the dashboard. Return `{ type: 'card', title, items, actions }` from `command`, or call `odyn.dashboard.setCard(card)` at any time (`null` hides it). Clicking an item or action runs `command` again with `itemId` or `actionId`.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
defineExtension({
|
|
42
|
+
commands: {
|
|
43
|
+
'open-board': async ({ odyn }) => { await odyn.ui.openDialog() },
|
|
44
|
+
'refresh-card': async ({ odyn, actionId }) => {
|
|
45
|
+
if (actionId === 'open') await odyn.ui.openDialog()
|
|
46
|
+
await odyn.ui.setBadge(2)
|
|
47
|
+
return { type: 'card', title: 'Standup', items: [{ id: 'a', title: 'Two updates waiting' }], actions: [{ id: 'open', title: 'Open board' }] }
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
32
53
|
Every extension runs sandboxed, and each call is checked against the permissions the user granted.
|
|
33
54
|
|
|
55
|
+
## Shared storage and who called a tool (kit version 6)
|
|
56
|
+
|
|
57
|
+
With `apiVersion: 6`, no extra permission needed:
|
|
58
|
+
|
|
59
|
+
- `odyn.storage` in your commands, and `surface.storage` in your pages: one text store for the whole extension. `get(key)`, `set(key, value)` (`null` removes it) and `keys(prefix?)`. Save JSON as text. Each value can be up to 5 MB, and 50 MB altogether. Everything is deleted when the extension is uninstalled.
|
|
60
|
+
- `ui:dialog` + `composerMenuItem: { title, icon, command }` (kit version 6) — an entry in the message box's + menu, e.g. "Attach initiative". A click runs `command`, told which chat through `caller`.
|
|
61
|
+
- `odyn.ui.openDialog({ chatId?, item? })` (kit version 6) opens your page for a chat and at one of your own items, e.g. `{ chatId: caller.chatId, item: planId }`. The page reads both from its context (`context.chatId`, `context.item`). With no options it opens as before.
|
|
62
|
+
- `odyn.chats.list()` and `chats.current()` also say `isWorking` (the agent is mid-reply) and `workspaceChatId` (the workspace's main chat, shared by every chat in that workspace). Both are missing on older Odyn versions.
|
|
63
|
+
- `ui:chatHeader` + `chatChip: { command }` (kit version 6) — a small label with a coloured dot next to a chat's title. `odyn.ui.setChatChip(chatId, { label, color? })` sets it for one chat (label up to 40 characters; colour `gray`, `blue`, `green`, `amber`, `red` or `purple`); `null` removes it. A click runs `command`, told which chat through `caller`, and may open your page.
|
|
64
|
+
- Ask-first cards (kit version 6, no extra permission): an `agentTool` command may answer with a card that has one or two `actions`, e.g. "This looks like part of X — [Not now] [Link chat]". Odyn shows it in the chat under the tool call and tells the agent to wait. Nothing happens until the person clicks: the click runs the same command again with the same `input` and the button's id in `actionId`, and `caller` set to the chat. Each card can be answered once; if your command fails, the card opens again. Card buttons can't use `opensDialog` — call `odyn.ui.openDialog()` instead.
|
|
65
|
+
- A command with `placement: 'chatContext'` (one per extension, `agent:context` permission, kit version 6) is never shown. Odyn runs it when a chat starts a fresh session and after a long chat is summed up, telling it the caller like an agent tool. Its answer (text, or any view, read as text) goes in front of the user's message, cut to 2,000 characters. Answer within 2 seconds or the message goes without it; return `null` to add nothing.
|
|
66
|
+
- `odyn.notes.run(tool, target, input)` (and `surface.notes.run` in pages), Odyn's own extensions only: runs one of Odyn's note and initiative tools. With a chat id as `target` it runs as if that chat's agent called it; with `{ projectId }` it runs for the person on your page (no chat needed, changes are recorded as theirs, and chat-only extras such as a card in the chat are skipped). Either way it takes the same input and the same team-sharing rules, and returns `{ text, isError }`. `ListNotes`, `ReadNote`, `ListInitiatives` and `ReadInitiative` need `notes:read`; `CreateNote`, `EditNote`, `PatchNote`, `RenameNote`, `DeleteNote`, `CreateInitiative`, `MoveNotesToInitiative`, `MarkInitiativeComplete`, `EditInitiativeArtifacts` and `EditInitiativeGroups` need `notes:write`. Kit version 6.
|
|
67
|
+
- `odyn.notes.list(projectId)`, `odyn.notes.initiative(projectId, initiativeId)` and `odyn.notes.read(projectId, noteId)` (and the same on `surface.notes`), Odyn's own extensions only, need `notes:read`: structured reads for your page. `list` gives the project's initiatives and its loose notes (notes in no initiative), `initiative` one initiative's groups and documents in order (`null` if it isn't in that project), `read` one note as markdown with its `version` (pass that to `EditNote` / `PatchNote` so a newer copy is never overwritten). Same team-sharing rules as the tools.
|
|
68
|
+
- In pages (kit version 6), `surface.chats.list(projectId?)`, `surface.projects.list()` and `surface.ui.setChatChip(chatId, chip)` work the same as their `odyn.` versions, with the same `chats:read` / `projects:read` / `ui:chatHeader` permissions.
|
|
69
|
+
- An agent tool's context has `caller: { chatId, projectId, workspaceChatId, kind }` saying which chat (or `kind: 'spryte'` for Odyn's coordinating agent) called it. `workspaceChatId` is shared by every chat in one workspace. It is missing for clicks.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
defineExtension({
|
|
73
|
+
commands: {
|
|
74
|
+
'link-chat': async ({ odyn, caller, input }) => {
|
|
75
|
+
if (!caller) return 'Only an agent can link its chat.'
|
|
76
|
+
await odyn.storage.set(`link:${caller.workspaceChatId}`, JSON.stringify({ planId: input.planId }))
|
|
77
|
+
return 'Linked.'
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Screenshots
|
|
84
|
+
|
|
85
|
+
Add `screenshots` to the `odyn` section of `package.json` to show a strip of pictures on your extension's page in Odyn's Marketplace. With none, Odyn shows a placeholder with your icon.
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
"screenshots": ["screenshots/1.png", "screenshots/2.webp"]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- Up to 5, each a path inside your extension's folder.
|
|
92
|
+
- `.png`, `.jpg`, `.jpeg` or `.webp`, at most 1 MB each.
|
|
93
|
+
- Forward slashes only; no `..`, no paths starting with `/` or a drive like `C:`, and no web addresses.
|
|
94
|
+
|
|
95
|
+
`odyn-extension check` (and `build` and `publish`) makes sure every file is there and small enough.
|
|
96
|
+
|
|
97
|
+
## Feedback
|
|
98
|
+
|
|
99
|
+
People can send feedback (a bug, an idea or a question, with an optional screenshot) and give a thumbs up from your extension's page in Odyn. Feedback arrives anonymously as a GitHub issue on [New-Genre/odyn-extensions](https://github.com/New-Genre/odyn-extensions/issues), labelled `ext:<your id>` plus the kind, and mentions your extension's `owners`.
|
|
100
|
+
|
|
101
|
+
Answer by commenting on the issue — the sender sees your replies inside Odyn under "Your feedback". Closing the issue shows it to them as closed. The sender is anonymous, so never ask for personal details in a reply.
|
|
102
|
+
|
|
34
103
|
**[Read the docs](https://www.odyn.build/docs/extensions)** for what extensions can and can't do, every permission and limit, and the full reference. To publish one to Odyn's Marketplace, see the [contribution guide](https://github.com/New-Genre/odyn-extensions/blob/main/CONTRIBUTING.md).
|
|
35
104
|
|
|
36
105
|
MIT licence.
|
package/cli/build.mjs
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
* Every manifest is checked with the same checker Odyn runs on a download,
|
|
11
11
|
* so a broken one fails here rather than on someone's Mac.
|
|
12
12
|
*/
|
|
13
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import { join, relative, resolve } from 'node:path'
|
|
15
15
|
import react from '@vitejs/plugin-react'
|
|
16
16
|
import tailwindcss from '@tailwindcss/postcss'
|
|
17
17
|
import { build } from 'vite'
|
|
18
|
-
import { validateManifest } from '../dist/index.js'
|
|
18
|
+
import { MAX_EXTENSION_SCREENSHOT_BYTES, validateManifest } from '../dist/index.js'
|
|
19
19
|
|
|
20
20
|
const PANEL_DIR = join('dist', 'panel')
|
|
21
21
|
const HELPER_SOURCE = join('src', 'helper.ts')
|
|
@@ -43,7 +43,22 @@ export function checkExtension(sourceDir, { official = false } = {}) {
|
|
|
43
43
|
const problems = []
|
|
44
44
|
if (pkg.private !== true) problems.push('"private": true is required, so it is never published to npm by mistake.')
|
|
45
45
|
const manifest = { ...pkg.odyn, version: pkg.version }
|
|
46
|
-
|
|
46
|
+
const manifestProblems = validateManifest(manifest, { official })
|
|
47
|
+
problems.push(...manifestProblems)
|
|
48
|
+
// The paths themselves are only worth looking for once the manifest is sound.
|
|
49
|
+
if (manifestProblems.length === 0) problems.push(...screenshotFileProblems(sourceDir, manifest.screenshots ?? []))
|
|
50
|
+
return problems
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Checks every screenshot the manifest lists is a real picture file of at most 1 MB. */
|
|
54
|
+
function screenshotFileProblems(sourceDir, screenshots) {
|
|
55
|
+
const problems = []
|
|
56
|
+
for (const path of screenshots) {
|
|
57
|
+
const file = join(sourceDir, path)
|
|
58
|
+
const info = existsSync(file) ? statSync(file) : null
|
|
59
|
+
if (!info?.isFile()) problems.push(`Screenshot "${path}" was not found in this folder.`)
|
|
60
|
+
else if (info.size > MAX_EXTENSION_SCREENSHOT_BYTES) problems.push(`Screenshot "${path}" is bigger than 1 MB.`)
|
|
61
|
+
}
|
|
47
62
|
return problems
|
|
48
63
|
}
|
|
49
64
|
|
package/cli/github.mjs
CHANGED
|
@@ -17,10 +17,9 @@ const API = 'https://api.github.com'
|
|
|
17
17
|
/**
|
|
18
18
|
* Odyn's GitHub sign-in app (New-Genre's "Odyn extensions" OAuth app, with
|
|
19
19
|
* device sign-in switched on). Device sign-in — a short code you type on
|
|
20
|
-
* github.com — needs no secret, so the id is safe to ship.
|
|
21
|
-
* app exists; until then `gh auth login` or `GITHUB_TOKEN` is used.
|
|
20
|
+
* github.com — needs no secret, so the id is safe to ship.
|
|
22
21
|
*/
|
|
23
|
-
const ODYN_GITHUB_CLIENT_ID = ''
|
|
22
|
+
const ODYN_GITHUB_CLIENT_ID = 'Ov23lipXr50lgj85CbN5'
|
|
24
23
|
const GITHUB_CLIENT_ID = process.env.ODYN_GITHUB_CLIENT_ID?.trim() || ODYN_GITHUB_CLIENT_ID
|
|
25
24
|
const TOKEN_FILE = join(homedir(), '.config', 'odyn-extension', 'github.json')
|
|
26
25
|
const FORK_READY_ATTEMPTS = 20
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** The newest building-kit version. Bumped when something new is added. */
|
|
2
|
-
export declare const EXTENSION_API_VERSION =
|
|
2
|
+
export declare const EXTENSION_API_VERSION = 6;
|
|
3
3
|
/** The oldest building-kit version Odyn still runs. */
|
|
4
4
|
export declare const EXTENSION_API_MIN_VERSION = 1;
|
|
5
5
|
/** Everything an extension can ask the user to allow. */
|
|
6
|
-
export declare const EXTENSION_PERMISSIONS: readonly ["chats:read", "projects:read", "agents:start", "network", "composer:attach", "screenshots:read", "programs:run", "agent:tools"];
|
|
6
|
+
export declare const EXTENSION_PERMISSIONS: readonly ["chats:read", "projects:read", "agents:start", "network", "composer:attach", "screenshots:read", "programs:run", "agent:tools", "ui:sidebar", "ui:dialog", "ui:dashboard", "notes:read", "notes:write", "agent:context", "ui:chatHeader"];
|
|
7
7
|
export type ExtensionPermission = (typeof EXTENSION_PERMISSIONS)[number];
|
|
8
8
|
/** Permissions only extensions made by Odyn may ask for. */
|
|
9
9
|
export declare const OFFICIAL_ONLY_PERMISSIONS: readonly ExtensionPermission[];
|
|
@@ -18,9 +18,18 @@ export declare function isOdynExtensionId(id: string): boolean;
|
|
|
18
18
|
/** Systems an extension can say it runs on. Leave out to run everywhere. */
|
|
19
19
|
export declare const EXTENSION_PLATFORMS: readonly ["darwin", "linux", "win32"];
|
|
20
20
|
export type ExtensionPlatform = (typeof EXTENSION_PLATFORMS)[number];
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Where an extension can show up inside Odyn. `chatContext` (kit version 6,
|
|
23
|
+
* `agent:context`) is never shown: Odyn runs it when a chat starts, and after
|
|
24
|
+
* a long chat is summed up, and puts its short text answer in front of the
|
|
25
|
+
* user's message.
|
|
26
|
+
*/
|
|
27
|
+
export declare const EXTENSION_PLACEMENTS: readonly ["sidebarPanel", "chatCard", "agentTool", "settings", "chatContext"];
|
|
23
28
|
export type ExtensionPlacement = (typeof EXTENSION_PLACEMENTS)[number];
|
|
29
|
+
/** The most a `chatContext` command's answer may add to a message; anything longer is cut. */
|
|
30
|
+
export declare const EXTENSION_CONTEXT_MAX_CHARS = 2000;
|
|
31
|
+
/** How long Odyn waits for a `chatContext` command before sending the message without it. */
|
|
32
|
+
export declare const EXTENSION_CONTEXT_TIMEOUT_MS = 2000;
|
|
24
33
|
export interface ExtensionCommand {
|
|
25
34
|
/** Unique within the extension, e.g. "show-standup". */
|
|
26
35
|
readonly name: string;
|
|
@@ -46,11 +55,18 @@ export interface ExtensionCommandInput {
|
|
|
46
55
|
/** A value the agent or a form handed to a command. */
|
|
47
56
|
export type ExtensionInputValue = string | number | boolean;
|
|
48
57
|
/** Where an extension's own page can be shown. */
|
|
49
|
-
export declare const EXTENSION_SURFACE_PLACEMENTS: readonly ["composer", "settings"];
|
|
58
|
+
export declare const EXTENSION_SURFACE_PLACEMENTS: readonly ["composer", "settings", "dialog"];
|
|
50
59
|
export type ExtensionSurfacePlacement = (typeof EXTENSION_SURFACE_PLACEMENTS)[number];
|
|
51
60
|
/** The icons Odyn can draw on the button that opens a page. */
|
|
52
61
|
export declare const EXTENSION_SURFACE_ICONS: readonly ["puzzle", "pencil", "image", "note"];
|
|
53
62
|
export type ExtensionSurfaceIcon = (typeof EXTENSION_SURFACE_ICONS)[number];
|
|
63
|
+
/**
|
|
64
|
+
* The icons an extension can pick for itself in the Marketplace list and on
|
|
65
|
+
* its page. Any name Odyn doesn't know (say, one added in a newer Odyn) shows
|
|
66
|
+
* the puzzle piece instead, so it never stops an extension installing.
|
|
67
|
+
*/
|
|
68
|
+
export declare const EXTENSION_ICONS: readonly ["puzzle", "pencil", "image", "note", "inbox", "cursor", "palette", "globe", "phone", "list"];
|
|
69
|
+
export type ExtensionIcon = (typeof EXTENSION_ICONS)[number];
|
|
54
70
|
export declare const SURFACE_MIN_HEIGHT = 120;
|
|
55
71
|
export declare const SURFACE_MAX_HEIGHT = 600;
|
|
56
72
|
/**
|
|
@@ -63,7 +79,9 @@ export interface ExtensionSurface {
|
|
|
63
79
|
readonly title: string;
|
|
64
80
|
/**
|
|
65
81
|
* `composer`: slides open above the message box, with a toolbar button.
|
|
66
|
-
* `settings`: shown inside the extension's settings
|
|
82
|
+
* `settings`: shown inside the extension's settings page.
|
|
83
|
+
* `dialog`: fills a full-window dialog, opened only after a click (see
|
|
84
|
+
* `odyn.ui.openDialog`). Needs `apiVersion` 5 and the `ui:dialog` permission.
|
|
67
85
|
*/
|
|
68
86
|
readonly placement: ExtensionSurfacePlacement;
|
|
69
87
|
/** The page inside the extension's folder, e.g. "dist/panel/index.html". */
|
|
@@ -72,6 +90,47 @@ export interface ExtensionSurface {
|
|
|
72
90
|
/** Height in pixels. Defaults to 260. */
|
|
73
91
|
readonly height?: number;
|
|
74
92
|
}
|
|
93
|
+
/** A button in Odyn's sidebar, under "Marketplace". Clicking it runs `command`. */
|
|
94
|
+
export interface ExtensionSidebarItem {
|
|
95
|
+
readonly title: string;
|
|
96
|
+
readonly icon: ExtensionSurfaceIcon;
|
|
97
|
+
/** A command in `commands`. It may call `odyn.ui.openDialog()`. */
|
|
98
|
+
readonly command: string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* An entry in the message box's + menu (kit version 6, `ui:dialog`). Clicking
|
|
102
|
+
* it runs `command`, told which chat it is through `caller`, so the command can
|
|
103
|
+
* open the extension's page for that chat with `odyn.ui.openDialog({ chatId })`.
|
|
104
|
+
*/
|
|
105
|
+
export interface ExtensionComposerMenuItem {
|
|
106
|
+
readonly title: string;
|
|
107
|
+
readonly icon: ExtensionSurfaceIcon;
|
|
108
|
+
/** A command in `commands`. It may call `odyn.ui.openDialog()`. */
|
|
109
|
+
readonly command: string;
|
|
110
|
+
}
|
|
111
|
+
/** The chip next to a chat's title. Clicking it runs `command`, told which chat it is. */
|
|
112
|
+
export interface ExtensionChatChipSource {
|
|
113
|
+
/** A command in `commands`. It may call `odyn.ui.openDialog()`. */
|
|
114
|
+
readonly command: string;
|
|
115
|
+
}
|
|
116
|
+
/** The colours a chat chip's dot can be. Odyn picks the exact shade for the theme. */
|
|
117
|
+
export declare const EXTENSION_CHIP_COLORS: readonly ["gray", "blue", "green", "amber", "red", "purple"];
|
|
118
|
+
export type ExtensionChipColor = (typeof EXTENSION_CHIP_COLORS)[number];
|
|
119
|
+
/** The longest label a chat chip can carry. */
|
|
120
|
+
export declare const CHAT_CHIP_MAX_LABEL = 40;
|
|
121
|
+
/** What a chat chip shows: a short label and a coloured dot. */
|
|
122
|
+
export interface ExtensionChatChip {
|
|
123
|
+
readonly label: string;
|
|
124
|
+
/** Leave out for gray. */
|
|
125
|
+
readonly color?: ExtensionChipColor;
|
|
126
|
+
}
|
|
127
|
+
/** Why a chat chip can't be drawn. An empty list means it is fine. */
|
|
128
|
+
export declare function chatChipProblems(value: unknown): readonly string[];
|
|
129
|
+
/** Where a dashboard card comes from: Odyn runs `command` whenever the dashboard opens. */
|
|
130
|
+
export interface ExtensionDashboardCardSource {
|
|
131
|
+
/** A command in `commands` that returns `{ type: 'card', … }`, or nothing to hide the card. */
|
|
132
|
+
readonly command: string;
|
|
133
|
+
}
|
|
75
134
|
export interface ExtensionPreference {
|
|
76
135
|
readonly name: string;
|
|
77
136
|
readonly title: string;
|
|
@@ -106,6 +165,13 @@ export interface ExtensionHelper {
|
|
|
106
165
|
readonly links?: readonly string[];
|
|
107
166
|
/** Hosts `download` may fetch from (redirects must stay inside the list). */
|
|
108
167
|
readonly downloadHosts?: readonly string[];
|
|
168
|
+
/**
|
|
169
|
+
* Page calls (names given to `odyn.pages.handle`) that only look and never
|
|
170
|
+
* change anything, e.g. "setupStatus". Only these may be asked for from a
|
|
171
|
+
* page opened in a browser through Web Access; every other call stays on
|
|
172
|
+
* the Mac running Odyn.
|
|
173
|
+
*/
|
|
174
|
+
readonly browserSafeCalls?: readonly string[];
|
|
109
175
|
}
|
|
110
176
|
/** A command the agent must ask before using, e.g. `{ program: 'xcrun', subcommand: 'simctl' }`. */
|
|
111
177
|
export interface ExtensionGatedCommand {
|
|
@@ -150,11 +216,28 @@ export interface ExtensionManifest {
|
|
|
150
216
|
readonly helper?: ExtensionHelper;
|
|
151
217
|
/** A guide and permission-checked commands for the agent. */
|
|
152
218
|
readonly agent?: ExtensionAgent;
|
|
219
|
+
/** Its icon in the Marketplace, one of `EXTENSION_ICONS`. Defaults to the puzzle piece. */
|
|
220
|
+
readonly icon?: ExtensionIcon;
|
|
153
221
|
/**
|
|
154
222
|
* GitHub usernames of the people who look after it. Changes to the
|
|
155
223
|
* extension in the public repo need one of them (or Odyn) to approve.
|
|
156
224
|
*/
|
|
157
225
|
readonly owners?: readonly string[];
|
|
226
|
+
/**
|
|
227
|
+
* Pictures of the extension, shown as a strip on its page in Odyn's
|
|
228
|
+
* Marketplace. Each is a path inside the extension's folder, e.g.
|
|
229
|
+
* "screenshots/1.png" — up to 5, each a .png, .jpg, .jpeg or .webp file of
|
|
230
|
+
* at most 1 MB. With none, Odyn shows a placeholder with the icon.
|
|
231
|
+
*/
|
|
232
|
+
readonly screenshots?: readonly string[];
|
|
233
|
+
/** A button in Odyn's sidebar. Needs `apiVersion` 5 and `ui:sidebar`. */
|
|
234
|
+
readonly sidebarItem?: ExtensionSidebarItem;
|
|
235
|
+
/** A card on Odyn's dashboard. Needs `apiVersion` 5 and `ui:dashboard`. */
|
|
236
|
+
readonly dashboardCard?: ExtensionDashboardCardSource;
|
|
237
|
+
/** An entry in the message box's + menu. Needs `apiVersion` 6 and `ui:dialog`. */
|
|
238
|
+
readonly composerMenuItem?: ExtensionComposerMenuItem;
|
|
239
|
+
/** A chip next to chats' titles, set per chat with `odyn.ui.setChatChip`. Needs `apiVersion` 6 and `ui:chatHeader`. */
|
|
240
|
+
readonly chatChip?: ExtensionChatChipSource;
|
|
158
241
|
}
|
|
159
242
|
export interface ExtensionAction {
|
|
160
243
|
readonly id: string;
|
|
@@ -168,11 +251,34 @@ export interface ListItem {
|
|
|
168
251
|
readonly accessory?: string;
|
|
169
252
|
readonly actions?: readonly ExtensionAction[];
|
|
170
253
|
}
|
|
254
|
+
export declare const DASHBOARD_CARD_MAX_ITEMS = 5;
|
|
255
|
+
export declare const DASHBOARD_CARD_MAX_ACTIONS = 2;
|
|
256
|
+
export declare const DASHBOARD_CARD_MAX_TITLE = 80;
|
|
257
|
+
export declare const DASHBOARD_CARD_MAX_SUBTITLE = 140;
|
|
258
|
+
export interface DashboardCardItem {
|
|
259
|
+
readonly id: string;
|
|
260
|
+
readonly title: string;
|
|
261
|
+
readonly subtitle?: string;
|
|
262
|
+
}
|
|
263
|
+
export interface DashboardCardAction {
|
|
264
|
+
readonly id: string;
|
|
265
|
+
readonly title: string;
|
|
266
|
+
/** Opens the extension's `dialog` page straight away, without running code. */
|
|
267
|
+
readonly opensDialog?: boolean;
|
|
268
|
+
}
|
|
269
|
+
/** The card Odyn draws on its dashboard: a few updates or to-dos, and up to two buttons. */
|
|
270
|
+
export interface ExtensionDashboardCard {
|
|
271
|
+
readonly title: string;
|
|
272
|
+
readonly items: readonly DashboardCardItem[];
|
|
273
|
+
readonly actions?: readonly DashboardCardAction[];
|
|
274
|
+
}
|
|
171
275
|
export type ExtensionView = {
|
|
172
276
|
readonly type: 'list';
|
|
173
277
|
readonly items: readonly ListItem[];
|
|
174
278
|
readonly emptyText?: string;
|
|
175
|
-
} | {
|
|
279
|
+
} | ({
|
|
280
|
+
readonly type: 'card';
|
|
281
|
+
} & ExtensionDashboardCard) | {
|
|
176
282
|
readonly type: 'detail';
|
|
177
283
|
readonly markdown: string;
|
|
178
284
|
readonly actions?: readonly ExtensionAction[];
|
|
@@ -190,6 +296,14 @@ export interface ChatSummary {
|
|
|
190
296
|
readonly projectId: string;
|
|
191
297
|
readonly title: string;
|
|
192
298
|
readonly updatedAt: string;
|
|
299
|
+
/** True while the chat's agent is in the middle of a reply. Odyn 2026-11 and later. */
|
|
300
|
+
readonly isWorking?: boolean;
|
|
301
|
+
/**
|
|
302
|
+
* The workspace's main chat. Every chat in one workspace shares it, so it is
|
|
303
|
+
* the key for anything that should apply to the whole workspace. Odyn
|
|
304
|
+
* 2026-11 and later.
|
|
305
|
+
*/
|
|
306
|
+
readonly workspaceChatId?: string;
|
|
193
307
|
}
|
|
194
308
|
export interface ProjectSummary {
|
|
195
309
|
readonly id: string;
|
|
@@ -201,6 +315,14 @@ export interface ChatMessageSummary {
|
|
|
201
315
|
readonly createdAt: string;
|
|
202
316
|
}
|
|
203
317
|
/** Odyn's side of the conversation. Every call is permission-checked by Odyn. */
|
|
318
|
+
/** An extension's own shared storage (kit version 6). Save JSON as text. */
|
|
319
|
+
export interface ExtensionStorage {
|
|
320
|
+
get(key: string): Promise<string | null>;
|
|
321
|
+
/** `null` removes the value. */
|
|
322
|
+
set(key: string, value: string | null): Promise<void>;
|
|
323
|
+
/** Keys that start with `prefix` (all of them when left out), in order. */
|
|
324
|
+
keys(prefix?: string): Promise<readonly string[]>;
|
|
325
|
+
}
|
|
204
326
|
export interface OdynApi {
|
|
205
327
|
readonly chats: {
|
|
206
328
|
list(projectId?: string): Promise<readonly ChatSummary[]>;
|
|
@@ -227,15 +349,168 @@ export interface OdynApi {
|
|
|
227
349
|
showToast(message: string): Promise<void>;
|
|
228
350
|
/** The values the user saved in the extension's settings. */
|
|
229
351
|
preferences(): Promise<Readonly<Record<string, string | boolean>>>;
|
|
352
|
+
readonly ui: {
|
|
353
|
+
/**
|
|
354
|
+
* Opens the extension's `dialog` page. Only works while a command started
|
|
355
|
+
* by a click (the sidebar button, a dashboard card, a chat chip or the
|
|
356
|
+
* message box's + menu) is running. From kit version 6 it can say which
|
|
357
|
+
* chat and which item to show; the page reads them from its context.
|
|
358
|
+
*/
|
|
359
|
+
openDialog(options?: ExtensionDialogOptions): Promise<void>;
|
|
360
|
+
/** The number on the sidebar button. `null` or 0 clears it. */
|
|
361
|
+
setBadge(count: number | null): Promise<void>;
|
|
362
|
+
/** The chip next to this chat's title. `null` removes it. Needs `ui:chatHeader` and a `chatChip`. */
|
|
363
|
+
setChatChip(chatId: string, chip: ExtensionChatChip | null): Promise<void>;
|
|
364
|
+
};
|
|
365
|
+
readonly dashboard: {
|
|
366
|
+
/** Replaces the dashboard card at any time. `null` hides it. */
|
|
367
|
+
setCard(card: ExtensionDashboardCard | null): Promise<void>;
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* The extension's own shared storage: text values by key, kept for the whole
|
|
371
|
+
* extension rather than one chat, and readable from its pages too. Cleared
|
|
372
|
+
* when the extension is uninstalled. Needs `apiVersion` 6; no permission.
|
|
373
|
+
*/
|
|
374
|
+
readonly storage: ExtensionStorage;
|
|
375
|
+
/**
|
|
376
|
+
* Odyn's note and initiative tools, run as if the given chat's agent called
|
|
377
|
+
* them, under the same team visibility rules. Read tools need `notes:read`,
|
|
378
|
+
* the rest `notes:write`. Kit version 6.
|
|
379
|
+
*/
|
|
380
|
+
readonly notes: ExtensionNotesApi;
|
|
381
|
+
}
|
|
382
|
+
/** The note and initiative tools that only look. */
|
|
383
|
+
export declare const EXTENSION_NOTE_READ_TOOLS: readonly ["ListNotes", "ReadNote", "ListInitiatives", "ReadInitiative"];
|
|
384
|
+
/** The note and initiative tools that change something. */
|
|
385
|
+
export declare const EXTENSION_NOTE_WRITE_TOOLS: readonly ["CreateNote", "EditNote", "PatchNote", "RenameNote", "DeleteNote", "CreateInitiative", "MoveNotesToInitiative", "MarkInitiativeComplete", "EditInitiativeArtifacts", "EditInitiativeGroups"];
|
|
386
|
+
export type ExtensionNoteTool = (typeof EXTENSION_NOTE_READ_TOOLS)[number] | (typeof EXTENSION_NOTE_WRITE_TOOLS)[number];
|
|
387
|
+
/** What a note tool answered: the same text an agent would see. */
|
|
388
|
+
export interface ExtensionNoteToolResult {
|
|
389
|
+
readonly text: string;
|
|
390
|
+
readonly isError: boolean;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Who a note tool works for. A chat id runs it as that chat's agent: the chat
|
|
394
|
+
* picks the project, and changes are recorded as the agent's. `{ projectId }`
|
|
395
|
+
* runs it for the person on the extension's own page, where there is no chat:
|
|
396
|
+
* changes are recorded as theirs, and chat-only extras (a card in the chat,
|
|
397
|
+
* attaching the chat's workspace) are skipped. The id is a chat's `projectId`
|
|
398
|
+
* (from `chats.list` or `projects.list`).
|
|
399
|
+
*/
|
|
400
|
+
export type ExtensionNoteTarget = string | {
|
|
401
|
+
readonly projectId: string;
|
|
402
|
+
};
|
|
403
|
+
/** Where an initiative is in its life. */
|
|
404
|
+
export type ExtensionInitiativeStatus = 'active' | 'shipped' | 'archived';
|
|
405
|
+
/** One initiative in a project's list. */
|
|
406
|
+
export interface ExtensionInitiativeSummary {
|
|
407
|
+
readonly id: string;
|
|
408
|
+
readonly title: string;
|
|
409
|
+
readonly description: string | null;
|
|
410
|
+
readonly status: ExtensionInitiativeStatus;
|
|
411
|
+
readonly documentCount: number;
|
|
412
|
+
readonly updatedAt: string;
|
|
413
|
+
}
|
|
414
|
+
/** A note that is not in any initiative. */
|
|
415
|
+
export interface ExtensionLooseNote {
|
|
416
|
+
readonly id: string;
|
|
417
|
+
readonly title: string;
|
|
418
|
+
readonly description: string | null;
|
|
419
|
+
readonly updatedAt: string;
|
|
420
|
+
readonly visibility: 'private' | 'team';
|
|
421
|
+
}
|
|
422
|
+
/** A project's initiatives and loose notes, as the person may see them. */
|
|
423
|
+
export interface ExtensionNotesList {
|
|
424
|
+
readonly initiatives: readonly ExtensionInitiativeSummary[];
|
|
425
|
+
readonly notes: readonly ExtensionLooseNote[];
|
|
426
|
+
/** Why the synced half could not be read (the list is then this machine's notes only); null when complete. */
|
|
427
|
+
readonly degraded: string | null;
|
|
428
|
+
}
|
|
429
|
+
/** A named stretch of an initiative's documents. */
|
|
430
|
+
export interface ExtensionInitiativeGroup {
|
|
431
|
+
readonly id: string;
|
|
432
|
+
readonly name: string;
|
|
433
|
+
/** Where new documents go by default. At most one group is active. */
|
|
434
|
+
readonly active: boolean;
|
|
435
|
+
readonly done: boolean;
|
|
436
|
+
}
|
|
437
|
+
/** One document in an initiative, without its body. */
|
|
438
|
+
export interface ExtensionInitiativeDocument {
|
|
439
|
+
readonly id: string;
|
|
440
|
+
readonly title: string;
|
|
441
|
+
readonly description: string | null;
|
|
442
|
+
readonly updatedAt: string;
|
|
443
|
+
/** Who changed it last; null when unknown. */
|
|
444
|
+
readonly updatedBy: 'user' | 'agent' | null;
|
|
445
|
+
/** Changed since the person last opened it on this machine. */
|
|
446
|
+
readonly updatedSinceSeen: boolean;
|
|
447
|
+
/** Its group; null when ungrouped. */
|
|
448
|
+
readonly groupId: string | null;
|
|
449
|
+
}
|
|
450
|
+
/** One initiative with its groups and documents, in order. */
|
|
451
|
+
export interface ExtensionInitiative {
|
|
452
|
+
readonly id: string;
|
|
453
|
+
readonly title: string;
|
|
454
|
+
readonly description: string | null;
|
|
455
|
+
readonly status: ExtensionInitiativeStatus;
|
|
456
|
+
readonly createdAt: string;
|
|
457
|
+
readonly groups: readonly ExtensionInitiativeGroup[];
|
|
458
|
+
readonly documents: readonly ExtensionInitiativeDocument[];
|
|
459
|
+
}
|
|
460
|
+
/** A note or document read as markdown. */
|
|
461
|
+
export interface ExtensionNote {
|
|
462
|
+
readonly id: string;
|
|
463
|
+
readonly title: string;
|
|
464
|
+
readonly description: string | null;
|
|
465
|
+
readonly visibility: 'private' | 'team';
|
|
466
|
+
readonly updatedAt: string;
|
|
467
|
+
readonly markdown: string;
|
|
468
|
+
/** A document's version: pass it to `EditNote` / `PatchNote` so a newer copy is never overwritten. Null for other notes. */
|
|
469
|
+
readonly version: number | null;
|
|
470
|
+
/** Why this machine's copy was read rather than the server's; null when fresh. */
|
|
471
|
+
readonly offline: string | null;
|
|
472
|
+
}
|
|
473
|
+
export interface ExtensionNotesApi {
|
|
474
|
+
/** Runs one note tool for a chat or a project, with the tool's usual input. */
|
|
475
|
+
run(tool: ExtensionNoteTool, target: ExtensionNoteTarget, input: Readonly<Record<string, unknown>>): Promise<ExtensionNoteToolResult>;
|
|
476
|
+
/** A project's initiatives and loose notes, for a page. Needs `notes:read`. */
|
|
477
|
+
list(projectId: string): Promise<ExtensionNotesList>;
|
|
478
|
+
/** One initiative with its groups and documents, or null when it is not in that project. Needs `notes:read`. */
|
|
479
|
+
initiative(projectId: string, initiativeId: string): Promise<ExtensionInitiative | null>;
|
|
480
|
+
/** One note or document as markdown, or null when it is not in that project. Needs `notes:read`. */
|
|
481
|
+
read(projectId: string, noteId: string): Promise<ExtensionNote | null>;
|
|
230
482
|
}
|
|
231
483
|
/** What a command receives when it runs. */
|
|
232
484
|
export interface CommandContext {
|
|
233
485
|
readonly odyn: OdynApi;
|
|
234
|
-
/**
|
|
486
|
+
/**
|
|
487
|
+
* Set when the user picked an action on something the command drew. For an
|
|
488
|
+
* agent tool, set when the person clicked a button on the chat card the tool
|
|
489
|
+
* answered with — `input` is then the tool's original input, and `caller`
|
|
490
|
+
* the chat the card is in (kit version 6).
|
|
491
|
+
*/
|
|
235
492
|
readonly actionId?: string;
|
|
236
493
|
readonly itemId?: string;
|
|
237
494
|
/** Set when a form was submitted, or the agent called an agent tool. */
|
|
238
495
|
readonly input?: Readonly<Record<string, ExtensionInputValue>>;
|
|
496
|
+
/**
|
|
497
|
+
* Who called this agent tool: their chat, project and workspace. Missing
|
|
498
|
+
* for clicks, and for agents Odyn cannot tie to one chat. Kit version 6.
|
|
499
|
+
*/
|
|
500
|
+
readonly caller?: ExtensionToolCaller;
|
|
501
|
+
}
|
|
502
|
+
/** The chat whose agent called an agent tool. */
|
|
503
|
+
export interface ExtensionToolCaller {
|
|
504
|
+
readonly chatId: string;
|
|
505
|
+
readonly projectId: string;
|
|
506
|
+
/**
|
|
507
|
+
* The workspace's main chat. Every chat in one workspace (a folder of work
|
|
508
|
+
* with its own branch) shares it, so it is the key to use for anything that
|
|
509
|
+
* should apply to the whole workspace. A chat with no workspace is its own.
|
|
510
|
+
*/
|
|
511
|
+
readonly workspaceChatId: string;
|
|
512
|
+
/** `spryte` when a Spryte (Odyn's coordinating agent) is calling. */
|
|
513
|
+
readonly kind: 'chat' | 'spryte';
|
|
239
514
|
}
|
|
240
515
|
/** Draws a view, or (for an agent tool) returns text for the agent. */
|
|
241
516
|
export type CommandHandler = (context: CommandContext) => Promise<ExtensionView | string | void>;
|
|
@@ -249,6 +524,8 @@ export interface ExtensionRunRequest {
|
|
|
249
524
|
readonly actionId?: string;
|
|
250
525
|
readonly itemId?: string;
|
|
251
526
|
readonly input?: Readonly<Record<string, ExtensionInputValue>>;
|
|
527
|
+
/** Who called an agent tool; see `CommandContext.caller`. */
|
|
528
|
+
readonly caller?: ExtensionToolCaller;
|
|
252
529
|
}
|
|
253
530
|
export type ExtensionRunResult = {
|
|
254
531
|
readonly ok: true;
|
|
@@ -264,7 +541,7 @@ export interface ExtensionBridge {
|
|
|
264
541
|
finish(callId: string, result: ExtensionRunResult): void;
|
|
265
542
|
}
|
|
266
543
|
/** Every method name the bridge accepts, so Odyn can refuse anything else. */
|
|
267
|
-
export declare const EXTENSION_API_METHODS: readonly ["chats.list", "chats.messages", "chats.current", "projects.list", "agents.start", "fetch", "showToast", "preferences"];
|
|
544
|
+
export declare const EXTENSION_API_METHODS: readonly ["chats.list", "chats.messages", "chats.current", "projects.list", "agents.start", "fetch", "showToast", "preferences", "ui.openDialog", "ui.setBadge", "ui.setChatChip", "dashboard.setCard", "storage.get", "storage.set", "storage.keys", "notes.run", "notes.get"];
|
|
268
545
|
export type ExtensionApiMethod = (typeof EXTENSION_API_METHODS)[number];
|
|
269
546
|
/**
|
|
270
547
|
* The one call an extension's entry file makes. Inside Odyn it hooks the
|
|
@@ -272,6 +549,23 @@ export type ExtensionApiMethod = (typeof EXTENSION_API_METHODS)[number];
|
|
|
272
549
|
* hands the definition back.
|
|
273
550
|
*/
|
|
274
551
|
export declare function defineExtension(definition: ExtensionDefinition): ExtensionDefinition;
|
|
552
|
+
/** The most pictures a manifest's `screenshots` may list. */
|
|
553
|
+
export declare const MAX_EXTENSION_SCREENSHOTS = 5;
|
|
554
|
+
/** The biggest a single screenshot file may be: 1 MB. */
|
|
555
|
+
export declare const MAX_EXTENSION_SCREENSHOT_BYTES: number;
|
|
556
|
+
/** The picture types a screenshot may be (compared ignoring capitals). */
|
|
557
|
+
export declare const EXTENSION_SCREENSHOT_EXTENSIONS: readonly string[];
|
|
558
|
+
/** Why a dashboard card can't be drawn. An empty list means it is fine. */
|
|
559
|
+
export declare function dashboardCardProblems(value: unknown): readonly string[];
|
|
560
|
+
/**
|
|
561
|
+
* Whether an agent tool's `card` answer can be shown in the chat as a card
|
|
562
|
+
* the person answers (kit version 6). It is a dashboard card with one or two
|
|
563
|
+
* buttons; a click runs the same tool again with the button's `actionId`.
|
|
564
|
+
* Use it to ask before doing something, e.g. "This looks like part of X —
|
|
565
|
+
* [Not now] [Link chat]". A card that fails these checks reaches the agent
|
|
566
|
+
* as plain text, as before.
|
|
567
|
+
*/
|
|
568
|
+
export declare function chatCardProblems(value: unknown): readonly string[];
|
|
275
569
|
/** Returns every problem with a manifest; an empty list means it is fine. */
|
|
276
570
|
export interface ManifestCheckOptions {
|
|
277
571
|
/**
|
|
@@ -286,15 +580,28 @@ export declare function validateManifest(value: unknown, options?: ManifestCheck
|
|
|
286
580
|
/** Every message between a page and Odyn carries this tag. */
|
|
287
581
|
export declare const SURFACE_MESSAGE_TAG = "odyn-surface";
|
|
288
582
|
/** Every call a page can make, so Odyn can refuse anything else. */
|
|
289
|
-
export declare const EXTENSION_SURFACE_METHODS: readonly ["chatStorage.get", "chatStorage.set", "composer.attachImage", "helper.call"];
|
|
583
|
+
export declare const EXTENSION_SURFACE_METHODS: readonly ["chatStorage.get", "chatStorage.set", "composer.attachImage", "helper.call", "storage.get", "storage.set", "storage.keys", "notes.run", "notes.get", "chats.list", "projects.list", "ui.setChatChip"];
|
|
290
584
|
export type ExtensionSurfaceMethod = (typeof EXTENSION_SURFACE_METHODS)[number];
|
|
291
585
|
/** The most a page may keep for one chat, and the largest picture it may attach. */
|
|
292
586
|
export declare const SURFACE_STORAGE_MAX_BYTES = 5000000;
|
|
587
|
+
/** The most an extension may keep in its shared `storage` altogether. */
|
|
588
|
+
export declare const EXTENSION_STORAGE_MAX_BYTES = 50000000;
|
|
293
589
|
export declare const SURFACE_IMAGE_MAX_BYTES = 10000000;
|
|
590
|
+
/** The longest `item` a dialog can be opened at. */
|
|
591
|
+
export declare const DIALOG_ITEM_MAX_LENGTH = 200;
|
|
592
|
+
/** Where to open the extension's page (kit version 6). Both are optional. */
|
|
593
|
+
export interface ExtensionDialogOptions {
|
|
594
|
+
/** The chat the page is for, e.g. the `caller.chatId` of a chip click. */
|
|
595
|
+
readonly chatId?: string;
|
|
596
|
+
/** Something of the extension's own to show first, e.g. an initiative id. Plain text. */
|
|
597
|
+
readonly item?: string;
|
|
598
|
+
}
|
|
294
599
|
/** What the page is showing for. Sent on open and whenever it changes. */
|
|
295
600
|
export interface SurfaceContext {
|
|
296
601
|
readonly chatId: string | null;
|
|
297
602
|
readonly theme: 'light' | 'dark';
|
|
603
|
+
/** The `item` the page was opened at with `odyn.ui.openDialog`, or `null`. */
|
|
604
|
+
readonly item?: string | null;
|
|
298
605
|
}
|
|
299
606
|
/** A picture handed over by Odyn (a screenshot) or attached by the page. */
|
|
300
607
|
export interface SurfaceImage {
|
|
@@ -349,6 +656,23 @@ export interface OdynSurfaceApi {
|
|
|
349
656
|
/** `null` removes the value. */
|
|
350
657
|
set(chatId: string, key: string, value: string | null): Promise<void>;
|
|
351
658
|
};
|
|
659
|
+
/** The extension's shared storage, the same store its own code sees as `odyn.storage`. Needs `apiVersion` 6. */
|
|
660
|
+
readonly storage: ExtensionStorage;
|
|
661
|
+
/** Odyn's note and initiative tools; see `OdynApi.notes`. Needs `notes:read` / `notes:write`. */
|
|
662
|
+
readonly notes: ExtensionNotesApi;
|
|
663
|
+
/** Chats, the same as `odyn.chats.list` (kit version 6). Needs `chats:read`. */
|
|
664
|
+
readonly chats: {
|
|
665
|
+
/** One project's chats when given its id, otherwise every chat. */
|
|
666
|
+
list(projectId?: string): Promise<readonly ChatSummary[]>;
|
|
667
|
+
};
|
|
668
|
+
/** Projects, the same as `odyn.projects.list` (kit version 6). Needs `projects:read`. */
|
|
669
|
+
readonly projects: {
|
|
670
|
+
list(): Promise<readonly ProjectSummary[]>;
|
|
671
|
+
};
|
|
672
|
+
readonly ui: {
|
|
673
|
+
/** Puts the extension's chip next to a chat's title, or removes it with `null`; the same as `odyn.ui.setChatChip` (kit version 6). Needs `ui:chatHeader`. */
|
|
674
|
+
setChatChip(chatId: string, chip: ExtensionChatChip | null): Promise<void>;
|
|
675
|
+
};
|
|
352
676
|
readonly composer: {
|
|
353
677
|
/** Puts a picture onto the user's message. Needs `composer:attach`. */
|
|
354
678
|
attachImage(image: SurfaceImage): Promise<void>;
|
|
@@ -463,6 +787,14 @@ export interface OdynHelperApi {
|
|
|
463
787
|
/** Sends a message to every open page of this extension. */
|
|
464
788
|
post(message: unknown): Promise<void>;
|
|
465
789
|
};
|
|
790
|
+
readonly ui: {
|
|
791
|
+
/** The number on the sidebar button. `null` or 0 clears it. Needs `ui:sidebar`. */
|
|
792
|
+
setBadge(count: number | null): Promise<void>;
|
|
793
|
+
};
|
|
794
|
+
readonly dashboard: {
|
|
795
|
+
/** Replaces the dashboard card. `null` hides it. Needs `ui:dashboard`. */
|
|
796
|
+
setCard(card: ExtensionDashboardCard | null): Promise<void>;
|
|
797
|
+
};
|
|
466
798
|
}
|
|
467
799
|
/** Things that happen, delivered to the helper. */
|
|
468
800
|
export interface HelperEvents {
|
|
@@ -483,7 +815,7 @@ export interface HelperDefinition extends HelperEvents {
|
|
|
483
815
|
start(odyn: OdynHelperApi): Promise<void> | void;
|
|
484
816
|
}
|
|
485
817
|
/** Every call a helper can make, so Odyn can refuse anything else. */
|
|
486
|
-
export declare const EXTENSION_HELPER_METHODS: readonly ["programs.find", "programs.run", "programs.launch", "folders.find", "data.path", "data.download", "data.remove", "data.makeFolder", "openLink", "showToast", "sessions.set", "sessions.frame", "pages.post"];
|
|
818
|
+
export declare const EXTENSION_HELPER_METHODS: readonly ["programs.find", "programs.run", "programs.launch", "folders.find", "data.path", "data.download", "data.remove", "data.makeFolder", "openLink", "showToast", "sessions.set", "sessions.frame", "pages.post", "ui.setBadge", "dashboard.setCard"];
|
|
487
819
|
export type ExtensionHelperMethod = (typeof EXTENSION_HELPER_METHODS)[number];
|
|
488
820
|
/** The events Odyn sends a helper. */
|
|
489
821
|
export declare const EXTENSION_HELPER_EVENTS: readonly ["watch", "unwatch", "input", "close", "agentCommand", "chatClosed", "shutdown", "pageCall"];
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// Keep it dependency-free: it is shared by Odyn, the store checker and every
|
|
13
13
|
// extension.
|
|
14
14
|
/** The newest building-kit version. Bumped when something new is added. */
|
|
15
|
-
export const EXTENSION_API_VERSION =
|
|
15
|
+
export const EXTENSION_API_VERSION = 6;
|
|
16
16
|
/** The oldest building-kit version Odyn still runs. */
|
|
17
17
|
export const EXTENSION_API_MIN_VERSION = 1;
|
|
18
18
|
// --- Manifest -------------------------------------------------------------
|
|
@@ -30,9 +30,23 @@ export const EXTENSION_PERMISSIONS = [
|
|
|
30
30
|
'programs:run',
|
|
31
31
|
/** Give the agent a guide, and ask before it uses the listed commands. */
|
|
32
32
|
'agent:tools',
|
|
33
|
+
/** Add a button to Odyn's sidebar (`sidebarItem`) with a number badge. Needs `apiVersion` 5. */
|
|
34
|
+
'ui:sidebar',
|
|
35
|
+
/** Open a full-window page (a `dialog` surface) after a click. Needs `apiVersion` 5. */
|
|
36
|
+
'ui:dialog',
|
|
37
|
+
/** Show a card on Odyn's dashboard (`dashboardCard`). Needs `apiVersion` 5. */
|
|
38
|
+
'ui:dashboard',
|
|
39
|
+
/** Read the project's notes and initiatives. Official extensions only; needs `apiVersion` 6. */
|
|
40
|
+
'notes:read',
|
|
41
|
+
/** Create, change, move and delete notes and initiatives, team-shared ones included. Official extensions only; needs `apiVersion` 6. */
|
|
42
|
+
'notes:write',
|
|
43
|
+
/** Add a short note to a chat's first message (its `chatContext` command). Needs `apiVersion` 6. */
|
|
44
|
+
'agent:context',
|
|
45
|
+
/** Put a chip next to a chat's title (`chatChip`). Needs `apiVersion` 6. */
|
|
46
|
+
'ui:chatHeader',
|
|
33
47
|
];
|
|
34
48
|
/** Permissions only extensions made by Odyn may ask for. */
|
|
35
|
-
export const OFFICIAL_ONLY_PERMISSIONS = ['programs:run'];
|
|
49
|
+
export const OFFICIAL_ONLY_PERMISSIONS = ['programs:run', 'notes:read', 'notes:write'];
|
|
36
50
|
/**
|
|
37
51
|
* The extensions made by Odyn. Only these may run Mac programs or a background
|
|
38
52
|
* helper, and only these show as "Official". Everything else in Odyn's
|
|
@@ -43,6 +57,7 @@ export const ODYN_EXTENSION_IDS = [
|
|
|
43
57
|
'computer-use',
|
|
44
58
|
'design-skills',
|
|
45
59
|
'fast-browser',
|
|
60
|
+
'initiatives',
|
|
46
61
|
'message-outline',
|
|
47
62
|
'phones',
|
|
48
63
|
'scratchpad',
|
|
@@ -53,16 +68,67 @@ export function isOdynExtensionId(id) {
|
|
|
53
68
|
}
|
|
54
69
|
/** Systems an extension can say it runs on. Leave out to run everywhere. */
|
|
55
70
|
export const EXTENSION_PLATFORMS = ['darwin', 'linux', 'win32'];
|
|
56
|
-
/**
|
|
57
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Where an extension can show up inside Odyn. `chatContext` (kit version 6,
|
|
73
|
+
* `agent:context`) is never shown: Odyn runs it when a chat starts, and after
|
|
74
|
+
* a long chat is summed up, and puts its short text answer in front of the
|
|
75
|
+
* user's message.
|
|
76
|
+
*/
|
|
77
|
+
export const EXTENSION_PLACEMENTS = ['sidebarPanel', 'chatCard', 'agentTool', 'settings', 'chatContext'];
|
|
78
|
+
/** The most a `chatContext` command's answer may add to a message; anything longer is cut. */
|
|
79
|
+
export const EXTENSION_CONTEXT_MAX_CHARS = 2_000;
|
|
80
|
+
/** How long Odyn waits for a `chatContext` command before sending the message without it. */
|
|
81
|
+
export const EXTENSION_CONTEXT_TIMEOUT_MS = 2_000;
|
|
58
82
|
/** The kinds of value an agent tool's input can take. */
|
|
59
83
|
export const EXTENSION_INPUT_TYPES = ['string', 'number', 'boolean'];
|
|
60
84
|
/** Where an extension's own page can be shown. */
|
|
61
|
-
export const EXTENSION_SURFACE_PLACEMENTS = ['composer', 'settings'];
|
|
85
|
+
export const EXTENSION_SURFACE_PLACEMENTS = ['composer', 'settings', 'dialog'];
|
|
62
86
|
/** The icons Odyn can draw on the button that opens a page. */
|
|
63
87
|
export const EXTENSION_SURFACE_ICONS = ['puzzle', 'pencil', 'image', 'note'];
|
|
88
|
+
/**
|
|
89
|
+
* The icons an extension can pick for itself in the Marketplace list and on
|
|
90
|
+
* its page. Any name Odyn doesn't know (say, one added in a newer Odyn) shows
|
|
91
|
+
* the puzzle piece instead, so it never stops an extension installing.
|
|
92
|
+
*/
|
|
93
|
+
export const EXTENSION_ICONS = [...EXTENSION_SURFACE_ICONS, 'inbox', 'cursor', 'palette', 'globe', 'phone', 'list'];
|
|
64
94
|
export const SURFACE_MIN_HEIGHT = 120;
|
|
65
95
|
export const SURFACE_MAX_HEIGHT = 600;
|
|
96
|
+
/** The colours a chat chip's dot can be. Odyn picks the exact shade for the theme. */
|
|
97
|
+
export const EXTENSION_CHIP_COLORS = ['gray', 'blue', 'green', 'amber', 'red', 'purple'];
|
|
98
|
+
/** The longest label a chat chip can carry. */
|
|
99
|
+
export const CHAT_CHIP_MAX_LABEL = 40;
|
|
100
|
+
/** Why a chat chip can't be drawn. An empty list means it is fine. */
|
|
101
|
+
export function chatChipProblems(value) {
|
|
102
|
+
if (typeof value !== 'object' || value === null)
|
|
103
|
+
return ['The chip is not an object.'];
|
|
104
|
+
const chip = value;
|
|
105
|
+
const problems = [];
|
|
106
|
+
if (typeof chip.label !== 'string' || chip.label.trim() === '' || chip.label.length > CHAT_CHIP_MAX_LABEL) {
|
|
107
|
+
problems.push(`The chip needs a label of at most ${CHAT_CHIP_MAX_LABEL} characters.`);
|
|
108
|
+
}
|
|
109
|
+
if (chip.color !== undefined && !EXTENSION_CHIP_COLORS.includes(chip.color))
|
|
110
|
+
problems.push('The chip has an unknown colour.');
|
|
111
|
+
return problems;
|
|
112
|
+
}
|
|
113
|
+
export const DASHBOARD_CARD_MAX_ITEMS = 5;
|
|
114
|
+
export const DASHBOARD_CARD_MAX_ACTIONS = 2;
|
|
115
|
+
export const DASHBOARD_CARD_MAX_TITLE = 80;
|
|
116
|
+
export const DASHBOARD_CARD_MAX_SUBTITLE = 140;
|
|
117
|
+
/** The note and initiative tools that only look. */
|
|
118
|
+
export const EXTENSION_NOTE_READ_TOOLS = ['ListNotes', 'ReadNote', 'ListInitiatives', 'ReadInitiative'];
|
|
119
|
+
/** The note and initiative tools that change something. */
|
|
120
|
+
export const EXTENSION_NOTE_WRITE_TOOLS = [
|
|
121
|
+
'CreateNote',
|
|
122
|
+
'EditNote',
|
|
123
|
+
'PatchNote',
|
|
124
|
+
'RenameNote',
|
|
125
|
+
'DeleteNote',
|
|
126
|
+
'CreateInitiative',
|
|
127
|
+
'MoveNotesToInitiative',
|
|
128
|
+
'MarkInitiativeComplete',
|
|
129
|
+
'EditInitiativeArtifacts',
|
|
130
|
+
'EditInitiativeGroups',
|
|
131
|
+
];
|
|
66
132
|
/** Every method name the bridge accepts, so Odyn can refuse anything else. */
|
|
67
133
|
export const EXTENSION_API_METHODS = [
|
|
68
134
|
'chats.list',
|
|
@@ -73,6 +139,15 @@ export const EXTENSION_API_METHODS = [
|
|
|
73
139
|
'fetch',
|
|
74
140
|
'showToast',
|
|
75
141
|
'preferences',
|
|
142
|
+
'ui.openDialog',
|
|
143
|
+
'ui.setBadge',
|
|
144
|
+
'ui.setChatChip',
|
|
145
|
+
'dashboard.setCard',
|
|
146
|
+
'storage.get',
|
|
147
|
+
'storage.set',
|
|
148
|
+
'storage.keys',
|
|
149
|
+
'notes.run',
|
|
150
|
+
'notes.get',
|
|
76
151
|
];
|
|
77
152
|
function buildOdynApi(bridge) {
|
|
78
153
|
const call = (method, ...args) => bridge.call(method, args);
|
|
@@ -87,6 +162,23 @@ function buildOdynApi(bridge) {
|
|
|
87
162
|
fetch: (url, init) => call('fetch', url, init ?? null),
|
|
88
163
|
showToast: (message) => call('showToast', message),
|
|
89
164
|
preferences: () => call('preferences'),
|
|
165
|
+
ui: {
|
|
166
|
+
openDialog: (options) => call('ui.openDialog', options ?? null),
|
|
167
|
+
setBadge: (count) => call('ui.setBadge', count),
|
|
168
|
+
setChatChip: (chatId, chip) => call('ui.setChatChip', chatId, chip),
|
|
169
|
+
},
|
|
170
|
+
dashboard: { setCard: (card) => call('dashboard.setCard', card) },
|
|
171
|
+
storage: {
|
|
172
|
+
get: (key) => call('storage.get', key),
|
|
173
|
+
set: (key, value) => call('storage.set', key, value),
|
|
174
|
+
keys: (prefix) => call('storage.keys', prefix ?? ''),
|
|
175
|
+
},
|
|
176
|
+
notes: {
|
|
177
|
+
run: (tool, target, input) => call('notes.run', tool, target, input),
|
|
178
|
+
list: (projectId) => call('notes.get', 'list', projectId),
|
|
179
|
+
initiative: (projectId, initiativeId) => call('notes.get', 'initiative', projectId, initiativeId),
|
|
180
|
+
read: (projectId, noteId) => call('notes.get', 'note', projectId, noteId),
|
|
181
|
+
},
|
|
90
182
|
};
|
|
91
183
|
}
|
|
92
184
|
function errorText(error) {
|
|
@@ -119,6 +211,47 @@ const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
|
119
211
|
/** A GitHub username: letters, digits and single dashes, up to 39 characters. */
|
|
120
212
|
const GITHUB_USERNAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
|
|
121
213
|
const ENTRY_PATTERN = /^(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.html$/;
|
|
214
|
+
/** The most pictures a manifest's `screenshots` may list. */
|
|
215
|
+
export const MAX_EXTENSION_SCREENSHOTS = 5;
|
|
216
|
+
/** The biggest a single screenshot file may be: 1 MB. */
|
|
217
|
+
export const MAX_EXTENSION_SCREENSHOT_BYTES = 1024 * 1024;
|
|
218
|
+
/** The picture types a screenshot may be (compared ignoring capitals). */
|
|
219
|
+
export const EXTENSION_SCREENSHOT_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'];
|
|
220
|
+
/** What is wrong with one screenshot path, or null when it is fine. */
|
|
221
|
+
function screenshotPathProblem(path) {
|
|
222
|
+
if (typeof path !== 'string' || path.trim() === '')
|
|
223
|
+
return 'Every screenshot must be a path to a file.';
|
|
224
|
+
const label = `Screenshot "${path}"`;
|
|
225
|
+
// A colon catches both web addresses ("https:") and Windows drives ("C:").
|
|
226
|
+
if (path.includes(':'))
|
|
227
|
+
return `${label} must be a path inside the extension's folder, not a web address or drive.`;
|
|
228
|
+
if (path.includes('\\'))
|
|
229
|
+
return `${label} must use forward slashes (/).`;
|
|
230
|
+
if (path.startsWith('/'))
|
|
231
|
+
return `${label} must be a path inside the extension's folder, not one starting with "/".`;
|
|
232
|
+
if (path.split('/').includes('..'))
|
|
233
|
+
return `${label} must not step outside the extension's folder with "..".`;
|
|
234
|
+
const lower = path.toLowerCase();
|
|
235
|
+
if (!EXTENSION_SCREENSHOT_EXTENSIONS.some((ending) => lower.endsWith(ending))) {
|
|
236
|
+
return `${label} must be a ${EXTENSION_SCREENSHOT_EXTENSIONS.join(', ')} file.`;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
function screenshotProblems(screenshots) {
|
|
241
|
+
if (screenshots === undefined)
|
|
242
|
+
return [];
|
|
243
|
+
if (!Array.isArray(screenshots))
|
|
244
|
+
return ['`screenshots` must be a list of file paths.'];
|
|
245
|
+
const problems = [];
|
|
246
|
+
if (screenshots.length > MAX_EXTENSION_SCREENSHOTS)
|
|
247
|
+
problems.push(`\`screenshots\` can list at most ${MAX_EXTENSION_SCREENSHOTS} pictures.`);
|
|
248
|
+
for (const path of screenshots) {
|
|
249
|
+
const problem = screenshotPathProblem(path);
|
|
250
|
+
if (problem)
|
|
251
|
+
problems.push(problem);
|
|
252
|
+
}
|
|
253
|
+
return problems;
|
|
254
|
+
}
|
|
122
255
|
function surfaceProblems(surfaces, apiVersion) {
|
|
123
256
|
if (surfaces.length === 0)
|
|
124
257
|
return [];
|
|
@@ -148,11 +281,151 @@ function surfaceProblems(surfaces, apiVersion) {
|
|
|
148
281
|
if (surfaces.filter((surface) => surface.placement === 'composer').length > 1)
|
|
149
282
|
problems.push('Only one page can sit in the message box.');
|
|
150
283
|
if (surfaces.filter((surface) => surface.placement === 'settings').length > 1)
|
|
151
|
-
problems.push('
|
|
284
|
+
problems.push('An extension can have only one settings page.');
|
|
285
|
+
if (surfaces.filter((surface) => surface.placement === 'dialog').length > 1)
|
|
286
|
+
problems.push('Only one page can fill the dialog.');
|
|
287
|
+
if (apiVersion < 5 && surfaces.some((surface) => surface.placement === 'dialog'))
|
|
288
|
+
problems.push('A dialog page needs `apiVersion` 5.');
|
|
152
289
|
if (apiVersion < 3 && surfaces.some((surface) => surface.placement === 'settings'))
|
|
153
290
|
problems.push('A settings page needs `apiVersion` 3.');
|
|
154
291
|
return problems;
|
|
155
292
|
}
|
|
293
|
+
/** Checks the sidebar button, the dashboard card and the `ui:` permissions (kit version 5). */
|
|
294
|
+
function uiProblems(manifest, apiVersion) {
|
|
295
|
+
const problems = [];
|
|
296
|
+
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions : [];
|
|
297
|
+
const commandNames = Array.isArray(manifest.commands) ? manifest.commands.map((command) => command?.name) : [];
|
|
298
|
+
const asksUi = permissions.some((permission) => permission === 'ui:sidebar' || permission === 'ui:dialog' || permission === 'ui:dashboard');
|
|
299
|
+
if (apiVersion < 5 && asksUi)
|
|
300
|
+
problems.push('The `ui:` permissions need `apiVersion` 5.');
|
|
301
|
+
const asksNotes = permissions.some((permission) => permission === 'notes:read' || permission === 'notes:write');
|
|
302
|
+
if (apiVersion < 6 && asksNotes)
|
|
303
|
+
problems.push('The `notes:` permissions need `apiVersion` 6.');
|
|
304
|
+
const item = manifest.sidebarItem;
|
|
305
|
+
if (item !== undefined) {
|
|
306
|
+
if (apiVersion < 5)
|
|
307
|
+
problems.push('`sidebarItem` needs `apiVersion` 5.');
|
|
308
|
+
if (!permissions.includes('ui:sidebar'))
|
|
309
|
+
problems.push('`sidebarItem` needs the `ui:sidebar` permission.');
|
|
310
|
+
if (typeof item !== 'object' || item === null)
|
|
311
|
+
problems.push('`sidebarItem` must be an object.');
|
|
312
|
+
else {
|
|
313
|
+
if (typeof item.title !== 'string' || item.title.trim() === '' || item.title.length > DASHBOARD_CARD_MAX_TITLE)
|
|
314
|
+
problems.push('`sidebarItem` needs a short title.');
|
|
315
|
+
if (!EXTENSION_SURFACE_ICONS.includes(item.icon))
|
|
316
|
+
problems.push('`sidebarItem` has an unknown icon.');
|
|
317
|
+
if (!commandNames.includes(item.command))
|
|
318
|
+
problems.push('`sidebarItem.command` must name one of the `commands`.');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const menuItem = manifest.composerMenuItem;
|
|
322
|
+
if (menuItem !== undefined) {
|
|
323
|
+
if (apiVersion < 6)
|
|
324
|
+
problems.push('`composerMenuItem` needs `apiVersion` 6.');
|
|
325
|
+
if (!permissions.includes('ui:dialog'))
|
|
326
|
+
problems.push('`composerMenuItem` needs the `ui:dialog` permission.');
|
|
327
|
+
if (typeof menuItem !== 'object' || menuItem === null)
|
|
328
|
+
problems.push('`composerMenuItem` must be an object.');
|
|
329
|
+
else {
|
|
330
|
+
if (typeof menuItem.title !== 'string' || menuItem.title.trim() === '' || menuItem.title.length > DASHBOARD_CARD_MAX_TITLE)
|
|
331
|
+
problems.push('`composerMenuItem` needs a short title.');
|
|
332
|
+
if (!EXTENSION_SURFACE_ICONS.includes(menuItem.icon))
|
|
333
|
+
problems.push('`composerMenuItem` has an unknown icon.');
|
|
334
|
+
if (!commandNames.includes(menuItem.command))
|
|
335
|
+
problems.push('`composerMenuItem.command` must name one of the `commands`.');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const card = manifest.dashboardCard;
|
|
339
|
+
if (card !== undefined) {
|
|
340
|
+
if (apiVersion < 5)
|
|
341
|
+
problems.push('`dashboardCard` needs `apiVersion` 5.');
|
|
342
|
+
if (!permissions.includes('ui:dashboard'))
|
|
343
|
+
problems.push('`dashboardCard` needs the `ui:dashboard` permission.');
|
|
344
|
+
if (typeof card !== 'object' || card === null || !commandNames.includes(card.command))
|
|
345
|
+
problems.push('`dashboardCard.command` must name one of the `commands`.');
|
|
346
|
+
}
|
|
347
|
+
const chip = manifest.chatChip;
|
|
348
|
+
if (chip !== undefined) {
|
|
349
|
+
if (apiVersion < 6)
|
|
350
|
+
problems.push('`chatChip` needs `apiVersion` 6.');
|
|
351
|
+
if (!permissions.includes('ui:chatHeader'))
|
|
352
|
+
problems.push('`chatChip` needs the `ui:chatHeader` permission.');
|
|
353
|
+
if (typeof chip !== 'object' || chip === null || !commandNames.includes(chip.command))
|
|
354
|
+
problems.push('`chatChip.command` must name one of the `commands`.');
|
|
355
|
+
}
|
|
356
|
+
else if (permissions.includes('ui:chatHeader')) {
|
|
357
|
+
problems.push('`ui:chatHeader` needs a `chatChip`.');
|
|
358
|
+
}
|
|
359
|
+
const surfaces = Array.isArray(manifest.surfaces) ? manifest.surfaces : [];
|
|
360
|
+
if (surfaces.some((surface) => surface?.placement === 'dialog') && !permissions.includes('ui:dialog'))
|
|
361
|
+
problems.push('A dialog page needs the `ui:dialog` permission.');
|
|
362
|
+
return problems;
|
|
363
|
+
}
|
|
364
|
+
function isShortText(value, max) {
|
|
365
|
+
return typeof value === 'string' && value.trim() !== '' && value.length <= max;
|
|
366
|
+
}
|
|
367
|
+
/** Why a dashboard card can't be drawn. An empty list means it is fine. */
|
|
368
|
+
export function dashboardCardProblems(value) {
|
|
369
|
+
if (typeof value !== 'object' || value === null)
|
|
370
|
+
return ['The card is not an object.'];
|
|
371
|
+
const card = value;
|
|
372
|
+
const problems = [];
|
|
373
|
+
if (!isShortText(card.title, DASHBOARD_CARD_MAX_TITLE))
|
|
374
|
+
problems.push(`The card needs a title of up to ${DASHBOARD_CARD_MAX_TITLE} characters.`);
|
|
375
|
+
if (!Array.isArray(card.items) || card.items.length > DASHBOARD_CARD_MAX_ITEMS)
|
|
376
|
+
problems.push(`\`items\` must be a list of up to ${DASHBOARD_CARD_MAX_ITEMS}.`);
|
|
377
|
+
else {
|
|
378
|
+
const ids = new Set();
|
|
379
|
+
for (const item of card.items) {
|
|
380
|
+
if (typeof item !== 'object' || item === null || !isShortText(item.id, DASHBOARD_CARD_MAX_TITLE) || ids.has(item.id ?? ''))
|
|
381
|
+
problems.push('Every item needs its own `id`.');
|
|
382
|
+
else
|
|
383
|
+
ids.add(item.id ?? '');
|
|
384
|
+
if (!isShortText(item?.title, DASHBOARD_CARD_MAX_TITLE))
|
|
385
|
+
problems.push(`Every item needs a title of up to ${DASHBOARD_CARD_MAX_TITLE} characters.`);
|
|
386
|
+
if (item?.subtitle !== undefined && !isShortText(item.subtitle, DASHBOARD_CARD_MAX_SUBTITLE))
|
|
387
|
+
problems.push(`An item's subtitle can be up to ${DASHBOARD_CARD_MAX_SUBTITLE} characters.`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (card.actions !== undefined) {
|
|
391
|
+
if (!Array.isArray(card.actions) || card.actions.length > DASHBOARD_CARD_MAX_ACTIONS)
|
|
392
|
+
problems.push(`\`actions\` must be a list of up to ${DASHBOARD_CARD_MAX_ACTIONS}.`);
|
|
393
|
+
else {
|
|
394
|
+
const ids = new Set();
|
|
395
|
+
for (const action of card.actions) {
|
|
396
|
+
if (typeof action !== 'object' || action === null || !isShortText(action.id, DASHBOARD_CARD_MAX_TITLE) || ids.has(action.id ?? ''))
|
|
397
|
+
problems.push('Every action needs its own `id`.');
|
|
398
|
+
else
|
|
399
|
+
ids.add(action.id ?? '');
|
|
400
|
+
if (!isShortText(action?.title, DASHBOARD_CARD_MAX_TITLE))
|
|
401
|
+
problems.push(`Every action needs a title of up to ${DASHBOARD_CARD_MAX_TITLE} characters.`);
|
|
402
|
+
if (action?.opensDialog !== undefined && typeof action.opensDialog !== 'boolean')
|
|
403
|
+
problems.push('`opensDialog` must be true or false.');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return problems;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Whether an agent tool's `card` answer can be shown in the chat as a card
|
|
411
|
+
* the person answers (kit version 6). It is a dashboard card with one or two
|
|
412
|
+
* buttons; a click runs the same tool again with the button's `actionId`.
|
|
413
|
+
* Use it to ask before doing something, e.g. "This looks like part of X —
|
|
414
|
+
* [Not now] [Link chat]". A card that fails these checks reaches the agent
|
|
415
|
+
* as plain text, as before.
|
|
416
|
+
*/
|
|
417
|
+
export function chatCardProblems(value) {
|
|
418
|
+
const problems = [...dashboardCardProblems(value)];
|
|
419
|
+
if (problems.length > 0)
|
|
420
|
+
return problems;
|
|
421
|
+
const actions = value.actions ?? [];
|
|
422
|
+
if (actions.length === 0)
|
|
423
|
+
problems.push('A chat card needs at least one button in `actions`.');
|
|
424
|
+
if (actions.some((action) => action.opensDialog !== undefined)) {
|
|
425
|
+
problems.push('Chat card buttons cannot use `opensDialog` — call `odyn.ui.openDialog()` from the command instead.');
|
|
426
|
+
}
|
|
427
|
+
return problems;
|
|
428
|
+
}
|
|
156
429
|
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
157
430
|
const SCRIPT_PATTERN = /^(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.js$/;
|
|
158
431
|
const FOLDER_PATTERN = /^(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/;
|
|
@@ -203,6 +476,9 @@ function helperProblems(manifest, apiVersion) {
|
|
|
203
476
|
if (helper.downloadHosts !== undefined && (!Array.isArray(helper.downloadHosts) || helper.downloadHosts.some((host) => typeof host !== 'string' || !/^[a-z0-9.-]+$/.test(host)))) {
|
|
204
477
|
problems.push('`helper.downloadHosts` must be a list of host names.');
|
|
205
478
|
}
|
|
479
|
+
if (helper.browserSafeCalls !== undefined && (!Array.isArray(helper.browserSafeCalls) || helper.browserSafeCalls.some((name) => typeof name !== 'string' || !NAME_PATTERN.test(name)))) {
|
|
480
|
+
problems.push('`helper.browserSafeCalls` must be a list of call names.');
|
|
481
|
+
}
|
|
206
482
|
return problems;
|
|
207
483
|
}
|
|
208
484
|
function agentProblems(manifest, apiVersion) {
|
|
@@ -307,12 +583,13 @@ export function validateManifest(value, options = {}) {
|
|
|
307
583
|
problems.push(`\`platforms\` must list some of: ${EXTENSION_PLATFORMS.join(', ')}.`);
|
|
308
584
|
}
|
|
309
585
|
}
|
|
310
|
-
problems.push(...helperProblems(manifest, apiVersion), ...agentProblems(manifest, apiVersion));
|
|
586
|
+
problems.push(...helperProblems(manifest, apiVersion), ...agentProblems(manifest, apiVersion), ...uiProblems(manifest, apiVersion));
|
|
311
587
|
if (manifest.owners !== undefined) {
|
|
312
588
|
if (!Array.isArray(manifest.owners) || manifest.owners.some((owner) => typeof owner !== 'string' || !GITHUB_USERNAME_PATTERN.test(owner))) {
|
|
313
589
|
problems.push('`owners` must be a list of GitHub usernames.');
|
|
314
590
|
}
|
|
315
591
|
}
|
|
592
|
+
problems.push(...screenshotProblems(manifest.screenshots));
|
|
316
593
|
const hasSurfaces = Array.isArray(surfaces) && surfaces.length > 0;
|
|
317
594
|
const hasHelper = manifest.helper !== undefined;
|
|
318
595
|
// From version 4 a guide for the agent is enough on its own.
|
|
@@ -335,9 +612,24 @@ export function validateManifest(value, options = {}) {
|
|
|
335
612
|
problems.push(`Agent tool "${command.name}" needs a description.`);
|
|
336
613
|
problems.push(...inputProblems(command, apiVersion));
|
|
337
614
|
}
|
|
615
|
+
problems.push(...contextProblems(manifest, apiVersion));
|
|
338
616
|
}
|
|
339
617
|
return problems;
|
|
340
618
|
}
|
|
619
|
+
function contextProblems(manifest, apiVersion) {
|
|
620
|
+
const contextCommands = (manifest.commands ?? []).filter((command) => command?.placement === 'chatContext');
|
|
621
|
+
const asksContext = Array.isArray(manifest.permissions) && manifest.permissions.includes('agent:context');
|
|
622
|
+
if (contextCommands.length === 0)
|
|
623
|
+
return asksContext ? ['`agent:context` needs a `chatContext` command.'] : [];
|
|
624
|
+
const problems = [];
|
|
625
|
+
if (apiVersion < 6)
|
|
626
|
+
problems.push('A `chatContext` command needs `apiVersion` 6.');
|
|
627
|
+
if (!asksContext)
|
|
628
|
+
problems.push('A `chatContext` command needs the `agent:context` permission.');
|
|
629
|
+
if (contextCommands.length > 1)
|
|
630
|
+
problems.push('An extension can have only one `chatContext` command.');
|
|
631
|
+
return problems;
|
|
632
|
+
}
|
|
341
633
|
// --- An extension's own page (kit version 2) -----------------------------
|
|
342
634
|
//
|
|
343
635
|
// The page runs in a sealed box inside Odyn's window. It talks to Odyn only by
|
|
@@ -346,10 +638,27 @@ export function validateManifest(value, options = {}) {
|
|
|
346
638
|
/** Every message between a page and Odyn carries this tag. */
|
|
347
639
|
export const SURFACE_MESSAGE_TAG = 'odyn-surface';
|
|
348
640
|
/** Every call a page can make, so Odyn can refuse anything else. */
|
|
349
|
-
export const EXTENSION_SURFACE_METHODS = [
|
|
641
|
+
export const EXTENSION_SURFACE_METHODS = [
|
|
642
|
+
'chatStorage.get',
|
|
643
|
+
'chatStorage.set',
|
|
644
|
+
'composer.attachImage',
|
|
645
|
+
'helper.call',
|
|
646
|
+
'storage.get',
|
|
647
|
+
'storage.set',
|
|
648
|
+
'storage.keys',
|
|
649
|
+
'notes.run',
|
|
650
|
+
'notes.get',
|
|
651
|
+
'chats.list',
|
|
652
|
+
'projects.list',
|
|
653
|
+
'ui.setChatChip',
|
|
654
|
+
];
|
|
350
655
|
/** The most a page may keep for one chat, and the largest picture it may attach. */
|
|
351
656
|
export const SURFACE_STORAGE_MAX_BYTES = 5_000_000;
|
|
657
|
+
/** The most an extension may keep in its shared `storage` altogether. */
|
|
658
|
+
export const EXTENSION_STORAGE_MAX_BYTES = 50_000_000;
|
|
352
659
|
export const SURFACE_IMAGE_MAX_BYTES = 10_000_000;
|
|
660
|
+
/** The longest `item` a dialog can be opened at. */
|
|
661
|
+
export const DIALOG_ITEM_MAX_LENGTH = 200;
|
|
353
662
|
export function isSurfaceMessage(value) {
|
|
354
663
|
return typeof value === 'object' && value !== null && value.tag === SURFACE_MESSAGE_TAG;
|
|
355
664
|
}
|
|
@@ -412,6 +721,20 @@ export function connectSurface() {
|
|
|
412
721
|
get: (chatId, key) => call('chatStorage.get', chatId, key),
|
|
413
722
|
set: (chatId, key, value) => call('chatStorage.set', chatId, key, value),
|
|
414
723
|
},
|
|
724
|
+
storage: {
|
|
725
|
+
get: (key) => call('storage.get', key),
|
|
726
|
+
set: (key, value) => call('storage.set', key, value),
|
|
727
|
+
keys: (prefix) => call('storage.keys', prefix ?? ''),
|
|
728
|
+
},
|
|
729
|
+
notes: {
|
|
730
|
+
run: (tool, target, input) => call('notes.run', tool, target, input),
|
|
731
|
+
list: (projectId) => call('notes.get', 'list', projectId),
|
|
732
|
+
initiative: (projectId, initiativeId) => call('notes.get', 'initiative', projectId, initiativeId),
|
|
733
|
+
read: (projectId, noteId) => call('notes.get', 'note', projectId, noteId),
|
|
734
|
+
},
|
|
735
|
+
chats: { list: (projectId) => call('chats.list', projectId ?? null) },
|
|
736
|
+
projects: { list: () => call('projects.list') },
|
|
737
|
+
ui: { setChatChip: (chatId, chip) => call('ui.setChatChip', chatId, chip) },
|
|
415
738
|
composer: {
|
|
416
739
|
attachImage: (image) => call('composer.attachImage', image),
|
|
417
740
|
close: () => post({ tag: SURFACE_MESSAGE_TAG, kind: 'close' }),
|
|
@@ -448,6 +771,8 @@ export const EXTENSION_HELPER_METHODS = [
|
|
|
448
771
|
'sessions.set',
|
|
449
772
|
'sessions.frame',
|
|
450
773
|
'pages.post',
|
|
774
|
+
'ui.setBadge',
|
|
775
|
+
'dashboard.setCard',
|
|
451
776
|
];
|
|
452
777
|
/** The events Odyn sends a helper. */
|
|
453
778
|
export const EXTENSION_HELPER_EVENTS = ['watch', 'unwatch', 'input', 'close', 'agentCommand', 'chatClosed', 'shutdown', 'pageCall'];
|
|
@@ -483,6 +808,10 @@ export function defineHelper(definition) {
|
|
|
483
808
|
},
|
|
484
809
|
post: (message) => call('pages.post', message),
|
|
485
810
|
},
|
|
811
|
+
ui: { setBadge: (count) => call('ui.setBadge', count) },
|
|
812
|
+
dashboard: {
|
|
813
|
+
setCard: (card) => call('dashboard.setCard', card),
|
|
814
|
+
},
|
|
486
815
|
};
|
|
487
816
|
const settle = (replyId, work) => {
|
|
488
817
|
Promise.resolve()
|
|
@@ -564,6 +893,11 @@ export function starterFiles(options) {
|
|
|
564
893
|
'```',
|
|
565
894
|
'',
|
|
566
895
|
'In Odyn, open Marketplace → Extensions → Install from folder and pick this folder.',
|
|
896
|
+
'',
|
|
897
|
+
'Optional: add up to 5 pictures to the Marketplace page by listing them in the',
|
|
898
|
+
'`"odyn"` section of package.json, e.g. `"screenshots": ["screenshots/1.png"]`',
|
|
899
|
+
'(.png, .jpg, .jpeg or .webp, at most 1 MB each).',
|
|
900
|
+
'',
|
|
567
901
|
'Docs: https://www.odyn.build/docs/extensions',
|
|
568
902
|
'',
|
|
569
903
|
].join('\n'),
|
package/dist/ui/button.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type VariantProps } from 'class-variance-authority';
|
|
|
3
3
|
/** The Odyn button styles, for use on other elements too. */
|
|
4
4
|
export declare const buttonVariants: (props?: ({
|
|
5
5
|
variant?: "default" | "destructive" | "link" | "outline" | "secondary" | "ghost" | null | undefined;
|
|
6
|
-
size?: "default" | "
|
|
6
|
+
size?: "default" | "icon" | "xs" | "sm" | "lg" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
|
|
7
7
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
8
8
|
export type ButtonProps = ComponentProps<'button'> & VariantProps<typeof buttonVariants>;
|
|
9
9
|
/** A button in Odyn's style. Icon-only buttons need an `aria-label`. */
|
|
@@ -16,7 +16,15 @@ export declare const ORB_FRAMES_PER_SECOND = 12;
|
|
|
16
16
|
export declare const ORB_ROOT_ATTRIBUTE = "data-orb";
|
|
17
17
|
/** Present on an orb held on its opening frame; the clock leaves those alone. */
|
|
18
18
|
export declare const ORB_PAUSED_ATTRIBUTE = "data-orb-paused";
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Moves every clocked animation to where it should be at `now`.
|
|
21
|
+
*
|
|
22
|
+
* Reads first, then writes. Asking a CSS animation for its `playState` makes
|
|
23
|
+
* the browser bring every style on the page up to date, and setting
|
|
24
|
+
* `currentTime` makes them stale again. Mixing the two in one loop paid for a
|
|
25
|
+
* whole-page style pass per animation, per tick — the main cause of lag when
|
|
26
|
+
* switching chats. Splitting them means at most one style pass per tick.
|
|
27
|
+
*/
|
|
20
28
|
export declare function tickOrbClock(now: number): void;
|
|
21
29
|
/**
|
|
22
30
|
* Hands these animations to the shared clock, carrying on from wherever each
|
|
@@ -31,17 +31,28 @@ function stopClockIfIdle() {
|
|
|
31
31
|
clearInterval(clockTimer);
|
|
32
32
|
clockTimer = null;
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Moves every clocked animation to where it should be at `now`.
|
|
36
|
+
*
|
|
37
|
+
* Reads first, then writes. Asking a CSS animation for its `playState` makes
|
|
38
|
+
* the browser bring every style on the page up to date, and setting
|
|
39
|
+
* `currentTime` makes them stale again. Mixing the two in one loop paid for a
|
|
40
|
+
* whole-page style pass per animation, per tick — the main cause of lag when
|
|
41
|
+
* switching chats. Splitting them means at most one style pass per tick.
|
|
42
|
+
*/
|
|
35
43
|
export function tickOrbClock(now) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
// Read every animation's state BEFORE moving any of them. Reading
|
|
45
|
+
// `playState` makes the browser bring the whole page's styles up to date,
|
|
46
|
+
// and moving an animation makes them stale again — so reading and moving
|
|
47
|
+
// in turn redid the whole page's styles once per dot, about 200 times a
|
|
48
|
+
// second, which grew with the page until a long chat froze the window.
|
|
49
|
+
const stale = [...clockedAnimations.keys()].filter((animation) => animation.playState === 'idle');
|
|
50
|
+
// The stylesheet removed these (the orb left the page, or reduce-motion was
|
|
51
|
+
// switched on), so stop driving them — setting their time would revive them.
|
|
52
|
+
for (const animation of stale)
|
|
53
|
+
clockedAnimations.delete(animation);
|
|
54
|
+
for (const [animation, origin] of clockedAnimations)
|
|
43
55
|
animation.currentTime = now - origin;
|
|
44
|
-
}
|
|
45
56
|
stopClockIfIdle();
|
|
46
57
|
}
|
|
47
58
|
function tickWhenVisible() {
|
|
@@ -56,10 +67,12 @@ function tickWhenVisible() {
|
|
|
56
67
|
* one already is. Handing over one that is already clocked does nothing.
|
|
57
68
|
*/
|
|
58
69
|
export function clockOrbAnimations(animations, now = performance.now()) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
// Reads first, then writes — see `tickOrbClock` for why.
|
|
71
|
+
const fresh = animations
|
|
72
|
+
.filter((animation) => !clockedAnimations.has(animation) && animation.playState !== 'idle')
|
|
73
|
+
.map((animation) => [animation, now - readCurrentTime(animation)]);
|
|
74
|
+
for (const [animation, origin] of fresh) {
|
|
75
|
+
clockedAnimations.set(animation, origin);
|
|
63
76
|
animation.pause();
|
|
64
77
|
}
|
|
65
78
|
if (clockedAnimations.size > 0)
|