@manudota/artist-mcp 2.2.2-staging.103 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agents.js CHANGED
@@ -117,7 +117,26 @@ const resolveRegistry = async () => {
117
117
  ]));
118
118
  const localRoot = localAgentRoot();
119
119
  if (localRoot) {
120
- for (const entry of (await readLocalRegistry(localRoot)).entries) {
120
+ const local = (await readLocalRegistry(localRoot)).entries;
121
+ // Replacing rather than layering, where the deployment says so.
122
+ //
123
+ // Layering is right for a musician who has copied out a few playbooks and
124
+ // edited them: the ones they did not copy should keep working. Hosted is
125
+ // the other case — one pack, written for one notebook, and a bundled
126
+ // playbook surviving because the custom pack happens not to name its id is
127
+ // a generic rule in force that nobody chose. Cleared before the local
128
+ // entries go in, so what is served is exactly what is in that directory.
129
+ //
130
+ // Only ever narrows: a mode this does not recognise layers, which is what
131
+ // every install that has never heard of it already does.
132
+ // No emptiness check here, deliberately. `readLocalRegistry` already throws
133
+ // for a directory with no `artist/` and for one holding no Markdown, so by
134
+ // this line `local` cannot be empty and a guard would be unreachable code
135
+ // claiming to protect something. The strictness this mode needs is the
136
+ // strictness the local layer already has.
137
+ if (process.env.ARTIST_MCP_PACK_MODE === 'replace')
138
+ byId.clear();
139
+ for (const entry of local) {
121
140
  byId.set(entry.id, { ...entry, source: 'local', origin: localRoot });
122
141
  }
123
142
  }
package/dist/api.d.ts CHANGED
@@ -9,6 +9,29 @@
9
9
  export declare const GRAPH = "https://graph.microsoft.com/v1.0";
10
10
  export declare const GMAIL = "https://gmail.googleapis.com/gmail/v1";
11
11
  export declare const CALENDAR = "https://www.googleapis.com/calendar/v3";
12
+ /**
13
+ * How many provider requests one tool call may have in flight at once.
14
+ *
15
+ * Jitter above spreads the *retries* of a herd; nothing spread the herd's
16
+ * first arrival, and that is the burst the logs actually show — forty
17
+ * `attempt=1 waited=0s` refusals landing in the same millisecond, because a
18
+ * survey of a forty-page notebook opened forty Graph requests simultaneously.
19
+ * Every one of them then had to win its own retry ladder against a provider
20
+ * that was only busy because of them.
21
+ *
22
+ * Four is chosen to be boring rather than optimal: it is slow enough that
23
+ * OneNote answers instead of refusing, and parallel enough that a survey is
24
+ * still meaningfully faster than a loop. The number that matters is "not N".
25
+ */
26
+ export declare const FANOUT_LIMIT = 4;
27
+ /**
28
+ * `Promise.all(items.map(fn))` with a ceiling on how many run at once.
29
+ *
30
+ * Results stay in the order of `items`, and a rejection rejects the whole
31
+ * call, so this is a drop-in for the `Promise.all` it replaces: callers that
32
+ * already catch per item keep behaving exactly as they did.
33
+ */
34
+ export declare const mapWithConcurrency: <T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>) => Promise<R[]>;
12
35
  /**
13
36
  * What a call needs from the grant, for the one failure that is not a fault.
14
37
  *
package/dist/api.js CHANGED
@@ -47,6 +47,42 @@ const THROTTLE_BUDGET_MS = 8_000;
47
47
  * arrivals scattered across the window.
48
48
  */
49
49
  const jittered = (ms) => Math.round(ms / 2 + Math.random() * (ms / 2));
50
+ /**
51
+ * How many provider requests one tool call may have in flight at once.
52
+ *
53
+ * Jitter above spreads the *retries* of a herd; nothing spread the herd's
54
+ * first arrival, and that is the burst the logs actually show — forty
55
+ * `attempt=1 waited=0s` refusals landing in the same millisecond, because a
56
+ * survey of a forty-page notebook opened forty Graph requests simultaneously.
57
+ * Every one of them then had to win its own retry ladder against a provider
58
+ * that was only busy because of them.
59
+ *
60
+ * Four is chosen to be boring rather than optimal: it is slow enough that
61
+ * OneNote answers instead of refusing, and parallel enough that a survey is
62
+ * still meaningfully faster than a loop. The number that matters is "not N".
63
+ */
64
+ export const FANOUT_LIMIT = 4;
65
+ /**
66
+ * `Promise.all(items.map(fn))` with a ceiling on how many run at once.
67
+ *
68
+ * Results stay in the order of `items`, and a rejection rejects the whole
69
+ * call, so this is a drop-in for the `Promise.all` it replaces: callers that
70
+ * already catch per item keep behaving exactly as they did.
71
+ */
72
+ export const mapWithConcurrency = async (items, limit, fn) => {
73
+ const results = new Array(items.length);
74
+ let next = 0;
75
+ const worker = async () => {
76
+ for (;;) {
77
+ const index = next++;
78
+ if (index >= items.length)
79
+ return;
80
+ results[index] = await fn(items[index], index);
81
+ }
82
+ };
83
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
84
+ return results;
85
+ };
50
86
  /**
51
87
  * What the provider actually asked for, when it says.
52
88
  *
@@ -30,6 +30,10 @@ import { type ProviderName } from './tokens.js';
30
30
  * fails on any edit to this table so that the change has to be deliberate.
31
31
  */
