@manudota/artist-mcp 2.3.0 → 2.3.1-staging.112

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,26 +117,7 @@ const resolveRegistry = async () => {
117
117
  ]));
118
118
  const localRoot = localAgentRoot();
119
119
  if (localRoot) {
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) {
120
+ for (const entry of (await readLocalRegistry(localRoot)).entries) {
140
121
  byId.set(entry.id, { ...entry, source: 'local', origin: localRoot });
141
122
  }
142
123
  }
package/dist/api.d.ts CHANGED
@@ -9,29 +9,6 @@
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[]>;
35
12
  /**
36
13
  * What a call needs from the grant, for the one failure that is not a fault.
37
14
  *
package/dist/api.js CHANGED
@@ -47,42 +47,6 @@ 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
- };
86
50
  /**
87
51
  * What the provider actually asked for, when it says.
88
52
  *
@@ -30,10 +30,6 @@ 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
- };
37
33
  readonly list_notes: {
38
34
  readonly provider: "microsoft";
39
35
  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 { listNotebooks, listNotes, mapNotes, readNote } from './notes.js';
19
+ import { 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,7 +39,6 @@ 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' },
43
42
  list_notes: { provider: 'microsoft', effect: 'read' },
44
43
  map_notes: { provider: 'microsoft', effect: 'read' },
45
44
  read_note: { provider: 'microsoft', effect: 'read' },
@@ -112,8 +111,6 @@ export const connectedProviders = async () => {
112
111
  export const dispatchWith = (resolve, record = recordWrite) => async (op, params = {}) => {
113
112
  const token = await resolve(PROVIDER_FOR[op]);
114
113
  switch (op) {
115
- case 'list_notebooks':
116
- return (await listNotebooks(token));
117
114
  case 'list_notes':
118
115
  return (await listNotes(token));
119
116
  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 { FANOUT_LIMIT, gmailGet, mapWithConcurrency } from './api.js';
8
+ import { gmailGet } 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 mapWithConcurrency(ids, FANOUT_LIMIT, async (id) => {
122
+ const emails = await Promise.all(ids.map(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,25 +8,6 @@
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;
30
11
  export type NoteSummary = {
31
12
  id: string;
32
13
  title: string;
@@ -84,31 +65,6 @@ export declare const pageDatesAreCreationDates: (sections: readonly {
84
65
  * both are marked.
85
66
  */
86
67
  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
- }>;
112
68
  /**
113
69
  * `/me/onenote/pages` looks like the obvious call and works right up until the
114
70
  * account has too many sections, at which point Graph fails the whole request
@@ -184,31 +140,6 @@ export type NoteSketch = NoteSummary & {
184
140
  chars_total: number | null;
185
141
  error: string | null;
186
142
  };
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
- };
212
143
  /**
213
144
  * Sketch every page in a chosen notebook, cheaply, so a notebook can be triaged
214
145
  * without reading all of it.
@@ -225,7 +156,10 @@ export type MapNotesResult = {
225
156
  *
226
157
  * Scoped to one notebook, as intake requires. Nothing maps across scopes.
227
158
  */
228
- export declare const mapNotes: (token: string, pages: NoteSummary[], { deadlineMs, now }?: MapNotesOptions) => Promise<MapNotesResult>;
159
+ export declare const mapNotes: (token: string, pages: NoteSummary[]) => Promise<{
160
+ sketches: NoteSketch[];
161
+ read_in_full: number;
162
+ }>;
229
163
  /** A page attachment as `read_note` reports it: named, never read. */
230
164
  export type PageAttachment = Omit<PageResource, 'position'>;
