@yuiseki/gyazocli 0.2.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/README.md CHANGED
@@ -131,14 +131,31 @@ Configured in a client:
131
131
  - `gyazo_summary`: what a day or a range adds up to, with the same options as
132
132
  `gyazo summary`: `date`, `today`, `limit`, `max_pages`, `use_cache`. No
133
133
  arguments means the week up to yesterday.
134
+ - `gyazo_recent`: what arrived since a moment or since a capture you have
135
+ already seen. Arguments: `minutes`, `since`, `after_image_id`, `limit`,
136
+ `max_pages`. No arguments means the last 30 minutes.
134
137
  - `gyazo_collection`: a collection and the captures in it. Arguments:
135
- `id_or_url` (required) and `sort` (`added`, `created` or `captured`).
136
-
137
- All of them are read-only, and all of them return metadata rather than image
138
- bytes: URLs, timestamp, OCR text, title, source application and page, and
139
- location when the capture carries one. Use the URLs in a result to show the
140
- capture itself. Handing base64 image data to a model turned out not to work
141
- well in practice, and describing a capture does.
138
+ `id_or_url` (required), `sort` (`added`, `created` or `captured`), `page` and
139
+ `per`. Reports `total_image_count`, `returned_image_count` and `truncated`.
140
+ - `gyazo_collections`: the collections, with their IDs, filtered by `query`
141
+ against their names.
142
+ - `gyazo_image_content`: the pixels of one capture, as image content.
143
+ Arguments: `id_or_url` (required), `width` (default 1024), `format`
144
+ (`webp` or `jpeg`) and `max_bytes`.
145
+
146
+ All of them are read-only. Everything except `gyazo_image_content` returns
147
+ metadata rather than image bytes: URLs, timestamps, OCR text, title, source
148
+ application and page, and location when the capture carries one. URLs, timestamp, OCR text, title, source application and page, and
149
+ location when the capture carries one. A capture with a location gets a `location` holding
150
+ `latitude`, `longitude`, `country_code` and an address in Japanese and
151
+ English, each with its `locality` and `admin1`, plus `altitude_m` and
152
+ `heading_deg` where the response carries the raw EXIF, which is the case for
153
+ captures read through a collection. `captured_at` is when the shutter was
154
+ pressed, as distinct from the upload time in `created_at`.
155
+
156
+ For the pixels, `gyazo_image_content` returns a width-limited rendition, one
157
+ capture at a time. Returning image bytes from the list and search tools is
158
+ what made this awkward in practice, so those stay metadata-only.
142
159
 
143
160
  Tool names and arguments follow
