@skyelight/mcp 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyelight/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for Skyelight \u2014 read feedback items from your project as an agent",
5
5
  "type": "module",
6
6
  "bin": {
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),
@@ -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: "0.1.0" },
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: { code: -32602, message: `Unknown resource: ${params?.uri}` },
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 { text, data } = await callTool(toolName, args, {
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
- content: [{ type: "text", text }],
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: { code: METHOD_NOT_FOUND, message: `Unknown method: ${method}` },
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(server, { input = process.stdin, output = process.stdout } = {}) {
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<{ text: string; data: unknown }>;
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. Use when " +
39
- "you do not know what exists yet, or to find which workspace a " +
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: 'A user id, or "none" for unassigned items.',
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, and the anchor identifying the element the person pointed at.",
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
- return "You are not a member of any workspace yet.";
239
+ lines.push("You are not a member of any workspace yet.");
240
+ return lines.join("\n");
222
241
  }
223
242
 
224
- const lines = [`${plural(total, "workspace", "workspaces")} you can reach:`];
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 } = result;
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
- // Stated before the anchor: a screenshot answers "what did this look like"
373
- // in one step, where a selector needs the page opened first.
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(`Screenshot: ${item.evidence.screenshotUrl}`);
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 res.changed
564
- ? `Moved to ${res.status}.`
565
- : `Already ${res.status} — nothing to do.`;
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 { text: renderItem(item), data: item };
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}`);
@@ -0,0 +1,3 @@
1
+ /** Types for version.js — see tools.d.ts for why declarations are shipped. */
2
+
3
+ export declare const VERSION: string;
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.0";