@manudota/artist-mcp 1.8.2 → 1.8.3-staging.91

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
@@ -74,8 +74,8 @@ Google is supporting evidence, and needs the separate Google connection:
74
74
  | --- | --- |
75
75
  | `list_emails` | Gmail search results — subject, sender, date, snippet |
76
76
  | `read_email` | Message body, plus what is attached by name, type, and size |
77
- | `map_attachment` | What is on each page of a PDF, without reading it |
78
- | `read_attachment` | Images as pictures; PDFs and `.docx` as text, in page ranges |
77
+ | `map_gmail_attachment` | What is on each page of a PDF, without reading it |
78
+ | `read_gmail_attachment` | Images as pictures; PDFs and `.docx` as text, in page ranges |
79
79
  | `list_events` | Calendar events in a window, recurrences expanded and flagged |
80
80
  | `read_event` | Description and attendees for one event |
81
81
 
@@ -11,6 +11,19 @@
11
11
  * - `npm:unpdf@1` became a normal dependency import.
12
12
  * - Response shapes are declared, because json() is typed loosely under Deno.
13
13
  */
14
+ /**
15
+ * Two ceilings, and neither is decoration.
16
+ *
17
+ * Extraction decodes a PDF into memory, and the cost tracks pages and text
18
+ * volume rather than file size: a 195 KB, 176-page file measured 185 MB of
19
+ * heap when every page was read at once, against a 256 MB function. So the
20
+ * character cap is enforced *during* the page loop with an early stop, which
21
+ * held the same file to 19 MB. A byte cap alone does not protect this.
22
+ *
23
+ * The byte cap is the cruder guard, on what is fetched at all. Contracts and
24
+ * riders sit far below it — a real 7-page rider was 0.6 MB.
25
+ */
26
+ export declare const MAX_ATTACHMENT_BYTES: number;
14
27
  /**
15
28
  * Exported because `read_note` holds itself to the same number. One policy on
16
29
  * how much text may arrive in a single answer, in one place, rather than two
@@ -131,6 +144,42 @@ export declare function imageResult(meta: {
131
144
  text: string;
132
145
  };
133
146
  export declare function unsupportedNote(mimeType: string, filename: string): string;
147
+ /**
148
+ * How bytes arrive. Everything below this line works on the result and has no
149
+ * idea where it came from, which is what lets one PDF/image/.docx path serve
150
+ * both a mail message and a OneNote page. See issue #70.
151
+ */
152
+ export type AttachmentLoader = () => Promise<{
153
+ oversized: true;
154
+ meta: AttachmentMeta;
155
+ } | {
156
+ oversized: false;
157
+ meta: AttachmentMeta;
158
+ bytes: Uint8Array;
159
+ }>;
160
+ /**
161
+ * The Gmail loader: the only part of this file that knows about messages.
162
+ *
163
+ * The ids are validated here, when the loader is built, rather than inside the
164
+ * closure. A malformed id is a caller mistake and should be reported before any
165
+ * other argument is looked at, which is where it was reported before the loader
166
+ * became a parameter.
167
+ */
168
+ export declare function gmailLoader(token: string, emailId: unknown, attachmentId: unknown): AttachmentLoader;
169
+ /**
170
+ * What is known about a file before it is read, whatever it came from.
171
+ *
172
+ * Written out rather than derived from the Gmail entry it used to be, because a
173
+ * page resource has no Gmail id and `size` is genuinely unknown until the bytes
174
+ * arrive: OneNote offers no size ahead of the fetch at all. Gmail's own entries
175
+ * satisfy this structurally and carry their extra field along untouched.
176
+ */
177
+ export type AttachmentMeta = {
178
+ id: string;
179
+ filename: string;
180
+ mime_type: string;
181
+ size: number | null;
182
+ };
134
183
  /**
135
184
  * Map an attachment: what is on each page, without reading any of it.
136
185
  *
@@ -138,10 +187,16 @@ export declare function unsupportedNote(mimeType: string, filename: string): str
138
187
  * page one. This costs one text pass and returns a few hundred bytes, so a
139
188
  * caller can name the pages worth reading and then read only those.
140
189
  */