144
161
  [nota/gyazo-mcp-server](https://github.com/nota/gyazo-mcp-server), so a client
package/dist/api.js CHANGED
@@ -8,6 +8,10 @@ exports.getImageDetail = getImageDetail;
8
8
  exports.searchImages = searchImages;
9
9
  exports.getCurrentUser = getCurrentUser;
10
10
  exports.getCollection = getCollection;
11
+ exports.listCollections = listCollections;
12
+ exports.getCollectionDetail = getCollectionDetail;
13
+ exports.listCollectionImages = listCollectionImages;
14
+ exports.fetchImageRendition = fetchImageRendition;
11
15
  exports.uploadImage = uploadImage;
12
16
  const axios_1 = __importDefault(require("axios"));
13
17
  const form_data_1 = __importDefault(require("form-data"));
@@ -15,6 +19,7 @@ const config_1 = require("./config");
15
19
  const DEFAULT_API_ORIGIN = 'https://api.gyazo.com';
16
20
  const DEFAULT_UPLOAD_ORIGIN = 'https://upload.gyazo.com';
17
21
  const DEFAULT_WEB_ORIGIN = 'https://gyazo.com';
22
+ const DEFAULT_IMAGE_ORIGIN = 'https://i.gyazo.com';
18
23
  function stripTrailingSlash(origin) {
19
24
  return origin.replace(/\/+$/, '');
20
25
  }
@@ -27,11 +32,17 @@ function uploadOrigin() {
27
32
  function webOrigin() {
28
33
  return stripTrailingSlash(config_1.config.GYAZO_WEB_ORIGIN || DEFAULT_WEB_ORIGIN);
29
34
  }
35
+ function imageOrigin() {
36
+ return stripTrailingSlash(config_1.config.GYAZO_IMAGE_ORIGIN || DEFAULT_IMAGE_ORIGIN);
37
+ }
30
38
  const apiBaseUrl = () => `${apiOrigin()}/api/images`;
31
39
  const apiSearchUrl = () => `${apiOrigin()}/api/search`;
32
40
  const apiUsersMeUrl = () => `${apiOrigin()}/api/users/me`;
33
41
  const apiUploadUrl = () => `${uploadOrigin()}/api/upload`;
34
42
  const webCollectionUrl = (id) => `${webOrigin()}/collections/${id}.json`;
43
+ const apiCollectionsUrl = () => `${apiOrigin()}/api/v2/collections`;
44
+ const apiCollectionUrl = (id) => `${apiCollectionsUrl()}/${id}`;
45
+ const apiCollectionImagesUrl = (id) => `${apiCollectionUrl(id)}/images`;
35
46
  async function requestWithRetry(url, params = {}, headers) {
36
47
  const requestHeaders = headers ?? { Authorization: `Bearer ${config_1.config.GYAZO_ACCESS_TOKEN}` };
37
48
  try {
@@ -73,6 +84,54 @@ async function getCollection(collectionId, options = {}) {
73
84
  }
74
85
  return requestWithRetry(webCollectionUrl(collectionId), {}, headers);
75
86
  }
87
+ /**
88
+ * The collections the token can see, newest activity first as the API orders
89
+ * them. Needed to turn a collection people call by name into an ID.
90
+ */
91
+ async function listCollections() {
92
+ const data = await requestWithRetry(apiCollectionsUrl());
93
+ if (Array.isArray(data))
94
+ return data;
95
+ return Array.isArray(data?.collections) ? data.collections : [];
96
+ }
97
+ /** A collection's own fields, without its images. */
98
+ async function getCollectionDetail(collectionId) {
99
+ return requestWithRetry(apiCollectionUrl(collectionId));
100
+ }
101
+ /**
102
+ * A page of a collection's images. Unlike the public web endpoint, which
103
+ * returns the first 100 and ignores every paging parameter, this one really
104
+ * pages, and its images carry the raw EXIF.
105
+ */
106
+ async function listCollectionImages(collectionId, page = 1, per = 100) {
107
+ const data = await requestWithRetry(apiCollectionImagesUrl(collectionId), { page, per });
108
+ if (Array.isArray(data))
109
+ return data;
110
+ return Array.isArray(data?.images) ? data.images : [];
111
+ }
112
+ /**
113
+ * A width-limited rendition of a capture.
114
+ *
115
+ * The original can be several megabytes, which is no use to a model, and
116
+ * resizing locally would mean a native image library. Gyazo will do it: the
117
+ * rendition route takes a width and needs no credentials, so a capture can be
118
+ * handed over at a size that fits. 1024 wide lands around 130 KB as webp.
119
+ */
120
+ async function fetchImageRendition(imageId, width, format = 'webp') {
121
+ const extension = format === 'jpeg' ? 'jpg' : 'webp';
122
+ const url = `${imageOrigin()}/thumb/${width}_w/${imageId}.${extension}`;
123
+ const response = await axios_1.default.get(url, { responseType: 'arraybuffer' });
124
+ const data = Buffer.from(response.data);
125
+ const contentType = String(response.headers['content-type'] || '').split(';')[0].trim();
126
+ return {
127
+ data,
128
+ mimeType: contentType || `image/${format}`,
129
+ bytes: data.length,
130
+ url,
131
+ width,
132
+ format,
133
+ };
134
+ }
76
135
  async function uploadImage(options) {
77
136
  const form = new form_data_1.default();
78
137
  form.append('access_token', config_1.config.GYAZO_ACCESS_TOKEN || '');
package/dist/config.js CHANGED
@@ -16,6 +16,7 @@ const configSchema = zod_1.z.object({
16
16
  GYAZO_API_ORIGIN: zod_1.z.string().optional(),
17
17
  GYAZO_UPLOAD_ORIGIN: zod_1.z.string().optional(),
18
18
  GYAZO_WEB_ORIGIN: zod_1.z.string().optional(),
19
+ GYAZO_IMAGE_ORIGIN: zod_1.z.string().optional(),
19
20
  });
20
21
  exports.config = configSchema.parse({
21
22
  GYAZO_ACCESS_TOKEN: process.env.GYAZO_ACCESS_TOKEN,
@@ -25,6 +26,7 @@ exports.config = configSchema.parse({
25
26
  GYAZO_API_ORIGIN: process.env.GYAZO_API_ORIGIN,
26
27
  GYAZO_UPLOAD_ORIGIN: process.env.GYAZO_UPLOAD_ORIGIN,
27
28
  GYAZO_WEB_ORIGIN: process.env.GYAZO_WEB_ORIGIN,
29
+ GYAZO_IMAGE_ORIGIN: process.env.GYAZO_IMAGE_ORIGIN,
28
30
  });
29
31
  function setAccessToken(token) {
30
32
  exports.config.GYAZO_ACCESS_TOKEN = token;
package/dist/format.js CHANGED
@@ -13,6 +13,7 @@ exports.cleanTextForDomain = cleanTextForDomain;
13
13
  exports.stripInlineUrls = stripInlineUrls;
14
14
  exports.sanitizeSummaryText = sanitizeSummaryText;
15
15
  exports.getAddressEntry = getAddressEntry;
16
+ exports.getAddressComponentCode = getAddressComponentCode;
16
17
  exports.getAddressComponent = getAddressComponent;
17
18
  exports.buildJaLocationLabel = buildJaLocationLabel;
18
19
  exports.buildEnLocationLabel = buildEnLocationLabel;
@@ -97,6 +98,28 @@ function getAddressEntry(exifAddress, locale) {
97
98
  return undefined;
98
99
  return entry;
99
100
  }
101
+ /**
102
+ * The short form of a component, which is what a country code is: `JP` rather
103
+ * than `日本` or `Japan`, and the same in every language.
104
+ */
105
+ function getAddressComponentCode(addressEntry, type) {
106
+ if (!addressEntry || typeof addressEntry !== 'object')
107
+ return undefined;
108
+ const components = Array.isArray(addressEntry.address_components)
109
+ ? addressEntry.address_components
110
+ : [];
111
+ for (const component of components) {
112
+ if (!component || typeof component !== 'object')
113
+ continue;
114
+ const types = Array.isArray(component.types) ? component.types : [];
115
+ if (!types.includes(type))
116
+ continue;
117
+ const value = normalizeText(component.short_name);
118
+ if (value)
119
+ return value;
120
+ }
121
+ return undefined;
122
+ }
100
123
  function getAddressComponent(addressEntry, type) {
101
124
  if (!addressEntry || typeof addressEntry !== 'object')
102
125
  return undefined;
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ program
26
26
  .name('gyazo')
27
27
  .description('Gyazo Memory CLI for AI Secretary')
28
28
  .option('--mcp-server', 'run as a Model Context Protocol server over stdio')
29
- .version('0.2.0');
29
+ .version('0.4.0');
30
30
  (0, config_1.registerConfigCommand)(program);
31
31
  (0, list_1.registerListCommand)(program);
32
32
  (0, get_1.registerGetCommand)(program);
package/dist/mcp.js CHANGED
@@ -20,6 +20,7 @@ const api_1 = require("./api");
20
20
  const credentials_1 = require("./credentials");
21
21
  const ids_1 = require("./ids");
22
22
  const dates_1 = require("./dates");
23
+ const format_1 = require("./format");
23
24
  const memory_1 = require("./services/memory");
24
25
  const analytics_1 = require("./services/analytics");
25
26
  const collections_1 = require("./services/collections");
@@ -35,31 +36,132 @@ function serverVersion() {
35
36
  // level below it, so this holds both in the repository and once installed.
36
37
  return require('../package.json').version;
37
38
  }
39
+ /** null and undefined both mean the capture does not carry the field. */
40
+ function present(value) {
41
+ return value !== null && value !== undefined;
42
+ }
43
+ /**
44
+ * Both languages, always. A Japanese address reads poorly for a place abroad,
45
+ * and an English one reads poorly at home, and which of those applies is not
46
+ * something this server can decide for the model.
47
+ */
48
+ const ADDRESS_LOCALES = ['ja', 'en'];
49
+ function readNumber(value) {
50
+ if (typeof value === 'number')
51
+ return Number.isFinite(value) ? value : undefined;
52
+ if (typeof value === 'string' && value.trim() !== '') {
53
+ const parsed = Number(value);
54
+ return Number.isFinite(parsed) ? parsed : undefined;
55
+ }
56
+ return undefined;
57
+ }
58
+ function readAddresses(exifAddress) {
59
+ const addresses = {};
60
+ for (const locale of ADDRESS_LOCALES) {
61
+ const entry = (0, format_1.getAddressEntry)(exifAddress, locale);
62
+ const text = (0, format_1.normalizeText)(entry?.address);
63
+ if (!text)
64
+ continue;
65
+ const locality = (0, format_1.getAddressComponent)(entry, 'locality');
66
+ const admin1 = (0, format_1.getAddressComponent)(entry, 'administrative_area_level_1');
67
+ addresses[locale] = {
68
+ text,
69
+ ...(locality ? { locality } : {}),
70
+ ...(admin1 ? { admin1 } : {}),
71
+ };
72
+ }
73
+ return Object.keys(addresses).length > 0 ? addresses : undefined;
74
+ }
38
75
  /**
39
- * The fields worth handing to a model: enough to cite a capture and to open it,
40
- * without the parts of the API response it cannot act on. Absent fields stay
41
- * absent rather than becoming null.
76
+ * Where a capture was taken, in the fields a model can reason about.
42
77
  *
43
- * Metadata only, on purpose. Handing image bytes to a model as base64 was the
44
- * ambitious part of the upstream design and it did not hold up in use, so a
45
- * capture is described here and its URLs are given for anything that wants the
46
- * pixels.
78
+ * The coordinates live under `metadata`; the top-level `exif_normalized` is
79
+ * null in every response this CLI reads, though it is still honoured in case
80
+ * an endpoint starts filling it. Altitude and heading only exist in the raw
81
+ * EXIF, which `/api/images/<id>` does not return, so they appear for captures
82
+ * read through a collection and are absent otherwise rather than guessed.
47
83
  */
84
+ function readLocation(image) {
85
+ const source = image?.metadata?.exif_normalized ?? image?.exif_normalized;
86
+ const latitude = source?.latitude;
87
+ const longitude = source?.longitude;
88
+ if (typeof latitude !== 'number' || typeof longitude !== 'number') {
89
+ return undefined;
90
+ }
91
+ const exif = image?.metadata?.exif;
92
+ const altitude = readNumber(exif?.['Altitude']);
93
+ const heading = readNumber(exif?.['GPS Image Direction']);
94
+ const headingReferenceCode = (0, format_1.normalizeText)(exif?.['GPS Image Direction Reference']);
95
+ const headingReference = headingReferenceCode === 'M' ? 'magnetic' : headingReferenceCode === 'T' ? 'true' : undefined;
96
+ const exifAddress = image?.metadata?.exif_address;
97
+ const addresses = readAddresses(exifAddress);
98
+ const countryCode = ADDRESS_LOCALES.map((locale) => (0, format_1.getAddressComponentCode)((0, format_1.getAddressEntry)(exifAddress, locale), 'country')).find(present);
99
+ return {
100
+ latitude,
101
+ longitude,
102
+ ...(altitude !== undefined ? { altitude_m: altitude } : {}),
103
+ ...(heading !== undefined ? { heading_deg: heading } : {}),
104
+ ...(heading !== undefined && headingReference ? { heading_reference: headingReference } : {}),
105
+ ...(countryCode ? { country_code: countryCode } : {}),
106
+ ...(addresses ? { address: addresses } : {}),
107
+ };
108
+ }
109
+ /** When the shutter was pressed, as opposed to when the capture was uploaded. */
110
+ function readCapturedAt(image) {
111
+ const capturedAt = image?.exif_captured_at ?? image?.metadata?.exif_normalized?.time ?? undefined;
112
+ return present(capturedAt) && typeof capturedAt === 'string' ? capturedAt : undefined;
113
+ }
114
+ /**
115
+ * The OCR text, from wherever this response carries it. Same mistake as the
116
+ * coordinates: the responses that have OCR keep it under `metadata`, and the
117
+ * top-level field comes back null.
118
+ */
119
+ function readOcr(image) {
120
+ const ocr = present(image?.ocr) ? image.ocr : image?.metadata?.ocr;
121
+ return present(ocr) && present(ocr.description) ? ocr : undefined;
122
+ }
48
123
  function toMetadata(image) {
124
+ const location = readLocation(image);
125
+ const capturedAt = readCapturedAt(image);
126
+ const ocr = readOcr(image);
49
127
  return {
50
128
  image_id: image.image_id,
51
129
  permalink_url: image.permalink_url,
52
130
  url: image.url,
53
- ...(image.thumb_url !== undefined ? { thumb_url: image.thumb_url } : {}),
54
- ...(image.type !== undefined ? { mimeType: `image/${image.type}` } : {}),
131
+ ...(present(image.thumb_url) ? { thumb_url: image.thumb_url } : {}),
132
+ ...(present(image.type) ? { mimeType: `image/${image.type}` } : {}),
55
133
  created_at: image.created_at,
56
- ...(image.alt_text !== undefined ? { alt_text: image.alt_text } : {}),
57
- ...(image.ocr !== undefined ? { ocr: image.ocr } : {}),
58
- ...(image.metadata !== undefined ? { metadata: image.metadata } : {}),
59
- ...(image.exif_normalized !== undefined ? { exif_normalized: image.exif_normalized } : {}),
134
+ ...(capturedAt !== undefined ? { captured_at: capturedAt } : {}),
135
+ ...(present(image.alt_text) && image.alt_text !== '' ? { alt_text: image.alt_text } : {}),
136
+ ...(ocr !== undefined ? { ocr } : {}),
137
+ ...(location !== undefined ? { location } : {}),
138
+ ...(present(image.metadata) ? { metadata: image.metadata } : {}),
60
139
  };
61
140
  }
62
141
  const NO_IMAGES = { content: [{ type: 'text', text: 'No images found' }] };
142
+ /**
143
+ * Every call, with how long it took, on stderr. stdout belongs to the
144
+ * protocol, and the host that starts this server is where its stderr ends up,
145
+ * which is the only place an operator can see that one tool is slow.
146
+ */
147
+ function logged(name, handler) {
148
+ return async (args) => {
149
+ const startedAt = Date.now();
150
+ const given = Object.entries((args || {}))
151
+ .filter(([, value]) => value !== undefined && value !== false)
152
+ .map(([key, value]) => `${key}=${JSON.stringify(value)}`)
153
+ .join(' ');
154
+ try {
155
+ const result = await handler(args);
156
+ console.error(`[gyazo-mcp] ${name} ok ${Date.now() - startedAt}ms ${given}`.trimEnd());
157
+ return result;
158
+ }
159
+ catch (error) {
160
+ console.error(`[gyazo-mcp] ${name} failed ${Date.now() - startedAt}ms ${given}`.trimEnd(), `- ${error?.message || error}`);
161
+ throw error;
162
+ }
163
+ };
164
+ }
63
165
  function asJsonResult(payload) {
64
166
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
65
167
  }
@@ -108,7 +210,7 @@ function createMcpServer() {
108
210
  .describe('Number of results per page (max: 100)'),
109
211
  },
110
212
  annotations: { readOnlyHint: true, openWorldHint: true },
111
- }, async ({ query, page, per }) => {
213
+ }, logged('gyazo_search', async ({ query, page, per }) => {
112
214
  const images = await (0, api_1.searchImages)(query, page, per);
113
215
  if (!images || images.length === 0) {
114
216
  return NO_IMAGES;
@@ -121,7 +223,7 @@ function createMcpServer() {
121
223
  },
122
224
  ],
123
225
  };
124
- });
226
+ }));
125
227
  server.registerTool('gyazo_image', {
126
228
  title: 'Describe one Gyazo capture',
127
229
  description: 'Fetch the metadata of one capture on Gyazo: its URLs, timestamp, OCR text, ' +
@@ -135,7 +237,7 @@ function createMcpServer() {
135
237
  'https://gyazo.com/<id> permalink, or a direct image URL all work.'),
136
238
  },
137
239
  annotations: { readOnlyHint: true, openWorldHint: true },
138
- }, async ({ id_or_url }) => {
240
+ }, logged('gyazo_image', async ({ id_or_url }) => {
139
241
  const imageId = (0, ids_1.normalizeImageId)(id_or_url);
140
242
  if (!imageId) {
141
243
  throw new Error(`'${id_or_url}' is not a Gyazo image ID or URL. Pass a 32-character ID or a ` +
@@ -147,7 +249,7 @@ function createMcpServer() {
147
249
  return NO_IMAGES;
148
250
  }
149
251
  return asMetadataResult(image);
150
- });
252
+ }));
151
253
  server.registerTool('gyazo_latest_image', {
152
254
  title: 'Describe the most recent Gyazo capture',
153
255
  description: 'Fetch the metadata of the capture the user uploaded most recently. Useful when ' +
@@ -157,14 +259,14 @@ function createMcpServer() {
157
259
  // are dropped rather than refused.
158
260
  inputSchema: {},
159
261
  annotations: { readOnlyHint: true, openWorldHint: true },
160
- }, async () => {
262
+ }, logged('gyazo_latest_image', async () => {
161
263
  const images = await (0, api_1.listImages)(1, 1);
162
264
  const latest = images && images[0];
163
265
  if (!latest) {
164
266
  return NO_IMAGES;
165
267
  }
166
268
  return asMetadataResult(latest);
167
- });
269
+ }));
168
270
  server.registerTool('gyazo_list', {
169
271
  title: 'List Gyazo captures',
170
272
  description: 'List the captures the user uploaded, newest first, taking the same options as ' +
@@ -209,7 +311,7 @@ function createMcpServer() {
209
311
  .describe('Answer from the local cache where possible. Set false to force a fetch'),
210
312
  },
211
313
  annotations: { readOnlyHint: true, openWorldHint: true },
212
- }, async (args) => {
314
+ }, logged('gyazo_list', async (args) => {
213
315
  const { page, limit, today, photos, uploaded, max_pages: maxPages, use_cache: useCache } = args;
214
316
  if (photos && uploaded) {
215
317
  throw new Error('photos and uploaded cannot be used together.');
@@ -236,7 +338,7 @@ function createMcpServer() {
236
338
  alias,
237
339
  });
238
340
  return asMetadataListResult(images);
239
- });
341
+ }));
240
342
  server.registerTool('gyazo_summary', {
241
343
  title: 'Summarise a stretch of Gyazo captures',
242
344
  description: 'What a day or a range adds up to: how many captures each day, and which ' +
@@ -267,12 +369,12 @@ function createMcpServer() {
267
369
  .describe('Answer from the local cache where possible. Set false to force a fetch'),
268
370
  },
269
371
  annotations: { readOnlyHint: true, openWorldHint: true },
270
- }, async (args) => {
372
+ }, logged('gyazo_summary', async (args) => {
271
373
  const { today, limit, max_pages: maxPages, use_cache: useCache } = args;
272
374
  const targetDate = requireDate(args.date, today) || (0, dates_1.buildRecentWeekRangeUntilYesterday)();
273
375
  const dailySummaries = await (0, analytics_1.buildSummary)({ targetDate, maxPages, useCache });
274
376
  return asJsonResult((0, analytics_1.toSummaryJson)(targetDate.dateKey, dailySummaries, limit));
275
- });
377
+ }));
276
378
  server.registerTool('gyazo_collection', {
277
379
  title: 'Read a Gyazo collection',
278
380
  description: 'The metadata of a collection and of the captures in it. A collection ID looks ' +
@@ -288,30 +390,223 @@ function createMcpServer() {
288
390
  .default('added')
289
391
  .describe('Image order: added (as the collection holds them), created (upload time) or ' +
290
392
  'captured (when the photo was taken)'),
393
+ page: zod_1.z.number().int().min(1).default(1).describe('Page of images to read'),
394
+ per: zod_1.z
395
+ .number()
396
+ .int()
397
+ .min(1)
398
+ .max(100)
399
+ .default(100)
400
+ .describe('Images per page (max: 100)'),
291
401
  },
292
402
  annotations: { readOnlyHint: true, openWorldHint: true },
293
- }, async ({ id_or_url, sort }) => {
403
+ }, logged('gyazo_collection', async ({ id_or_url, sort, page, per }) => {
294
404
  const collectionId = (0, ids_1.normalizeCollectionId)(id_or_url);
295
405
  if (!collectionId) {
296
406
  throw new Error(`'${id_or_url}' is not a Gyazo collection ID or URL. Pass a 32-character ID or a ` +
297
407
  'https://gyazo.com/collections/<id> URL. A https://gyazo.com/<id> URL is a single ' +
298
408
  'capture, which gyazo_image reads.');
299
409
  }
300
- const { collection, images } = await (0, collections_1.readCollection)(collectionId, {
410
+ const result = await (0, collections_1.readCollection)(collectionId, {
301
411
  sort: sort,
412
+ paginated: true,
413
+ page,
414
+ per,
302
415
  });
416
+ const { collection, images } = result;
303
417
  return asJsonResult({
304
418
  id: collection?.id ?? collectionId,
305
- ...(collection?.name !== undefined ? { name: collection.name } : {}),
419
+ ...(present(collection?.name) ? { name: collection.name } : {}),
306
420
  ...(collection?.description ? { description: collection.description } : {}),
307
- ...(collection?.url !== undefined ? { url: collection.url } : {}),
308
- ...(collection?.total_image_count !== undefined
309
- ? { total_image_count: collection.total_image_count }
421
+ ...(present(collection?.url) ? { url: collection.url } : {}),
422
+ ...(result.totalImageCount !== undefined
423
+ ? { total_image_count: result.totalImageCount }
310
424
  : {}),
311
- ...(collection?.user !== undefined ? { user: collection.user } : {}),
425
+ returned_image_count: result.returnedImageCount,
426
+ page: result.page,
427
+ per: result.per,
428
+ // Said out loud, because a collection that stops without saying so
429
+ // reads as a complete answer. Ask for the next page to see the rest.
430
+ truncated: result.truncated,
431
+ ...(present(collection?.user) ? { user: collection.user } : {}),
312
432
  images: images.map(toMetadata),
313
433
  });
314
- });
434
+ }));
435
+ server.registerTool('gyazo_recent', {
436
+ title: 'What the user captured recently',
437
+ description: 'The captures that arrived since a moment, or since a capture you have already ' +
438
+ 'seen. Use this when the user says they just captured something, and pass ' +
439
+ 'after_image_id with the newest capture you have already looked at so that you ' +
440
+ 'get only what is new. With no arguments it covers the last 30 minutes.',
441
+ inputSchema: {
442
+ minutes: zod_1.z
443
+ .number()
444
+ .int()
445
+ .min(1)
446
+ .max(1440)
447
+ .optional()
448
+ .describe('How far back to look, in minutes. Defaults to 30 when nothing else is given'),
449
+ since: zod_1.z
450
+ .string()
451
+ .optional()
452
+ .describe('An ISO 8601 timestamp to look back to, instead of minutes'),
453
+ after_image_id: zod_1.z
454
+ .string()
455
+ .optional()
456
+ .describe('The newest capture you have already seen, as an ID or a Gyazo URL. Returns ' +
457
+ 'only what came after it, and reports if it cannot be found'),
458
+ limit: zod_1.z
459
+ .number()
460
+ .int()
461
+ .min(1)
462
+ .max(100)
463
+ .default(20)
464
+ .describe('Most captures to return (max: 100)'),
465
+ max_pages: zod_1.z
466
+ .number()
467
+ .int()
468
+ .min(1)
469
+ .max(20)
470
+ .default(5)
471
+ .describe('How many pages of 100 to walk before giving up on the boundary'),
472
+ },
473
+ annotations: { readOnlyHint: true, openWorldHint: true },
474
+ }, logged('gyazo_recent', async (args) => {
475
+ const { limit, max_pages: maxPages } = args;
476
+ let afterImageId;
477
+ if (args.after_image_id) {
478
+ const normalized = (0, ids_1.normalizeImageId)(args.after_image_id);
479
+ if (!normalized) {
480
+ throw new Error(`'${args.after_image_id}' is not a Gyazo image ID or URL. Pass the ID of the ` +
481
+ 'newest capture you have already seen.');
482
+ }
483
+ afterImageId = normalized;
484
+ }
485
+ let since;
486
+ if (args.since) {
487
+ if (args.minutes !== undefined) {
488
+ throw new Error('since and minutes cannot be used together.');
489
+ }
490
+ const parsed = new Date(args.since);
491
+ if (Number.isNaN(parsed.getTime())) {
492
+ throw new Error(`'${args.since}' is not a timestamp this can read. Pass an ISO 8601 value such ` +
493
+ 'as 2026-09-08T11:42:00+09:00.');
494
+ }
495
+ since = parsed;
496
+ }
497
+ else if (args.minutes !== undefined) {
498
+ since = new Date(Date.now() - args.minutes * 60_000);
499
+ }
500
+ else if (!afterImageId) {
501
+ since = new Date(Date.now() - 30 * 60_000);
502
+ }
503
+ const result = await (0, memory_1.listCapturesSince)({ since, afterImageId, limit, maxPages });
504
+ if (result.watermarkMissing) {
505
+ throw new Error(`after_image_id ${afterImageId} was not found in the ${result.pagesWalked} most ` +
506
+ 'recent pages of captures. It may be older than that, or belong to another ' +
507
+ 'account. Ask for a window in minutes instead, or raise max_pages.');
508
+ }
509
+ return asMetadataListResult(result.images);
510
+ }));
511
+ server.registerTool('gyazo_collections', {
512
+ title: 'Find a Gyazo collection by name',
513
+ description: 'The collections the user has, with their IDs and how many captures each holds. ' +
514
+ 'Use this to turn a collection the user names out loud into the ID that ' +
515
+ 'gyazo_collection needs.',
516
+ inputSchema: {
517
+ query: zod_1.z
518
+ .string()
519
+ .optional()
520
+ .describe('Part of a collection name to match, case-insensitively. Omit for all of them'),
521
+ },
522
+ annotations: { readOnlyHint: true, openWorldHint: true },
523
+ }, logged('gyazo_collections', async ({ query }) => {
524
+ const collections = await (0, collections_1.findCollections)(query);
525
+ if (collections.length === 0) {
526
+ return {
527
+ content: [
528
+ {
529
+ type: 'text',
530
+ text: query
531
+ ? `No collections match ${JSON.stringify(query)}.`
532
+ : 'No collections found.',
533
+ },
534
+ ],
535
+ };
536
+ }
537
+ return asJsonResult(collections.map((collection) => ({
538
+ id: collection.id,
539
+ ...(present(collection.name) ? { name: collection.name } : {}),
540
+ ...(collection.description ? { description: collection.description } : {}),
541
+ ...(present(collection.total_image_count)
542
+ ? { total_image_count: collection.total_image_count }
543
+ : {}),
544
+ ...(present(collection.url) ? { url: collection.url } : {}),
545
+ ...(present(collection.list_updated_at)
546
+ ? { list_updated_at: collection.list_updated_at }
547
+ : {}),
548
+ })));
549
+ }));
550
+ server.registerTool('gyazo_image_content', {
551
+ title: 'Look at a Gyazo capture',
552
+ description: 'The pixels of one capture, as image content you can actually look at. Use it ' +
553
+ 'after gyazo_recent or gyazo_image when the metadata is not enough and you need ' +
554
+ 'to see what the user is looking at: a sign, a menu, a building. Returns a ' +
555
+ 'width-limited rendition rather than the original, which is usually several ' +
556
+ 'megabytes. One capture at a time: for a set, read the metadata first and ask ' +
557
+ 'for the ones that matter.',
558
+ inputSchema: {
559
+ id_or_url: zod_1.z
560
+ .string()
561
+ .min(1)
562
+ .describe('ID or URL of the capture on Gyazo'),
563
+ width: zod_1.z
564
+ .number()
565
+ .int()
566
+ .min(64)
567
+ .max(2000)
568
+ .default(1024)
569
+ .describe('Width in pixels. 1024 is legible for signs and menus; 512 is cheaper'),
570
+ format: zod_1.z
571
+ .enum(['webp', 'jpeg'])
572
+ .default('webp')
573
+ .describe('webp is about a third the size of jpeg for the same width'),
574
+ max_bytes: zod_1.z
575
+ .number()
576
+ .int()
577
+ .min(10_000)
578
+ .max(4_000_000)
579
+ .default(750_000)
580
+ .describe('Refuse rather than return anything larger than this'),
581
+ },
582
+ annotations: { readOnlyHint: true, openWorldHint: true },
583
+ }, logged('gyazo_image_content', async ({ id_or_url, width, format, max_bytes: maxBytes }) => {
584
+ const imageId = (0, ids_1.normalizeImageId)(id_or_url);
585
+ if (!imageId) {
586
+ throw new Error(`'${id_or_url}' is not a Gyazo image ID or URL. Pass a 32-character ID or a ` +
587
+ 'https://gyazo.com/<id> URL.');
588
+ }
589
+ const rendition = await (0, api_1.fetchImageRendition)(imageId, width, format);
590
+ if (rendition.bytes > maxBytes) {
591
+ throw new Error(`The ${width}px ${format} rendition of ${imageId} is ${rendition.bytes} bytes, over ` +
592
+ `the max_bytes limit of ${maxBytes}. Ask for a smaller width, or raise max_bytes. ` +
593
+ `The rendition is at ${rendition.url}.`);
594
+ }
595
+ return {
596
+ content: [
597
+ {
598
+ type: 'text',
599
+ text: `${imageId} at ${rendition.width}px wide, ${rendition.bytes} bytes as ` +
600
+ `${rendition.mimeType}. This is a resized rendition, not the original.`,
601
+ },
602
+ {
603
+ type: 'image',
604
+ data: rendition.data.toString('base64'),
605
+ mimeType: rendition.mimeType,
606
+ },
607
+ ],
608
+ };
609
+ }));
315
610
  return server;
316
611
  }
