@skyelight/mcp 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -0
- package/README.md +55 -14
- package/package.json +2 -1
- package/src/client.js +9 -0
- package/src/images.d.ts +42 -0
- package/src/images.js +193 -0
- package/src/server.js +23 -6
- package/src/tools.d.ts +6 -1
- package/src/tools.js +116 -16
- package/src/version.d.ts +3 -0
- package/src/version.js +15 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes to `@skyelight/mcp`. Dates are npm publish dates.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
What a version number means here: the tools are the interface. A **minor** is a
|
|
9
|
+
new tool, a new argument, or a new field in a result — an agent that ignores it
|
|
10
|
+
keeps working. A **patch** is a fix or a wording change. A **major** would be
|
|
11
|
+
removing a tool or an argument, or changing what an existing field means, and
|
|
12
|
+
there has not been one.
|
|
13
|
+
|
|
14
|
+
## 0.4.1 — 2026-09-19
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Two wrong permission claims in the README**, which is what npm serves on
|
|
19
|
+
the package page. It said a reviewer "reads and cannot write" — a reviewer
|
|
20
|
+
replies and raises items like every other role. It also said resolving stays
|
|
21
|
+
a person's call "for a thread you do not own", which read as a rule for
|
|
22
|
+
everyone; the actual rule touches only reviewers, who may move threads they
|
|
23
|
+
opened and nobody else's. Both are now taken from `convex/mcp/writes.ts`.
|
|
24
|
+
- Documented the 0.4.0 tool surface, which the README had not caught up with:
|
|
25
|
+
the image blocks, merged duplicates, `assignee: "me"`, and the read ceiling.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- This changelog, and it ships in the package.
|
|
30
|
+
|
|
31
|
+
## 0.4.0 — 2026-09-19
|
|
32
|
+
|
|
33
|
+
Nine things the Skyelight web app had shown a person for months that the tool
|
|
34
|
+
results did not carry.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **`get_item` returns the pictures as pictures.** The screenshot taken when
|
|
39
|
+
the pin was left, and any images people attached to the thread, come back as
|
|
40
|
+
MCP `image` content blocks instead of links no agent tool can open. Capped at
|
|
41
|
+
four per call and 4MB each; anything skipped is named with its link and the
|
|
42
|
+
reason. The stdio transport fetches the storage URL, and the remote endpoint
|
|
43
|
+
reads its own storage directly.
|
|
44
|
+
- **Merged duplicates leave the list**, and the item they were merged into
|
|
45
|
+
carries `reportCount`. Five reports of one problem used to read as five bugs.
|
|
46
|
+
- **`list_workspaces` returns `you`** — the caller's own user id and name — and
|
|
47
|
+
`list_items` accepts `assignee: "me"`. "What is assigned to me" had no way to
|
|
48
|
+
be asked before.
|
|
49
|
+
- **`get_item` carries `linear`**, naming the issue a thread was handed to and
|
|
50
|
+
who it was delegated to, so an agent stops starting work a Linear-hosted
|
|
51
|
+
agent is already finishing.
|
|
52
|
+
- **`get_item` carries `attachments` and `reactions`.** Attachments cover the
|
|
53
|
+
thread and its replies, in reading order; reactions are counted by emoji
|
|
54
|
+
rather than listed one row per person.
|
|
55
|
+
- **List rows use the classifier's one-line summary** where there is one, with
|
|
56
|
+
`excerptSource` saying whether the line is a machine summary or the
|
|
57
|
+
reporter's own words. Rows used to be the first 140 characters of whatever
|
|
58
|
+
somebody typed.
|
|
59
|
+
- **The 2,000-item read ceiling states itself.** `capped` is set when it binds
|
|
60
|
+
and the narration calls the counts a floor rather than a total. The scan also
|
|
61
|
+
reads newest-first, so the items it keeps when it binds are the live ones.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- `set_status` returned a bare string where the server expected `{ text, data }`,
|
|
66
|
+
so a successful move answered with an empty content block and the model had
|
|
67
|
+
no way to tell it had worked.
|
|
68
|
+
- `initialize` reported `0.1.0`. Both transports carried their own stale copy
|
|
69
|
+
of that literal while the package shipped 0.3.0. There is one `VERSION`
|
|
70
|
+
constant now, and a test asserts it matches `package.json`.
|
|
71
|
+
- `status=deferred` was refused with a 400 by the API the stdio transport calls,
|
|
72
|
+
although every tool schema has offered `deferred` since it existed.
|
|
73
|
+
|
|
74
|
+
## 0.3.0 — 2026-09-17
|
|
75
|
+
|
|
76
|
+
### Added
|
|
77
|
+
|
|
78
|
+
- Grok in the installer's client list, and a README that lists it.
|
|
79
|
+
|
|
80
|
+
## 0.2.0 — 2026-09-09
|
|
81
|
+
|
|
82
|
+
### Added
|
|
83
|
+
|
|
84
|
+
- `npx @skyelight/mcp init` — finds the coding agent, registers the remote
|
|
85
|
+
server with it, and leaves sign-in to OAuth, so nothing is pasted and no key
|
|
86
|
+
is stored on disk.
|
|
87
|
+
- The `ui://` item card, for clients that can render an MCP Apps resource. The
|
|
88
|
+
text result is complete on its own, so the card is strictly additive.
|
|
89
|
+
|
|
90
|
+
## 0.1.4 — 2026-09-09
|
|
91
|
+
|
|
92
|
+
### Added
|
|
93
|
+
|
|
94
|
+
- The component that rendered the pinned element, where `@skyelight/build`
|
|
95
|
+
stamped it.
|
|
96
|
+
|
|
97
|
+
## 0.1.3 — 2026-09-09
|
|
98
|
+
|
|
99
|
+
### Added
|
|
100
|
+
|
|
101
|
+
- Projects sort by real activity rather than by when the project record last
|
|
102
|
+
changed, so "anything new here?" gets a true answer.
|
|
103
|
+
|
|
104
|
+
## 0.1.2 — 2026-09-08
|
|
105
|
+
|
|
106
|
+
### Added
|
|
107
|
+
|
|
108
|
+
- The source file and line behind a pin, and a repo permalink pinned to the
|
|
109
|
+
commit the page was built from.
|
|
110
|
+
|
|
111
|
+
## 0.1.1 — 2026-09-08
|
|
112
|
+
|
|
113
|
+
### Fixed
|
|
114
|
+
|
|
115
|
+
- The README npm was serving.
|
|
116
|
+
|
|
117
|
+
## 0.1.0 — 2026-09-08
|
|
118
|
+
|
|
119
|
+
First release. An MCP server over stdio that reads the feedback people left on
|
|
120
|
+
a running app — the thread, the page, the element they pointed at — and reports
|
|
121
|
+
back on it (SKY-273).
|
package/README.md
CHANGED
|
@@ -83,21 +83,29 @@ Tools then default to that project and nobody has to pass an id. Without it,
|
|
|
83
83
|
|
|
84
84
|
## Tools
|
|
85
85
|
|
|
86
|
-
| Tool | What it is for
|
|
87
|
-
| ----------------- |
|
|
88
|
-
| `list_workspaces` | Which workspaces this credential can reach.
|
|
89
|
-
| `list_projects` | The projects inside them, with the ids the other tools take.
|
|
90
|
-
| `list_items` | What is outstanding. Filter by page, type, status, assignee.
|
|
91
|
-
| `search_items` | Find items whose thread mentions some text, replies included.
|
|
92
|
-
| `get_item` | Everything needed to work one item: thread, page, anchor, code. |
|
|
93
|
-
| `post_update` | Report back on the thread the feedback came from.
|
|
94
|
-
| `create_item` | Raise a new item — an audit finding, something you noticed.
|
|
95
|
-
| `set_status` | Move an item to open, deferred or resolved.
|
|
86
|
+
| Tool | What it is for |
|
|
87
|
+
| ----------------- | ----------------------------------------------------------------------- |
|
|
88
|
+
| `list_workspaces` | Which workspaces this credential can reach, and who you are. |
|
|
89
|
+
| `list_projects` | The projects inside them, with the ids the other tools take. |
|
|
90
|
+
| `list_items` | What is outstanding. Filter by page, type, status, assignee. |
|
|
91
|
+
| `search_items` | Find items whose thread mentions some text, replies included. |
|
|
92
|
+
| `get_item` | Everything needed to work one item: thread, page, anchor, code, images. |
|
|
93
|
+
| `post_update` | Report back on the thread the feedback came from. |
|
|
94
|
+
| `create_item` | Raise a new item — an audit finding, something you noticed. |
|
|
95
|
+
| `set_status` | Move an item to open, deferred or resolved. |
|
|
96
96
|
|
|
97
97
|
`list_items` leads with a summary — totals, breakdown by type and by page — so
|
|
98
98
|
an agent can tell you the shape of the work before pulling any of it. Rows are
|
|
99
99
|
stubs; `get_item` is where the thread and the anchor live.
|
|
100
100
|
|
|
101
|
+
**"What is assigned to me."** `list_workspaces` returns your own user id, and
|
|
102
|
+
`list_items` takes `assignee: "me"`, so the most ordinary question anyone asks
|
|
103
|
+
an agent needs no id looked up by hand.
|
|
104
|
+
|
|
105
|
+
**Merged duplicates do not appear twice.** When somebody merges five reports of
|
|
106
|
+
one problem in the web app, the duplicates leave the list and the item they were
|
|
107
|
+
merged into says `5 reports`. A list that shows all five reads as five bugs.
|
|
108
|
+
|
|
101
109
|
## What the agent sees
|
|
102
110
|
|
|
103
111
|
```
|
|
@@ -107,12 +115,40 @@ Busiest pages: /checkout (8), /settings (4).
|
|
|
107
115
|
|
|
108
116
|
2 matches:
|
|
109
117
|
- The pay button does nothing on the second click
|
|
110
|
-
bug · open · /checkout · unassigned · 2 replies — id i1
|
|
118
|
+
bug · open · /checkout · unassigned · 2 replies · 5 reports — id i1
|
|
111
119
|
```
|
|
112
120
|
|
|
113
121
|
Deliberately prose rather than JSON. A tool that returns a bare array invites a
|
|
114
122
|
model to read the array out; this one invites it to summarise.
|
|
115
123
|
|
|
124
|
+
A row's line is the one-sentence summary Skyelight's classifier wrote, where
|
|
125
|
+
there is one, rather than the first 140 characters of whatever somebody typed —
|
|
126
|
+
four truncated paragraphs are not something you can choose between.
|
|
127
|
+
|
|
128
|
+
One call reads the 2,000 most recent items in a project. Past that the result
|
|
129
|
+
says so, in words, rather than presenting a slice as the total.
|
|
130
|
+
|
|
131
|
+
### The pictures
|
|
132
|
+
|
|
133
|
+
`get_item` returns the screenshot taken when the pin was left, and any images
|
|
134
|
+
people attached to the thread, as **image content blocks** — things a model can
|
|
135
|
+
actually look at, not links it cannot open.
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
Screenshot of the page when this was pinned: attached below.
|
|
139
|
+
2 images attached to this thread: also below.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Capped at four per call. Anything too large to inline comes back as its link
|
|
143
|
+
with the reason, so a picture never disappears silently.
|
|
144
|
+
|
|
145
|
+
### What somebody else already did
|
|
146
|
+
|
|
147
|
+
`get_item` also names the Linear issue a thread was handed to, and who it was
|
|
148
|
+
delegated to, so an agent does not start work a Linear-hosted agent is
|
|
149
|
+
finishing. Reactions come back counted by emoji — a thread with one comment and
|
|
150
|
+
nine thumbs up is not a thread with one comment.
|
|
151
|
+
|
|
116
152
|
### Where the code is
|
|
117
153
|
|
|
118
154
|
If the app was built with [`@skyelight/build`](https://www.npmjs.com/package/@skyelight/build),
|
|
@@ -146,8 +182,13 @@ reason. A personal token is the only credential the API accepts.
|
|
|
146
182
|
The server enforces nothing; the API does. Whatever your account can do in the
|
|
147
183
|
web app, the agent can do through your token, and nothing more:
|
|
148
184
|
|
|
149
|
-
-
|
|
185
|
+
- **Owner, admin, collaborator and reviewer** all read and write — a reviewer
|
|
186
|
+
can reply on a thread and raise one, which is what a client seat is for.
|
|
187
|
+
- A **reviewer** can only `set_status` on threads they opened themselves. Every
|
|
188
|
+
other role can move anyone's. It is the rule the web app already applies:
|
|
189
|
+
closing someone else's report is a decision about their report.
|
|
150
190
|
- A **project-bound** credential answers 404 outside its project rather than
|
|
151
191
|
reporting that something exists but is forbidden.
|
|
152
|
-
-
|
|
153
|
-
|
|
192
|
+
- Workspaces your plan or seat does not cover are **listed and marked** rather
|
|
193
|
+
than hidden, with the reason, so a client can see the workspace they know
|
|
194
|
+
exists and be told which gate closed instead of being told it is missing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyelight/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "MCP server for Skyelight \u2014 read feedback items from your project as an agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"LICENSE",
|
|
12
12
|
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
13
14
|
"src"
|
|
14
15
|
],
|
|
15
16
|
"engines": {
|
package/src/client.js
CHANGED
|
@@ -85,6 +85,15 @@ export function createClient({ apiUrl, token, fetchImpl = fetch }) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
return {
|
|
88
|
+
/**
|
|
89
|
+
* The fetch this client was built with, for the one thing that is not an
|
|
90
|
+
* API call: pulling a storage URL the API just handed us so its bytes can
|
|
91
|
+
* be returned as an image. Exposed rather than wrapped because it carries
|
|
92
|
+
* no bearer token — those links are their own credential, and attaching
|
|
93
|
+
* ours to an off-API request is how a token ends up somewhere it was
|
|
94
|
+
* never meant to go.
|
|
95
|
+
*/
|
|
96
|
+
fetchImpl,
|
|
88
97
|
listWorkspaces: () => request("/api/v1/workspaces"),
|
|
89
98
|
listProjects: (params) => request("/api/v1/projects", params),
|
|
90
99
|
listItems: (params) => request("/api/v1/items", params),
|
package/src/images.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Types for images.js — see tools.d.ts for why declarations are shipped. */
|
|
2
|
+
|
|
3
|
+
export interface TextBlock {
|
|
4
|
+
type: "text";
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ImageBlock {
|
|
9
|
+
type: "image";
|
|
10
|
+
/** Base64, no data: prefix — what the MCP content block wants. */
|
|
11
|
+
data: string;
|
|
12
|
+
mimeType: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ContentBlock = TextBlock | ImageBlock;
|
|
16
|
+
|
|
17
|
+
export declare const MAX_IMAGES: number;
|
|
18
|
+
export declare const MAX_IMAGE_BYTES: number;
|
|
19
|
+
|
|
20
|
+
export interface ImageSource {
|
|
21
|
+
url: string;
|
|
22
|
+
storageId: string | null;
|
|
23
|
+
label: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What a loader hands back: the bytes, or why there are none. */
|
|
27
|
+
export type LoadedImage =
|
|
28
|
+
| { bytes: Uint8Array; mimeType?: string; skipped?: undefined }
|
|
29
|
+
| { skipped: string };
|
|
30
|
+
|
|
31
|
+
export declare function imageSources(item: unknown): ImageSource[];
|
|
32
|
+
|
|
33
|
+
export declare function imageBlocksFor(
|
|
34
|
+
item: unknown,
|
|
35
|
+
options?: {
|
|
36
|
+
/** Where the bytes come from. Defaults to fetching `source.url`. */
|
|
37
|
+
load?: (source: ImageSource) => Promise<LoadedImage>;
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
maxImages?: number;
|
|
40
|
+
maxBytes?: number;
|
|
41
|
+
},
|
|
42
|
+
): Promise<ContentBlock[]>;
|
package/src/images.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence, as something a model can actually look at.
|
|
3
|
+
*
|
|
4
|
+
* `get_item` used to print `Screenshot: https://…` and stop there. No agent
|
|
5
|
+
* tool turns a link into a picture, so the most expensive thing Skyelight
|
|
6
|
+
* captures — the state of the page at the moment somebody pinned it — reached
|
|
7
|
+
* nobody. The same was true of the images people attach themselves, which are
|
|
8
|
+
* frequently the whole diagnosis: the reply that says "here, look".
|
|
9
|
+
*
|
|
10
|
+
* MCP tool results are a LIST of content blocks and an image is one of them,
|
|
11
|
+
* so the fix is to fetch the bytes and hand them over as
|
|
12
|
+
* `{ type: "image", data, mimeType }`.
|
|
13
|
+
*
|
|
14
|
+
* Shared by both transports on purpose. The stdio package and the remote
|
|
15
|
+
* endpoint are two doors onto the same server, and an agent that can see a
|
|
16
|
+
* screenshot through one and not the other is the kind of difference nobody
|
|
17
|
+
* can debug from the outside.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How many pictures one call may carry.
|
|
22
|
+
*
|
|
23
|
+
* A thread with a screenshot on every reply would otherwise put a dozen
|
|
24
|
+
* images into a single result. Four is the evidence capture plus the first
|
|
25
|
+
* few things people attached, which is where the information is — an
|
|
26
|
+
* eleventh screenshot of the same page has never changed a diagnosis.
|
|
27
|
+
*/
|
|
28
|
+
export const MAX_IMAGES = 4;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The most one picture may weigh.
|
|
32
|
+
*
|
|
33
|
+
* Captures are JPEG at quality 0.85 and at most 2x device density, so a large
|
|
34
|
+
* viewport lands around a megabyte and nothing legitimate comes close to this.
|
|
35
|
+
* The cap is here for what people attach, which is arbitrary: somebody drags
|
|
36
|
+
* in a full-resolution phone screenshot and every subsequent call carries it.
|
|
37
|
+
*
|
|
38
|
+
* Over the line, the link is reported instead, with the size, so the reader
|
|
39
|
+
* knows a picture exists and why it is not here. Downscaling would be better
|
|
40
|
+
* and is not available: neither the dependency-free stdio package nor the
|
|
41
|
+
* Convex runtime has an image codec to re-encode with.
|
|
42
|
+
*/
|
|
43
|
+
export const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
/** Bytes to base64, in both runtimes this module is loaded into. */
|
|
46
|
+
function toBase64(bytes) {
|
|
47
|
+
if (typeof Buffer !== "undefined") {
|
|
48
|
+
return Buffer.from(bytes).toString("base64");
|
|
49
|
+
}
|
|
50
|
+
// Convex runs a V8 isolate with no Buffer. `btoa` takes a binary string,
|
|
51
|
+
// and building one in chunks keeps the argument list to `fromCharCode`
|
|
52
|
+
// inside what a call stack will take.
|
|
53
|
+
let binary = "";
|
|
54
|
+
const CHUNK = 0x8000;
|
|
55
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
56
|
+
binary += String.fromCharCode.apply(
|
|
57
|
+
null,
|
|
58
|
+
Array.from(bytes.subarray(i, i + CHUNK)),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return btoa(binary);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The pictures on one item, in the order a reader wants them.
|
|
66
|
+
*
|
|
67
|
+
* Evidence first: it answers "what did this look like" without opening
|
|
68
|
+
* anything. Then what people attached, thread before replies, which is the
|
|
69
|
+
* order `attachments` already arrives in.
|
|
70
|
+
*/
|
|
71
|
+
export function imageSources(item) {
|
|
72
|
+
const sources = [];
|
|
73
|
+
if (item?.evidence?.screenshotUrl) {
|
|
74
|
+
sources.push({
|
|
75
|
+
url: item.evidence.screenshotUrl,
|
|
76
|
+
storageId: item.evidence.screenshotStorageId ?? null,
|
|
77
|
+
label: "The page when this was pinned:",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
for (const a of item?.attachments ?? []) {
|
|
81
|
+
if (!a?.url) continue;
|
|
82
|
+
sources.push({
|
|
83
|
+
url: a.url,
|
|
84
|
+
storageId: a.storageId ?? null,
|
|
85
|
+
label: `Attached by ${a.author ?? "someone"}${
|
|
86
|
+
a.where === "reply" ? ", on a reply" : ""
|
|
87
|
+
}:`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return sources;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The default loader: pull the storage URL over HTTP.
|
|
95
|
+
*
|
|
96
|
+
* What the stdio package has to do, because the bytes are on the other side
|
|
97
|
+
* of the network from it. The remote endpoint runs inside the deployment that
|
|
98
|
+
* holds them and passes its own loader instead — see `load` below.
|
|
99
|
+
*/
|
|
100
|
+
function httpLoader(fetchImpl) {
|
|
101
|
+
return async (source) => {
|
|
102
|
+
let res;
|
|
103
|
+
try {
|
|
104
|
+
res = await fetchImpl(source.url);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return { skipped: `could not be fetched (${err.message})` };
|
|
107
|
+
}
|
|
108
|
+
if (!res.ok) return { skipped: `could not be fetched (${res.status})` };
|
|
109
|
+
const mimeType = (res.headers?.get?.("content-type") ?? "")
|
|
110
|
+
.split(";")[0]
|
|
111
|
+
.trim();
|
|
112
|
+
return { bytes: new Uint8Array(await res.arrayBuffer()), mimeType };
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Content blocks to append after `get_item`'s text.
|
|
118
|
+
*
|
|
119
|
+
* Each picture is introduced by a line saying what it is, because four
|
|
120
|
+
* unlabelled images of the same application are four things a reader has to
|
|
121
|
+
* work out. Anything that could not be inlined comes back as a closing note
|
|
122
|
+
* with its link, so a picture never disappears silently.
|
|
123
|
+
*
|
|
124
|
+
* Never throws. A screenshot that will not load is a worse work order, not a
|
|
125
|
+
* failed tool call — the thread, the anchor and the source line are all still
|
|
126
|
+
* in the text block above it.
|
|
127
|
+
*
|
|
128
|
+
* `load` is how the bytes arrive. It is injected because the two transports
|
|
129
|
+
* are in different places relative to the file: the stdio package is across a
|
|
130
|
+
* network and fetches the URL, and the remote endpoint is inside the
|
|
131
|
+
* deployment that stores it and reads it straight out of storage. Everything
|
|
132
|
+
* after that — the labels, the size ceiling, the cap, the notes — is one code
|
|
133
|
+
* path, which is the only way the two doors keep answering the same.
|
|
134
|
+
*/
|
|
135
|
+
export async function imageBlocksFor(item, options = {}) {
|
|
136
|
+
const {
|
|
137
|
+
fetchImpl = fetch,
|
|
138
|
+
load,
|
|
139
|
+
maxImages = MAX_IMAGES,
|
|
140
|
+
maxBytes = MAX_IMAGE_BYTES,
|
|
141
|
+
} = options;
|
|
142
|
+
const loader = load ?? httpLoader(fetchImpl);
|
|
143
|
+
|
|
144
|
+
const sources = imageSources(item);
|
|
145
|
+
if (sources.length === 0) return [];
|
|
146
|
+
|
|
147
|
+
const blocks = [];
|
|
148
|
+
const notes = [];
|
|
149
|
+
for (const source of sources.slice(0, maxImages)) {
|
|
150
|
+
let got;
|
|
151
|
+
try {
|
|
152
|
+
got = await loader(source);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
got = { skipped: `could not be read (${err.message})` };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Storage serves what was uploaded. Anything that is not an image is a
|
|
158
|
+
// misfiled attachment, and a non-image in an image block fails at the
|
|
159
|
+
// client rather than here, which is a worse place to find out.
|
|
160
|
+
if (!got.skipped && got.mimeType && !got.mimeType.startsWith("image/")) {
|
|
161
|
+
got = { skipped: `is not an image (${got.mimeType})` };
|
|
162
|
+
}
|
|
163
|
+
if (!got.skipped && got.bytes.byteLength > maxBytes) {
|
|
164
|
+
got = {
|
|
165
|
+
skipped:
|
|
166
|
+
`is ${Math.round(got.bytes.byteLength / 1024)}KB, over the ` +
|
|
167
|
+
`${Math.round(maxBytes / 1024)}KB limit for an inline image`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (got.skipped) {
|
|
172
|
+
notes.push(`${source.label} ${source.url} — ${got.skipped}.`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
blocks.push({ type: "text", text: source.label });
|
|
176
|
+
blocks.push({
|
|
177
|
+
type: "image",
|
|
178
|
+
data: toBase64(got.bytes),
|
|
179
|
+
mimeType: got.mimeType || "image/jpeg",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const overflow = sources.length - maxImages;
|
|
184
|
+
if (overflow > 0) {
|
|
185
|
+
notes.push(
|
|
186
|
+
`${overflow} more image${overflow === 1 ? "" : "s"} on this thread, not shown.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (notes.length > 0) {
|
|
190
|
+
blocks.push({ type: "text", text: notes.join("\n") });
|
|
191
|
+
}
|
|
192
|
+
return blocks;
|
|
193
|
+
}
|
package/src/server.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { toolDefinitions, callTool } from "./tools.js";
|
|
15
15
|
import { ApiError } from "./client.js";
|
|
16
|
+
import { VERSION } from "./version.js";
|
|
16
17
|
import {
|
|
17
18
|
ITEM_CARD_URI,
|
|
18
19
|
clientSupportsUi,
|
|
@@ -80,7 +81,7 @@ export function createServer({ client, config, name = "skyelight" }) {
|
|
|
80
81
|
result = {
|
|
81
82
|
protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
|
|
82
83
|
capabilities: { tools: {}, resources: {} },
|
|
83
|
-
serverInfo: { name, version:
|
|
84
|
+
serverInfo: { name, version: VERSION },
|
|
84
85
|
instructions:
|
|
85
86
|
"Skyelight holds feedback people left directly on pages of a running app. " +
|
|
86
87
|
"list_items to see what is outstanding, get_item for the full thread and the " +
|
|
@@ -106,7 +107,10 @@ export function createServer({ client, config, name = "skyelight" }) {
|
|
|
106
107
|
return {
|
|
107
108
|
jsonrpc: "2.0",
|
|
108
109
|
id,
|
|
109
|
-
error: {
|
|
110
|
+
error: {
|
|
111
|
+
code: -32602,
|
|
112
|
+
message: `Unknown resource: ${params?.uri}`,
|
|
113
|
+
},
|
|
110
114
|
};
|
|
111
115
|
}
|
|
112
116
|
result = { contents: [itemCardContents()] };
|
|
@@ -115,12 +119,19 @@ export function createServer({ client, config, name = "skyelight" }) {
|
|
|
115
119
|
case "tools/call": {
|
|
116
120
|
const { name: toolName, arguments: args = {} } = params ?? {};
|
|
117
121
|
try {
|
|
118
|
-
const {
|
|
122
|
+
const {
|
|
123
|
+
text,
|
|
124
|
+
data,
|
|
125
|
+
content: extra,
|
|
126
|
+
} = await callTool(toolName, args, {
|
|
119
127
|
client,
|
|
120
128
|
config,
|
|
121
129
|
});
|
|
122
130
|
result = {
|
|
123
|
-
|
|
131
|
+
// A tool result is a LIST of blocks. `get_item` uses the rest
|
|
132
|
+
// of it for images; everything else returns the one block it
|
|
133
|
+
// always did.
|
|
134
|
+
content: [{ type: "text", text }, ...(extra ?? [])],
|
|
124
135
|
structuredContent: data,
|
|
125
136
|
};
|
|
126
137
|
} catch (err) {
|
|
@@ -144,7 +155,10 @@ export function createServer({ client, config, name = "skyelight" }) {
|
|
|
144
155
|
return {
|
|
145
156
|
jsonrpc: "2.0",
|
|
146
157
|
id,
|
|
147
|
-
error: {
|
|
158
|
+
error: {
|
|
159
|
+
code: METHOD_NOT_FOUND,
|
|
160
|
+
message: `Unknown method: ${method}`,
|
|
161
|
+
},
|
|
148
162
|
};
|
|
149
163
|
}
|
|
150
164
|
|
|
@@ -185,7 +199,10 @@ function toolErrorText(err) {
|
|
|
185
199
|
* Wire a server to a byte stream. Split on newlines and ignore blank lines;
|
|
186
200
|
* a partial line is held until the rest arrives.
|
|
187
201
|
*/
|
|
188
|
-
export function serveStdio(
|
|
202
|
+
export function serveStdio(
|
|
203
|
+
server,
|
|
204
|
+
{ input = process.stdin, output = process.stdout } = {},
|
|
205
|
+
) {
|
|
189
206
|
let buffer = "";
|
|
190
207
|
|
|
191
208
|
input.setEncoding?.("utf8");
|
package/src/tools.d.ts
CHANGED
|
@@ -40,6 +40,11 @@ export declare function callTool(
|
|
|
40
40
|
name: string,
|
|
41
41
|
args: Record<string, unknown>,
|
|
42
42
|
deps: { client: unknown; config: { projectId?: string | null } },
|
|
43
|
-
): Promise<{
|
|
43
|
+
): Promise<{
|
|
44
|
+
text: string;
|
|
45
|
+
data: unknown;
|
|
46
|
+
/** Blocks to append after the text. `get_item` returns images here. */
|
|
47
|
+
content?: import("./images.js").ContentBlock[];
|
|
48
|
+
}>;
|
|
44
49
|
|
|
45
50
|
export declare const PROJECT_HINT: string;
|
package/src/tools.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* still attached for anything that wants to compute on it.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { imageBlocksFor } from "./images.js";
|
|
11
|
+
|
|
10
12
|
const PROJECT_HINT =
|
|
11
13
|
'No project. Pass projectId, or add .skyelight.json with {"projectId": "..."}.';
|
|
12
14
|
|
|
@@ -35,9 +37,9 @@ export function toolDefinitions(opts = {}) {
|
|
|
35
37
|
{
|
|
36
38
|
name: "list_workspaces",
|
|
37
39
|
description:
|
|
38
|
-
"Workspaces you can reach, with the role you hold in each
|
|
39
|
-
"you do not know what exists yet,
|
|
40
|
-
"project belongs to.",
|
|
40
|
+
"Workspaces you can reach, with the role you hold in each, and who " +
|
|
41
|
+
"you are. Use when you do not know what exists yet, to find which " +
|
|
42
|
+
"workspace a project belongs to, or to learn your own user id.",
|
|
41
43
|
inputSchema: { type: "object", properties: {} },
|
|
42
44
|
},
|
|
43
45
|
{
|
|
@@ -85,7 +87,9 @@ export function toolDefinitions(opts = {}) {
|
|
|
85
87
|
},
|
|
86
88
|
assignee: {
|
|
87
89
|
type: "string",
|
|
88
|
-
description:
|
|
90
|
+
description:
|
|
91
|
+
'A user id, "me" for whoever this connection belongs to, or ' +
|
|
92
|
+
'"none" for unassigned items.',
|
|
89
93
|
},
|
|
90
94
|
limit: { type: "number", description: "Max items (default 50)." },
|
|
91
95
|
},
|
|
@@ -185,7 +189,9 @@ export function toolDefinitions(opts = {}) {
|
|
|
185
189
|
name: "get_item",
|
|
186
190
|
description:
|
|
187
191
|
"Everything needed to work one item: the whole thread in order, the page and URL it is " +
|
|
188
|
-
"on,
|
|
192
|
+
"on, the anchor identifying the element the person pointed at, and the pictures \u2014 " +
|
|
193
|
+
"what the page looked like when it was pinned, plus anything people attached \u2014 " +
|
|
194
|
+
"returned as images you can look at rather than links you cannot.",
|
|
189
195
|
inputSchema: {
|
|
190
196
|
type: "object",
|
|
191
197
|
properties: {
|
|
@@ -216,12 +222,25 @@ function topBreakdown(counts, limit = 3) {
|
|
|
216
222
|
* array out, and a model handed a sentence summarises.
|
|
217
223
|
*/
|
|
218
224
|
export function renderWorkspaces(result) {
|
|
219
|
-
const { workspaces, total } = result;
|
|
225
|
+
const { workspaces, total, you } = result;
|
|
226
|
+
const lines = [];
|
|
227
|
+
|
|
228
|
+
// First, because it is the answer to a question nothing else here can
|
|
229
|
+
// answer, and the id below is what `list_items` wants as `assignee`.
|
|
230
|
+
if (you) {
|
|
231
|
+
lines.push(
|
|
232
|
+
you.name ? `You are ${you.name} — ${you.id}.` : `You are ${you.id}.`,
|
|
233
|
+
);
|
|
234
|
+
lines.push('Pass "me" as assignee to list_items to see your own work.');
|
|
235
|
+
lines.push("");
|
|
236
|
+
}
|
|
237
|
+
|
|
220
238
|
if (total === 0) {
|
|
221
|
-
|
|
239
|
+
lines.push("You are not a member of any workspace yet.");
|
|
240
|
+
return lines.join("\n");
|
|
222
241
|
}
|
|
223
242
|
|
|
224
|
-
|
|
243
|
+
lines.push(`${plural(total, "workspace", "workspaces")} you can reach:`);
|
|
225
244
|
lines.push("");
|
|
226
245
|
for (const w of workspaces) {
|
|
227
246
|
const projects = plural(w.projectCount, "project", "projects");
|
|
@@ -279,7 +298,8 @@ export function renderProjects(result) {
|
|
|
279
298
|
}
|
|
280
299
|
|
|
281
300
|
export function renderList(result, { searched } = {}) {
|
|
282
|
-
const { project, summary, items, matched, truncated } =
|
|
301
|
+
const { project, summary, items, matched, truncated, capped, scanLimit } =
|
|
302
|
+
result;
|
|
283
303
|
const lines = [];
|
|
284
304
|
|
|
285
305
|
lines.push(
|
|
@@ -288,6 +308,17 @@ export function renderList(result, { searched } = {}) {
|
|
|
288
308
|
`${summary.resolved} resolved, ${summary.unassigned} unassigned.`,
|
|
289
309
|
);
|
|
290
310
|
|
|
311
|
+
// Said immediately after the counts, because it is what those counts mean.
|
|
312
|
+
// A floor stated as a total is the kind of wrong nobody catches: the number
|
|
313
|
+
// looks like every other number this tool has ever returned.
|
|
314
|
+
if (capped) {
|
|
315
|
+
lines.push(
|
|
316
|
+
`Those counts are a floor, not a total: this project is larger than ` +
|
|
317
|
+
`the ${(scanLimit ?? 2000).toLocaleString("en-US")} most recent items ` +
|
|
318
|
+
`one call reads. Filter by page, type or status for an exact count.`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
291
322
|
const byType = topBreakdown(summary.byType);
|
|
292
323
|
if (byType) lines.push(`By type: ${byType}.`);
|
|
293
324
|
const byPage = topBreakdown(summary.byPage);
|
|
@@ -315,6 +346,10 @@ export function renderList(result, { searched } = {}) {
|
|
|
315
346
|
if (i.assignee) bits.push(`assigned to ${i.assignee}`);
|
|
316
347
|
else bits.push("unassigned");
|
|
317
348
|
if (i.replyCount > 0) bits.push(plural(i.replyCount, "reply", "replies"));
|
|
349
|
+
// How many people hit this, once merging has told us they are the same
|
|
350
|
+
// thing. Silent at 1, which is almost every row — a list where every line
|
|
351
|
+
// ends in "1 report" has spent its width saying nothing.
|
|
352
|
+
if (i.reportCount > 1) bits.push(`${i.reportCount} reports`);
|
|
318
353
|
lines.push(`- ${i.excerpt}`);
|
|
319
354
|
lines.push(` ${bits.join(" · ")} — id ${i.id}`);
|
|
320
355
|
}
|
|
@@ -341,6 +376,32 @@ export function renderItem(item) {
|
|
|
341
376
|
// line below, and that is the half an agent cannot work out for itself.
|
|
342
377
|
const repo = item.project && item.project.repo;
|
|
343
378
|
if (repo) lines.push(`Code: ${repo.url}`);
|
|
379
|
+
|
|
380
|
+
// Before anything else worth reading: this thread is not the one to work.
|
|
381
|
+
// Everything below still describes it faithfully, and acting on it would
|
|
382
|
+
// put the answer where nobody is looking.
|
|
383
|
+
if (item.duplicateOf) {
|
|
384
|
+
lines.push(
|
|
385
|
+
`Merged as a duplicate of item ${item.duplicateOf}. Work that one — ` +
|
|
386
|
+
`a reply here will not reach the thread people are following.`,
|
|
387
|
+
);
|
|
388
|
+
} else if (item.reportCount > 1) {
|
|
389
|
+
lines.push(
|
|
390
|
+
`${item.reportCount} people reported this ` +
|
|
391
|
+
`(${item.reportCount - 1} merged into it).`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Who else is already on it. An agent that starts work a Linear-hosted
|
|
396
|
+
// agent is finishing produces a second answer nobody asked for, and the
|
|
397
|
+
// first anyone hears of it is a duplicate branch.
|
|
398
|
+
for (const l of item.linear ?? []) {
|
|
399
|
+
const handed = l.delegateName
|
|
400
|
+
? ` — handed to ${l.delegateName}${l.delegatedMode ? `, asked to ${l.delegatedMode}` : ""}`
|
|
401
|
+
: "";
|
|
402
|
+
lines.push(`Linear: ${l.identifier}${handed}. ${l.url}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
344
405
|
if (item.assignee?.name) {
|
|
345
406
|
// The mode is the ask. An agent that reads "investigate" and opens a
|
|
346
407
|
// pull request has done the wrong job well.
|
|
@@ -369,12 +430,31 @@ export function renderItem(item) {
|
|
|
369
430
|
}
|
|
370
431
|
}
|
|
371
432
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
433
|
+
// How many people felt strongly enough to say so without writing a reply.
|
|
434
|
+
// A thread with one comment and nine thumbs up is not a thread with one
|
|
435
|
+
// comment, and nothing here said so.
|
|
436
|
+
if (item.reactions?.length) {
|
|
437
|
+
lines.push("");
|
|
438
|
+
lines.push(
|
|
439
|
+
`Reactions: ${item.reactions
|
|
440
|
+
.map((r) => `${r.emoji} ${r.count}`)
|
|
441
|
+
.join(", ")}.`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Stated before the anchor: a picture answers "what did this look like" in
|
|
446
|
+
// one step, where a selector needs the page opened first.
|
|
447
|
+
//
|
|
448
|
+
// The URL is deliberately not here. These arrive as image blocks after this
|
|
449
|
+
// text, which is the point of the change — and a link that has already been
|
|
450
|
+
// honoured is a line a reader has to decide about for nothing. When one
|
|
451
|
+
// cannot be fetched, the block builder says so and prints the link then.
|
|
374
452
|
if (item.evidence) {
|
|
375
453
|
if (item.evidence.screenshotUrl) {
|
|
376
454
|
lines.push("");
|
|
377
|
-
lines.push(
|
|
455
|
+
lines.push(
|
|
456
|
+
"Screenshot of the page when this was pinned: attached below.",
|
|
457
|
+
);
|
|
378
458
|
} else if (item.evidence.expired) {
|
|
379
459
|
lines.push("");
|
|
380
460
|
lines.push(
|
|
@@ -383,6 +463,13 @@ export function renderItem(item) {
|
|
|
383
463
|
}
|
|
384
464
|
}
|
|
385
465
|
|
|
466
|
+
if (item.attachments?.length) {
|
|
467
|
+
const n = item.attachments.length;
|
|
468
|
+
lines.push(
|
|
469
|
+
`${plural(n, "image", "images")} attached to this thread: also below.`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
386
473
|
lines.push("");
|
|
387
474
|
lines.push("They were pointing at:");
|
|
388
475
|
// elementText first: it is what a person would have named, and the one
|
|
@@ -560,15 +647,28 @@ export async function callTool(name, args, { client, config }) {
|
|
|
560
647
|
status: args.status,
|
|
561
648
|
});
|
|
562
649
|
if (res?.error) throw new Error(res.error);
|
|
563
|
-
return
|
|
564
|
-
|
|
565
|
-
|
|
650
|
+
// `{ text, data }` like every other tool. It used to return a bare
|
|
651
|
+
// string, which the stdio server destructured into `text: undefined` —
|
|
652
|
+
// so a successful set_status answered with an empty content block and
|
|
653
|
+
// the model had no way to tell it had worked.
|
|
654
|
+
return {
|
|
655
|
+
text: res.changed
|
|
656
|
+
? `Moved to ${res.status}.`
|
|
657
|
+
: `Already ${res.status} — nothing to do.`,
|
|
658
|
+
data: res,
|
|
659
|
+
};
|
|
566
660
|
}
|
|
567
661
|
|
|
568
662
|
if (name === "get_item") {
|
|
569
663
|
if (!args.itemId) throw new Error("get_item needs an itemId");
|
|
570
664
|
const item = await client.getItem(args.itemId);
|
|
571
|
-
return {
|
|
665
|
+
return {
|
|
666
|
+
text: renderItem(item),
|
|
667
|
+
data: item,
|
|
668
|
+
// The pictures, as pictures. Appended after the text so the work order
|
|
669
|
+
// still reads top-down, and empty whenever there is nothing to show.
|
|
670
|
+
content: await imageBlocksFor(item, { fetchImpl: client.fetchImpl }),
|
|
671
|
+
};
|
|
572
672
|
}
|
|
573
673
|
|
|
574
674
|
throw new Error(`Unknown tool: ${name}`);
|
package/src/version.d.ts
ADDED
package/src/version.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One version number, read by everything that reports one.
|
|
3
|
+
*
|
|
4
|
+
* `initialize` used to answer `0.1.0` from a literal in `server.js` while the
|
|
5
|
+
* package on npm was 0.3.0, and the remote endpoint had its own copy of the
|
|
6
|
+
* same stale literal. A client that logs the server version — or a person
|
|
7
|
+
* reading it to work out which build they are talking to — was told something
|
|
8
|
+
* that had not been true for two releases.
|
|
9
|
+
*
|
|
10
|
+
* A constant rather than a read of `package.json`, because this module is
|
|
11
|
+
* bundled into the Convex deployment for the remote endpoint and there is no
|
|
12
|
+
* filesystem there. `test/server.test.js` asserts the two agree, so the pair
|
|
13
|
+
* cannot drift again without a test going red.
|
|
14
|
+
*/
|
|
15
|
+
export const VERSION = "0.4.1";
|