@manudota/artist-mcp 2.0.1-staging.94 → 2.1.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.
@@ -1,9 +1,13 @@
1
1
  # Patch
2
2
 
3
3
  Once the musician has decided something, hand them the smallest fragment that
4
- records it, and say which part of the page it goes under. They paste it. The
5
- page is unchanged until they do, and nothing here can change it — so a patch is
6
- written as something to paste, never as something that has happened.
4
+ records it, and say which part of the page it goes under.
5
+
6
+ Usually they paste it, and the page is unchanged until they do. On the pages
7
+ this tool made itself it can apply the fragment instead, having shown it and
8
+ been told yes. Which of the two applies is decided in "Whose page is it" below,
9
+ and it is the only thing about a patch that changed: everything else here was
10
+ never a consequence of being unable to write.
7
11
 
8
12
  A recommendation they agreed with and a page that still does not say it is the
9
13
  same finding waiting to be re-derived next week. The page is the state. Ending
@@ -22,6 +26,13 @@ Offer once, alongside the answer, and do not ask again if they leave it. Whether
22
26
  their pages get updated is theirs to decide, and a second offer reads as
23
27
  chasing.
24
28
 
29
+ Applying is not a lighter kind of offering. Show the change and wait for a yes
30
+ in words, every time, exactly as creating a page and every calendar write does —
31
+ an edit is smaller and that is not a reason to ask less. The confirmation the
32
+ tool requires is a check that the values are the ones displayed; it cannot tell
33
+ whether anybody read them. A patch they leave is left, and one they decline is
34
+ not applied later because the conversation moved on.
35
+
25
36
  **One patch records a finding rather than a decision**: the cross-reference in
26
37
  `policy:divergence`, offered from the moment two pages are suspected of being
27
38
  one event and not from when it is confirmed. It is the exception because it
@@ -30,6 +41,28 @@ they later conclude. Everything above still applies to it. Do not withhold it
30
41
  for want of a decision; a twin found today is otherwise unknown tomorrow, and
31
42
  the finding dies with the session that made it.
32
43
 
44
+ ## Whose page is it
45
+
46
+ An install granted `onenote-edit` can change **pages this tool created** and no
47
+ others. Microsoft refuses the rest, so that is not a rule to keep — but it is
48
+ also not a rule you can check, because **reading a page always works and only
49
+ writing is refused.** A preview of an edit to the musician's own page will
50
+ succeed, show a change and invite a yes, and the refusal lands afterwards.
51
+
52
+ So offer to apply only where you can see the page is this tool's own:
53
+
54
+ - **A page created in this conversation** — a consolidated page, a filled-out
55
+ template — may be offered as an applied change.
56
+ - **Everywhere else, hand it over to be pasted.** Their pages, pages from
57
+ earlier sessions, pages you merely believe this tool made: a fragment, as
58
+ always. This is narrower than the tool permits, deliberately. A needless paste
59
+ costs a moment; an offer that fails after they have said yes costs their trust
60
+ in every offer after it.
61
+
62
+ Never say which of the two it will be until you know. "I can update that for
63
+ you" about a page you have not established is this tool's own is a promise made
64
+ before the fact.
65
+
33
66
  ## The smallest fragment that records it
34
67
 
35
68
  A one-line decision is one line. Never restate the page around the change, and
@@ -93,8 +126,15 @@ decision next to it.
93
126
 
94
127
  ## Form
95
128
 
96
- A patch is handed over the way a template is: as a file, following Delivery and
97
- Shape in `policy:intake` rather than a second convention invented here.
129
+ An applied patch has no file. The preview is its form: it shows the text that
130
+ would be overwritten and the text replacing it, and that is what the musician
131
+ approves. Do not also produce a file for it — a fragment to paste alongside a
132
+ change already made is a second copy of the same decision, and the one they
133
+ paste will duplicate it.
134
+
135
+ A patch to be pasted is handed over the way a template is: as a file, following
136
+ Delivery and Shape in `policy:intake` rather than a second convention invented
137
+ here.
98
138
  Self-contained HTML with visible table borders, never a code fence, never a
99
139
  source format. One file per destination page, openable on its own — a decision
100
140
  landing on two pages is two files, as a cross-reference is.
@@ -137,13 +177,27 @@ page says nothing about any of this until they paste it.
137
177
 
138
178
  ## Boundaries
139
179
 
140
- - Never claim a page was changed. Not "added", not "updated", not "recorded on
141
- the page" — it is offered, and it stays offered until they say otherwise.
180
+ - Never claim a page was changed unless the tool reported changing it. A patch
181
+ offered, or previewed, or agreed to and not yet applied, is not a page that
182
+ says anything new.
183
+ - Never call a change undoable. OneNote keeps no version of a page and a recycle
184
+ bin does not catch what this writes; what was overwritten is kept in this
185
+ install's own write log, which is a real recovery and not one the musician can
186
+ perform in OneNote. Say that, rather than "I can put it back".
142
187
  - Never track what was pasted or assume an earlier fragment landed. Nothing here
143
- remembers, and a page that looks patched is a page you have re-read.
188
+ remembers, and a page that looks patched is a page you have re-read. This now
189
+ has a second reason: the ids that address a page's parts move every time it is
190
+ written to, so anything remembered about its structure is stale the moment it
191
+ changes.
144
192
  - Never patch two pages into agreement with each other. Where two records of one
145
193
  event conflict, `policy:divergence` governs, and a cross-reference is the only
146
194
  fragment offered before the fields are settled.
147
- - Never delete or replace what is on the page as part of a patch. Where a
148
- decision contradicts a value already written, say both and let them choose
149
- what to do with the old one.
195
+ - Never delete anything. Not a page, not a section of one, and there is no tool
196
+ that can — a deleted page is not in the notebook's recycle bin.
197
+ - Replace only text **this tool itself wrote**, and only where the musician has
198
+ now settled it differently — correcting a fee on a page it generated is the
199
+ case this exists for. A page it created is its own output right up until they
200
+ write on it, and their words are theirs wherever they put them.
201
+ - Where a decision contradicts a value already on the page and that value is not
202
+ this tool's own text, say both and let them choose what to do with the old
203
+ one. Unchanged, and it is the common case.
@@ -45,9 +45,9 @@
45
45
  "id": "policy:patch",
46
46
  "kind": "policy",
47
47
  "name": "Patch",
48
- "description": "Once the musician has decided something, hand them the smallest fragment that records it, and say which part of the page it goes under. They paste it. The page is unchanged until they do, and nothing here can change it — so a patch is written as something to paste, never as something that has happened.",
48
+ "description": "Once the musician has decided something, hand them the smallest fragment that records it, and say which part of the page it goes under.",
49
49
  "file": ".artist/policies/PATCH.md",
50
- "sha256": "b5da16f37d6210d93d323e181c08cf98b0bf4f3a0d9b77518bdf343296122b9f"
50
+ "sha256": "d844496f2720befd02efa0ae940355e569acf2467e18906bad7e0769350653e2"
51
51
  },
