dsh-plugin-onenote 0.0.0-stage → 0.2.1

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/src/state.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Local state for the OneNote plugin: tokens, notebook consent, and one pending
3
+ * device-code attempt.
4
+ *
5
+ * Everything lives under `$DSH_HOME/onenote`, outside the workspace, so it
6
+ * survives restarts and is never picked up as project files. Tokens are the only
7
+ * secret written here, so the token and sign-in files are created 0600 and each
8
+ * write goes through a temp file plus rename — an interrupted write can never
9
+ * leave a half-written token or allow-list behind.
10
+ */
11
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ /** The Azure public-client app registration this bundle is built for. */
16
+ export const DEFAULT_CLIENT_ID = 'a2e9773e-e783-4e5c-83ff-8c627eec0e6e';
17
+
18
+ /** Delegated scopes: read notes, keep a refresh token, and name the account. */
19
+ export const SCOPES = ['offline_access', 'Notes.Read', 'User.Read'];
20
+
21
+ /** Resolve the plugin's data directory, following the runtime's DSH home. */
22
+ export function dataDir() {
23
+ const home = process.env.DSH_HOME?.trim();
24
+ return join(home !== undefined && home.length > 0 ? home : join(homedir(), '.dsh'), 'onenote');
25
+ }
26
+
27
+ function filePath(name) {
28
+ return join(dataDir(), name);
29
+ }
30
+
31
+ function readJson(name) {
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(filePath(name), 'utf8'));
34
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ function writeJson(name, value, mode = 0o600) {
41
+ mkdirSync(dataDir(), { recursive: true, mode: 0o700 });
42
+ const target = filePath(name);
43
+ const temp = `${target}.${process.pid}.${Date.now()}.tmp`;
44
+ writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode });
45
+ try {
46
+ chmodSync(temp, mode);
47
+ } catch {
48
+ /* a filesystem without POSIX modes still keeps the file inside the user's home */
49
+ }
50
+ renameSync(temp, target);
51
+ }
52
+
53
+ function removeFile(name) {
54
+ try {
55
+ rmSync(filePath(name), { force: true });
56
+ } catch {
57
+ /* an absent file is already in the desired state */
58
+ }
59
+ }
60
+
61
+ /** Read stored tokens, treating a malformed or absent file as signed out. */
62
+ export function readTokens() {
63
+ const raw = readJson('tokens.json');
64
+ if (raw === undefined) return undefined;
65
+ if (typeof raw.accessToken !== 'string' || raw.accessToken.length === 0) return undefined;
66
+ return {
67
+ accessToken: raw.accessToken,
68
+ refreshToken: typeof raw.refreshToken === 'string' && raw.refreshToken.length > 0 ? raw.refreshToken : undefined,
69
+ // An unreadable expiry counts as expired, so the next call refreshes instead of failing.
70
+ expiresAt: typeof raw.expiresAt === 'number' ? raw.expiresAt : 0,
71
+ scope: typeof raw.scope === 'string' ? raw.scope : SCOPES.join(' '),
72
+ account: normalizeAccount(raw.account),
73
+ };
74
+ }
75
+
76
+ /** Persist tokens after a sign-in or refresh. */
77
+ export function writeTokens(tokens) {
78
+ writeJson('tokens.json', tokens);
79
+ }
80
+
81
+ /** Drop the stored tokens. */
82
+ export function clearTokens() {
83
+ removeFile('tokens.json');
84
+ }
85
+
86
+ function normalizeAccount(account) {
87
+ if (account === null || typeof account !== 'object' || Array.isArray(account)) return undefined;
88
+ const displayName = typeof account.displayName === 'string' && account.displayName.length > 0 ? account.displayName : undefined;
89
+ const userPrincipalName = typeof account.userPrincipalName === 'string' && account.userPrincipalName.length > 0 ? account.userPrincipalName : undefined;
90
+ const mail = typeof account.mail === 'string' && account.mail.length > 0 ? account.mail : undefined;
91
+ if (displayName === undefined && userPrincipalName === undefined && mail === undefined) return undefined;
92
+ return { displayName, userPrincipalName, mail };
93
+ }
94
+
95
+ /**
96
+ * The notebook allow-list: the only notebooks the reading tools will touch.
97
+ * Entries keep the display name so the model can show a name without another
98
+ * request, while the id stays the authority for every permission check.
99
+ */
100
+ export function readAllowedNotebooks() {
101
+ const raw = readJson('settings.json');
102
+ const list = raw?.allowedNotebooks;
103
+ if (!Array.isArray(list)) return [];
104
+ const seen = new Set();
105
+ const result = [];
106
+ for (const entry of list) {
107
+ if (entry === null || typeof entry !== 'object') continue;
108
+ const id = typeof entry.id === 'string' ? entry.id.trim() : '';
109
+ if (id.length === 0 || seen.has(id)) continue;
110
+ seen.add(id);
111
+ result.push({ id, name: typeof entry.name === 'string' && entry.name.length > 0 ? entry.name : id });
112
+ }
113
+ return result;
114
+ }
115
+
116
+ /** Replace the allow-list. */
117
+ export function writeAllowedNotebooks(notebooks) {
118
+ writeJson('settings.json', {
119
+ allowedNotebooks: notebooks.map((notebook) => ({ id: notebook.id, name: notebook.name })),
120
+ updatedAt: new Date().toISOString(),
121
+ });
122
+ }
123
+
124
+ /** Read the in-flight device-code attempt, if it has not expired. */
125
+ export function readPendingSignIn() {
126
+ const raw = readJson('signin.json');
127
+ if (raw === undefined) return undefined;
128
+ if (typeof raw.deviceCode !== 'string' || raw.deviceCode.length === 0) return undefined;
129
+ if (typeof raw.expiresAt !== 'number' || raw.expiresAt <= Date.now()) return undefined;
130
+ return {
131
+ deviceCode: raw.deviceCode,
132
+ userCode: typeof raw.userCode === 'string' ? raw.userCode : '',
133
+ verificationUri: typeof raw.verificationUri === 'string' ? raw.verificationUri : 'https://microsoft.com/devicelogin',
134
+ message: typeof raw.message === 'string' ? raw.message : undefined,
135
+ intervalMs: typeof raw.intervalMs === 'number' && raw.intervalMs >= 1000 ? raw.intervalMs : 5000,
136
+ expiresAt: raw.expiresAt,
137
+ clientId: typeof raw.clientId === 'string' ? raw.clientId : undefined,
138
+ tenant: typeof raw.tenant === 'string' ? raw.tenant : undefined,
139
+ };
140
+ }
141
+
142
+ /** Remember the pending device-code attempt so a later call can finish it. */
143
+ export function writePendingSignIn(pending) {
144
+ writeJson('signin.json', pending);
145
+ }
146
+
147
+ /** Forget the pending attempt. */
148
+ export function clearPendingSignIn() {
149
+ removeFile('signin.json');
150
+ }
package/src/tools.js ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * The model-facing tools. Every one of them is a thin shell over `service.js`,
3
+ * so the consent check and the Graph call live in exactly one place.
4
+ *
5
+ * Sign-in and consent changes are deliberately absent: those belong to the
6
+ * user, in Settings → Plugins → OneNote. A tool can therefore never widen its
7
+ * own read scope, which is what makes the allow-list a real boundary rather
8
+ * than a suggestion.
9
+ */
10
+ import { defineTool } from '@deepseek-ai/dsh-tools';
11
+ import { describeFailure } from './service.js';
12
+
13
+ const textResult = (text) => [{ type: 'text', text }];
14
+
15
+ function clampInt(value, fallback, min = 1, max = 200) {
16
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
17
+ return Math.min(max, Math.max(min, Math.trunc(value)));
18
+ }
19
+
20
+ /** Copy only the defined fields of a source record, so no key holds `undefined`. */
21
+ function compact(source) {
22
+ const out = {};
23
+ for (const [key, value] of Object.entries(source)) {
24
+ if (value !== undefined) out[key] = value;
25
+ }
26
+ return out;
27
+ }
28
+
29
+ const PAGE_ITEM = {
30
+ type: 'object',
31
+ additionalProperties: false,
32
+ properties: {
33
+ id: { type: 'string', required: true },
34
+ title: { type: 'string', required: true },
35
+ lastModified: { type: 'string' },
36
+ sectionName: { type: 'string' },
37
+ notebookName: { type: 'string' },
38
+ webUrl: { type: 'string' },
39
+ },
40
+ };
41
+
42
+ /**
43
+ * Register the OneNote tools on the plugin context.
44
+ * @param {object} ctx - the Cordis plugin context.
45
+ * @param {object} service - the shared OneNote service from `service.js`.
46
+ * @returns {Array<() => void>} disposers for the registrations.
47
+ */
48
+ export function registerTools(ctx, service) {
49
+ const disposers = [];
50
+ const register = (definition) => {
51
+ disposers.push(ctx.tools.register(definition));
52
+ };
53
+
54
+ register(defineTool({
55
+ name: 'onenote_status',
56
+ description:
57
+ 'Report whether Microsoft OneNote is connected, which account is signed in, and which notebooks this agent may read. Signing in and choosing notebooks happen in Settings → Plugins → OneNote, so this tool never changes them.',
58
+ parameters: {},
59
+ output: {
60
+ schema: {
61
+ type: 'object',
62
+ additionalProperties: false,
63
+ properties: {
64
+ signedIn: { type: 'boolean', required: true },
65
+ message: { type: 'string', required: true },
66
+ account: { type: 'string' },
67
+ expiresAt: { type: 'string' },
68
+ allowedNotebooks: {
69
+ type: 'array',
70
+ items: {
71
+ type: 'object',
72
+ additionalProperties: false,
73
+ properties: {
74
+ id: { type: 'string', required: true },
75
+ name: { type: 'string', required: true },
76
+ },
77
+ },
78
+ },
79
+ },
80
+ },
81
+ render: (_args, value) => textResult(value.message),
82
+ },
83
+ async execute() {
84
+ try {
85
+ const status = service.status();
86
+ if (!status.signedIn) {
87
+ return {
88
+ signedIn: false,
89
+ message: 'OneNote is not connected. Ask the user to open Settings → Plugins → OneNote and sign in.',
90
+ };
91
+ }
92
+ const who = status.account?.displayName;
93
+ const names = status.allowedNotebooks.map((entry) => entry.name);
94
+ const scope = names.length === 0
95
+ ? 'No notebook is allowed yet, so nothing is readable: the user must choose notebooks in Settings → Plugins → OneNote.'
96
+ : `Readable notebooks: ${names.join(', ')}. Every other notebook is unreadable by design.`;
97
+ return compact({
98
+ signedIn: true,
99
+ message: `Signed in${who === undefined ? '' : ` as ${who}`}. ${scope}`,
100
+ account: who,
101
+ expiresAt: status.expiresAt,
102
+ allowedNotebooks: names.length === 0 ? undefined : status.allowedNotebooks,
103
+ });
104
+ } catch (error) {
105
+ throw new Error(describeFailure(error));
106
+ }
107
+ },
108
+ }));
109
+
110
+ register(defineTool({
111
+ name: 'onenote_list_pages',
112
+ description:
113
+ 'Browse the allowed OneNote notebooks. With no arguments it returns each allowed notebook with its sections. With notebook_id it returns that notebook\'s sections and recent pages. With section_id it returns that section\'s pages. Only notebooks the user allowed are readable.',
114
+ parameters: {
115
+ notebook_id: {
116
+ type: 'string',
117
+ description: 'A notebook id from onenote_status; omit to list the allowed notebooks.',
118
+ },
119
+ section_id: {
120
+ type: 'string',
121
+ description: 'A section id from a previous call; returns that section\'s pages.',
122
+ },
123
+ limit: {
124
+ type: 'number',
125
+ description: 'Maximum pages to return (1-200, default 50).',
126
+ },
127
+ },
128
+ output: {
129
+ schema: {
130
+ type: 'object',
131
+ additionalProperties: false,
132
+ properties: {
133
+ message: { type: 'string', required: true },
134
+ notebooks: {
135
+ type: 'array',
136
+ items: {
137
+ type: 'object',
138
+ additionalProperties: false,
139
+ properties: {
140
+ id: { type: 'string', required: true },
141
+ name: { type: 'string', required: true },
142
+ sections: {
143
+ type: 'array',
144
+ items: {
145
+ type: 'object',
146
+ additionalProperties: false,
147
+ properties: {
148
+ id: { type: 'string', required: true },
149
+ name: { type: 'string', required: true },
150
+ },
151
+ },
152
+ },
153
+ },
154
+ },
155
+ },
156
+ pages: { type: 'array', items: PAGE_ITEM },
157
+ },
158
+ },
159
+ render: (_args, value) => {
160
+ const lines = [value.message];
161
+ for (const notebook of value.notebooks ?? []) {
162
+ lines.push(`Notebook: ${notebook.name} — id ${notebook.id}`);
163
+ for (const section of notebook.sections ?? []) lines.push(` - section ${section.name} — id ${section.id}`);
164
+ }
165
+ for (const page of value.pages ?? []) {
166
+ const where = [page.notebookName, page.sectionName].filter((part) => typeof part === 'string').join(' / ');
167
+ lines.push(`- ${page.title}${where.length > 0 ? ` (${where})` : ''} — id ${page.id}${page.lastModified === undefined ? '' : ` — modified ${page.lastModified}`}`);
168
+ }
169
+ return textResult(lines.join('\n'));
170
+ },
171
+ },
172
+ async execute(args, exec) {
173
+ try {
174
+ return await service.listPages({
175
+ notebookId: typeof args.notebook_id === 'string' ? args.notebook_id : undefined,
176
+ sectionId: typeof args.section_id === 'string' ? args.section_id : undefined,
177
+ limit: clampInt(args.limit, 50, 1, 200),
178
+ }, exec.signal);
179
+ } catch (error) {
180
+ throw new Error(describeFailure(error));
181
+ }
182
+ },
183
+ }));
184
+
185
+ register(defineTool({
186
+ name: 'onenote_search',
187
+ description:
188
+ 'Search the allowed OneNote notebooks. Matches page titles first, then the opening text of the most recently modified pages, and returns page ids to read with onenote_read_page. Only notebooks the user allowed are searched.',
189
+ parameters: {
190
+ query: {
191
+ type: 'string',
192
+ required: true,
193
+ description: 'Text to look for; matching is case-insensitive.',
194
+ },
195
+ limit: {
196
+ type: 'number',
197
+ description: 'Maximum matches to return (1-50, default 10).',
198
+ },
199
+ },
200
+ output: {
201
+ schema: {
202
+ type: 'object',
203
+ additionalProperties: false,
204
+ properties: {
205
+ message: { type: 'string', required: true },
206
+ results: {
207
+ type: 'array',
208
+ items: {
209
+ type: 'object',
210
+ additionalProperties: false,
211
+ properties: {
212
+ id: { type: 'string', required: true },
213
+ title: { type: 'string', required: true },
214
+ notebookName: { type: 'string' },
215
+ sectionName: { type: 'string' },
216
+ lastModified: { type: 'string' },
217
+ matchedIn: { type: 'string', required: true, enum: ['title', 'body'] },
218
+ snippet: { type: 'string' },
219
+ },
220
+ },
221
+ },
222
+ },
223
+ },
224
+ render: (_args, value) => {
225
+ const lines = [value.message];
226
+ for (const hit of value.results ?? []) {
227
+ const where = [hit.notebookName, hit.sectionName].filter((part) => typeof part === 'string').join(' / ');
228
+ lines.push(`- ${hit.title}${where.length > 0 ? ` (${where})` : ''} — id ${hit.id} — matched in ${hit.matchedIn}${hit.snippet === undefined ? '' : `: ${hit.snippet}`}`);
229
+ }
230
+ return textResult(lines.join('\n'));
231
+ },
232
+ },
233
+ async execute(args, exec) {
234
+ try {
235
+ return await service.search({
236
+ query: typeof args.query === 'string' ? args.query : '',
237
+ limit: clampInt(args.limit, 10, 1, 50),
238
+ }, exec.signal);
239
+ } catch (error) {
240
+ throw new Error(describeFailure(error));
241
+ }
242
+ },
243
+ }));
244
+
245
+ register(defineTool({
246
+ name: 'onenote_read_page',
247
+ description:
248
+ 'Read one OneNote page as Markdown, including its headings, lists, tables, links, and code blocks. Refuses any page whose notebook the user did not allow. Use onenote_list_pages or onenote_search to obtain a page id.',
249
+ parameters: {
250
+ page_id: {
251
+ type: 'string',
252
+ required: true,
253
+ description: 'A page id from onenote_list_pages or onenote_search.',
254
+ },
255
+ max_chars: {
256
+ type: 'number',
257
+ description: 'Maximum characters of Markdown to return (2000-200000); larger pages are truncated with a notice.',
258
+ },
259
+ },
260
+ output: {
261
+ schema: {
262
+ type: 'object',
263
+ additionalProperties: false,
264
+ properties: {
265
+ id: { type: 'string', required: true },
266
+ title: { type: 'string', required: true },
267
+ notebookName: { type: 'string' },
268
+ sectionName: { type: 'string' },
269
+ lastModified: { type: 'string' },
270
+ webUrl: { type: 'string' },
271
+ truncated: { type: 'boolean', required: true },
272
+ content: { type: 'string', required: true },
273
+ },
274
+ },
275
+ render: (_args, value) => {
276
+ const where = [value.notebookName, value.sectionName].filter((part) => typeof part === 'string').join(' / ');
277
+ const header = `# ${value.title}${where.length > 0 ? `\n_${where}_` : ''}${value.lastModified === undefined ? '' : `\n_last modified ${value.lastModified}_`}`;
278
+ const notice = value.truncated ? '\n\n[page truncated; call onenote_read_page with a larger max_chars for the rest]' : '';
279
+ return textResult(`${header}\n\n${value.content}${notice}`);
280
+ },
281
+ },
282
+ async execute(args, exec) {
283
+ try {
284
+ return await service.readPage({
285
+ pageId: typeof args.page_id === 'string' ? args.page_id : '',
286
+ maxChars: clampInt(args.max_chars, 60000, 2000, 200000),
287
+ }, exec.signal);
288
+ } catch (error) {
289
+ throw new Error(describeFailure(error));
290
+ }
291
+ },
292
+ }));
293
+
294
+ return disposers;
295
+ }