317
612
  async function runMcpServer() {
@@ -7,6 +7,7 @@ exports.collectionSortKey = collectionSortKey;
7
7
  exports.sortCollectionImages = sortCollectionImages;
8
8
  exports.printCollectionMarkdown = printCollectionMarkdown;
9
9
  exports.readCollection = readCollection;
10
+ exports.findCollections = findCollections;
10
11
  /**
11
12
  * Collections. A collection ID is indistinguishable from an image ID, so the
12
13
  * only unambiguous way in is the URL form, which is why the check here is
@@ -14,6 +15,7 @@ exports.readCollection = readCollection;
14
15
  * edits collections.
15
16
  */
16
17
  const api_1 = require("../api");
18
+ const credentials_1 = require("../credentials");
17
19
  const ids_1 = require("../ids");
18
20
  const format_1 = require("../format");
19
21
  const images_1 = require("./images");
@@ -84,14 +86,57 @@ function printCollectionMarkdown(collection, images) {
84
86
  (0, images_1.printListImages)(images);
85
87
  }
86
88
  }
87
- /**
88
- * A collection and its images in the requested order. The API returns the
89
- * images in the order they were added, which is the default here too.
90
- */
91
89
  async function readCollection(collectionId, options = {}) {
90
+ const sort = options.sort || 'added';
91
+ const page = options.page && options.page > 0 ? options.page : 1;
92
+ const per = options.per && options.per > 0 ? Math.min(options.per, 100) : 100;
93
+ const useApi = Boolean(options.paginated) && !options.anonymous && Boolean((0, credentials_1.resolveAccessToken)());
94
+ if (useApi) {
95
+ const [collection, images] = await Promise.all([
96
+ (0, api_1.getCollectionDetail)(collectionId),
97
+ (0, api_1.listCollectionImages)(collectionId, page, per),
98
+ ]);
99
+ const total = collection?.total_image_count;
100
+ const sorted = sortCollectionImages(images, sort);
101
+ return {
102
+ collection,
103
+ images: sorted,
104
+ page,
105
+ per,
106
+ returnedImageCount: sorted.length,
107
+ totalImageCount: typeof total === 'number' ? total : undefined,
108
+ truncated: typeof total === 'number' ? page * per < total : false,
109
+ source: 'api',
110
+ };
111
+ }
112
+ // Only --anonymous drops the token here: a private collection of your own
113
+ // reads fine through the web endpoint with it.
92
114
  const collection = await (0, api_1.getCollection)(collectionId, {
93
115
  anonymous: Boolean(options.anonymous),
94
116
  });
95
- const images = sortCollectionImages(Array.isArray(collection?.images) ? collection.images : [], options.sort || 'added');
96
- return { collection, images };
117
+ const images = sortCollectionImages(Array.isArray(collection?.images) ? collection.images : [], sort);
118
+ const total = collection?.total_image_count;
119
+ return {
120
+ collection,
121
+ images,
122
+ page: 1,
123
+ per: images.length,
124
+ returnedImageCount: images.length,
125
+ totalImageCount: typeof total === 'number' ? total : undefined,
126
+ truncated: typeof total === 'number' ? images.length < total : false,
127
+ source: 'web',
128
+ };
129
+ }
130
+ /**
131
+ * Collections whose name contains the query, or all of them when there is no
132
+ * query. Matching is case-insensitive and ignores surrounding whitespace,
133
+ * because a name people say out loud rarely matches one stored with emoji and
134
+ * padding.
135
+ */
136
+ async function findCollections(query) {
137
+ const collections = await (0, api_1.listCollections)();
138
+ const needle = (0, format_1.normalizeText)(query)?.toLowerCase();
139
+ if (!needle)
140
+ return collections;
141
+ return collections.filter((collection) => (collection.name || '').toLowerCase().includes(needle));
97
142
  }