52
52
  {
53
53
  "id": "project-type:concert",
package/dist/api.d.ts CHANGED
@@ -73,3 +73,40 @@ export declare const CALENDAR_LIST_NEED: ScopeNeed;
73
73
  * See docs/decisions/0003-onenote-writes.md.
74
74
  */
75
75
  export declare const onenoteCreatePage: (sectionId: string, xhtml: string, token: string) => Promise<Response>;
76
+ /**
77
+ * Read a page back with the ids a patch can aim at.
78
+ *
79
+ * `includeIDs=true` is not optional and not a nicety: without it Graph returns
80
+ * only the `data-id` values the page was created with, and a replace requires
81
+ * the generated `id`. Microsoft warns those "might change after a page update",
82
+ * so this is called immediately before the command that uses what it returns —
83
+ * never once per session, and never carried across a write.
84
+ *
85
+ * The ETag comes back too, because it is the only thing that can tell us the
86
+ * page moved between the read and the write. Verified honoured: a stale
87
+ * `If-Match` is answered 412 rather than ignored.
88
+ */
89
+ export declare const onenotePageContent: (pageId: string, token: string) => Promise<{
90
+ html: string;
91
+ etag: string | null;
92
+ }>;
93
+ /**
94
+ * Apply patch commands to one page.
95
+ *
96
+ * Narrow in the same way `onenoteCreatePage` is, and here the narrowness is
97
+ * load-bearing rather than defensive: this is the only function in the package
98
+ * that can destroy something a musician might want back. A general
99
+ * `graphPatch(path)` would make that reachable from anywhere.
100
+ *
101
+ * Not retried. A 5xx does not say whether the patch landed, and a repeated
102
+ * replace against an id read before the first attempt would aim at whatever
103
+ * that id points at now. Where a caller wants to try again it must read the
104
+ * page again first, which is the same rule the ids themselves impose.
105
+ *
106
+ * See docs/decisions/0004-onenote-page-maintenance.md.
107
+ */
108
+ export declare const onenotePatchPage: (pageId: string, commands: readonly {
109
+ target: string;
110
+ action: string;
111
+ content: string;
112
+ }[], token: string, etag: string | null) => Promise<void>;
package/dist/api.js CHANGED
@@ -304,3 +304,86 @@ export const onenoteCreatePage = async (sectionId, xhtml, token) => {
304
304
  }
305
305
  throw new GraphError(`OneNote refused to create the page (${res.status}). ${detail}`, false);
306
306
  };
307
+ /**
308
+ * Read a page back with the ids a patch can aim at.
309
+ *
310
+ * `includeIDs=true` is not optional and not a nicety: without it Graph returns
311
+ * only the `data-id` values the page was created with, and a replace requires
312
+ * the generated `id`. Microsoft warns those "might change after a page update",
313
+ * so this is called immediately before the command that uses what it returns —
314
+ * never once per session, and never carried across a write.
315
+ *
316
+ * The ETag comes back too, because it is the only thing that can tell us the
317
+ * page moved between the read and the write. Verified honoured: a stale
318
+ * `If-Match` is answered 412 rather than ignored.
319
+ */
320
+ export const onenotePageContent = async (pageId, token) => {
321
+ // graphGet rather than a bare path: getWithRetry takes a whole URL, and the
322
+ // throttling and scope handling live in that wrapper.
323
+ const res = await graphGet(`/me/onenote/pages/${encodeURIComponent(pageId)}/content?includeIDs=true`, token);
324
+ return { html: await res.text(), etag: res.headers.get('etag') };
325
+ };
326
+ /**
327
+ * Apply patch commands to one page.
328
+ *
329
+ * Narrow in the same way `onenoteCreatePage` is, and here the narrowness is
330
+ * load-bearing rather than defensive: this is the only function in the package
331
+ * that can destroy something a musician might want back. A general
332
+ * `graphPatch(path)` would make that reachable from anywhere.
333
+ *
334
+ * Not retried. A 5xx does not say whether the patch landed, and a repeated
335
+ * replace against an id read before the first attempt would aim at whatever
336
+ * that id points at now. Where a caller wants to try again it must read the
337
+ * page again first, which is the same rule the ids themselves impose.
338
+ *
339
+ * See docs/decisions/0004-onenote-page-maintenance.md.
340
+ */
341
+ export const onenotePatchPage = async (pageId, commands, token, etag) => {
342
+ const res = await fetch(`${GRAPH}/me/onenote/pages/${encodeURIComponent(pageId)}/content`, {
343
+ method: 'PATCH',
344
+ headers: {
345
+ authorization: `Bearer ${token}`,
346
+ 'content-type': 'application/json',
347
+ ...(etag === null ? {} : { 'if-match': etag }),
348
+ },
349
+ body: JSON.stringify(commands),
350
+ });
351
+ if (res.status === 204)
352
+ return;
353
+ const detail = (await res.text().catch(() => '')).slice(0, 300);
354
+ // The page changed under us. Reported as what it is rather than retried: the
355
+ // musician typing in OneNote while this ran is the likeliest cause, and
356
+ // writing over that is the exact harm the header exists to prevent.
357
+ if (res.status === 412) {
358
+ throw new GraphError('That page changed while this change was being prepared, so nothing was ' +
359
+ 'written — the edit would have overwritten whatever was just done to it. ' +
360
+ 'Ask again to work from the current version of the page.', false);
361
+ }
362
+ // 40003 is a page the musician wrote; 40006 is a page another application
363
+ // created. Both are Microsoft refusing on ownership, which is the boundary
364
+ // this capability rests on, so neither is reported as a fault.
365
+ if ((res.status === 401 || res.status === 403) && /40003|40006/.test(detail)) {
366
+ throw new GraphError('That page was not created by artist-mcp, so it cannot be changed. This ' +
367
+ 'tool can only edit its own pages — a page you wrote is refused by ' +
368
+ 'Microsoft, not by this tool, and that is deliberate.', false);
369
+ }
370
+ if ((res.status === 401 || res.status === 403) && /40004|scope/i.test(detail)) {
371
+ throw new ScopeError('This Microsoft connection cannot edit OneNote pages. Reconnect with ' +
372
+ '`artist-mcp connect microsoft` — a refresh token carries the scopes it ' +
373
+ 'was granted with, so an existing connection cannot edit until renewed.', 'edit OneNote pages', false);
374
+ }
375
+ // 20134 means the target did not resolve, which for a data-id is the missing
376
+ // '#' and for a generated id means it moved since the read. 20138 means the
377
+ // element resolved and does not support that action. They read alike and are
378
+ // not alike, so they are told apart here rather than in a log nobody reads.
379
+ if (/20134/.test(detail)) {
380
+ throw new GraphError('That part of the page could not be found, which usually means the page ' +
381
+ 'changed since it was read. Nothing was written. Ask again to work from ' +
382
+ 'the current version.', false);
383
+ }
384
+ if (/20138/.test(detail)) {
385
+ throw new GraphError('That part of the page cannot be changed in that way — OneNote does not ' +
386
+ 'allow it for that kind of element. Nothing was written.', false);
387
+ }
388
+ throw new GraphError(`OneNote refused to change the page (${res.status}). ${detail}`, false);
389
+ };
package/dist/audit.d.ts CHANGED
@@ -42,6 +42,22 @@ export type WriteRecord = {
42
42
  * docs/decisions/0003-onenote-writes.md.
43
43
  */
44
44
  created_by_app_id?: string | null;
45
+ /**
46
+ * For a replaced OneNote element: exactly what was on the page beforehand.
47
+ *
48
+ * This is the undo, and there is no other one. OneNote keeps no version of a
49
+ * page and a Graph write leaves nothing in the recycle bin, so what stood
50
+ * where the change landed exists nowhere else the moment the PATCH returns
51
+ * 204. The audit line is not a note about the write here — it *is* the
52
+ * recovery, which is what the header of this file has always claimed the
53
+ * point of these records to be.
54
+ *
55
+ * Held as the element's HTML rather than its text, because restoring it means
56
+ * writing it back. It restores content and not bytes: OneNote rewrites the
57
+ * markup it stores, so a restored paragraph comes back carrying its words and
58
+ * not its original HTML. See docs/decisions/0004-onenote-page-maintenance.md.
59
+ */
60
+ pre_image?: string | null;
45
61
  };