231
165
  export type NoteContent = {
@@ -273,4 +207,3 @@ export type NoteContent = {
273
207
  export declare const readNote: (token: string, noteId: unknown, fromPart?: unknown, { withEditIds }?: {
274
208
  withEditIds?: boolean;
275
209
  }) => Promise<NoteContent>;
276
- export {};
package/dist/notes.js CHANGED
@@ -6,34 +6,11 @@
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';
10
9
  import { MAX_TEXT_CHARS } from './attachments.js';
11
10
  import { GraphError } from './client.js';
12
- import { FANOUT_LIMIT, graphGet, mapWithConcurrency } from './api.js';
11
+ import { graphGet } from './api.js';
13
12
  import { pageResources } from './page-attachments.js';
14
13
  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);
37
14
  /** Graph ids are opaque, but they are concatenated into URLs, so they are checked like any other value. */
38
15
  const ONENOTE_ID = /^[A-Za-z0-9!._~-]{1,300}$/;
39
16
  /**
@@ -112,40 +89,6 @@ export const htmlToText = (html) => html
112
89
  .join('\n')
113
90
  .replace(/\n{3,}/g, '\n\n')
114
91
  .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
- };
149
92
  /**
150
93
  * `/me/onenote/pages` looks like the obvious call and works right up until the
151
94
  * account has too many sections, at which point Graph fails the whole request
@@ -156,68 +99,46 @@ export const listNotebooks = async (token) => {
156
99
  * Do not "simplify" this back to the single call.
157
100
  */
158
101
  export const listNotes = async (token) => {
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)',
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,
189
120
  notebook: section.parentNotebook?.displayName ?? null,
190
- last_modified: section.lastModifiedDateTime ?? null,
191
- pages: (perSection[i] ?? []).length,
121
+ last_modified: p.lastModifiedDateTime ?? null,
192
122
  }));
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
- }
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 };
221
142
  };
222
143
  /**
223
144
  * The sections that changed within a window.
@@ -330,25 +251,6 @@ const previewOf = async (token, id) => {
330
251
  const { previewText } = (await res.json());
331
252
  return (previewText ?? '').trim();
332
253
  };
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');
352
254
  /**
353
255
  * Sketch every page in a chosen notebook, cheaply, so a notebook can be triaged
354
256
  * without reading all of it.
@@ -365,102 +267,61 @@ const NOT_REACHED = Symbol('not reached');
365
267
  *
366
268
  * Scoped to one notebook, as intake requires. Nothing maps across scopes.
367
269
  */
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
- }
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 };
464
325
  };
465
326
  /**
466
327
  * Read one page, in parts only when it is too long for a single answer.
@@ -236,27 +236,7 @@ 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']),
246
239
  };
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']);
260
240
  /**
261
241
  * What may appear in a `style`, which OneNote's own tables lean on heavily for
262
242
  * borders and widths — so forbidding it outright would mean every replaced
@@ -294,13 +274,6 @@ const checkAttributes = (tag, raw) => {
294
274
  'OneNote. Nothing was written. Keep the markup to tables, rows, cells, ' +
295
275
  'paragraphs, lists and simple emphasis.');
296
276
  }
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
- }
304
277
  if (name === 'style' && !SAFE_STYLE.test(value)) {
305
278
  throw failure(`The style on <${tag}> contains something this tool will not send: ` +
306
279
  `"${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, notebookKeyFor } from "./notes.js";
9
+ import { narrowNotes, narrowSections } 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,6 +26,20 @@ 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;
29
43
  /**
30
44
  * Settle which notebook is being worked in, before anything reads a page.
31
45
  *
@@ -34,63 +48,46 @@ const describeSize = (bytes) => {
34
48
  * intake depends on, and two copies of it would eventually disagree. The caller
35
49
  * names itself so the instruction says which tool to call again.
36
50
  */
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)
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)
47
54
  return { message: "No notes found." };
48
- const names = notebooks.map((n) => n.name);
55
+ const names = [...new Set(notes.map((n) => n.notebook ?? "(unnamed notebook)"))];
49
56
  // Handing back every page across every notebook invites work on the wrong
50
57
  // one. With a choice to be made and nothing chosen, the pages are withheld
51
58
  // until the user has actually made it.
52
59
  // A name that arrives before this session has ever seen the list did not come
53
60
  // from the tool, so it is either the user's or a guess — and the two are
54
61
  // indistinguishable from here. Ask, the same way an omitted name asks.
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"}`);
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
+ });
68
69
  return {
69
70
  message: `This account has ${names.length} notebooks:\n${counts.join("\n")}\n\n` +
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."
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."
78
78
  : `Ask the user which notebook to work in, then call ${tool} again ` +
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."),
79
+ "with that name. Do not guess, and do not work across notebooks " +
80
+ "unless the user asks for it."),
81
81
  };
82
82
  }
83
+ notebooksHaveBeenListed = true;
83
84
  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");