@@ -14,6 +14,7 @@ exports.cacheSearchResultImages = cacheSearchResultImages;
14
14
  exports.supplementAltTextFromSearchCache = supplementAltTextFromSearchCache;
15
15
  exports.supplementAltTextForDisplay = supplementAltTextForDisplay;
16
16
  exports.listCaptures = listCaptures;
17
+ exports.listCapturesSince = listCapturesSince;
17
18
  /**
18
19
  * The memory this CLI keeps: the local cache of captures, and the walks over
19
20
  * the Gyazo API that fill it. A command asks for a day or a range, and this
@@ -371,3 +372,46 @@ async function listCaptures(options) {
371
372
  }
372
373
  return { images: await (0, api_1.listImages)(pageNumber, limit) };
373
374
  }
375
+ /**
376
+ * What arrived since a moment, or since a capture. The listing comes back
377
+ * newest first, so the walk stops at the first capture that is older than the
378
+ * boundary rather than reading to the end.
379
+ *
380
+ * A watermark that never turns up is reported, not papered over: returning
381
+ * everything walked would read as "all of this is new", which is the wrong
382
+ * answer told confidently.
383
+ */
384
+ async function listCapturesSince(options) {
385
+ const { since, afterImageId, limit, maxPages } = options;
386
+ const collected = [];
387
+ let pagesWalked = 0;
388
+ let reachedBoundary = false;
389
+ for (let page = 1; page <= maxPages && !reachedBoundary; page++) {
390
+ const images = await (0, api_1.listImages)(page, 100);
391
+ pagesWalked = page;
392
+ if (images.length === 0)
393
+ break;
394
+ for (const image of images) {
395
+ if (afterImageId && image.image_id === afterImageId) {
396
+ reachedBoundary = true;
397
+ break;
398
+ }
399
+ if (since) {
400
+ const createdAt = new Date(image.created_at);
401
+ if (Number.isNaN(createdAt.getTime()))
402
+ continue;
403
+ if (createdAt < since) {
404
+ reachedBoundary = true;
405
+ break;
406
+ }
407
+ }
408
+ collected.push(image);
409
+ }
410
+ if (images.length < 100)
411
+ break;
412
+ }
413
+ if (afterImageId && !reachedBoundary) {
414
+ return { images: [], pagesWalked, watermarkMissing: true };
415
+ }
416
+ return { images: collected.slice(0, limit), pagesWalked };
417
+ }
@@ -13,7 +13,7 @@ Adopt and document the existing top-level command structure.
13
13
 