32
32
  export declare const OPERATIONS: {
33
+ readonly list_notebooks: {
34
+ readonly provider: "microsoft";
35
+ readonly effect: "read";
36
+ };
33
37
  readonly list_notes: {
34
38
  readonly provider: "microsoft";
35
39
  readonly effect: "read";
package/dist/dispatch.js CHANGED
@@ -16,7 +16,7 @@ import { gmailLoader, mapAttachment, readAttachment } from './attachments.js';
16
16
  import { oneNoteLoader } from './page-attachments.js';
17
17
  import { createEvent, deleteEvent, listCalendars, listEvents, previewDeleteEvent, previewEvent, previewRescheduleEvent, readEvent, rescheduleEvent, } from './calendar.js';
18
18
  import { listEmails, readEmail } from './mail.js';
19
- import { listNotes, mapNotes, readNote } from './notes.js';
19
+ import { listNotebooks, listNotes, mapNotes, readNote } from './notes.js';
20
20
  import { applyEdit, previewEdit } from './onenote-patch.js';
21
21
  import { createPage, previewPage } from './onenote-write.js';
22
22
  import { accessTokenFor } from './oauth.js';
@@ -39,6 +39,7 @@ import { loadTokens } from './tokens.js';
39
39
  * fails on any edit to this table so that the change has to be deliberate.
40
40
  */
41
41
  export const OPERATIONS = {
42
+ list_notebooks: { provider: 'microsoft', effect: 'read' },
42
43
  list_notes: { provider: 'microsoft', effect: 'read' },
43
44
  map_notes: { provider: 'microsoft', effect: 'read' },
44
45
  read_note: { provider: 'microsoft', effect: 'read' },
@@ -111,6 +112,8 @@ export const connectedProviders = async () => {
111
112
  export const dispatchWith = (resolve, record = recordWrite) => async (op, params = {}) => {
112
113
  const token = await resolve(PROVIDER_FOR[op]);
113
114
  switch (op) {
115
+ case 'list_notebooks':
116
+ return (await listNotebooks(token));
114
117
  case 'list_notes':
115
118
  return (await listNotes(token));
116
119
  case 'map_notes':
package/dist/mail.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * thing being worked on. Ported from the edge function with behaviour intact.
6
6
  */
7
7
  import { GraphError } from './client.js';
8
- import { gmailGet } from './api.js';
8
+ import { FANOUT_LIMIT, gmailGet, mapWithConcurrency } from './api.js';
9
9
  import { htmlToText } from './notes.js';
10
10
  /** Gmail ids are opaque hex-ish strings; anything else never reaches a URL. */
11
11
  export const GMAIL_ID = /^[A-Za-z0-9_-]{1,128}$/;
@@ -119,7 +119,7 @@ export const listEmails = async (token, rawQuery) => {
119
119
  const ids = ((await listRes.json()).messages ?? [])
120
120
  .map((m) => m.id)
121
121
  .filter((id) => typeof id === 'string' && GMAIL_ID.test(id));
122
- const emails = await Promise.all(ids.map(async (id) => {
122
+ const emails = await mapWithConcurrency(ids, FANOUT_LIMIT, async (id) => {
123
123
  const res = await gmailGet(`/users/me/messages/${encodeURIComponent(id)}` +
124
124
  '?format=metadata&metadataHeaders=Subject&metadataHeaders=From' +
125
125
  '&metadataHeaders=To&metadataHeaders=Date', token);
@@ -133,7 +133,7 @@ export const listEmails = async (token, rawQuery) => {
133
133
  date: header(msg.payload?.headers, 'Date'),
134
134
  snippet: msg.snippet ?? null,
135
135
  };
136
- }));
136
+ });
137
137
  return { emails };
138
138
  };
139
139
  export const readEmail = async (token, emailId) => {
package/dist/notes.d.ts CHANGED
@@ -8,6 +8,25 @@
8
8
  */
9
9
  import { type PageResource } from './page-attachments.js';
10
10
  import { type EditablePart } from './onenote-patch.js';
11
+ /**
12
+ * A short value proving a caller has actually seen this account's notebook list.
13
+ *
14
+ * The gate it serves used to be a module-level boolean — "has anything listed
15
+ * the notebooks yet" — which is a fair approximation of one CLI session and
16
+ * nonsense on hosted, where every request may land on a different instance and
17
+ * the flag is shared by every user on the one it lands on. It refused real
18
+ * selections on a cold instance and admitted unseen ones on a warm one.
19
+ *
20
+ * Derived from the notebook names rather than stored, so it needs no session
21
+ * and no shared storage: the same account yields the same key on any instance,
22
+ * a client that never saw the list cannot produce it, and a key from an account
23
+ * with different notebooks does not match. It is not a secret and does not need
24
+ * to be — it proves the list was seen, nothing more.
25
+ *
26
+ * Order and case are normalised so the key does not change when Graph returns
27
+ * the same notebooks in a different order.
28
+ */
29
+ export declare const notebookKeyFor: (names: readonly string[]) => string;
11
30
  export type NoteSummary = {
12
31
  id: string;
13
32
  title: string;
@@ -65,6 +84,31 @@ export declare const pageDatesAreCreationDates: (sections: readonly {
65
84
  * both are marked.
66
85
  */
67
86
  export declare const htmlToText: (html: string) => string;
87
+ /**
88
+ * Which notebooks this account has, and nothing else.
89
+ *
90
+ * One Graph request. `listNotes` answers the same question as a side effect of
91
+ * fetching every page of every section — a hundred requests on an organised
92
+ * account — and the notebook question is asked far more often than the page
93
+ * one: every call that has to ask "which notebook?" paid for the pages of all
94
+ * of them to print a list of names.
95
+ *
96
+ * That is what was throttling the account. Graph refused with 20166, "the app
97
+ * has issued too many requests on behalf of this user", against a listing whose
98
+ * answer was already contained in the single request that starts it, since
99
+ * `parentNotebook` expands inline on the sections call.
100
+ *
101
+ * Counted in sections rather than pages, because a page count is exactly the
102
+ * thing that cannot be known without the hundred requests. A number that costs
103
+ * a hundred requests to be slightly more familiar is not worth it, and the
104
+ * count is only there to help the user recognise which notebook is which.
105
+ */
106
+ export declare const listNotebooks: (token: string) => Promise<{
107
+ notebooks: {
108
+ name: string;
109
+ sections: number;
110
+ }[];
111
+ }>;
68
112
  /**
69
113
  * `/me/onenote/pages` looks like the obvious call and works right up until the
70
114
  * account has too many sections, at which point Graph fails the whole request
@@ -140,6 +184,31 @@ export type NoteSketch = NoteSummary & {
140
184
  chars_total: number | null;
141
185
  error: string | null;
142
186
  };
187
+ /**
188
+ * How long a survey may spend before it returns what it has.
189
+ *
190
+ * The hosted route dies at sixty seconds (`maxDuration` in the MCP route), and
191
+ * dying there is the worst available outcome: the caller waits the full minute
192
+ * and receives nothing, not even the pages that were already sketched. Three
193
+ * of those in a row is what a chat client's retries look like from the logs.
194
+ *
195
+ * Forty-five leaves room for the answer to be assembled and sent after the
196
+ * last sketch lands. The number is a floor on usefulness rather than a tuning
197
+ * knob: whatever the notebook looks like, the survey now returns.
198
+ */
199
+ export declare const MAP_DEADLINE_MS = 45000;
200
+ type MapNotesOptions = {
201
+ deadlineMs?: number;
202
+ /** Injectable so a test can reach the deadline without spending the time. */
203
+ now?: () => number;
204
+ };
205
+ export type MapNotesResult = {
206
+ sketches: NoteSketch[];
207
+ read_in_full: number;
208
+ /** Pages the deadline cut off. Unsurveyed, and reported as such. */
209
+ not_reached: number;
210
+ elapsed_ms: number;
211
+ };
143
212
  /**
144
213
  * Sketch every page in a chosen notebook, cheaply, so a notebook can be triaged
145
214
  * without reading all of it.
@@ -156,10 +225,7 @@ export type NoteSketch = NoteSummary & {
156
225
  *
157
226
  * Scoped to one notebook, as intake requires. Nothing maps across scopes.
158
227
  */
159
- export declare const mapNotes: (token: string, pages: NoteSummary[]) => Promise<{
160
- sketches: NoteSketch[];
161
- read_in_full: number;
162
- }>;
228
+ export declare const mapNotes: (token: string, pages: NoteSummary[], { deadlineMs, now }?: MapNotesOptions) => Promise<MapNotesResult>;
163
229
  /** A page attachment as `read_note` reports it: named, never read. */
164
230
  export type PageAttachment = Omit<PageResource, 'position'>;
165
231
  export type NoteContent = {
@@ -207,3 +273,4 @@ export type NoteContent = {
207
273
  export declare const readNote: (token: string, noteId: unknown, fromPart?: unknown, { withEditIds }?: {
208
274
  withEditIds?: boolean;
209
275
  }) => Promise<NoteContent>;
276
+ export {};
package/dist/notes.js CHANGED
@@ -6,11 +6,34 @@
6
6
  * enumeration below, which exists for a reason that is not obvious from the
7
7
  * code alone.
8
8
  */
9
+ import { createHash } from 'node:crypto';
9
10
  import { MAX_TEXT_CHARS } from './attachments.js';
10
11
  import { GraphError } from './client.js';
11
- import { graphGet } from './api.js';
12
+ import { FANOUT_LIMIT, graphGet, mapWithConcurrency } from './api.js';
12
13
  import { pageResources } from './page-attachments.js';
13
14
  import { editablePartsFrom } from './onenote-patch.js';
15
+ /**
16
+ * A short value proving a caller has actually seen this account's notebook list.
17
+ *
18
+ * The gate it serves used to be a module-level boolean — "has anything listed
19
+ * the notebooks yet" — which is a fair approximation of one CLI session and
20
+ * nonsense on hosted, where every request may land on a different instance and
21
+ * the flag is shared by every user on the one it lands on. It refused real
22
+ * selections on a cold instance and admitted unseen ones on a warm one.
23
+ *
24
+ * Derived from the notebook names rather than stored, so it needs no session
25
+ * and no shared storage: the same account yields the same key on any instance,
26
+ * a client that never saw the list cannot produce it, and a key from an account
27
+ * with different notebooks does not match. It is not a secret and does not need
28
+ * to be — it proves the list was seen, nothing more.
29
+ *
30
+ * Order and case are normalised so the key does not change when Graph returns
31
+ * the same notebooks in a different order.
32
+ */
33
+ export const notebookKeyFor = (names) => createHash('sha256')
34
+ .update([...names].map((n) => n.trim().toLowerCase()).sort().join('\n'))
35
+ .digest('hex')
36
+ .slice(0, 8);
14
37
  /** Graph ids are opaque, but they are concatenated into URLs, so they are checked like any other value. */
15
38
  const ONENOTE_ID = /^[A-Za-z0-9!._~-]{1,300}$/;
16
39
  /**
@@ -89,6 +112,40 @@ export const htmlToText = (html) => html
89
112
  .join('\n')
90
113
  .replace(/\n{3,}/g, '\n\n')
91
114
  .trim();
115
+ /**
116
+ * Which notebooks this account has, and nothing else.
117
+ *
118
+ * One Graph request. `listNotes` answers the same question as a side effect of
119
+ * fetching every page of every section — a hundred requests on an organised
120
+ * account — and the notebook question is asked far more often than the page
121
+ * one: every call that has to ask "which notebook?" paid for the pages of all
122
+ * of them to print a list of names.
123
+ *
124
+ * That is what was throttling the account. Graph refused with 20166, "the app
125
+ * has issued too many requests on behalf of this user", against a listing whose
126
+ * answer was already contained in the single request that starts it, since
127
+ * `parentNotebook` expands inline on the sections call.
128
+ *
129
+ * Counted in sections rather than pages, because a page count is exactly the
130
+ * thing that cannot be known without the hundred requests. A number that costs
131
+ * a hundred requests to be slightly more familiar is not worth it, and the
132
+ * count is only there to help the user recognise which notebook is which.
133
+ */
134
+ export const listNotebooks = async (token) => {
135
+ const res = await graphGet('/me/onenote/sections?$select=id,displayName' +
136
+ '&$expand=parentNotebook($select=displayName)&$top=100', token);
137
+ const sections = (await res.json()).value ?? [];
138
+ const counts = new Map();
139
+ for (const section of sections) {
140
+ if (typeof section.id !== 'string' || !ONENOTE_ID.test(section.id))
141
+ continue;
142
+ const name = section.parentNotebook?.displayName ?? '(unnamed notebook)';
143
+ counts.set(name, (counts.get(name) ?? 0) + 1);
144
+ }
145
+ return {
146
+ notebooks: [...counts].map(([name, sectionCount]) => ({ name, sections: sectionCount })),
147
+ };
148
+ };
92
149
  /**
93
150
  * `/me/onenote/pages` looks like the obvious call and works right up until the
94
151
  * account has too many sections, at which point Graph fails the whole request
@@ -99,46 +156,68 @@ export const htmlToText = (html) => html
99
156
  * Do not "simplify" this back to the single call.
100
157
  */
101
158
  export const listNotes = async (token) => {
102
- const sectionsRes = await graphGet(
103
- // lastModifiedDateTime is what the detector reads, and it is the field
104
- // that actually tracks change — unlike the one on a page. Selected here
105
- // rather than fetched separately: the request is made either way.
106
- '/me/onenote/sections?$select=id,displayName,lastModifiedDateTime' +
107
- '&$expand=parentNotebook($select=displayName)&$top=100', token);
108
- const sections = (await sectionsRes.json()).value ?? [];
109
- const usable = sections.filter((s) => typeof s.id === 'string' && ONENOTE_ID.test(s.id));
110
- const perSection = await Promise.all(usable.map(async (section) => {
111
- const res = await graphGet(`/me/onenote/sections/${section.id}/pages` +
112
- // createdDateTime is selected only so the two can be compared. It
113
- // costs nothing — the request is made either way.
114
- '?$select=id,title,createdDateTime,lastModifiedDateTime&$top=100', token);
115
- const pages = (await res.json()).value ?? [];
116
- return pages.map((p) => ({
117
- id: p.id,
118
- title: p.title ?? '(untitled)',
119
- section: section.displayName ?? null,
159
+ const startedAt = Date.now();
160
+ let sectionCount = 0;
161
+ let failure = null;
162
+ try {
163
+ const sectionsRes = await graphGet(
164
+ // lastModifiedDateTime is what the detector reads, and it is the field
165
+ // that actually tracks change — unlike the one on a page. Selected here
166
+ // rather than fetched separately: the request is made either way.
167
+ '/me/onenote/sections?$select=id,displayName,lastModifiedDateTime' +
168
+ '&$expand=parentNotebook($select=displayName)&$top=100', token);
169
+ const sections = (await sectionsRes.json()).value ?? [];
170
+ const usable = sections.filter((s) => typeof s.id === 'string' && ONENOTE_ID.test(s.id));
171
+ sectionCount = usable.length;
172
+ const perSection = await mapWithConcurrency(usable, FANOUT_LIMIT, async (section) => {
173
+ const res = await graphGet(`/me/onenote/sections/${section.id}/pages` +
174
+ // createdDateTime is selected only so the two can be compared. It
175
+ // costs nothing — the request is made either way.
176
+ '?$select=id,title,createdDateTime,lastModifiedDateTime&$top=100', token);
177
+ const pages = (await res.json()).value ?? [];
178
+ return pages.map((p) => ({
179
+ id: p.id,
180
+ title: p.title ?? '(untitled)',
181
+ section: section.displayName ?? null,
182
+ notebook: section.parentNotebook?.displayName ?? null,
183
+ last_modified: p.lastModifiedDateTime ?? null,
184
+ }));
185
+ });
186
+ const sectionSummaries = usable.map((section, i) => ({
187
+ id: section.id,
188
+ name: section.displayName ?? '(unnamed section)',
120
189
  notebook: section.parentNotebook?.displayName ?? null,
121
- last_modified: p.lastModifiedDateTime ?? null,
190
+ last_modified: section.lastModifiedDateTime ?? null,
191
+ pages: (perSection[i] ?? []).length,
122
192
  }));
123
- }));
124
- const sectionSummaries = usable.map((section, i) => ({
125
- id: section.id,
126
- name: section.displayName ?? '(unnamed section)',
127
- notebook: section.parentNotebook?.displayName ?? null,
128
- last_modified: section.lastModifiedDateTime ?? null,
129
- pages: (perSection[i] ?? []).length,
130
- }));
131
- const creationDates = pageDatesAreCreationDates(sectionSummaries, perSection);
132
- // Newest first, matching the order the single-call version happened to
133
- // return; pages with no timestamp sort last rather than jumping to the top.
134
- const notes = perSection
135
- .flat()
136
- .map((n) => ({ ...n, date_is_creation: creationDates }))
137
- .sort((a, b) => (b.last_modified ?? '').localeCompare(a.last_modified ?? ''));
138
- // Sections newest first for the same reason, and because when page dates are
139
- // useless this is the ordering the caller actually reads.
140
- const sectionList = sectionSummaries.sort((a, b) => (b.last_modified ?? '').localeCompare(a.last_modified ?? ''));
141
- return { notes, sections: sectionList, page_dates_are_creation_dates: creationDates };
193
+ const creationDates = pageDatesAreCreationDates(sectionSummaries, perSection);
194
+ // Newest first, matching the order the single-call version happened to
195
+ // return; pages with no timestamp sort last rather than jumping to the top.
196
+ const notes = perSection
197
+ .flat()
198
+ .map((n) => ({ ...n, date_is_creation: creationDates }))
199
+ .sort((a, b) => (b.last_modified ?? '').localeCompare(a.last_modified ?? ''));
200
+ // Sections newest first for the same reason, and because when page dates are
201
+ // useless this is the ordering the caller actually reads.
202
+ const sectionList = sectionSummaries.sort((a, b) => (b.last_modified ?? '').localeCompare(a.last_modified ?? ''));
203
+ return { notes, sections: sectionList, page_dates_are_creation_dates: creationDates };
204
+ }
205
+ catch (err) {
206
+ failure = err instanceof Error ? err.message : String(err);
207
+ throw err;
208
+ }
209
+ finally {
210
+ // Logged in a `finally`, and that is the whole point of it.
211
+ //
212
+ // The survey's own line sat after its await, so a run that threw logged
213
+ // nothing — and a throttled listing throws HERE, before the survey is ever
214
+ // reached, which is why a failed map produced nineteen 429s and no
215
+ // evidence. A measurement that exists only when nothing went wrong
216
+ // measures the wrong runs.
217
+ console.warn(`[artist-mcp] list_notes sections=${sectionCount} ` +
218
+ `elapsed=${Date.now() - startedAt}ms` +
219
+ (failure === null ? '' : ` failed=${failure}`));
220
+ }
142
221
  };
143
222
  /**
144
223
  * The sections that changed within a window.
@@ -251,6 +330,25 @@ const previewOf = async (token, id) => {
251
330
  const { previewText } = (await res.json());
252
331
  return (previewText ?? '').trim();
253
332
  };
333
+ /**
334
+ * How long a survey may spend before it returns what it has.
335
+ *
336
+ * The hosted route dies at sixty seconds (`maxDuration` in the MCP route), and
337
+ * dying there is the worst available outcome: the caller waits the full minute
338
+ * and receives nothing, not even the pages that were already sketched. Three
339
+ * of those in a row is what a chat client's retries look like from the logs.
340
+ *
341
+ * Forty-five leaves room for the answer to be assembled and sent after the
342
+ * last sketch lands. The number is a floor on usefulness rather than a tuning
343
+ * knob: whatever the notebook looks like, the survey now returns.
344
+ */
345
+ export const MAP_DEADLINE_MS = 45_000;
346
+ /**
347
+ * A page the deadline was reached before. Not an error and not an empty page —
348
+ * distinguishing it from both is the whole point, because a page that was never
349
+ * looked at must never read as a page with nothing on it.
350
+ */
351
+ const NOT_REACHED = Symbol('not reached');
254
352
  /**
255
353
  * Sketch every page in a chosen notebook, cheaply, so a notebook can be triaged
256
354
  * without reading all of it.
@@ -267,61 +365,102 @@ const previewOf = async (token, id) => {
267
365
  *
268
366
  * Scoped to one notebook, as intake requires. Nothing maps across scopes.
269
367
  */
270
- export const mapNotes = async (token, pages) => {
271
- const sketches = await Promise.all(pages.map(async (page) => {
272
- const base = { ...page, chars_total: null, error: null };
273
- let preview = '';
274
- let reason = null;
275
- try {
276
- preview = await previewOf(token, page.id);
277
- // A preview this thin cannot separate a working unit from a stray
278
- // note, which is the one thing the map exists to do.
279
- if (preview === '')
280
- reason = 'the page has no preview text';
281
- else if (preview.length < PREVIEW_FLOOR)
282
- reason = `the preview was only ${preview.length} characters`;
283
- }
284
- catch (err) {
285
- reason = `the preview call failed (${err instanceof Error ? err.message : String(err)})`;
286
- }
287
- if (reason === null) {
288
- return {
289
- ...base,
290
- sketch: preview,
291
- source: 'preview',
292
- fell_back: null,
293
- // Graph gives no page length and does not say it truncated, so a
294
- // preview at full length is the only sign there is more. Inferred,
295
- // and reported as an inference rather than as a fact.
296
- more: preview.length >= PREVIEW_LIKELY_CAP,
297
- };
298
- }
299
- // Case by case, and only for the pages that need it.
300
- try {
301
- const { text, chars_total } = await readNote(token, page.id);
302
- return {
303
- ...base,
304
- sketch: text.slice(0, DERIVED_SKETCH_CHARS),
305
- source: 'page',
306
- fell_back: reason,
307
- more: chars_total > DERIVED_SKETCH_CHARS,
308
- chars_total,
309
- };
310
- }
311
- catch (err) {
312
- // Both routes failed. Reported as a gap, never dropped from the map:
313
- // a page missing from a survey reads as a page that is not there.
314
- return {
315
- ...base,
316
- sketch: null,
317
- source: 'none',
318
- fell_back: reason,
319
- more: false,
320
- error: err instanceof Error ? err.message : String(err),
321
- };
322
- }
323
- }));
324
- return { sketches, read_in_full: sketches.filter((s) => s.source === 'page').length };
368
+ export const mapNotes = async (token, pages, { deadlineMs = MAP_DEADLINE_MS, now = Date.now } = {}) => {
369
+ const mapStartedAt = now();
370
+ const stopAt = mapStartedAt + deadlineMs;
371
+ // Held outside the try so the `finally` can report them whatever happens.
372
+ let sketched = 0;
373
+ let readInFull = 0;
374
+ let notReached = 0;
375
+ let mapFailure = null;
376
+ try {
377
+ const outcomes = await mapWithConcurrency(pages, FANOUT_LIMIT, async (page) => {
378
+ // Checked before the work, not during it: a page that has started is
379
+ // allowed to finish, because abandoning a request mid-flight costs the
380
+ // same quota as completing it and returns nothing for the spend.
381
+ if (now() >= stopAt) {
382
+ notReached += 1;
383
+ return NOT_REACHED;
384
+ }
385
+ const base = { ...page, chars_total: null, error: null };
386
+ let preview = '';
387
+ let reason = null;
388
+ try {
389
+ preview = await previewOf(token, page.id);
390
+ // A preview this thin cannot separate a working unit from a stray
391
+ // note, which is the one thing the map exists to do.
392
+ if (preview === '')
393
+ reason = 'the page has no preview text';
394
+ else if (preview.length < PREVIEW_FLOOR)
395
+ reason = `the preview was only ${preview.length} characters`;
396
+ }
397
+ catch (err) {
398
+ reason = `the preview call failed (${err instanceof Error ? err.message : String(err)})`;
399
+ }
400
+ if (reason === null) {
401
+ return {
402
+ ...base,
403
+ sketch: preview,
404
+ source: 'preview',
405
+ fell_back: null,
406
+ // Graph gives no page length and does not say it truncated, so a
407
+ // preview at full length is the only sign there is more. Inferred,
408
+ // and reported as an inference rather than as a fact.
409
+ more: preview.length >= PREVIEW_LIKELY_CAP,
410
+ };
411
+ }
412
+ // Case by case, and only for the pages that need it.
413
+ try {
414
+ const { text, chars_total } = await readNote(token, page.id);
415
+ return {
416
+ ...base,
417
+ sketch: text.slice(0, DERIVED_SKETCH_CHARS),
418
+ source: 'page',
419
+ fell_back: reason,
420
+ more: chars_total > DERIVED_SKETCH_CHARS,
421
+ chars_total,
422
+ };
423
+ }
424
+ catch (err) {
425
+ // Both routes failed. Reported as a gap, never dropped from the map:
426
+ // a page missing from a survey reads as a page that is not there.
427
+ return {
428
+ ...base,
429
+ sketch: null,
430
+ source: 'none',
431
+ fell_back: reason,
432
+ more: false,
433
+ error: err instanceof Error ? err.message : String(err),
434
+ };
435
+ }
436
+ });
437
+ const sketches = outcomes.filter((o) => o !== NOT_REACHED);
438
+ sketched = sketches.length;
439
+ readInFull = sketches.filter((s) => s.source === 'page').length;
440
+ return {
441
+ sketches,
442
+ read_in_full: readInFull,
443
+ not_reached: notReached,
444
+ elapsed_ms: now() - mapStartedAt,
445
+ };
446
+ }
447
+ catch (err) {
448
+ mapFailure = err instanceof Error ? err.message : String(err);
449
+ throw err;
450
+ }
451
+ finally {
452
+ // The line that says where the time went — on every run, the failed ones
453
+ // included, because those are the runs actually worth measuring.
454
+ //
455
+ // Fallbacks are the suspect: a preview costs one request, a fallback costs
456
+ // two plus the page's whole HTML, and arithmetic says forty previews at
457
+ // four concurrent should take about five seconds rather than sixty. This
458
+ // settles it with evidence instead.
459
+ console.warn(`[artist-mcp] map_notes pages=${pages.length} sketched=${sketched} ` +
460
+ `read_in_full=${readInFull} not_reached=${notReached} ` +
461
+ `elapsed=${now() - mapStartedAt}ms` +
462
+ (mapFailure === null ? '' : ` failed=${mapFailure}`));
463
+ }
325
464
  };
326
465
  /**
327
466
  * Read one page, in parts only when it is too long for a single answer.
@@ -236,7 +236,27 @@ const ATTRS_BY_TAG = {
236
236
  td: new Set(['colspan', 'rowspan', 'align', 'valign', 'width']),
237
237
  th: new Set(['colspan', 'rowspan', 'align', 'valign', 'width']),
238
238
  a: new Set(['href']),
239
+ // A checkbox is a `data-tag` on the paragraph, so without this a task can be
240
+ // read and never written: `htmlToText` has rendered these as `[ ]` and `[x]`
241
+ // since before anything could write, and a patch carrying one was refused.
242
+ // Per-tag rather than global, because a tag on a table cell or a heading is
243
+ // not a task and OneNote renders it somewhere nobody asked for a checkbox.
244
+ p: new Set(['data-tag']),
245
+ li: new Set(['data-tag']),
239
246
  };
247
+ /**
248
+ * The only `data-tag` values this tool will write.
249
+ *
250
+ * OneNote defines dozens — `important`, `question`, `remember-for-later` and
251
+ * the rest — and every one of them is a claim about the line it marks. These
252
+ * two are the only ones with an agreed meaning here: a task, and a task that is
253
+ * done. Anything else is a model deciding that something is important, which is
254
+ * exactly the kind of judgement that must not arrive as page markup.
255
+ *
256
+ * Narrow on purpose, and cheap to widen if a use turns up that is really asked
257
+ * for rather than merely available.
258
+ */
259
+ const TASK_TAGS = new Set(['to-do', 'to-do:completed']);
240
260
  /**
241
261
  * What may appear in a `style`, which OneNote's own tables lean on heavily for
242
262
  * borders and widths — so forbidding it outright would mean every replaced
@@ -274,6 +294,13 @@ const checkAttributes = (tag, raw) => {
274
294
  'OneNote. Nothing was written. Keep the markup to tables, rows, cells, ' +
275
295
  'paragraphs, lists and simple emphasis.');
276
296
  }
297
+ if (name === 'data-tag' && !TASK_TAGS.has(value)) {
298
+ throw failure(`<${tag}> carries data-tag="${value}", which this tool will not send to ` +
299
+ 'OneNote. Nothing was written. Only "to-do" and "to-do:completed" are ' +
300
+ 'written: an open task and a done one. Every other OneNote tag states ' +
301
+ 'something about the line — that it is important, or a question — and ' +
302
+ 'that is the musician\'s to say, not this tool\'s.');
303
+ }
277
304
  if (name === 'style' && !SAFE_STYLE.test(value)) {
278
305
  throw failure(`The style on <${tag}> contains something this tool will not send: ` +
279
306
  `"${value}". Nothing was written. Plain declarations such as ` +
package/dist/server.js CHANGED
@@ -6,7 +6,7 @@ import { WRITE_CAPABILITIES, isGranted } from "./grants.js";
6
6
  import { listAgentWorkflows, loadAgentWorkflow } from "./agents.js";
7
7
  import { GraphError } from "./client.js";
8
8
  import { call as localCall } from "./dispatch.js";
9
- import { narrowNotes, narrowSections } from "./notes.js";
9
+ import { narrowNotes, narrowSections, notebookKeyFor } from "./notes.js";
10
10
  /** One call per page, so a notebook nobody put a number on does not become hundreds of requests. */
11
11
  const DEFAULT_MAP_PAGES = 40;
12
12
  /** Times are stated with their zone; the calendar's zone need not be the reader's. */
@@ -26,20 +26,6 @@ const describeSize = (bytes) => {
26
26
  return `${Math.round(bytes / 1024)} KB`;
27
27
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
28
28
  };
29
- /**
30
- * Whether this server has ever handed back the list of notebooks.
31
- *
32
- * The one thing the server can actually know about where a notebook name came
33
- * from. It cannot see who typed it — but a name arriving before the list was
34
- * ever served cannot have come from the tool, so it came from somewhere outside
35
- * the conversation. That is the case worth catching: a session inferred a
36
- * notebook from saved context and answered about the wrong one, correctly and
37
- * without saying which.
38
- *
39
- * Only ever set to true, and only by serving the list. It says nothing after a
40
- * session has seen the notebooks once, which is the honest limit of it.
41
- */
42
- let notebooksHaveBeenListed = false;
43
29
  /**
44
30
  * Settle which notebook is being worked in, before anything reads a page.
45
31
  *
@@ -48,46 +34,63 @@ let notebooksHaveBeenListed = false;
48
34
  * intake depends on, and two copies of it would eventually disagree. The caller
49
35
  * names itself so the instruction says which tool to call again.
50
36
  */
51
- const selectNotebook = async (call, notebook, tool) => {
52
- const { notes, sections = [], page_dates_are_creation_dates = false, } = await call("list_notes");
53
- if (notes.length === 0)
37
+ const selectNotebook = async (call, notebook, notebookKey, tool) => {
38
+ // The notebook question first, and on its own, because it is the cheap one.
39
+ //
40
+ // `list_notes` answers it as a side effect of fetching every page of every
41
+ // section — a hundred Graph requests on an organised account — and most calls
42
+ // that reach here only need the names: to ask which notebook, or to check
43
+ // that a supplied one exists. Paying for every page of every notebook to
44
+ // print a list of names is what had Graph refusing with 20166.
45
+ const { notebooks } = await call("list_notebooks");
46
+ if (notebooks.length === 0)
54
47
  return { message: "No notes found." };
55
- const names = [...new Set(notes.map((n) => n.notebook ?? "(unnamed notebook)"))];
48
+ const names = notebooks.map((n) => n.name);
56
49
  // Handing back every page across every notebook invites work on the wrong
57
50
  // one. With a choice to be made and nothing chosen, the pages are withheld
58
51
  // until the user has actually made it.
59
52
  // A name that arrives before this session has ever seen the list did not come
60
53
  // from the tool, so it is either the user's or a guess — and the two are
61
54
  // indistinguishable from here. Ask, the same way an omitted name asks.
62
- const unseenName = notebook !== undefined && !notebooksHaveBeenListed;
63
- if ((!notebook || unseenName) && names.length > 1) {
64
- notebooksHaveBeenListed = true;
65
- const counts = names.map((name) => {
66
- const total = notes.filter((n) => (n.notebook ?? "(unnamed notebook)") === name).length;
67
- return `- ${name} — ${total} page${total === 1 ? "" : "s"}`;
68
- });
55
+ // Proven against the account's own notebooks rather than against a flag this
56
+ // process happens to hold. Required only where the choice is real: one
57
+ // notebook is not a choice, and demanding proof of a list with a single entry
58
+ // would be ceremony.
59
+ const expected = notebookKeyFor(names);
60
+ const proven = notebookKey !== undefined && notebookKey.trim().toLowerCase() === expected;
61
+ const unproven = notebook !== undefined && !proven;
62
+ if ((!notebook || unproven) && names.length > 1) {
63
+ // Sections rather than pages: a page count is precisely the thing that
64
+ // cannot be known without the hundred requests this path exists to avoid,
65
+ // and the number is only here to help the user tell one notebook from
66
+ // another.
67
+ const counts = notebooks.map(({ name, sections: n }) => `- ${name} — ${n} section${n === 1 ? "" : "s"}`);
69
68
  return {
70
69
  message: `This account has ${names.length} notebooks:\n${counts.join("\n")}\n\n` +
71
- (unseenName
72
- ? `You asked for "${notebook}", but nothing in this conversation has ` +
73
- "named a notebook yet. Ask the user which one they mean — including " +
74
- "whether it is that one — and call " +
75
- `${tool} again once they have said. A notebook you know of from ` +
76
- "elsewhere is a guess, and a guess here produces an answer that is " +
77
- "correct about the wrong pages."
70
+ `notebook_key: ${expected}\n\n` +
71
+ (unproven
72
+ ? `You asked for "${notebook}" without the notebook_key from this ` +
73
+ "list, so nothing in this conversation had seen the notebooks yet. " +
74
+ "Ask the user which one they mean — including whether it is that " +
75
+ `one — and call ${tool} again with that name AND the notebook_key ` +
76
+ "above. A notebook you know of from elsewhere is a guess, and a " +
77
+ "guess here produces an answer that is correct about the wrong pages."
78
78
  : `Ask the user which notebook to work in, then call ${tool} again ` +
79
- "with that name. Do not guess, and do not work across notebooks " +
80
- "unless the user asks for it."),
79
+ "with that name and the notebook_key above. Do not guess, and do " +
80
+ "not work across notebooks unless the user asks for it."),
81
81
  };
82
82
  }
83
- notebooksHaveBeenListed = true;
84
83
  const wanted = notebook?.trim().toLowerCase();
84
+ // Checked before the expensive call, not after it: a misspelled notebook
85
+ // should cost one request to refuse, not a hundred.
86
+ if (wanted !== undefined && !names.some((name) => name.trim().toLowerCase() === wanted)) {
87
+ return { message: `No notebook named "${notebook}". Available: ${names.join(", ")}.` };
88
+ }
89
+ // Settled. Only now are the pages worth what they cost.
90
+ const { notes, sections = [], page_dates_are_creation_dates = false, } = await call("list_notes");
85
91
  const pages = wanted
86
92
  ? notes.filter((n) => (n.notebook ?? "").trim().toLowerCase() === wanted)
87
93
  : notes;
88
- if (wanted && pages.length === 0) {
89
- return { message: `No notebook named "${notebook}". Available: ${names.join(", ")}.` };
90
- }
91
94
  // A name that was supplied walks straight past the question above, so a
92
95
  // guessed one is indistinguishable from a chosen one. Found in use: asked
93
96
  // about "this notebook" in a fresh chat, a session inferred one from saved
@@ -173,7 +176,7 @@ const renderChangedSections = (chosen, notebook, since) => {
173
176
  * the only way OneNote allows a cell to change.
174
177
  */
175
178
  const INDEX_ENTRIES = 12;
176
- const serverVersion = '2.2.2-staging.103'; // x-release-please-version
179
+ const serverVersion = '2.3.0'; // x-release-please-version
177
180
  const errorResult = (err) => {
178
181
  const message = err instanceof GraphError ? err.message : `Unexpected error: ${err}`;
179
182
  return { content: [{ type: "text", text: message }], isError: true };
@@ -715,6 +718,14 @@ const createServer = async (call, grants = []) => {
715
718
  "notebook you happen to know the user has: a plausible guess here " +
716
719
  "is indistinguishable from their choice and produces a confident " +
717
720
  "answer about the wrong notebook."),
721
+ notebook_key: z
722
+ .string()
723
+ .optional()
724
+ .describe("The notebook_key printed with the notebook list, passed back exactly " +
725
+ "as it was given. Required alongside `notebook` on an account with " +
726
+ "more than one notebook: it is how this tool knows the name came " +
727
+ "from the list rather than from somewhere outside the conversation. " +
728
+ "Never invent one, and never reuse one from an earlier session."),
718
729
  since: z
719
730
  .string()
720
731
  .optional()
@@ -734,9 +745,9 @@ const createServer = async (call, grants = []) => {
734
745
  .describe("Cap the number of pages returned, newest first. The reply says how " +
735
746
  "many matched, so a capped list is never mistaken for the whole " +
736
747
  "notebook."),
737
- }, async ({ notebook, since, limit }) => {
748
+ }, async ({ notebook, notebook_key, since, limit }) => {
738
749
  try {
739
- const chosen = await selectNotebook(call, notebook, "list_notes");
750
+ const chosen = await selectNotebook(call, notebook, notebook_key, "list_notes");
740
751
  if ("message" in chosen) {
741
752
  return { content: [{ type: "text", text: chosen.message }] };
742
753
  }
@@ -832,6 +843,14 @@ const createServer = async (call, grants = []) => {
832
843
  "this conversation, or named by the user in it. Omit when they have " +
833
844
  "not chosen one. Never fill it in from saved context or an earlier " +
834
845
  "session."),
846
+ notebook_key: z
847
+ .string()
848
+ .optional()
849
+ .describe("The notebook_key printed with the notebook list, passed back exactly " +
850
+ "as it was given. Required alongside `notebook` on an account with " +
851
+ "more than one notebook: it is how this tool knows the name came " +
852
+ "from the list rather than from somewhere outside the conversation. " +
853
+ "Never invent one, and never reuse one from an earlier session."),
835
854
  since: z
836
855
  .string()
837
856
  .optional()
@@ -844,9 +863,9 @@ const createServer = async (call, grants = []) => {
844
863
  .min(1)
845
864
  .optional()
846
865
  .describe(`Cap how many pages are sketched, newest first. Defaults to ${DEFAULT_MAP_PAGES}.`),
847
- }, async ({ notebook, since, limit }) => {
866
+ }, async ({ notebook, notebook_key, since, limit }) => {
848
867
  try {
849
- const chosen = await selectNotebook(call, notebook, "map_notes");
868
+ const chosen = await selectNotebook(call, notebook, notebook_key, "map_notes");
850
869
  if ("message" in chosen) {
851
870
  return { content: [{ type: "text", text: chosen.message }] };
852
871
  }
@@ -898,7 +917,7 @@ const createServer = async (call, grants = []) => {
898
917
  ],
899
918
  };
900
919
  }
901
- const { sketches, read_in_full } = await call("map_notes", { pages });
920
+ const { sketches, read_in_full, not_reached } = await call("map_notes", { pages });
902
921
  const blocks = sketches.map((s) => {
903
922
  const location = [s.notebook, s.section].filter(Boolean).join(" / ");
904
923
  const head = `## ${s.title}${location ? ` (${location})` : ""} — dated ${s.last_modified ?? "unknown"}` +
@@ -930,6 +949,16 @@ const createServer = async (call, grants = []) => {
930
949
  "usable preview and were read in full instead, so those sketches " +
931
950
  "cover more of the page than the rest.");
932
951
  }
952
+ if (not_reached > 0) {
953
+ // Said before the other truncations, because it is the one the
954
+ // caller did not ask for: `limit` is their own cap and this is the
955
+ // clock running out. A partial survey that does not say so is a
956
+ // survey the caller will read as complete.
957
+ caveats.push(`Stopped after ${sketches.length} of ${sketches.length + not_reached} pages: the ` +
958
+ "survey ran out of time before the rest were reached. Those pages are " +
959
+ "UNSURVEYED, not empty — call map_notes again with a smaller `limit` to " +
960
+ "cover them, or read_note the ones you already know you need.");
961
+ }
933
962
  if (pages.length < matched) {
934
963
  caveats.push(`Sketched the ${pages.length} newest by date of ${matched} pages. ` +
935
964
  "Raise `limit` or narrow with `since` for the rest.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manudota/artist-mcp",
3
- "version": "2.2.2-staging.103",
3
+ "version": "2.3.0",
4
4
  "description": "MCP server that reads your OneNote notes in Claude Desktop or Codex, with optional Gmail and Calendar as supporting evidence. Signs in on your own machine; writes are opt-in per install.",
5
5
  "keywords": [
6
6
  "mcp",