91
85
  const pages = wanted
92
86
  ? notes.filter((n) => (n.notebook ?? "").trim().toLowerCase() === wanted)
93
87
  : notes;
88
+ if (wanted && pages.length === 0) {
89
+ return { message: `No notebook named "${notebook}". Available: ${names.join(", ")}.` };
90
+ }
94
91
  // A name that was supplied walks straight past the question above, so a
95
92
  // guessed one is indistinguishable from a chosen one. Found in use: asked
96
93
  // about "this notebook" in a fresh chat, a session inferred one from saved
@@ -176,7 +173,7 @@ const renderChangedSections = (chosen, notebook, since) => {
176
173
  * the only way OneNote allows a cell to change.
177
174
  */
178
175
  const INDEX_ENTRIES = 12;
179
- const serverVersion = '2.3.0'; // x-release-please-version
176
+ const serverVersion = '2.3.1-staging.112'; // x-release-please-version
180
177
  const errorResult = (err) => {
181
178
  const message = err instanceof GraphError ? err.message : `Unexpected error: ${err}`;
182
179
  return { content: [{ type: "text", text: message }], isError: true };
@@ -718,14 +715,6 @@ const createServer = async (call, grants = []) => {
718
715
  "notebook you happen to know the user has: a plausible guess here " +
719
716
  "is indistinguishable from their choice and produces a confident " +
720
717
  "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."),
729
718
  since: z
730
719
  .string()
731
720
  .optional()
@@ -745,9 +734,9 @@ const createServer = async (call, grants = []) => {
745
734
  .describe("Cap the number of pages returned, newest first. The reply says how " +
746
735
  "many matched, so a capped list is never mistaken for the whole " +
747
736
  "notebook."),
748
- }, async ({ notebook, notebook_key, since, limit }) => {
737
+ }, async ({ notebook, since, limit }) => {
749
738
  try {
750
- const chosen = await selectNotebook(call, notebook, notebook_key, "list_notes");
739
+ const chosen = await selectNotebook(call, notebook, "list_notes");
751
740
  if ("message" in chosen) {
752
741
  return { content: [{ type: "text", text: chosen.message }] };
753
742
  }
@@ -843,14 +832,6 @@ const createServer = async (call, grants = []) => {
843
832
  "this conversation, or named by the user in it. Omit when they have " +
844
833
  "not chosen one. Never fill it in from saved context or an earlier " +
845
834
  "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."),
854
835
  since: z
855
836
  .string()
856
837
  .optional()
@@ -863,9 +844,9 @@ const createServer = async (call, grants = []) => {
863
844
  .min(1)
864
845
  .optional()
865
846
  .describe(`Cap how many pages are sketched, newest first. Defaults to ${DEFAULT_MAP_PAGES}.`),
866
- }, async ({ notebook, notebook_key, since, limit }) => {
847
+ }, async ({ notebook, since, limit }) => {
867
848
  try {
868
- const chosen = await selectNotebook(call, notebook, notebook_key, "map_notes");
849
+ const chosen = await selectNotebook(call, notebook, "map_notes");
869
850
  if ("message" in chosen) {
870
851
  return { content: [{ type: "text", text: chosen.message }] };
871
852
  }
@@ -917,7 +898,7 @@ const createServer = async (call, grants = []) => {
917
898
  ],
918
899
  };
919
900
  }
920
- const { sketches, read_in_full, not_reached } = await call("map_notes", { pages });
901
+ const { sketches, read_in_full } = await call("map_notes", { pages });
921
902
  const blocks = sketches.map((s) => {
922
903
  const location = [s.notebook, s.section].filter(Boolean).join(" / ");
923
904
  const head = `## ${s.title}${location ? ` (${location})` : ""} — dated ${s.last_modified ?? "unknown"}` +
@@ -949,16 +930,6 @@ const createServer = async (call, grants = []) => {
949
930
  "usable preview and were read in full instead, so those sketches " +
950
931
  "cover more of the page than the rest.");
951
932
  }
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
- }
962
933
  if (pages.length < matched) {
963
934
  caveats.push(`Sketched the ${pages.length} newest by date of ${matched} pages. ` +
964
935
  "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.3.0",
3
+ "version": "2.3.1-staging.112",
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",