14
14
  ### 1. Program Metadata
15
15
  - Binary name: `gyazo`
16
- - Version: `0.2.0`
16
+ - Version: `0.4.0`
17
17
  - Description: `Gyazo Memory CLI for AI Secretary`
18
18
 
19
19
  ### 2. Commands
@@ -2,9 +2,10 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Accepted. `gyazo_search`, `gyazo_image`, `gyazo_latest_image`, `gyazo_list`,
6
- `gyazo_summary` and `gyazo_collection` implemented, all read-only and all
7
- metadata only. `gyazo_upload` deliberately not.
5
+ Accepted. Nine tools, all read-only: `gyazo_search`, `gyazo_image`,
6
+ `gyazo_image_content`, `gyazo_latest_image`, `gyazo_list`, `gyazo_recent`,
7
+ `gyazo_summary`, `gyazo_collection`, `gyazo_collections`. `gyazo_upload`
8
+ deliberately not.
8
9
 
9
10
  ## Context
10
11
 
@@ -45,6 +46,28 @@ So every tool here returns metadata and URLs, and none returns pixels. A
45
46
  client that wants to show a capture opens the URL in the result. This also
46
47
  drops sharp from the dependency list entirely.
47
48
 
49
+ ## Pixels, after all, for one capture at a time
50
+
51
+ Metadata-only held for lists and searches and turned out to be too strict for
52
+ a single capture. The use case that matters is a person walking somewhere
53
+ unfamiliar saying "look at this": GPS and OCR give the place and the letters,
54
+ not what they are looking at.
55
+
56
+ `gyazo_image_content` is a separate tool rather than a flag on `gyazo_image`,
57
+ so a call that only wants metadata cannot come back with a megabyte. It
58
+ returns a width-limited rendition from Gyazo's own resize route, which needs
59
+ no credentials, so there is still no image library here. 1024px is about 130 KB
60
+ as webp. Over `max_bytes` it refuses and says what to change, rather than
61
+ sending something the host will drop.
62
+
63
+ ## Differential retrieval
64
+
65
+ Four captures in a row, then "look at what I just captured", is not a question
66
+ `gyazo_latest_image` can answer. `gyazo_recent` takes a window in minutes, an
67
+ explicit `since`, or `after_image_id` as a watermark, and returns what came
68
+ after. A watermark that cannot be found is reported: returning everything
69
+ walked would read as "all of this is new".
70
+
48
71
  ## Read-only by construction
49
72
 
50
73
  `gyazo_upload` is not implemented and no other tool writes. There is no need
@@ -74,7 +97,13 @@ exiting ones are now thin wrappers over them.
74
97
 
75
98
  - The result payload is the fields a model can act on: `image_id`,
76
99
  `permalink_url`, `url`, `thumb_url`, `mimeType`, `created_at`, `alt_text`,
77
- `ocr`, `metadata`, `exif_normalized`. Absent fields stay absent.
100
+ `ocr`, `location`, `metadata`. Absent fields stay absent, and a null field
101
+ counts as absent.
102
+ - `location` and `ocr` are read from under `metadata`, which is where Gyazo
103
+ puts them. The top-level `exif_normalized` and `ocr` are null in every
104
+ response this CLI receives, and reading those was why coordinates never
105
+ appeared in any payload. Fixtures written from the shape the code expected
106
+ hid it; they now come from real responses.
78
107
  - No `uri` field, unlike upstream: it points at an MCP resource, and this
79
108
  server does not serve resources yet.
80
109
  - `gyazo_latest_image` takes no arguments, while upstream declared a `name`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuiseki/gyazocli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Gyazo Memory CLI for AI Secretary",
5
5
  "repository": {
6
6
  "type": "git",