141
- export declare function mapAttachment(token: string, emailId: unknown, attachmentId: unknown): Promise<{
190
+ export declare function mapAttachment(load: AttachmentLoader,
191
+ /**
192
+ * The read tool to point at. Passed in rather than hardcoded: the map is
193
+ * source-neutral, and naming the mail tool while mapping a page attachment
194
+ * sends the model to a tool that cannot open it. See issue #70.
195
+ */
196
+ readTool: string): Promise<{
142
197
  filename: string;
143
198
  mime_type: string;
144
- size: number;
199
+ size: number | null;
145
200
  kind: "too_large";
146
201
  text: string;
147
202
  note: string;
@@ -173,7 +228,7 @@ export declare function mapAttachment(token: string, emailId: unknown, attachmen
173
228
  mime_type: string;
174
229
  size: number;
175
230
  }>;
176
- export declare function readAttachment(token: string, emailId: unknown, attachmentId: unknown, fromPage: unknown, pageCount: unknown): Promise<{
231
+ export declare function readAttachment(load: AttachmentLoader, fromPage: unknown, pageCount: unknown): Promise<{
177
232
  kind: "unsupported";
178
233
  images: never[];
179
234
  note: string;
@@ -197,7 +252,7 @@ export declare function readAttachment(token: string, emailId: unknown, attachme
197
252
  } | {
198
253
  filename: string;
199
254
  mime_type: string;
200
- size: number;
255
+ size: number | null;
201
256
  kind: "too_large";
202
257
  text: string;
203
258
  note: string;
@@ -32,7 +32,7 @@ const failure = (message) => new GraphError(message, false);
32
32
  * The byte cap is the cruder guard, on what is fetched at all. Contracts and
33
33
  * riders sit far below it — a real 7-page rider was 0.6 MB.
34
34
  */
35
- const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
35
+ export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
36
36
  /**
37
37
  * Exported because `read_note` holds itself to the same number. One policy on
38
38
  * how much text may arrive in a single answer, in one place, rather than two
@@ -844,7 +844,15 @@ export function unsupportedNote(mimeType, filename) {
844
844
  return `Reading ${mimeType} attachments is not built yet, so the contents of ` +
845
845
  `${filename} have not been read.`;
846
846
  }
847
- async function loadAttachment(token, emailId, attachmentId) {
847
+ /**
848
+ * The Gmail loader: the only part of this file that knows about messages.
849
+ *
850
+ * The ids are validated here, when the loader is built, rather than inside the
851
+ * closure. A malformed id is a caller mistake and should be reported before any
852
+ * other argument is looked at, which is where it was reported before the loader
853
+ * became a parameter.
854
+ */
855
+ export function gmailLoader(token, emailId, attachmentId) {
848
856
  if (typeof emailId !== "string" || !GMAIL_ID.test(emailId)) {
849
857
  throw failure("email_id is missing or malformed.");
850
858
  }
@@ -852,42 +860,52 @@ async function loadAttachment(token, emailId, attachmentId) {
852
860
  throw failure("attachment_id is missing or malformed.");
853
861
  }
854
862
  const id = encodeURIComponent(emailId);
855
- const msgRes = await gmailGet(`/users/me/messages/${id}?format=full`, token);
856
- const msg = (await msgRes.json());
857
- const attachments = extractAttachments(msg.payload);
858
- const meta = attachments.find((a) => a.id === attachmentId);
859
- if (!meta) {
860
- // Name what is there. A bare "not found" invites the caller to retry with
861
- // the same id, and this is the one error a caller can actually act on.
862
- const available = attachments.length
863
- ? attachments.map((a) => `${a.id} (${a.filename})`).join(", ")
864
- : "none";
865
- throw failure(`That message has no attachment ${attachmentId}. Available: ${available}.`);
866
- }
867
- if (meta.size !== null && meta.size > MAX_ATTACHMENT_BYTES) {
868
- return { oversized: true, meta };
869
- }
870
- const attRes = await gmailGet(`/users/me/messages/${id}/attachments/${encodeURIComponent(meta.gmail_id)}`, token);
871
- const payload = (await attRes.json());
872
- if (typeof payload.data !== "string") {
873
- throw failure("Gmail returned no data for that attachment.");
874
- }
875
- return { oversized: false, meta, bytes: decodeBytes(payload.data) };
863
+ return async () => {
864
+ const msgRes = await gmailGet(`/users/me/messages/${id}?format=full`, token);
865
+ const msg = (await msgRes.json());
866
+ const attachments = extractAttachments(msg.payload);
867
+ const meta = attachments.find((a) => a.id === attachmentId);
868
+ if (!meta) {
869
+ // Name what is there. A bare "not found" invites the caller to retry with
870
+ // the same id, and this is the one error a caller can actually act on.
871
+ const available = attachments.length
872
+ ? attachments.map((a) => `${a.id} (${a.filename})`).join(", ")
873
+ : "none";
874
+ throw failure(`That message has no attachment ${attachmentId}. Available: ${available}.`);
875
+ }
876
+ if (meta.size !== null && meta.size > MAX_ATTACHMENT_BYTES) {
877
+ return { oversized: true, meta };
878
+ }
879
+ const attRes = await gmailGet(`/users/me/messages/${id}/attachments/${encodeURIComponent(meta.gmail_id)}`, token);
880
+ const payload = (await attRes.json());
881
+ if (typeof payload.data !== "string") {
882
+ throw failure("Gmail returned no data for that attachment.");
883
+ }
884
+ return { oversized: false, meta, bytes: decodeBytes(payload.data) };
885
+ };
876
886
  }
877
887
  /** The refusal, worded once so reading and mapping cannot drift apart. */
878
888
  function tooLargeResult(meta) {
879
889
  return {
880
890
  filename: meta.filename,
881
891
  mime_type: meta.mime_type,
882
- size: meta.size ?? 0,
892
+ // Null, not 0. A size nobody measured must not arrive as a number: the
893
+ // renderer shows no size line at all rather than "0 B" beside a note
894
+ // saying the file is at least the limit. See issue #70.
895
+ size: meta.size,
883
896
  kind: "too_large",
884
897
  text: "",
885
- note:
886
- // One decimal: rounding 13.8 to "14" overstates a file sitting near
887
- // the limit, and near the limit is exactly when the number is read.
888
- `This file is ${((meta.size ?? 0) / (1024 * 1024)).toFixed(1)} MB, above the ` +
889
- `${MAX_ATTACHMENT_BYTES / (1024 * 1024)} MB limit for reading in chat. ` +
890
- `It was not fetched.`,
898
+ note: meta.size === null
899
+ // No size was ever available -- a OneNote resource reports none, and the
900
+ // fetch was stopped at the limit rather than run to the end. "At least"
901
+ // is all that can be said, so it is all that is said.
902
+ ? `This file is at least the ${MAX_ATTACHMENT_BYTES / (1024 * 1024)} MB ` +
903
+ `limit for reading in chat, so it was not read to the end.`
904
+ // One decimal: rounding 13.8 to "14" overstates a file sitting near
905
+ // the limit, and near the limit is exactly when the number is read.
906
+ : `This file is ${(meta.size / (1024 * 1024)).toFixed(1)} MB, above the ` +
907
+ `${MAX_ATTACHMENT_BYTES / (1024 * 1024)} MB limit for reading in chat. ` +
908
+ `It was not fetched.`,
891
909
  };
892
910
  }
893
911
  /**
@@ -897,8 +915,14 @@ function tooLargeResult(meta) {
897
915
  * page one. This costs one text pass and returns a few hundred bytes, so a
898
916
  * caller can name the pages worth reading and then read only those.
899
917
  */
900
- export async function mapAttachment(token, emailId, attachmentId) {
901
- const loaded = await loadAttachment(token, emailId, attachmentId);
918
+ export async function mapAttachment(load,
919
+ /**
920
+ * The read tool to point at. Passed in rather than hardcoded: the map is
921
+ * source-neutral, and naming the mail tool while mapping a page attachment
922
+ * sends the model to a tool that cannot open it. See issue #70.
923
+ */
924
+ readTool) {
925
+ const loaded = await load();
902
926
  if (loaded.oversized)
903
927
  return tooLargeResult(loaded.meta);
904
928
  const { meta, bytes } = loaded;
@@ -918,7 +942,7 @@ export async function mapAttachment(token, emailId, attachmentId) {
918
942
  (doc.parts_total > 1 ? ` in ${doc.parts_total} parts` : "") +
919
943
  `. There is no page map to give: a .docx records no pages, and its ` +
920
944
  `heading styles are not dependable enough to divide it honestly. ` +
921
- `Read it with read_attachment.`
945
+ `Read it with ${readTool}.`
922
946
  : `${meta.filename} could not be opened as a Word document.`,
923
947
  };
924
948
  }
@@ -956,18 +980,12 @@ export async function mapAttachment(token, emailId, attachmentId) {
956
980
  : `Every page of this ${map.pages_total}-page file is a picture, so ` +
957
981
  `there is nothing to map. `) +
958
982
  `A scan cannot be searched without reading the pages themselves — ask ` +
959
- `read_attachment for them.`
960
- : `${map.pages_total} pages. Use read_attachment with from_page and ` +
983
+ `${readTool} for them.`
984
+ : `${map.pages_total} pages. Use ${readTool} with from_page and ` +
961
985
  `page_count to read the ones that matter, rather than reading it all.`,
962
986
  };
963
987
  }
964
- export async function readAttachment(token, emailId, attachmentId, fromPage, pageCount) {
965
- if (typeof emailId !== "string" || !GMAIL_ID.test(emailId)) {
966
- throw failure("email_id is missing or malformed.");
967
- }
968
- if (typeof attachmentId !== "string" || !ATTACHMENT_ID.test(attachmentId)) {
969
- throw failure("attachment_id is missing or malformed.");
970
- }
988
+ export async function readAttachment(load, fromPage, pageCount) {
971
989
  if (fromPage !== undefined && fromPage !== null &&
972
990
  (typeof fromPage !== "number" || !Number.isFinite(fromPage) || fromPage < 1)) {
973
991
  throw failure("from_page must be a page number, 1 or greater.");
@@ -976,7 +994,7 @@ export async function readAttachment(token, emailId, attachmentId, fromPage, pag
976
994
  (typeof pageCount !== "number" || !Number.isFinite(pageCount) || pageCount < 1)) {
977
995
  throw failure("page_count must be a number of pages, 1 or greater.");
978
996
  }
979
- const loaded = await loadAttachment(token, emailId, attachmentId);
997
+ const loaded = await load();
980
998
  if (loaded.oversized)
981
999
  return tooLargeResult(loaded.meta);
982
1000
  const { meta, bytes } = loaded;
package/dist/connect.d.ts CHANGED
@@ -11,6 +11,16 @@
11
11
  * opt-in — the same asymmetry the product has everywhere else.
12
12
  */
13
13
  export declare const runConnect: (input?: string) => Promise<void>;
14
+ /**
15
+ * Reconnect whatever has lapsed, without the user having to work out which.
16
+ *
17
+ * The reconnect was always one command; knowing that it was needed, and for
18
+ * which provider, was the part that cost people a session. This closes that
19
+ * gap — and refuses to do anything when nothing has lapsed, so it can be run on
20
+ * a hunch without silently sending someone through a consent screen they did
21
+ * not need.
22
+ */
23
+ export declare const runReconnect: () => Promise<void>;
14
24
  /**
15
25
  * Removing the stored token stops this machine using the connection, but it
16
26
  * does not revoke the grant at the provider — only the user's account page can
package/dist/connect.js CHANGED
@@ -16,6 +16,7 @@ import { ENTRY_NAME, configPath, readConfig } from './config.js';
16
16
  import { parseGrants } from './grants.js';
17
17
  import { WRITE_SCOPES, connect } from './oauth.js';
18
18
  import { PROVIDERS } from './oauth.js';
19
+ import { expiryAtConnect, expiryNotice, expiryState } from './expiry.js';
19
20
  import { clearProvider, loadTokens, readProvider } from './tokens.js';
20
21
  const PROVIDER_NAMES = Object.keys(PROVIDERS);
21
22
  const parseProvider = (input) => {
@@ -40,7 +41,7 @@ export const runConnect = async (input) => {
40
41
  // provider, so passing all of them asks each provider for only its own.
41
42
  const grants = await installedGrants();
42
43
  const relevant = grants.filter((name) => WRITE_SCOPES[name]?.provider === provider);
43
- await connect(provider, grants);
44
+ const expiresAt = await connect(provider, grants);
44
45
  console.log(`\n${PROVIDERS[provider].label} connected — ${PURPOSE[provider]}.`);
45
46
  if (relevant.length === 0) {
46
47
  console.log('Read-only. Nothing is ever written back to your account.');
@@ -56,10 +57,42 @@ export const runConnect = async (input) => {
56
57
  : 'Nothing else is ever written back: not mail, and no event is ' +
57
58
  'changed except by the reschedule you granted.'));
58
59
  }
60
+ // Said at the one moment the user is certainly reading, which is the whole
61
+ // point: #94 describes a connection that dies weekly, an error that correctly
62
+ // advises a reconnect, and a cause nobody could see from either.
63
+ const lapses = expiryAtConnect(provider, expiresAt);
64
+ if (lapses !== undefined)
65
+ console.log(`\n${lapses}`);
59
66
  if (provider === 'microsoft' && (await readProvider('google')) === undefined) {
60
67
  console.log('\nTo add Gmail and Calendar as evidence: artist-mcp connect google');
61
68
  }
62
69
  };
70
+ /**
71
+ * Reconnect whatever has lapsed, without the user having to work out which.
72
+ *
73
+ * The reconnect was always one command; knowing that it was needed, and for
74
+ * which provider, was the part that cost people a session. This closes that
75
+ * gap — and refuses to do anything when nothing has lapsed, so it can be run on
76
+ * a hunch without silently sending someone through a consent screen they did
77
+ * not need.
78
+ */
79
+ export const runReconnect = async () => {
80
+ const { providers } = await loadTokens();
81
+ const lapsed = PROVIDER_NAMES.filter((name) => {
82
+ const entry = providers[name];
83
+ return entry !== undefined && expiryState(entry).kind === 'lapsed';
84
+ });
85
+ if (lapsed.length === 0) {
86
+ console.log('Nothing has lapsed. Run `artist-mcp status` to see what is connected.');
87
+ // Not an error: "nothing to do" is the answer, and exiting non-zero would
88
+ // make a scheduled check look like a failure every time it worked.
89
+ return;
90
+ }
91
+ for (const provider of lapsed) {
92
+ console.log(`\n${PROVIDERS[provider].label} lapsed — reconnecting.`);
93
+ await runConnect(provider);
94
+ }
95
+ };
63
96
  /**
64
97
  * Removing the stored token stops this machine using the connection, but it
65
98
  * does not revoke the grant at the provider — only the user's account page can
@@ -244,11 +277,26 @@ export const runStatus = async () => {
244
277
  console.log('Nothing connected on this machine. Start with: artist-mcp connect');
245
278
  return;
246
279
  }
280
+ let anyLapsed = false;
247
281
  for (const name of PROVIDER_NAMES) {
248
282
  const entry = providers[name];
249
283
  const label = PROVIDERS[name].label.padEnd(10);
250
- console.log(entry === undefined
251
- ? `${label} not connected`
252
- : `${label} connected ${entry.connectedAt.slice(0, 10)} — ${entry.scope}`);
284
+ if (entry === undefined) {
285
+ console.log(`${label} not connected`);
286
+ continue;
287
+ }
288
+ console.log(`${label} connected ${entry.connectedAt.slice(0, 10)} — ${entry.scope}`);
289
+ // On its own line and only when there is something to say. A connection
290
+ // with a week left says nothing, so the day it does say something the line
291
+ // is new rather than furniture.
292
+ const notice = expiryNotice(name, entry);
293
+ if (notice !== undefined) {
294
+ console.log(`${' '.repeat(11)}${notice}`);
295
+ if (expiryState(entry).kind === 'lapsed')
296
+ anyLapsed = true;
297
+ }
298
+ }
299
+ if (anyLapsed) {
300
+ console.log('\nOr reconnect everything that lapsed at once: artist-mcp reconnect');
253
301
  }
254
302
  };
@@ -50,14 +50,22 @@ export declare const OPERATIONS: {
50
50
  readonly provider: "google";
51
51
  readonly effect: "read";
52
52
  };
53
- readonly read_attachment: {
53
+ readonly read_gmail_attachment: {
54
54
  readonly provider: "google";
55
55
  readonly effect: "read";
56
56
  };
57
- readonly map_attachment: {
57
+ readonly map_gmail_attachment: {
58
58
  readonly provider: "google";
59
59
  readonly effect: "read";
60
60
  };
61
+ readonly read_page_attachment: {
62
+ readonly provider: "microsoft";
63
+ readonly effect: "read";
64
+ };
65
+ readonly map_page_attachment: {
66
+ readonly provider: "microsoft";
67
+ readonly effect: "read";
68
+ };
61
69
  readonly list_events: {
62
70
  readonly provider: "google";
63
71
  readonly effect: "read";
package/dist/dispatch.js CHANGED
@@ -12,7 +12,8 @@
12
12
  */
13
13
  import { GraphError } from './client.js';
14
14
  import { recordWrite } from './audit.js';
15
- import { mapAttachment, readAttachment } from './attachments.js';
15
+ import { gmailLoader, mapAttachment, readAttachment } from './attachments.js';
16
+ import { oneNoteLoader } from './page-attachments.js';
16
17
  import { createEvent, deleteEvent, listCalendars, listEvents, previewDeleteEvent, previewEvent, previewRescheduleEvent, readEvent, rescheduleEvent, } from './calendar.js';
17
18
  import { listEmails, readEmail } from './mail.js';
18
19
  import { listNotes, mapNotes, readNote } from './notes.js';
@@ -42,8 +43,14 @@ export const OPERATIONS = {
42
43
  read_note: { provider: 'microsoft', effect: 'read' },
43
44
  list_emails: { provider: 'google', effect: 'read' },
44
45
  read_email: { provider: 'google', effect: 'read' },
45
- read_attachment: { provider: 'google', effect: 'read' },
46
- map_attachment: { provider: 'google', effect: 'read' },
46
+ read_gmail_attachment: { provider: 'google', effect: 'read' },
47
+ map_gmail_attachment: { provider: 'google', effect: 'read' },
48
+ // Separate rows rather than a source parameter on the two above. The token is
49
+ // resolved from this table before the call is made, so one operation cannot
50
+ // span two providers without breaking the rule that a Gmail call never spends
51
+ // a Microsoft token. The model still sees two tools; see issue #70.
52
+ read_page_attachment: { provider: 'microsoft', effect: 'read' },
53
+ map_page_attachment: { provider: 'microsoft', effect: 'read' },
47
54
  list_events: { provider: 'google', effect: 'read' },
48
55
  read_event: { provider: 'google', effect: 'read' },
49
56
  list_calendars: { provider: 'google', effect: 'read' },
@@ -105,10 +112,14 @@ export const dispatchWith = (resolve, record = recordWrite) => async (op, params
105
112
  return (await listEmails(token, params.query));
106
113
  case 'read_email':
107
114
  return (await readEmail(token, params.email_id));
108
- case 'map_attachment':
109
- return (await mapAttachment(token, params.email_id, params.attachment_id));
110
- case 'read_attachment':
111
- return (await readAttachment(token, params.email_id, params.attachment_id, params.from_page, params.page_count));
115
+ case 'map_gmail_attachment':
116
+ return (await mapAttachment(gmailLoader(token, params.email_id, params.attachment_id), 'read_gmail_attachment'));
117
+ case 'read_gmail_attachment':
118
+ return (await readAttachment(gmailLoader(token, params.email_id, params.attachment_id), params.from_page, params.page_count));
119
+ case 'map_page_attachment':
120
+ return (await mapAttachment(oneNoteLoader(token, params.note_id, params.attachment_id), 'read_page_attachment'));
121
+ case 'read_page_attachment':
122
+ return (await readAttachment(oneNoteLoader(token, params.note_id, params.attachment_id), params.from_page, params.page_count));
112
123
  case 'list_events':
113
124
  return (await listEvents(token, params.calendar_id, params.query, params.time_min, params.time_max));
114
125
  case 'read_event':
@@ -0,0 +1,62 @@
1
+ /**
2
+ * What a connection's age means, and what to say about it.
3
+ *
4
+ * Split out and pure because it is a decision rather than an integration: the
5
+ * refresh failure in oauth.ts and the report in connect.ts must not describe
6
+ * the same connection differently, and the only way to be sure of that is for
7
+ * both to call this. It also makes the arithmetic testable against a fixed
8
+ * clock, which is the half that is easy to get wrong and hard to notice.
9
+ *
10
+ * The fact being reasoned about is #94: Google expires refresh tokens issued
11
+ * by an app in Testing publishing status after seven days. Nothing here knows
12
+ * that number — it arrives with the connection, from the deployment (see
13
+ * `statedExpiry`), so this file stays true when the app is verified.
14
+ */
15
+ import type { ProviderName, StoredTokens } from './tokens.js';
16
+ /** The one command that fixes any of this, per provider. */
17
+ export declare const reconnectCommand: (provider: ProviderName) => string;
18
+ export type ExpiryState =
19
+ /** The provider stated no limit, so there is nothing to say. */
20
+ {
21
+ kind: 'unlimited';
22
+ }
23
+ /** Stated a limit, still inside it. */
24
+ | {
25
+ kind: 'valid';
26
+ daysLeft: number;
27
+ expiresAt: string;
28
+ }
29
+ /** Stated a limit, and it has passed. */
30
+ | {
31
+ kind: 'lapsed';
32
+ daysAgo: number;
33
+ expiresAt: string;
34
+ };
35
+ export declare const expiryState: (tokens: StoredTokens, now?: Date) => ExpiryState;
36
+ /**
37
+ * The line `status` prints, or nothing when there is nothing to say.
38
+ *
39
+ * Silent while the limit is comfortably far off. A warning shown every single
40
+ * day is read as decoration by the third day, and the whole point of this is to
41
+ * be believed on the day it matters.
42
+ */
43
+ export declare const expiryNotice: (provider: ProviderName, tokens: StoredTokens, now?: Date) => string | undefined;
44
+ /**
45
+ * What to say at the end of a successful connect.
46
+ *
47
+ * Said here rather than discovered later, which is the entire complaint in #94:
48
+ * the connection simply stopped, the error advised a reconnect, the advice was
49
+ * right, and the cause stayed invisible for as long as anyone cared to look.
50
+ */
51
+ export declare const expiryAtConnect: (provider: ProviderName, expiresAt: string | undefined, now?: Date) => string | undefined;
52
+ /**
53
+ * Why a refresh was rejected, inferred from the connection's own age.
54
+ *
55
+ * The provider cannot tell us: Google answers `invalid_grant` with "Token has
56
+ * been expired or revoked" whether it lapsed on schedule or someone withdrew
57
+ * the app in their account settings. Those are opposite situations — one is
58
+ * expected and one is worth investigating — so the distinction is drawn here,
59
+ * from the expiry the connection recorded, and worded as the inference it is.
60
+ * Nothing below claims the provider said any of it.
61
+ */
62
+ export declare const explainRefreshFailure: (provider: ProviderName, tokens: StoredTokens, now?: Date) => string;
package/dist/expiry.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * What a connection's age means, and what to say about it.
3
+ *
4
+ * Split out and pure because it is a decision rather than an integration: the
5
+ * refresh failure in oauth.ts and the report in connect.ts must not describe
6
+ * the same connection differently, and the only way to be sure of that is for
7
+ * both to call this. It also makes the arithmetic testable against a fixed
8
+ * clock, which is the half that is easy to get wrong and hard to notice.
9
+ *
10
+ * The fact being reasoned about is #94: Google expires refresh tokens issued
11
+ * by an app in Testing publishing status after seven days. Nothing here knows
12
+ * that number — it arrives with the connection, from the deployment (see
13
+ * `statedExpiry`), so this file stays true when the app is verified.
14
+ */
15
+ /** Where a provider's own page for withdrawing an app's access lives. */
16
+ const CONNECTED_APPS = {
17
+ google: 'https://myaccount.google.com/connections',
18
+ microsoft: 'https://account.live.com/consent/Manage',
19
+ };
20
+ /** The one command that fixes any of this, per provider. */
21
+ export const reconnectCommand = (provider) => `artist-mcp connect ${provider}`;
22
+ const DAY_MS = 24 * 60 * 60 * 1000;
23
+ /**
24
+ * The two directions round opposite ways, because the honest answer differs.
25
+ *
26
+ * Time remaining rounds **up**: a connection with eleven hours left reads as
27
+ * "1 day", never "0 days", since reporting zero days left on something that
28
+ * still works teaches someone to ignore the line.
29
+ *
30
+ * Time elapsed rounds **down**: something that lapsed two days and three hours
31
+ * ago is "2 days ago", not "3". Overstating how long a connection has been
32
+ * dead is a small lie in the one place the user is checking the facts.
33
+ */
34
+ const daysLeftFrom = (from, to) => Math.max(1, Math.ceil((to - from) / DAY_MS));
35
+ const daysSince = (from, to) => Math.floor((to - from) / DAY_MS);
36
+ export const expiryState = (tokens, now = new Date()) => {
37
+ const { expiresAt } = tokens;
38
+ if (expiresAt === undefined)
39
+ return { kind: 'unlimited' };
40
+ const at = Date.parse(expiresAt);
41
+ // A stored date that will not parse is treated as no limit rather than as
42
+ // an expiry. A connection wrongly declared dead is worse than one whose
43
+ // warning is missing: the second still works.
44
+ if (Number.isNaN(at))
45
+ return { kind: 'unlimited' };
46
+ const ms = now.getTime();
47
+ return ms >= at
48
+ ? { kind: 'lapsed', daysAgo: daysSince(at, ms), expiresAt }
49
+ : { kind: 'valid', daysLeft: daysLeftFrom(ms, at), expiresAt };
50
+ };
51
+ /**
52
+ * The line `status` prints, or nothing when there is nothing to say.
53
+ *
54
+ * Silent while the limit is comfortably far off. A warning shown every single
55
+ * day is read as decoration by the third day, and the whole point of this is to
56
+ * be believed on the day it matters.
57
+ */
58
+ export const expiryNotice = (provider, tokens, now = new Date()) => {
59
+ const state = expiryState(tokens, now);
60
+ if (state.kind === 'lapsed') {
61
+ // Under a day is "today" rather than "0 days ago", which reads as a bug.
62
+ const when = state.daysAgo === 0 ? 'today' : `${state.daysAgo} day${state.daysAgo === 1 ? '' : 's'} ago`;
63
+ return `LAPSED ${when}. Reconnect: ${reconnectCommand(provider)}`;
64
+ }
65
+ if (state.kind === 'valid' && state.daysLeft <= 2) {
66
+ return (`expires in ${state.daysLeft} day${state.daysLeft === 1 ? '' : 's'}. ` +
67
+ `Reconnect any time: ${reconnectCommand(provider)}`);
68
+ }
69
+ return undefined;
70
+ };
71
+ /**
72
+ * What to say at the end of a successful connect.
73
+ *
74
+ * Said here rather than discovered later, which is the entire complaint in #94:
75
+ * the connection simply stopped, the error advised a reconnect, the advice was
76
+ * right, and the cause stayed invisible for as long as anyone cared to look.
77
+ */
78
+ export const expiryAtConnect = (provider, expiresAt, now = new Date()) => {
79
+ if (expiresAt === undefined)
80
+ return undefined;
81
+ const state = expiryState({ refreshToken: '', scope: '', connectedAt: '', expiresAt }, now);
82
+ if (state.kind !== 'valid')
83
+ return undefined;
84
+ return (`This connection lapses in ${state.daysLeft} days, on ${state.expiresAt.slice(0, 10)}. ` +
85
+ `That is a limit ${provider === 'google' ? 'Google' : 'the provider'} applies to this ` +
86
+ `app, not a fault, and reconnecting takes one command: ${reconnectCommand(provider)}`);
87
+ };
88
+ /**
89
+ * Why a refresh was rejected, inferred from the connection's own age.
90
+ *
91
+ * The provider cannot tell us: Google answers `invalid_grant` with "Token has
92
+ * been expired or revoked" whether it lapsed on schedule or someone withdrew
93
+ * the app in their account settings. Those are opposite situations — one is
94
+ * expected and one is worth investigating — so the distinction is drawn here,
95
+ * from the expiry the connection recorded, and worded as the inference it is.
96
+ * Nothing below claims the provider said any of it.
97
+ */
98
+ export const explainRefreshFailure = (provider, tokens, now = new Date()) => {
99
+ const state = expiryState(tokens, now);
100
+ const fix = `Reconnect with: ${reconnectCommand(provider)}`;
101
+ if (state.kind === 'lapsed') {
102
+ return (`This connection reached the ${state.expiresAt.slice(0, 10)} limit this app is ` +
103
+ `subject to, so it most likely expired on schedule rather than anything ` +
104
+ `going wrong. ${fix}`);
105
+ }
106
+ if (state.kind === 'valid') {
107
+ return (`This is earlier than the ${state.expiresAt.slice(0, 10)} limit this connection ` +
108
+ `was given, so something withdrew it rather than it lapsing — check whether ` +
109
+ `the app was removed at ${CONNECTED_APPS[provider]}. ${fix}`);
110
+ }
111
+ return fix;
112
+ };