46
62
  /**
47
63
  * Record a write that has already happened.
package/dist/connect.js CHANGED
@@ -49,13 +49,28 @@ export const runConnect = async (input) => {
49
49
  else {
50
50
  // Named, because the consent screen the user just approved was wider than
51
51
  // the one every other install sees, and they should be able to tell why.
52
+ // What Microsoft consent covered depends on which OneNote grants are held,
53
+ // and the difference is exactly the thing a user would want to know. This
54
+ // said unconditionally that no page could be changed "including the ones it
55
+ // creates — the permission it holds cannot express that", which was true of
56
+ // `Notes.Create` alone and becomes a false reassurance the moment
57
+ // `onenote-edit` is granted. A sentence that is wrong only for the widest
58
+ // install is the worst possible place to be wrong.
59
+ const canEdit = relevant.includes('onenote-edit');
60
+ const microsoft = canEdit
61
+ ? 'creating OneNote pages and changing the ones it created. It cannot ' +
62
+ 'touch a page you wrote or one another app created — Microsoft refuses ' +
63
+ 'those — and it cannot delete anything. A change it makes to its own ' +
64
+ 'page overwrites what was there, and OneNote keeps no version, so what ' +
65
+ 'it replaced is kept only in this install\'s write log.'
66
+ : 'creating OneNote pages. No page can be changed or deleted by this ' +
67
+ 'tool, including the ones it creates — the permission it holds cannot ' +
68
+ 'express that.';
52
69
  console.log(`This install was granted ${relevant.join(', ')}, so consent included ` +
53
- `${provider === 'microsoft' ? 'creating OneNote pages' : 'creating calendar events'}. ` +
54
70
  (provider === 'microsoft'
55
- ? 'No page can be changed or deleted by this tool, including the ones ' +
56
- 'it creates — the permission it holds cannot express that.'
57
- : 'Nothing else is ever written back: not mail, and no event is ' +
58
- 'changed except by the reschedule you granted.'));
71
+ ? microsoft
72
+ : 'creating calendar events. Nothing else is ever written back: not ' +
73
+ 'mail, and no event is changed except by the reschedule you granted.'));
59
74
  }
60
75
  // Said at the one moment the user is certainly reading, which is the whole
61
76
  // point: #94 describes a connection that dies weekly, an error that correctly
@@ -110,6 +110,14 @@ export declare const OPERATIONS: {
110
110
  readonly provider: "microsoft";
111
111
  readonly effect: "write";
112
112
  };
113
+ readonly preview_onenote_edit: {
114
+ readonly provider: "microsoft";
115
+ readonly effect: "read";
116
+ };
117
+ readonly edit_onenote_page: {
118
+ readonly provider: "microsoft";
119
+ readonly effect: "write";
120
+ };
113
121
  };
114
122
  export type Operation = keyof typeof OPERATIONS;
115
123
  /**
package/dist/dispatch.js CHANGED
@@ -17,6 +17,7 @@ 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
19
  import { listNotes, mapNotes, readNote } from './notes.js';
20
+ import { applyEdit, previewEdit } from './onenote-patch.js';
20
21
  import { createPage, previewPage } from './onenote-write.js';
21
22
  import { accessTokenFor } from './oauth.js';
22
23
  import { loadTokens } from './tokens.js';
@@ -78,6 +79,16 @@ export const OPERATIONS = {
78
79
  // `Notes.Create` cannot express an edit or a delete, so there is no sibling
79
80
  // row here to refuse. See docs/decisions/0003-onenote-writes.md.
80
81
  create_onenote_page: { provider: 'microsoft', effect: 'write' },
82
+ // Reads the page and shows the change against what is actually written there
83
+ // now. A read, and necessarily so: it is what has to happen before a write is
84
+ // allowed, and gating it would gate the safeguard rather than the danger.
85
+ preview_onenote_edit: { provider: 'microsoft', effect: 'read' },
86
+ // The first row here that can destroy something. Both actions ride one row
87
+ // and one grant because no scope separates them, so the distinction is made
88
+ // where it can be enforced — in the operation itself, where a replace cannot
89
+ // proceed unless what it would overwrite has been captured first.
90
+ // See docs/decisions/0004-onenote-page-maintenance.md.
91
+ edit_onenote_page: { provider: 'microsoft', effect: 'write' },
81
92
  };
82
93
  /** Kept as its own export: callers ask "which token" far more than "which effect". */
83
94
  const PROVIDER_FOR = Object.fromEntries(Object.entries(OPERATIONS).map(([op, meta]) => [op, meta.provider]));
@@ -140,6 +151,10 @@ export const dispatchWith = (resolve, record = recordWrite) => async (op, params
140
151
  return (await previewPage(token, params));
141
152
  case 'create_onenote_page':
142
153
  return (await createPage(token, params, record));
154
+ case 'preview_onenote_edit':
155
+ return (await previewEdit(token, params));
156
+ case 'edit_onenote_page':
157
+ return (await applyEdit(token, params, record));
143
158
  case 'list_calendars':
144
159
  // No parameters by design: the whole value is knowing the full set, and
145
160
  // a filtered list of what exists is the reach problem again.
package/dist/grants.d.ts CHANGED
@@ -31,6 +31,7 @@ export declare const WRITE_CAPABILITIES: {
31
31
  readonly 'calendar-create': "add a single event to a Google Calendar, after showing it to you first";
32
32
  readonly 'calendar-delete': string;
33
33
  readonly 'onenote-create': string;
34
+ readonly 'onenote-edit': string;
34
35
  };
35
36
  export type WriteCapability = keyof typeof WRITE_CAPABILITIES;
36
37
  /** Whether a bare token names a capability, for untangling mangled arguments. */
package/dist/grants.js CHANGED
@@ -39,6 +39,19 @@ export const WRITE_CAPABILITIES = {
39
39
  // See docs/decisions/0003-onenote-writes.md.
40
40
  'onenote-create': 'add a new page to a OneNote section, after showing it to you first. ' +
41
41
  'It cannot change or delete any page, including the ones it made.',
42
+ // Also not this file's boundary to keep, and for a stronger reason than
43
+ // `onenote-create`: `Notes.ReadWrite.CreatedByApp` distinguishes *this* app
44
+ // from every other, so a page the musician wrote is refused by Microsoft
45
+ // before our code is reached — verified as 401 40003, not assumed.
46
+ //
47
+ // One capability covering both actions rather than two. No scope separates
48
+ // appending from replacing, so a second grant would be a promise this
49
+ // repository keeps while reading like the provider-enforced kind, which is
50
+ // the confusion 0003 exists to prevent. What makes a replace survivable is
51
+ // the pre-image, not the name it was granted under.
52
+ // See docs/decisions/0004-onenote-page-maintenance.md.
53
+ 'onenote-edit': 'change a page that artist-mcp itself created, after showing it to you first. ' +
54
+ 'It cannot touch a page you wrote, and it cannot delete anything.',
42
55
  };
43
56
  /** Whether a bare token names a capability, for untangling mangled arguments. */
44
57
  export const isWriteCapability = (value) => typeof value === 'string' && Object.keys(WRITE_CAPABILITIES).includes(value);
package/dist/oauth.js CHANGED
@@ -157,6 +157,18 @@ export const WRITE_SCOPES = {
157
157
  // Microsoft. Asking for `Notes.ReadWrite` instead would hand back exactly
158
158
  // the position 0001 was stuck in. See docs/decisions/0003-onenote-writes.md.
159
159
  'onenote-create': { provider: 'microsoft', scopes: ['Notes.Create'] },
160
+ // Named in the body of Microsoft's own 403 for PATCH and DELETE, and absent
161
+ // from the documentation for the operations it enables — the permissions
162
+ // table for page-delete lists only Notes.ReadWrite and Notes.ReadWrite.All,
163
+ // and says higher privileges are "not available" for a personal account,
164
+ // while a personal account edits with this one. Treat that reference as
165
+ // describing intent and probe for behaviour; scripts/spike-onenote-patch.mjs
166
+ // is that probe.
167
+ //
168
+ // Enforced per-app, not per-scope-holder: a page another application created
169
+ // is refused with 403 40006, and one the musician wrote with 401 40003.
170
+ // Consent for it folds in Notes.Create unasked, observed on every run.
171
+ 'onenote-edit': { provider: 'microsoft', scopes: ['Notes.ReadWrite.CreatedByApp'] },
160
172
  };
161
173
  /**
162
174
  * What to ask consent for, given what this install was granted.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Editing a page this tool created, and only such a page.
3
+ *
4
+ * Like `onenote-write.ts`, there is no ownership boundary in this file, and for
5
+ * the same reason: `Notes.ReadWrite.CreatedByApp` refuses a page the musician
6
+ * wrote before our code is consulted — verified as `401 40003` against a real
7
+ * notebook. What *is* here is the machinery that makes a replace permissible at
8
+ * all, which is a different question and ours to get right. See
9
+ * docs/decisions/0004-onenote-page-maintenance.md.
10
+ *
11
+ * The rule that record rests on: **a replace may not proceed unless the content
12
+ * it is about to destroy has been captured first.** OneNote keeps no version and
13
+ * no recycle bin, so the pre-image is the only undo that exists. It is not a
14
+ * feature attached to replacing; it is the reason replacing is allowed.
15
+ *
16
+ * Three facts about Graph's patch API are encoded below rather than remembered,
17
+ * because two probe runs concluded the opposite of the truth from them:
18
+ *
19
+ * 1. A `data-id` target must carry a `#`. Without it the target does not
20
+ * resolve and Graph answers `20134`, which reads like "this element cannot
21
+ * be edited" and means "there is no such target".
22
+ * 2. `20138` is the *other* thing — the element resolved and does not support
23
+ * that action. A paragraph returns it for `append` and always will, which
24
+ * is documented behaviour and not a sign anything is wrong.
25
+ * 3. Replace requires the **generated** id, never a `data-id`, for everything
26
+ * except the title and images inside a div. Generated ids are returned only
27
+ * with `includeIDs=true`, and Microsoft warns they "might change after a
28
+ * page update" — so they are read immediately before the command that uses
29
+ * them, never carried across a write.
30
+ */
31
+ import { type RecordWrite } from './audit.js';
32
+ export declare const isGeneratedId: (id: string) => boolean;
33
+ export type PageTargets = {
34
+ /** Author-supplied, addressed with a leading `#`. Usable for append only. */
35
+ dataIds: string[];
36
+ /** Graph-generated, addressed bare. Required for replace. */
37
+ generatedIds: string[];
38
+ };
39
+ /**
40
+ * What the page currently offers as targets.
41
+ *
42
+ * Read from the output of `GET .../content?includeIDs=true`. Both attributes
43
+ * are collected because they are not interchangeable: `data-id` is ours and
44
+ * survives a round trip, `id` is Graph's and moves. A caller wanting to replace
45
+ * needs the second even when it labelled the element with the first.
46
+ */
47
+ export declare const readTargets: (html: string) => PageTargets;
48
+ /**
49
+ * Cut one element, with its children, out of the page HTML.
50
+ *
51
+ * Depth-counted rather than matched with a lazy regex. A non-greedy match to
52
+ * the first closing tag returns a fragment whenever the target has children,
53
+ * and a fragment restores as silent truncation — which is the precise failure
54
+ * a pre-image exists to prevent. Returns undefined rather than guessing when
55
+ * the markup does not balance.
56
+ */
57
+ export declare const extractElement: (html: string, attr: "id" | "data-id", value: string) => string | undefined;
58
+ /**
59
+ * The content a replace is about to destroy, or a refusal.
60
+ *
61
+ * Throws rather than returning undefined, because every caller must treat a
62
+ * failed capture as a reason not to write. Making that the return type would
63
+ * let a caller carry on with `?? ''` and destroy a paragraph with nothing held
64
+ * back, which is exactly the outcome 0004 forbids.
65
+ */
66
+ export declare const preImage: (html: string, generatedId: string) => string;
67
+ export type PatchCommand = {
68
+ target: string;
69
+ action: 'append' | 'replace';
70
+ content: string;
71
+ };
72
+ /**
73
+ * Append to a container.
74
+ *
75
+ * `body` and `title` are keywords and take no prefix; everything else appended
76
+ * to is addressed by `data-id`, which does. Getting this wrong is not a visible
77
+ * error — it is a `20134` that reads as though the element were uneditable.
78
+ */
79
+ export declare const appendCommand: (target: string, contentHtml: string) => PatchCommand;
80
+ /**
81
+ * Replace one element, named by its generated id.
82
+ *
83
+ * `data-id` is refused rather than translated. Graph accepts a `data-id` for a
84
+ * replace on images and objects only, and silently doing the wrong thing for
85
+ * every other element would mean a command that looks right and either fails
86
+ * confusingly or hits something other than what was meant.
87
+ */
88
+ export declare const replaceCommand: (generatedId: string, contentHtml: string) => PatchCommand;
89
+ /**
90
+ * Plain text as a single paragraph, which is what replaces a paragraph.
91
+ *
92
+ * A replace must yield an element of a kind the target supports, so this is
93
+ * deliberately not general: one `<p>`, with line breaks preserved and nothing
94
+ * else interpreted. Anything richer is a different operation and should be
95
+ * decided rather than smuggled in through the content string.
96
+ */
97
+ export declare const paragraph: (text: string) => string;
98
+ export type EditDraft = {
99
+ page_id: string;
100
+ action: 'append' | 'replace';
101
+ /** The generated id of the element to replace. Absent for an append. */
102
+ element_id: string | null;
103
+ text: string;
104
+ source_page: string | null;
105
+ };
106
+ export declare const editFrom: (params: Record<string, unknown>) => EditDraft;
107
+ /**
108
+ * The token a preview returns and an edit must carry back.
109
+ *
110
+ * Derived from the payload **and from what the change would destroy**, which is
111
+ * the one difference from the create token and the reason this is not shared
112
+ * with `onenote-write.ts`. A create can only be wrong about what it writes. A
113
+ * replace can also be wrong about what it overwrites — and the id naming that
114
+ * content moves after every page update, so an id that was correct at preview
115
+ * may point at something else by the time the edit is applied.
116
+ *
117
+ * Hashing the pre-image makes that case fail closed: the edit is refused unless
118
+ * the element still holds exactly what the musician was shown.
119
+ */
120
+ export declare const editToken: (draft: EditDraft, preImageHtml: string) => Promise<string>;
121
+ /**
122
+ * What the page currently says, element by element, so a caller can name the
123
+ * part to change without guessing an id.
124
+ *
125
+ * Every id here is read in this call and is only good for the next one. They
126
+ * are not stable identifiers and must never be stored, handed to a musician, or
127
+ * quoted back in a later session — Microsoft says they "might change after a
128
+ * page update", and that was observed rather than merely read.
129
+ */
130
+ export declare const readEditableParts: (token: string, pageId: string) => Promise<{
131
+ html: string;
132
+ etag: string | null;
133
+ parts: {
134
+ element_id: string;
135
+ text: string;
136
+ }[];
137
+ }>;
138
+ /**
139
+ * Show the change that would be made, and hand back the token making it needs.
140
+ *
141
+ * A read despite the name, exactly as the calendar and create previews are.
142
+ */
143
+ export declare function previewEdit(token: string, params: Record<string, unknown>): Promise<{
144
+ preview: null;
145
+ page_id: string;
146
+ parts: {
147
+ element_id: string;
148
+ text: string;
149
+ }[];
150
+ confirmation_token: null;
151
+ note: string;
152
+ } | {
153
+ preview: string;
154
+ page_id: string;
155
+ parts: {
156
+ element_id: string;
157
+ text: string;
158
+ }[];
159
+ confirmation_token: string;
160
+ note: string;
161
+ }>;
162
+ /**
163
+ * Apply the change, having shown it.
164
+ *
165
+ * The page is read again here rather than trusted from the preview, and that is
166
+ * not caution: generated ids move after any page update, so an id that was
167
+ * right at preview may name something else by now. Re-reading also produces the
168
+ * ETag that goes out as `If-Match`, which is what makes the musician typing in
169
+ * OneNote between the two calls a refusal rather than a silent overwrite.
170
+ *
171
+ * The confirmation token is checked against the payload *and* the pre-image, so
172
+ * an element whose content changed since the preview fails closed.
173
+ */
174
+ export declare function applyEdit(token: string, params: Record<string, unknown>, record: RecordWrite): Promise<{
175
+ changed: boolean;
176
+ page_id: string;
177
+ note: string;
178
+ }>;
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Editing a page this tool created, and only such a page.
3
+ *
4
+ * Like `onenote-write.ts`, there is no ownership boundary in this file, and for
5
+ * the same reason: `Notes.ReadWrite.CreatedByApp` refuses a page the musician
6
+ * wrote before our code is consulted — verified as `401 40003` against a real
7
+ * notebook. What *is* here is the machinery that makes a replace permissible at
8
+ * all, which is a different question and ours to get right. See
9
+ * docs/decisions/0004-onenote-page-maintenance.md.
10
+ *
11
+ * The rule that record rests on: **a replace may not proceed unless the content
12
+ * it is about to destroy has been captured first.** OneNote keeps no version and
13
+ * no recycle bin, so the pre-image is the only undo that exists. It is not a
14
+ * feature attached to replacing; it is the reason replacing is allowed.
15
+ *
16
+ * Three facts about Graph's patch API are encoded below rather than remembered,
17
+ * because two probe runs concluded the opposite of the truth from them:
18
+ *
19
+ * 1. A `data-id` target must carry a `#`. Without it the target does not
20
+ * resolve and Graph answers `20134`, which reads like "this element cannot
21
+ * be edited" and means "there is no such target".
22
+ * 2. `20138` is the *other* thing — the element resolved and does not support
23
+ * that action. A paragraph returns it for `append` and always will, which
24
+ * is documented behaviour and not a sign anything is wrong.
25
+ * 3. Replace requires the **generated** id, never a `data-id`, for everything
26
+ * except the title and images inside a div. Generated ids are returned only
27
+ * with `includeIDs=true`, and Microsoft warns they "might change after a
28
+ * page update" — so they are read immediately before the command that uses
29
+ * them, never carried across a write.
30
+ */
31
+ import { onenotePageContent, onenotePatchPage } from './api.js';
32
+ import { GraphError } from './client.js';
33
+ const failure = (message) => new GraphError(message, false);
34
+ /**
35
+ * A generated id as Graph emits it: `p:{guid}{40}`, and for a div nested in a
36
+ * div, the doubled `div:{guid}{42}:{guid}{45}`.
37
+ *
38
+ * Shape-checked because it is concatenated into a JSON command that Graph acts
39
+ * on, and because it is the one value here that decides what gets destroyed. A
40
+ * caller that has invented an id, or kept one across a write, should be refused
41
+ * rather than allowed to overwrite whatever that id now points at.
42
+ */
43
+ const GENERATED_ID = /^(?:p|div|ol|ul|li|table|img|object|h[1-6]):\{[0-9a-fA-F-]{36}\}\{\d+\}(?::\{[0-9a-fA-F-]{36}\}\{\d+\})?$/;
44
+ export const isGeneratedId = (id) => GENERATED_ID.test(id);
45
+ /**
46
+ * What the page currently offers as targets.
47
+ *
48
+ * Read from the output of `GET .../content?includeIDs=true`. Both attributes
49
+ * are collected because they are not interchangeable: `data-id` is ours and
50
+ * survives a round trip, `id` is Graph's and moves. A caller wanting to replace
51
+ * needs the second even when it labelled the element with the first.
52
+ */
53
+ export const readTargets = (html) => ({
54
+ dataIds: [...html.matchAll(/\sdata-id="([^"]+)"/g)].map((m) => m[1]),
55
+ generatedIds: [...html.matchAll(/\sid="([^"]+)"/g)]
56
+ .map((m) => m[1])
57
+ .filter((id) => isGeneratedId(id)),
58
+ });
59
+ /**
60
+ * Cut one element, with its children, out of the page HTML.
61
+ *
62
+ * Depth-counted rather than matched with a lazy regex. A non-greedy match to
63
+ * the first closing tag returns a fragment whenever the target has children,
64
+ * and a fragment restores as silent truncation — which is the precise failure
65
+ * a pre-image exists to prevent. Returns undefined rather than guessing when
66
+ * the markup does not balance.
67
+ */
68
+ export const extractElement = (html, attr, value) => {
69
+ const quoted = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
70
+ const open = new RegExp(`<([a-z][a-z0-9]*)\\b[^>]*\\b${attr}="${quoted}"[^>]*>`, 'i').exec(html);
71
+ if (open === null)
72
+ return undefined;
73
+ const tag = open[1];
74
+ const step = new RegExp(`<(/?)${tag}\\b[^>]*?(/?)>`, 'gi');
75
+ step.lastIndex = open.index;
76
+ let depth = 0;
77
+ let match;
78
+ while ((match = step.exec(html)) !== null) {
79
+ // A self-closing tag enters and leaves in one token.
80
+ if (match[2] === '/')
81
+ continue;
82
+ depth += match[1] === '/' ? -1 : 1;
83
+ if (depth === 0)
84
+ return html.slice(open.index, match.index + match[0].length);
85
+ }
86
+ return undefined;
87
+ };
88
+ /**
89
+ * The content a replace is about to destroy, or a refusal.
90
+ *
91
+ * Throws rather than returning undefined, because every caller must treat a
92
+ * failed capture as a reason not to write. Making that the return type would
93
+ * let a caller carry on with `?? ''` and destroy a paragraph with nothing held
94
+ * back, which is exactly the outcome 0004 forbids.
95
+ */
96
+ export const preImage = (html, generatedId) => {
97
+ if (!isGeneratedId(generatedId)) {
98
+ throw failure(`"${generatedId}" is not a generated element id. A replace has to name one, ` +
99
+ 'read from the page immediately beforehand.');
100
+ }
101
+ const captured = extractElement(html, 'id', generatedId);
102
+ if (captured === undefined) {
103
+ throw failure('The element to be replaced could not be read back from the page, so what ' +
104
+ 'the change would overwrite cannot be captured. Nothing was changed. ' +
105
+ 'OneNote keeps no version of a page, so a replace without that capture ' +
106
+ 'would be unrecoverable.');
107
+ }
108
+ return captured;
109
+ };
110
+ /**
111
+ * Append to a container.
112
+ *
113
+ * `body` and `title` are keywords and take no prefix; everything else appended
114
+ * to is addressed by `data-id`, which does. Getting this wrong is not a visible
115
+ * error — it is a `20134` that reads as though the element were uneditable.
116
+ */
117
+ export const appendCommand = (target, contentHtml) => {
118
+ if (target === 'title') {
119
+ throw failure('A title cannot be appended to; it can only be replaced.');
120
+ }
121
+ return {
122
+ target: target === 'body' || isGeneratedId(target) ? target : `#${target.replace(/^#/, '')}`,
123
+ action: 'append',
124
+ content: contentHtml,
125
+ };
126
+ };
127
+ /**
128
+ * Replace one element, named by its generated id.
129
+ *
130
+ * `data-id` is refused rather than translated. Graph accepts a `data-id` for a
131
+ * replace on images and objects only, and silently doing the wrong thing for
132
+ * every other element would mean a command that looks right and either fails
133
+ * confusingly or hits something other than what was meant.
134
+ */
135
+ export const replaceCommand = (generatedId, contentHtml) => {
136
+ if (!isGeneratedId(generatedId)) {
137
+ throw failure(`A replace must name a generated element id, not "${generatedId}". Read the ` +
138
+ 'page with includeIDs=true and use the id it reports for the element.');
139
+ }
140
+ return { target: generatedId, action: 'replace', content: contentHtml };
141
+ };
142
+ /**
143
+ * XHTML entities, as in onenote-write.ts and for the same reason: a model
144
+ * composing a correction from an email is passing through text it did not
145
+ * author, and unescaped markup would let that text decide what the page says.
146
+ */
147
+ const escapeXml = (value) => value
148
+ .replace(/&/g, '&amp;')
149
+ .replace(/</g, '&lt;')
150
+ .replace(/>/g, '&gt;')
151
+ .replace(/"/g, '&quot;')
152
+ .replace(/'/g, '&apos;');
153
+ /**
154
+ * Plain text as a single paragraph, which is what replaces a paragraph.
155
+ *
156
+ * A replace must yield an element of a kind the target supports, so this is
157
+ * deliberately not general: one `<p>`, with line breaks preserved and nothing
158
+ * else interpreted. Anything richer is a different operation and should be
159
+ * decided rather than smuggled in through the content string.
160
+ */
161
+ export const paragraph = (text) => {
162
+ const trimmed = text.trim();
163
+ if (trimmed === '')
164
+ throw failure('There is no text to write.');
165
+ return `<p>${escapeXml(trimmed).replace(/\n/g, '<br/>')}</p>`;
166
+ };
167
+ /** As in onenote-write.ts: opaque, but concatenated into URLs. */
168
+ const ONENOTE_ID = /^[A-Za-z0-9!._~-]{1,300}$/;
169
+ const MAX_TEXT_CHARS = 4_000;
170
+ export const editFrom = (params) => {
171
+ const pageId = params.page_id;
172
+ if (typeof pageId !== 'string' || !ONENOTE_ID.test(pageId)) {
173
+ throw failure('page_id is required, and must name the page to change.');
174
+ }
175
+ const action = params.action;
176
+ if (action !== 'append' && action !== 'replace') {
177
+ throw failure("action must be either 'append' or 'replace'. Nothing else can be done to a page.");
178
+ }
179
+ const text = params.text;
180
+ if (typeof text !== 'string' || text.trim() === '') {
181
+ throw failure('text is required: there is nothing to write.');
182
+ }
183
+ if (text.length > MAX_TEXT_CHARS) {
184
+ throw failure(`That is ${text.length} characters. An edit records a decision, not a page — ` +
185
+ `${MAX_TEXT_CHARS} is the most. A longer change is a new page.`);
186
+ }
187
+ const elementId = params.element_id;
188
+ if (action === 'replace') {
189
+ if (typeof elementId !== 'string' || !isGeneratedId(elementId)) {
190
+ throw failure('A replace needs element_id: the generated id of the part to change, as ' +
191
+ 'preview_onenote_edit reports it. Read the page again if you do not have one.');
192
+ }
193
+ }
194
+ else if (elementId !== undefined && elementId !== null && elementId !== '') {
195
+ throw failure('An append goes at the end of the page and takes no element_id. To change ' +
196
+ 'something already written, use replace.');
197
+ }
198
+ const source = params.source_page;
199
+ if (source !== undefined && source !== null && source !== '') {
200
+ if (typeof source !== 'string' || !ONENOTE_ID.test(source)) {
201
+ throw failure('source_page is malformed.');
202
+ }
203
+ }
204
+ return {
205
+ page_id: pageId,
206
+ action,
207
+ element_id: action === 'replace' ? elementId : null,
208
+ text,
209
+ source_page: typeof source === 'string' && source !== '' ? source : null,
210
+ };
211
+ };
212
+ const base32hex = (bytes) => {
213
+ const alphabet = '0123456789abcdefghijklmnopqrstuv';
214
+ let bits = 0;
215
+ let value = 0;
216
+ let out = '';
217
+ for (const byte of bytes) {
218
+ value = (value << 8) | byte;
219
+ bits += 8;
220
+ while (bits >= 5) {
221
+ out += alphabet[(value >>> (bits - 5)) & 31];
222
+ bits -= 5;
223
+ }
224
+ }
225
+ return out;
226
+ };
227
+ /**
228
+ * The token a preview returns and an edit must carry back.
229
+ *
230
+ * Derived from the payload **and from what the change would destroy**, which is
231
+ * the one difference from the create token and the reason this is not shared
232
+ * with `onenote-write.ts`. A create can only be wrong about what it writes. A
233
+ * replace can also be wrong about what it overwrites — and the id naming that
234
+ * content moves after every page update, so an id that was correct at preview
235
+ * may point at something else by the time the edit is applied.
236
+ *
237
+ * Hashing the pre-image makes that case fail closed: the edit is refused unless
238
+ * the element still holds exactly what the musician was shown.
239
+ */
240
+ export const editToken = async (draft, preImageHtml) => {
241
+ const canonical = [draft.page_id, draft.action, draft.element_id ?? '', draft.text].join(' ');
242
+ const data = new TextEncoder().encode(`confirm-edit ${canonical} ${preImageHtml}`);
243
+ return base32hex(new Uint8Array(await crypto.subtle.digest('SHA-256', data)));
244
+ };
245
+ /**
246
+ * Strip tags for display. The page is shown as the musician reads it, not as
247
+ * HTML — and this text is what they approve a destructive change against, so
248
+ * markup leaking into it is not cosmetic.
249
+ *
250
+ * Numeric entities are decoded as well as named ones. We write `&apos;`, but
251
+ * OneNote stores an apostrophe as `&#39;` and hands it back that way, so a
252
+ * decoder built only from what this file emits showed a preview reading
253
+ * "the product&#39;s own tool path" — observed on a real page.
254
+ *
255
+ * Ampersand last, or the entities the earlier passes produce get decoded again.
256
+ */
257
+ const asText = (html) => html
258
+ .replace(/<br\s*\/?>/gi, '\n')
259
+ .replace(/<[^>]+>/g, '')
260
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
261
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
262
+ .replace(/&lt;/g, '<')
263
+ .replace(/&gt;/g, '>')
264
+ .replace(/&quot;/g, '"')
265
+ .replace(/&apos;/g, "'")
266
+ .replace(/&nbsp;/g, ' ')
267
+ .replace(/&amp;/g, '&')
268
+ .trim();
269
+ /**
270
+ * What the page currently says, element by element, so a caller can name the
271
+ * part to change without guessing an id.
272
+ *
273
+ * Every id here is read in this call and is only good for the next one. They
274
+ * are not stable identifiers and must never be stored, handed to a musician, or
275
+ * quoted back in a later session — Microsoft says they "might change after a
276
+ * page update", and that was observed rather than merely read.
277
+ */
278
+ export const readEditableParts = async (token, pageId) => {
279
+ const { html, etag } = await onenotePageContent(pageId, token);
280
+ const parts = readTargets(html)
281
+ .generatedIds.filter((id) => /^(?:p|h[1-6]|li):/.test(id))
282
+ .map((id) => {
283
+ const element = extractElement(html, 'id', id);
284
+ return { element_id: id, text: element === undefined ? '' : asText(element) };
285
+ })
286
+ .filter((part) => part.text !== '');
287
+ return { html, etag, parts };
288
+ };
289
+ /**
290
+ * Show the change that would be made, and hand back the token making it needs.
291
+ *
292
+ * A read despite the name, exactly as the calendar and create previews are.
293
+ */
294
+ export async function previewEdit(token, params) {
295
+ // Naming a part to replace requires knowing its id, and an id is only
296
+ // knowable by reading the page. So a replace with no element_id is not a
297
+ // malformed call — it is the first half of the only workflow available, and
298
+ // the tool text says so. It used to be refused here, which meant a model
299
+ // following that text got an error and had to guess its way out.
300
+ //
301
+ // No confirmation token comes back from this branch. Nothing has been chosen
302
+ // yet, and a token would be one for a change nobody has described.
303
+ const wantsPart = params.action === 'replace' &&
304
+ (params.element_id === undefined || params.element_id === null || params.element_id === '');
305
+ if (wantsPart) {
306
+ const pageId = params.page_id;
307
+ if (typeof pageId !== 'string' || !ONENOTE_ID.test(pageId)) {
308
+ throw failure('page_id is required, and must name the page to change.');
309
+ }
310
+ const { parts } = await readEditableParts(token, pageId);
311
+ return {
312
+ preview: null,
313
+ page_id: pageId,
314
+ parts,
315
+ confirmation_token: null,
316
+ note: parts.length === 0
317
+ ? 'This page has no separately editable parts, so nothing on it can be ' +
318
+ 'replaced. An append can still add to the end of it.'
319
+ : 'Nothing has been previewed yet. These are the parts of the page as it ' +
320
+ 'stands. Name one as element_id and preview again to see the change ' +
321
+ 'itself. The ids were read just now and are good only for the next call.',
322
+ };
323
+ }
324
+ const draft = editFrom(params);
325
+ const { html, parts } = await readEditableParts(token, draft.page_id);
326
+ if (draft.action === 'append') {
327
+ return {
328
+ preview: `Add to the end of the page:\n\n${draft.text.trim()}`,
329
+ page_id: draft.page_id,
330
+ parts,
331
+ confirmation_token: await editToken(draft, ''),
332
+ note: 'Show this to the musician and wait for their yes. Nothing on the page is ' +
333
+ 'changed or removed by an append — it adds to the end.',
334
+ };
335
+ }
336
+ const captured = preImage(html, draft.element_id);
337
+ return {
338
+ preview: `Replace this:\n\n${asText(captured)}\n\nWith this:\n\n${draft.text.trim()}`,
339
+ page_id: draft.page_id,
340
+ parts,
341
+ confirmation_token: await editToken(draft, captured),
342
+ note: 'Show this to the musician and wait for their yes. This overwrites what is ' +
343
+ 'quoted above, and OneNote keeps no version of a page — what it replaces is ' +
344
+ 'kept in this install\'s own write log and nowhere else.',
345
+ };
346
+ }
347
+ /**
348
+ * Apply the change, having shown it.
349
+ *
350
+ * The page is read again here rather than trusted from the preview, and that is
351
+ * not caution: generated ids move after any page update, so an id that was
352
+ * right at preview may name something else by now. Re-reading also produces the
353
+ * ETag that goes out as `If-Match`, which is what makes the musician typing in
354
+ * OneNote between the two calls a refusal rather than a silent overwrite.
355
+ *
356
+ * The confirmation token is checked against the payload *and* the pre-image, so
357
+ * an element whose content changed since the preview fails closed.
358
+ */
359
+ export async function applyEdit(token, params, record) {
360
+ const draft = editFrom(params);
361
+ const { html, etag } = await onenotePageContent(draft.page_id, token);
362
+ const captured = draft.action === 'replace' ? preImage(html, draft.element_id) : '';
363
+ const expected = await editToken(draft, captured);
364
+ if (params.confirmation_token !== expected) {
365
+ throw failure(draft.action === 'replace'
366
+ ? 'That part of the page is not what was previewed — either the values ' +
367
+ 'changed, or the page did since it was shown. Nothing was written. Call ' +
368
+ 'preview_onenote_edit again and show the musician what it returns now.'
369
+ : 'The confirmation token does not match these values. Call ' +
370
+ 'preview_onenote_edit again with exactly what should be written, show the ' +
371
+ 'musician what it returns, then apply it with those same values.');
372
+ }
373
+ const command = draft.action === 'append'
374
+ ? appendCommand('body', paragraph(draft.text))
375
+ : replaceCommand(draft.element_id, paragraph(draft.text));
376
+ await onenotePatchPage(draft.page_id, [command], token, etag);
377
+ // After the write, and carrying what it destroyed. For a replace this line is
378
+ // the only copy of the previous content that exists anywhere.
379
+ await record({
380
+ operation: draft.action === 'append' ? 'append_onenote_page' : 'replace_onenote_element',
381
+ // The text itself, not merely that something happened. `summary` is
382
+ // documented as what was written "as the user would read it back", and the
383
+ // calendar rows honour that while these two originally named only the page
384
+ // id — so the log recorded the replaced text in `pre_image` and left the
385
+ // replacing text nowhere at all. Someone reading this line back is
386
+ // reconstructing an edit they cannot see any other way.
387
+ summary: draft.action === 'append'
388
+ ? `Added to the end of the page: ${draft.text.trim()}`
389
+ : `Replaced "${asText(captured)}" with "${draft.text.trim()}"`,
390
+ target: draft.page_id,
391
+ source_page: draft.source_page,
392
+ pre_image: draft.action === 'replace' ? captured : null,
393
+ });
394
+ return {
395
+ changed: true,
396
+ page_id: draft.page_id,
397
+ note: draft.action === 'append'
398
+ ? 'The page now carries this at the end. Nothing was removed.'
399
+ : 'The page now says this. What it said before is in this install\'s write ' +
400
+ 'log and nowhere else — OneNote keeps no version of a page, so tell the ' +
401
+ 'musician that rather than implying it can be undone in OneNote.',
402
+ };
403
+ }
package/dist/server.js CHANGED
@@ -164,7 +164,7 @@ const renderChangedSections = (chosen, notebook, since) => {
164
164
  caveats.join("\n\n"),
165
165
  ].join("\n\n");
166
166
  };
167
- const serverVersion = '2.0.1-staging.94'; // x-release-please-version
167
+ const serverVersion = '2.1.0'; // x-release-please-version
168
168
  const errorResult = (err) => {
169
169
  const message = err instanceof GraphError ? err.message : `Unexpected error: ${err}`;
170
170
  return { content: [{ type: "text", text: message }], isError: true };
@@ -1480,7 +1480,11 @@ const createServer = async (call, grants = []) => {
1480
1480
  "holds, not a policy it follows, so a page created by mistake stays " +
1481
1481
  "until the musician removes it in OneNote themselves. There is no bulk " +
1482
1482
  "form. It never changes an existing page, so it is not a way to add a " +
1483
- "line to one: paste that for the musician as always. Requires the " +
1483
+ "line to one: " +
1484
+ (isGranted(grants, "onenote-edit")
1485
+ ? "use preview_onenote_edit and edit_onenote_page for that. "
1486
+ : "paste that for the musician as always. ") +
1487
+ "Requires the " +
1484
1488
  "confirmation_token from a preview of these exact values; change any " +
1485
1489
  "field and the token stops matching, which means preview again and show " +
1486
1490
  "the musician the new version. Never write a value the musician chose in " +
@@ -1522,6 +1526,117 @@ const createServer = async (call, grants = []) => {
1522
1526
  }
1523
1527
  });
1524
1528
  }
1529
+ /**
1530
+ * Editing a page this tool wrote.
1531
+ *
1532
+ * Everything the create tools lean on inverts here. There, the strongest
1533
+ * thing the text could say was that a mistake is permanent, because a model
1534
+ * that believes a mistake is repairable creates more freely. Here a mistake
1535
+ * *is* repairable, and the danger runs the other way: a model that believes
1536
+ * an edit is free will make more of them, and a replace destroys what it
1537
+ * overwrites with only this install's own write log standing behind it.
1538
+ *
1539
+ * So the text below says the opposite thing plainly — that OneNote keeps no
1540
+ * version, that the record of what was overwritten lives here and nowhere
1541
+ * else, and that a page the musician wrote is unreachable no matter how the
1542
+ * request is phrased. See docs/decisions/0004-onenote-page-maintenance.md.
1543
+ */
1544
+ if (isGranted(grants, "onenote-edit")) {
1545
+ server.tool("preview_onenote_edit", "Call this before edit_onenote_page — it is the only way to obtain the confirmation_token that one requires, and it is what puts the exact change in front of the musician. SHOW THE MUSICIAN WHAT IT RETURNS AND WAIT FOR THEIR YES. " +
1546
+ "Reads the page as it stands right now and shows what the change would " +
1547
+ "do to it. Changes nothing. For a replace it quotes the text that would " +
1548
+ "be overwritten, which is the part the musician has to agree to. It also " +
1549
+ "returns the page's editable parts with an element_id for each, so pass " +
1550
+ "action 'replace' with no element_id first if you do not know which part " +
1551
+ "to change, read the parts back, then preview again naming one. Those " +
1552
+ "ids are read fresh each call and are good only for the next call — " +
1553
+ "never store one, repeat one to the musician, or reuse one from earlier " +
1554
+ "in the conversation.", {
1555
+ page_id: z
1556
+ .string()
1557
+ .describe("The page to change, by id from list_notes or read_note. The id, not the title"),
1558
+ action: z
1559
+ .enum(["append", "replace"])
1560
+ .describe("append adds a paragraph at the end and removes nothing. replace " +
1561
+ "overwrites one existing element and is the destructive one"),
1562
+ text: z.string().describe("The new text, plain. Markdown and HTML are NOT interpreted"),
1563
+ element_id: z
1564
+ .string()
1565
+ .optional()
1566
+ .describe("For replace only: which part to overwrite, using an element_id this " +
1567
+ "tool reported in the parts list on a previous call"),
1568
+ source_page: z
1569
+ .string()
1570
+ .optional()
1571
+ .describe("The page this change was decided from, so the write can be traced back"),
1572
+ }, async (params) => {
1573
+ try {
1574
+ const { preview, confirmation_token, parts, note } = await call("preview_onenote_edit", params);
1575
+ const listed = parts.length === 0
1576
+ ? ""
1577
+ : "\n\nThe editable parts of this page right now:\n" +
1578
+ parts.map((p) => ` ${p.element_id}\n ${p.text}`).join("\n");
1579
+ // No token means nothing has been chosen yet: this was the call that
1580
+ // asks which part to change. Saying "wait for their yes" here would
1581
+ // be asking the musician to approve a change nobody has described.
1582
+ if (confirmation_token === null) {
1583
+ return { content: [{ type: "text", text: `${note}${listed}` }] };
1584
+ }
1585
+ return {
1586
+ content: [
1587
+ {
1588
+ type: "text",
1589
+ text: `${preview}${listed}\n\n` +
1590
+ "Show this to the musician and wait for their yes. If this is a " +
1591
+ "replace, say plainly what it overwrites: OneNote keeps no " +
1592
+ "version of a page, so the previous text will exist only in this " +
1593
+ "install's write log afterwards. If they agree, call " +
1594
+ "edit_onenote_page with the SAME values and:\n" +
1595
+ ` confirmation_token: ${confirmation_token}`,
1596
+ },
1597
+ ],
1598
+ };
1599
+ }
1600
+ catch (err) {
1601
+ return errorResult(err);
1602
+ }
1603
+ });
1604
+ server.tool("edit_onenote_page", "Only call this after preview_onenote_edit AND after the musician has said yes to what the preview showed. Never call it to find out whether it would work. " +
1605
+ "Changes ONE page that artist-mcp itself created. It CANNOT touch a page " +
1606
+ "the musician wrote or one another app created — Microsoft refuses those, " +
1607
+ "not this tool — and it cannot delete anything. 'append' adds a paragraph " +
1608
+ "at the end and removes nothing. 'replace' overwrites one element and is " +
1609
+ "destructive: OneNote keeps no page version and a replaced paragraph is " +
1610
+ "recoverable only from this install's own write log, so never describe it " +
1611
+ "as undoable in OneNote. Requires the confirmation_token from a preview " +
1612
+ "of these exact values; if the page changed since that preview the token " +
1613
+ "stops matching and nothing is written, which means preview again and " +
1614
+ "show the musician the new version. Never write a value the musician " +
1615
+ "chose in chat to break a tie between pages that still disagree.", {
1616
+ page_id: z.string().describe("Exactly what the preview showed"),
1617
+ action: z.enum(["append", "replace"]).describe("Exactly what the preview showed"),
1618
+ text: z.string().describe("Exactly what the preview showed"),
1619
+ element_id: z
1620
+ .string()
1621
+ .optional()
1622
+ .describe("For replace: the same element_id the preview was given"),
1623
+ source_page: z
1624
+ .string()
1625
+ .optional()
1626
+ .describe("The page this change was decided from, so the write can be traced back"),
1627
+ confirmation_token: z
1628
+ .string()
1629
+ .describe("The token preview_onenote_edit returned for these exact values"),
1630
+ }, async (params) => {
1631
+ try {
1632
+ const { note } = await call("edit_onenote_page", params);
1633
+ return { content: [{ type: "text", text: note }] };
1634
+ }
1635
+ catch (err) {
1636
+ return errorResult(err);
1637
+ }
1638
+ });
1639
+ }
1525
1640
  server.tool("list_calendars", "Call this before concluding that something is NOT in the calendar. A search of one calendar that finds nothing is not evidence of absence — it is evidence about one calendar. Gigs commonly sit on a band, venue or shared calendar rather than the primary one. " +
1526
1641
  "Lists the Google calendars this musician has, with which one is primary " +
1527
1642
  "and whether each is writable by them. Read-only: this never creates, " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manudota/artist-mcp",
3
- "version": "2.0.1-staging.94",
3
+ "version": "2.1.0",
4
4
  "description": "MCP server that reads your OneNote notes, with optional Gmail and Calendar as supporting evidence, via a hosted connection.",
5
5
  "repository": {
6
6
  "type": "git",