@hames-ai/connectors 0.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.
@@ -0,0 +1,1921 @@
1
+ /**
2
+ * Microsoft Graph app-side tools (Pattern C, #110) — Server Only.
3
+ *
4
+ * Each tool calls Graph **as the signed-in user** via `graphFetch`, which
5
+ * resolves that user's delegated token server-side. Entra enforces the scope,
6
+ * so we don't write a scoping guard and the model never sees a credential.
7
+ *
8
+ * ## Registration is an explicit factory call (design S1/S4, #225 PR-3)
9
+ * This module never self-registers at import and never imports the host's
10
+ * token or conversion modules: the host's composition root
11
+ * (`app-tools/index.server.ts`) calls `registerGraphConnectorTools(deps)` with
12
+ * its own `graphFetch`, content classifier and Data Stash bridge. Every `deps`
13
+ * field is REQUIRED — a missing or non-function supplier throws at the
14
+ * factory call, never a silent default or an env fallback (PR-2 doctrine).
15
+ * The PR-C1 peel introduced the seam app-side; PR-C2 (this move) re-uses it
16
+ * unchanged except where a dynamic import into host code could not survive
17
+ * the move (the stash bridge, below).
18
+ *
19
+ * `GraphAuthRequiredError` is owned by THIS package (`graph/graph-auth.ts`,
20
+ * moved verbatim from the host's token module) so `instanceof` keeps working
21
+ * across the seam without the host needing to know the class moved.
22
+ *
23
+ * First slice was deliberately `User.Read`-only — the scope already has tenant
24
+ * admin consent, so the whole per-user token path was provable end-to-end with
25
+ * no new tenant configuration. Further tools slot in here once their scopes are
26
+ * consented and added to the sign-in request (`entra-config.server.ts`).
27
+ *
28
+ * `graph_file_ingest` is the one tool that does more than read-and-shape: it
29
+ * bridges Microsoft 365 into the **Data Stash**, so a file the person already
30
+ * owns becomes something later turns (retriever, sandbox, file viewer) can use
31
+ * without the bytes ever passing through the model's context.
32
+ *
33
+ * `graph_files_search` and `graph_files_list` are how a file is *found* in the
34
+ * first place, and both hand back the `drive_id` + `item_id` pair that names a
35
+ * file to any tool acting on one. Search keeps the **query language on the
36
+ * server**: the model passes structured arguments and this module composes the
37
+ * KQL, so no model-authored operator can reshape the query it didn't write.
38
+ */
39
+ import { assertServerOnImport } from '@hames-ai/harness-patterns/assert.server'
40
+ import { GraphAuthRequiredError } from './graph-auth'
41
+ import type { AppToolDefinition } from '../app-tools/registry'
42
+
43
+ assertServerOnImport()
44
+
45
+ // ============================================================================
46
+ // The injected seam (design S1/S4, #225 PR-3)
47
+ // ============================================================================
48
+
49
+ /** What the injected `graphFetch` accepts and resolves — declared here so this
50
+ * module types the seam without importing the host's token module (S1: the
51
+ * package never sees a token). Same shape as the host's own `graphFetch`
52
+ * (its delegated-token module IS the injected implementation). */
53
+ export interface GraphFetchInit {
54
+ method?: string
55
+ scopes?: readonly string[]
56
+ body?: unknown
57
+ /** Extra request headers, e.g. `Prefer: outlook.timezone="Europe/Brussels"`. */
58
+ headers?: Record<string, string>
59
+ /** `'json'` (default) parses JSON; `'base64'` returns raw bytes base64-encoded. */
60
+ responseType?: 'json' | 'base64'
61
+ }
62
+
63
+ /** Call Microsoft Graph as `userId` — the host's delegated-token module IS
64
+ * the injected implementation (S1: one seam, the package never sees a
65
+ * token). */
66
+ export type GraphFetchFn = (userId: string, path: string, init?: GraphFetchInit) => Promise<unknown>
67
+
68
+ /**
69
+ * The content classifier `graph_file_ingest` needs (S4) — ONE required
70
+ * supplier, injected rather than imported because the host's
71
+ * `doc-convert.server.ts` is its conversion pipeline and
72
+ * `guessMimeType`/`isTextMime` CANNOT move into the package: the host's
73
+ * stash (`stash/upload-service.server.ts`) imports them this cycle, and
74
+ * moving them would create a stash→connectors back-edge. A later "cleanup"
75
+ * must not reintroduce that edge.
76
+ */
77
+ export interface GraphContentClassifier {
78
+ /** Is document conversion enabled on this deployment (`STASH_CONVERT_DOCS`)? */
79
+ conversionEnabled(): boolean
80
+ /** Can this MIME type be converted to text? */
81
+ isConvertible(mimeType: string): boolean
82
+ /** Best-effort MIME type for a filename. */
83
+ guessMimeType(filename: string): string
84
+ /** Is this MIME type storable as UTF-8 text? */
85
+ isTextMime(mimeType: string): boolean
86
+ }
87
+
88
+ /** What `graph_file_ingest` writes into the conversation's Data Stash —
89
+ * declared structurally so the host's own richer document type satisfies it
90
+ * without the package importing it. */
91
+ export interface GraphStashDocumentInput {
92
+ /** Conversation whose stash the document belongs to. */
93
+ sessionId: string
94
+ filename: string
95
+ mimeType: string
96
+ /** UTF-8 text content, or base64 when `encoding` is set. */
97
+ content: string
98
+ encoding?: 'base64'
99
+ /** Persisted in the FIRST write so a status poll never reads a doc with no
100
+ * ingest status and flickers (same contract as the upload route). */
101
+ ingestStatus?: 'pending'
102
+ }
103
+
104
+ /** The storage layer of the Data Stash, as the ingest tool needs it — resolved
105
+ * LAZILY so composing the Graph tools never loads the storage stack. */
106
+ export interface GraphStashStore {
107
+ /** Store a document; resolves with at least its id and stored size. */
108
+ storeDocument(input: GraphStashDocumentInput): Promise<{ id: string; size: number }>
109
+ /** The stash's per-document byte ceiling, checked BEFORE download. */
110
+ maxContentBytes: number
111
+ }
112
+
113
+ /**
114
+ * The Data Stash bridge — the seam PR-C2 had to ADD to the design sketch,
115
+ * disclosed: the ingest tool's two lazy `import()`s reached into host modules
116
+ * (`document-store.server.ts`, `document-ingest.server.ts`) and could not
117
+ * survive the move. The host supplies both halves; laziness is preserved by
118
+ * contract (`loadStore` is called only on ingest, `ingest` only after a
119
+ * storing write), so nothing about when the storage stack loads changes.
120
+ */
121
+ export interface GraphStashBridge {
122
+ /** Lazily resolve the stash's storage layer. */
123
+ loadStore(): Promise<GraphStashStore>
124
+ /** Kick off the background ingest of a stored document — fire-and-forget by
125
+ * contract; failures are recorded in the document's ingest status by the
126
+ * implementation, not surfaced to the tool result. */
127
+ ingest(sessionId: string, documentId: string): Promise<unknown>
128
+ }
129
+
130
+ /** Everything the Graph tools close over — supplied by the composition root. */
131
+ export interface GraphConnectorDeps {
132
+ /** Where tools register: the host registry's `registerAppTool`. */
133
+ registerAppTool: (def: AppToolDefinition) => void
134
+ /** Delegated-token Graph fetch (S1). REQUIRED — throws if missing. */
135
+ graphFetch: GraphFetchFn
136
+ /** Content classification for the file-ingest path (S4). REQUIRED — throws
137
+ * if missing, including any missing member. */
138
+ content: GraphContentClassifier
139
+ /** The Data Stash bridge for the file-ingest path. REQUIRED — throws if
140
+ * missing, including any missing member. */
141
+ stash: GraphStashBridge
142
+ }
143
+
144
+ /** Fields we surface from `/me`. Explicit so we never dump the whole payload
145
+ * (which can include tenant metadata) into the model's context. */
146
+ const ME_FIELDS = [
147
+ 'displayName',
148
+ 'givenName',
149
+ 'surname',
150
+ 'userPrincipalName',
151
+ 'mail',
152
+ 'jobTitle',
153
+ 'officeLocation',
154
+ 'preferredLanguage',
155
+ ] as const
156
+
157
+ export interface GraphMeResult {
158
+ displayName: string | null
159
+ givenName: string | null
160
+ surname: string | null
161
+ userPrincipalName: string | null
162
+ mail: string | null
163
+ jobTitle: string | null
164
+ officeLocation: string | null
165
+ preferredLanguage: string | null
166
+ }
167
+
168
+ /** Pick + null-normalize the fields we advertise. */
169
+ export function shapeMe(raw: unknown): GraphMeResult {
170
+ const src = (raw ?? {}) as Record<string, unknown>
171
+ const out = {} as Record<string, string | null>
172
+ for (const f of ME_FIELDS) {
173
+ const v = src[f]
174
+ out[f] = typeof v === 'string' && v.trim() ? v : null
175
+ }
176
+ return out as unknown as GraphMeResult
177
+ }
178
+
179
+ // ============================================================================
180
+ // Calendar
181
+ // ============================================================================
182
+
183
+ /** IANA timezone Graph should render event times in. Defaults to the server's
184
+ * own zone, which is right for a single-tenant deployment; override with
185
+ * `GRAPH_TIMEZONE` if the app and its users don't share one. */
186
+ function graphTimeZone(): string {
187
+ return (
188
+ process.env.GRAPH_TIMEZONE?.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
189
+ )
190
+ }
191
+
192
+ /**
193
+ * Local-day bounds as naive ISO strings (no `Z`). Graph interprets these in the
194
+ * timezone from the `Prefer: outlook.timezone` header, so we must NOT send UTC
195
+ * instants here — that would shift the day boundary.
196
+ */
197
+ export function localDayBounds(now: Date, dayOffset = 0): { start: string; end: string } {
198
+ const d = new Date(now)
199
+ d.setDate(d.getDate() + dayOffset)
200
+ const pad = (n: number) => String(n).padStart(2, '0')
201
+ const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
202
+ return { start: `${day}T00:00:00`, end: `${day}T23:59:59` }
203
+ }
204
+
205
+ export interface CalendarEvent {
206
+ subject: string | null
207
+ start: string | null
208
+ end: string | null
209
+ isAllDay: boolean
210
+ location: string | null
211
+ organizer: string | null
212
+ onlineMeetingUrl: string | null
213
+ }
214
+
215
+ /** Flatten Graph's nested event shape into something compact for the model. */
216
+ export function shapeEvents(raw: unknown): CalendarEvent[] {
217
+ const items = (raw as { value?: unknown[] })?.value
218
+ if (!Array.isArray(items)) return []
219
+ return items.map((it) => {
220
+ const e = (it ?? {}) as Record<string, unknown>
221
+ const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v : null)
222
+ return {
223
+ subject: str(e.subject),
224
+ start: str((e.start as { dateTime?: unknown })?.dateTime),
225
+ end: str((e.end as { dateTime?: unknown })?.dateTime),
226
+ isAllDay: e.isAllDay === true,
227
+ location: str((e.location as { displayName?: unknown })?.displayName),
228
+ organizer: str(
229
+ ((e.organizer as { emailAddress?: Record<string, unknown> })?.emailAddress?.name ??
230
+ (e.organizer as { emailAddress?: Record<string, unknown> })?.emailAddress
231
+ ?.address) as unknown,
232
+ ),
233
+ onlineMeetingUrl: str(e.onlineMeetingUrl),
234
+ }
235
+ })
236
+ }
237
+
238
+ // ============================================================================
239
+ // Mail
240
+ // ============================================================================
241
+
242
+ export interface MailMessage {
243
+ subject: string | null
244
+ from: string | null
245
+ received: string | null
246
+ isRead: boolean
247
+ hasAttachments: boolean
248
+ preview: string | null
249
+ webLink: string | null
250
+ }
251
+
252
+ /** Compact Graph's message shape; `bodyPreview` is truncated to keep turns small. */
253
+ export function shapeMessages(raw: unknown): MailMessage[] {
254
+ const items = (raw as { value?: unknown[] })?.value
255
+ if (!Array.isArray(items)) return []
256
+ return items.map((it) => {
257
+ const m = (it ?? {}) as Record<string, unknown>
258
+ const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v : null)
259
+ const sender = (m.from as { emailAddress?: Record<string, unknown> })?.emailAddress
260
+ const preview = str(m.bodyPreview)
261
+ return {
262
+ subject: str(m.subject),
263
+ from: str((sender?.name ?? sender?.address) as unknown),
264
+ received: str(m.receivedDateTime),
265
+ isRead: m.isRead === true,
266
+ hasAttachments: m.hasAttachments === true,
267
+ preview: preview ? preview.slice(0, 300) : null,
268
+ webLink: str(m.webLink),
269
+ }
270
+ })
271
+ }
272
+
273
+ // ============================================================================
274
+ // Files → Data Stash
275
+ // ============================================================================
276
+
277
+ /** Narrowest scope that can read a driveItem and its content. */
278
+ const FILE_SCOPES = ['Files.Read.All'] as const
279
+
280
+ /** driveItem fields we need: enough to name, classify and size-check the file.
281
+ * Explicit because a full driveItem carries a lot we'd never use. */
282
+ const DRIVE_ITEM_SELECT = 'name,file,size,webUrl'
283
+
284
+ /** What the model gets back. Notably **not** the content: the bytes go to the
285
+ * Data Stash, and `documentId` is how later turns reach them. */
286
+ export interface GraphFileIngestResult {
287
+ documentId: string
288
+ filename: string
289
+ mimeType: string
290
+ /** Stored size in bytes (original bytes, not the base64 expansion). */
291
+ size: number
292
+ /** A background chunk→embed→index was started for this document. */
293
+ ingesting: boolean
294
+ /** Provenance — the file's Microsoft 365 link, for citing back to the person. */
295
+ webUrl: string | null
296
+ }
297
+
298
+ interface DriveItemMeta {
299
+ name: string | null
300
+ mimeType: string | null
301
+ /** Byte size, or null when Graph didn't report one. */
302
+ size: number | null
303
+ webUrl: string | null
304
+ isFile: boolean
305
+ }
306
+
307
+ /**
308
+ * `/drives/{drive}/items/{item}` when a drive is named, else the caller's own
309
+ * OneDrive. Ids are URL-encoded so a crafted id cannot escape its path segment
310
+ * (`../`) and address an unrelated Graph resource.
311
+ */
312
+ export function driveItemPath(itemId: string, driveId?: string | null): string {
313
+ const item = encodeURIComponent(itemId)
314
+ return driveId
315
+ ? `/drives/${encodeURIComponent(driveId)}/items/${item}`
316
+ : `/me/drive/items/${item}`
317
+ }
318
+
319
+ /** Pick the four things we need off a driveItem, tolerating a partial payload. */
320
+ export function shapeDriveItem(raw: unknown): DriveItemMeta {
321
+ const it = (raw ?? {}) as Record<string, unknown>
322
+ const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v : null)
323
+ const file = it.file as Record<string, unknown> | undefined
324
+ return {
325
+ name: str(it.name),
326
+ mimeType: str(file?.mimeType),
327
+ size: typeof it.size === 'number' && Number.isFinite(it.size) ? it.size : null,
328
+ webUrl: str(it.webUrl),
329
+ // The `file` facet is what distinguishes a file from a folder or package —
330
+ // `$select=file` returns it for files only.
331
+ isFile: file != null && typeof file === 'object',
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Turn a Graph 403 into an error a model can act on. graphFetch's generic
337
+ * message ("may lack consent … sign in") is actively MISLEADING for a 403 on a
338
+ * driveItem: the consented delegated scopes already cover ordinary files, so a
339
+ * 403 here almost always means the item lives where delegated tokens cannot
340
+ * reach — a SharePoint Embedded container (Loop pages/workspaces, Copilot
341
+ * pages; measured live: 22 of this tenant's 25 .loop items). Re-signing-in
342
+ * cannot help; app-only guest access is the tracked fix (#137).
343
+ *
344
+ * 401 and token-acquisition failures (status undefined) pass through — for
345
+ * those, "sign in again" is the correct advice.
346
+ */
347
+ function translateIngestDenial(err: unknown, itemId: string): unknown {
348
+ if (err instanceof GraphAuthRequiredError && err.status === 403) {
349
+ return new Error(
350
+ `Microsoft 365 denied access to item ${itemId} (403). This is a per-item denial, ` +
351
+ 'not a sign-in problem — signing in again will not help. Items stored in ' +
352
+ 'SharePoint Embedded containers (Microsoft Loop pages and workspaces) are not ' +
353
+ "readable with the app's delegated permissions (#137); only their search " +
354
+ 'metadata (title, link, snippet) is available. Relay that metadata instead. ' +
355
+ 'If this is an ordinary file, the account may genuinely lack access to it.',
356
+ )
357
+ }
358
+ return err
359
+ }
360
+
361
+ // ============================================================================
362
+ // Files — search and browse
363
+ //
364
+ // Both tools return the same flattened item shape and both reuse the drive
365
+ // helpers above (`driveItemPath`, `shapeDriveItem`), so a file found here is
366
+ // addressable by `graph_file_ingest` without the model reformatting anything.
367
+ // ============================================================================
368
+
369
+ /** Search reaches SharePoint as well as OneDrive, so it needs the sites scope on
370
+ * top of the file scope. Kept separate from `FILE_SCOPES` so browsing and
371
+ * ingesting stay on the narrower one. */
372
+ const FILE_SEARCH_SCOPES = [...FILE_SCOPES, 'Sites.Read.All'] as const
373
+
374
+ /** driveItem fields the browse tool reads. Explicit for the same reason as
375
+ * `DRIVE_ITEM_SELECT`, plus `parentReference` (the drive id + folder path) and
376
+ * `remoteItem` (a OneDrive root holds shortcuts to other drives as stubs). */
377
+ const DRIVE_ITEM_LIST_SELECT =
378
+ 'id,name,file,folder,size,webUrl,lastModifiedDateTime,parentReference,remoteItem'
379
+
380
+ // ----------------------------------------------------------------------------
381
+ // KQL composition — the app owns the query language
382
+ //
383
+ // Microsoft Search speaks KQL, which has clause grammar (`AND`, parentheses)
384
+ // and property restrictions (`filetype:exe`, `path:"…"`, `size>1000`). A model
385
+ // writing that string directly would be writing the query's *structure* from
386
+ // untrusted-shaped text: one stray quote in a filename it echoes back and the
387
+ // restriction we added is closed and a different one opened. So the model never
388
+ // writes KQL. It passes plain terms plus structured filters, the functions below
389
+ // compose every clause, and each user-supplied value is reduced to something
390
+ // that can only ever be a value.
391
+ // ----------------------------------------------------------------------------
392
+
393
+ /** What can end a quoted value or break the request line: the quote itself,
394
+ * plus C0 control characters and DEL. */
395
+ // Matching control characters is the entire point here: they are what would let
396
+ // user input break out of a KQL clause.
397
+ // eslint-disable-next-line no-control-regex
398
+ const PHRASE_UNSAFE = /["\u0000-\u001f\u007f]+/g
399
+
400
+ /** For unquoted terms, also the operators: `(` `)` group clauses, and `:` `<`
401
+ * `>` `=` are what bind a value to a managed property (`filetype:exe`). */
402
+ // eslint-disable-next-line no-control-regex -- see PHRASE_UNSAFE above.
403
+ const TERM_UNSAFE = /["():<>=\u0000-\u001f\u007f]+/g
404
+
405
+ /** KQL's boolean and ranking keywords, which it honours in upper case only. */
406
+ const KQL_KEYWORDS = /\b(AND|OR|NOT|NEAR|ONEAR|XRANK)\b/g
407
+
408
+ /**
409
+ * Quote a URL as a KQL value — today the `path:` site restriction.
410
+ *
411
+ * The double quote is **stripped, not escaped**. KQL publishes no escape
412
+ * sequence for a quote inside a value, so an escaping implementation would be
413
+ * inventing a contract Microsoft doesn't define and hoping the parser agrees;
414
+ * removal is the only handling whose behaviour is knowable. Control characters
415
+ * go with it — they would split the request line.
416
+ *
417
+ * Then *all* whitespace goes: a URL contains none, and a single space inside a
418
+ * restriction makes Search stop reading it as a restriction and treat the rest as
419
+ * free text — a silently wider search rather than an error. Whitespace is handled
420
+ * last because removing an unsafe character can itself leave a gap behind.
421
+ */
422
+ export function kqlUrlPhrase(value: string): string {
423
+ return `"${value.replace(PHRASE_UNSAFE, '').replace(/\s+/g, '')}"`
424
+ }
425
+
426
+ /**
427
+ * Strip KQL grammar out of free-text terms while keeping them searchable.
428
+ *
429
+ * Quoting the whole thing would turn every multi-word request into an exact
430
+ * phrase match and defeat stemming, so the terms stay bare and the *operators*
431
+ * are removed instead: quotes and parentheses (clause structure), and `:` `<`
432
+ * `>` `=` (what makes `filetype:exe` or `size>1000` a property restriction).
433
+ * KQL's boolean keywords are uppercase-only, so lowercasing them leaves the
434
+ * caller's words intact while reducing them to ordinary search terms.
435
+ *
436
+ * What survives cannot open a clause, close one, or restrict a property — the
437
+ * only structure in the composed query is the structure we added.
438
+ */
439
+ export function kqlTerms(value: string): string {
440
+ const cleaned = value.replace(TERM_UNSAFE, ' ').replace(/\s+/g, ' ').trim()
441
+ return cleaned.replace(KQL_KEYWORDS, (op) => op.toLowerCase())
442
+ }
443
+
444
+ /**
445
+ * `filetype:` clause from a supplied extension, or null when there isn't one.
446
+ *
447
+ * An extension is alphanumeric, so the leading alphanumeric run is taken and
448
+ * everything after it is discarded — `docx" OR filetype:exe` yields
449
+ * `filetype:docx`. That leaves no quoting question to get wrong. A leading dot
450
+ * (`.pdf`) is tolerated because models write it.
451
+ */
452
+ export function kqlFileType(value: string): string | null {
453
+ const match = /[a-z0-9]+/i.exec(value.trim().replace(/^\.+/, ''))
454
+ return match ? `filetype:${match[0].toLowerCase()}` : null
455
+ }
456
+
457
+ /**
458
+ * Quote a human phrase as a KQL value — today the `author:` restriction.
459
+ *
460
+ * Same strip-don't-escape rule as {@link kqlUrlPhrase} (KQL publishes no escape
461
+ * for a quote), but whitespace is COLLAPSED, not removed: names legitimately
462
+ * contain spaces, and inside a quoted phrase they are valid KQL. The
463
+ * whitespace-removal in `kqlUrlPhrase` is URL-specific, not a general rule.
464
+ * Null when nothing survives, so the clause is dropped like `site`/`file_type`.
465
+ */
466
+ export function kqlPhrase(value: string): string | null {
467
+ const cleaned = value.replace(PHRASE_UNSAFE, '').replace(/\s+/g, ' ').trim()
468
+ return cleaned ? `"${cleaned}"` : null
469
+ }
470
+
471
+ /**
472
+ * `LastModifiedTime` restriction from one or both ISO-ish dates.
473
+ *
474
+ * Injection-proof BY CONSTRUCTION, not by sanitization: the value is parsed
475
+ * with `new Date()` and the emitted text derives from the Date object
476
+ * (`toISOString().slice(0,10)`), so no caller character can transit into the
477
+ * query. An unparseable date THROWS rather than being silently dropped — a
478
+ * silently widened search is exactly the "filter didn't bite" churn this
479
+ * exists to prevent, and the thrown message round-trips to the model, which
480
+ * fixes the date on the next turn.
481
+ *
482
+ * Both bounds emit the single range clause `LastModifiedTime:a..b` — verified
483
+ * live (2026-07-30): two space-joined restrictions on the SAME property are
484
+ * SILENTLY IGNORED by Microsoft Search (the query behaves as if neither were
485
+ * there), while the range operator and explicit `AND` both filter correctly.
486
+ */
487
+ export function kqlModifiedRange(
488
+ after: string | null | undefined,
489
+ before: string | null | undefined,
490
+ ): string | null {
491
+ const day = (raw: string, argName: string): string => {
492
+ const d = new Date(raw.trim())
493
+ if (Number.isNaN(d.getTime())) {
494
+ throw new Error(`${argName} must be a date like 2026-07-01 (got "${raw}").`)
495
+ }
496
+ return d.toISOString().slice(0, 10)
497
+ }
498
+ const a = after?.trim() ? day(after, 'modified_after') : null
499
+ const b = before?.trim() ? day(before, 'modified_before') : null
500
+ if (a && b) return `LastModifiedTime:${a}..${b}`
501
+ if (a) return `LastModifiedTime>=${a}`
502
+ if (b) return `LastModifiedTime<=${b}`
503
+ return null
504
+ }
505
+
506
+ export interface FileSearchArgs {
507
+ /** Free-text terms from the caller. */
508
+ query: string
509
+ /** SharePoint site URL to restrict to, composed as `path:"…"`. */
510
+ site?: string | null
511
+ /** File extension to restrict to, composed as `filetype:…`. */
512
+ fileType?: string | null
513
+ /** Author display name, composed as `author:"…"`. */
514
+ author?: string | null
515
+ /** ISO-ish dates, composed as a `LastModifiedTime` restriction. Invalid
516
+ * values THROW (see kqlModifiedRange). */
517
+ modifiedAfter?: string | null
518
+ modifiedBefore?: string | null
519
+ }
520
+
521
+ /**
522
+ * Compose the KQL sent to `/search/query`.
523
+ *
524
+ * Terms first, then the restrictions, joined by whitespace — KQL's default
525
+ * operator is AND, so this reads as "these words, in this site, of this type".
526
+ * Returns `""` when nothing survives sanitization, which the tool treats as a
527
+ * missing query rather than sending Graph an empty search.
528
+ *
529
+ * A restriction is fragile in one direction worth naming: a stray space inside
530
+ * the clause makes Search stop reading it as a restriction and treat the rest as
531
+ * free text — silently widening the search instead of failing. So no clause here
532
+ * contains caller-controlled whitespace: `filetype:` takes an alphanumeric run,
533
+ * and the `path:` URL has its whitespace removed rather than preserved.
534
+ */
535
+ export function composeFileQuery({
536
+ query,
537
+ site,
538
+ fileType,
539
+ author,
540
+ modifiedAfter,
541
+ modifiedBefore,
542
+ }: FileSearchArgs): string {
543
+ const parts: string[] = []
544
+
545
+ const terms = kqlTerms(query ?? '')
546
+ if (terms) parts.push(terms)
547
+
548
+ const type = fileType?.trim() ? kqlFileType(fileType) : null
549
+ if (type) parts.push(type)
550
+
551
+ // `path:` is the documented way to scope Microsoft Search to one site (KQL has
552
+ // no `site:` operator — that's Purview eDiscovery). The value is a URL, so it
553
+ // needs quoting: it contains `:` and `/`.
554
+ if (site?.trim()) parts.push(`path:${kqlUrlPhrase(site)}`)
555
+
556
+ const byAuthor = author?.trim() ? kqlPhrase(author) : null
557
+ if (byAuthor) parts.push(`author:${byAuthor}`)
558
+
559
+ const modified = kqlModifiedRange(modifiedAfter, modifiedBefore)
560
+ if (modified) parts.push(modified)
561
+
562
+ return parts.join(' ')
563
+ }
564
+
565
+ // ----------------------------------------------------------------------------
566
+ // Response flattening
567
+ // ----------------------------------------------------------------------------
568
+
569
+ /**
570
+ * Strip Microsoft Search's summary markup and truncate.
571
+ *
572
+ * Search wraps each matched term in the summary as `<c0>term</c0>` (one `<cN>`
573
+ * per term) and marks elided text as `<ddd/>`. To a model that is broken markup
574
+ * it may well try to reproduce, so the highlight markers go and the elision
575
+ * becomes an ellipsis. Capped at 300 chars, the same budget as
576
+ * `graph_mail_recent`'s preview, because a page of matched text per hit is how a
577
+ * 25-result search blows a turn.
578
+ */
579
+ export function cleanSummary(raw: unknown): string | null {
580
+ if (typeof raw !== 'string') return null
581
+ const text = raw
582
+ .replace(/<\/?c\d+>/gi, '')
583
+ .replace(/<ddd\s*\/?>/gi, '…')
584
+ .replace(/\s+/g, ' ')
585
+ .trim()
586
+ return text ? text.slice(0, 300) : null
587
+ }
588
+
589
+ /**
590
+ * `parentReference.path` → a path a person could read.
591
+ *
592
+ * Graph reports it as `/drive/root:/Reports/Q3%20Plans` (or
593
+ * `/drives/{id}/root:/…`): an addressing prefix, a `root:` marker, then
594
+ * URL-encoded segments. The prefix is noise and the encoding reads as mojibake,
595
+ * so both go, leaving `Reports/Q3 Plans`. An item at the drive root has nothing
596
+ * after `root:` and is reported as `/`.
597
+ */
598
+ export function drivePath(raw: unknown): string | null {
599
+ if (typeof raw !== 'string' || !raw.trim()) return null
600
+ const marker = raw.indexOf('root:')
601
+ const rel = (marker >= 0 ? raw.slice(marker + 'root:'.length) : raw).replace(/^\/+/, '')
602
+ if (!rel) return '/'
603
+ try {
604
+ return decodeURIComponent(rel)
605
+ } catch {
606
+ // A malformed escape (`%zz`) must not fail the whole search — a slightly
607
+ // ugly path is a better result than no results.
608
+ return rel
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Best-effort containing-folder location derived from an item's `webUrl` —
614
+ * the fallback when `parentReference.path` is absent, which is EVERY
615
+ * `/search/query` hit (search resources carry `parentReference` with driveId /
616
+ * id / siteId but no `path`; `/children` listings do carry it).
617
+ *
618
+ * Only attempted for real SharePoint URLs (`*.sharepoint.com`, which covers
619
+ * `contoso-my.sharepoint.com` personal drives): Loop pages advertise
620
+ * `loop.cloud.microsoft/p/<base64>` — no folder to read — and Office viewer
621
+ * URLs (`/_layouts/15/Doc.aspx?...`) name a handler, not a location, so both
622
+ * yield null. The result is site-relative (`sites/Finance/Shared Documents/Q3`)
623
+ * rather than drive-relative like {@link drivePath} output — good enough for a
624
+ * person or a model citing where a file lives.
625
+ */
626
+ export function webUrlFolderPath(webUrl: unknown): string | null {
627
+ if (typeof webUrl !== 'string' || !webUrl.trim()) return null
628
+ try {
629
+ const url = new URL(webUrl)
630
+ if (!url.hostname.toLowerCase().endsWith('.sharepoint.com')) return null
631
+ if (url.pathname.includes('/_layouts/')) return null
632
+ const segments = url.pathname.split('/').filter(Boolean)
633
+ if (segments.length < 2) return null // nothing left once the item goes
634
+ const folder = segments.slice(0, -1).join('/')
635
+ try {
636
+ return decodeURIComponent(folder)
637
+ } catch {
638
+ // Malformed escape — an ugly path beats no path (same rule as drivePath).
639
+ return folder
640
+ }
641
+ } catch {
642
+ return null
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Readable site from `parentReference.siteId`, which Graph reports as
648
+ * `contoso.sharepoint.com,{siteGuid},{webGuid}`. Only the hostname means
649
+ * anything to a person or to a model citing a source, so the guids are dropped.
650
+ * An id with no hostname is passed through rather than invented over.
651
+ */
652
+ export function siteHost(raw: unknown): string | null {
653
+ if (typeof raw !== 'string' || !raw.trim()) return null
654
+ return raw.split(',')[0].trim() || null
655
+ }
656
+
657
+ /** What a found file looks like, whether it came from search or from browsing. */
658
+ export interface GraphFileRef {
659
+ name: string | null
660
+ /** Containing folder relative to the drive root; `/` at the root itself. */
661
+ path: string | null
662
+ /** SharePoint host the item lives on, or null for a plain OneDrive item. */
663
+ site: string | null
664
+ modified: string | null
665
+ size: number | null
666
+ /** Half of the handoff to the tools that act on a file — always surfaced. */
667
+ drive_id: string | null
668
+ /** The other half: the item's *own* id, not its parent's. */
669
+ item_id: string | null
670
+ webUrl: string | null
671
+ }
672
+
673
+ export interface GraphFileHit extends GraphFileRef {
674
+ /** Matched text, markup stripped. Null when Search returned no summary. */
675
+ snippet: string | null
676
+ }
677
+
678
+ export interface GraphFileEntry extends GraphFileRef {
679
+ isFolder: boolean
680
+ /** Items inside a folder; null for a file, so the model can tell "empty
681
+ * folder" from "not a folder". */
682
+ child_count: number | null
683
+ }
684
+
685
+ /**
686
+ * A OneDrive root listing contains shortcuts as well as files: "Add shortcut to
687
+ * My files" puts a stub driveItem in the root whose real identity sits under
688
+ * `remoteItem`. Unwrapping it keeps `drive_id` / `item_id` pointing at the file
689
+ * itself, because the stub's own ids address the shortcut — handing those to a
690
+ * tool that reads content would 404.
691
+ */
692
+ function unwrapRemote(raw: unknown): Record<string, unknown> {
693
+ const it = (raw ?? {}) as Record<string, unknown>
694
+ const remote = it.remoteItem
695
+ return remote && typeof remote === 'object'
696
+ ? { ...it, ...(remote as Record<string, unknown>) }
697
+ : it
698
+ }
699
+
700
+ /**
701
+ * Flatten one driveItem — from a search hit or from a folder listing.
702
+ * Name/size/webUrl extraction is `shapeDriveItem`'s, so the two file paths can't
703
+ * drift apart on what a "file" looks like; the rest is the location the model
704
+ * needs to navigate or to hand the file on.
705
+ */
706
+ export function shapeFileRef(raw: unknown): GraphFileRef {
707
+ const it = unwrapRemote(raw)
708
+ const base = shapeDriveItem(it)
709
+ const parent = (it.parentReference ?? {}) as Record<string, unknown>
710
+ const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v : null)
711
+ return {
712
+ name: base.name,
713
+ // Search hits never carry `parentReference.path` — fall back to reading
714
+ // the containing folder out of the webUrl (see webUrlFolderPath).
715
+ path: drivePath(parent.path) ?? webUrlFolderPath(base.webUrl),
716
+ site: siteHost(parent.siteId),
717
+ modified: str(it.lastModifiedDateTime),
718
+ size: base.size,
719
+ drive_id: str(parent.driveId),
720
+ // `parentReference.id` is the *folder* the item sits in; using it here would
721
+ // point every downstream call at the wrong resource.
722
+ item_id: str(it.id),
723
+ webUrl: base.webUrl,
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Flatten `/search/query`'s three levels of nesting
729
+ * (`value[].hitsContainers[].hits[].resource`) into one list.
730
+ *
731
+ * Hits arrive in rank order, so `rank` itself is dropped — a position in a list
732
+ * says the same thing in fewer tokens. `total` is summed over the containers
733
+ * that reported one and left null when none did, because Search omits it for
734
+ * some result sets and a fabricated 0 would read as "nothing found".
735
+ */
736
+ export function shapeSearchHits(raw: unknown): {
737
+ total: number | null
738
+ results: GraphFileHit[]
739
+ } {
740
+ const responses = (raw as { value?: unknown[] })?.value
741
+ if (!Array.isArray(responses)) return { total: null, results: [] }
742
+
743
+ const results: GraphFileHit[] = []
744
+ let total: number | null = null
745
+
746
+ for (const response of responses) {
747
+ const containers = (response as { hitsContainers?: unknown[] })?.hitsContainers
748
+ if (!Array.isArray(containers)) continue
749
+ for (const container of containers) {
750
+ const c = (container ?? {}) as { hits?: unknown[]; total?: unknown }
751
+ if (typeof c.total === 'number' && Number.isFinite(c.total)) {
752
+ total = (total ?? 0) + c.total
753
+ }
754
+ if (!Array.isArray(c.hits)) continue
755
+ for (const hit of c.hits) {
756
+ const h = (hit ?? {}) as Record<string, unknown>
757
+ const ref = shapeFileRef(h.resource)
758
+ results.push({
759
+ ...ref,
760
+ // For a driveItem hit `hitId` *is* the item id, which makes it the
761
+ // fallback when a hit came back without its resource expanded.
762
+ item_id: ref.item_id ?? (typeof h.hitId === 'string' ? h.hitId : null),
763
+ snippet: cleanSummary(h.summary),
764
+ })
765
+ }
766
+ }
767
+ }
768
+ return { total, results }
769
+ }
770
+
771
+ /** Flatten a driveItem collection (a `children` listing) for browsing. */
772
+ export function shapeFileEntries(raw: unknown): GraphFileEntry[] {
773
+ const items = (raw as { value?: unknown[] })?.value
774
+ if (!Array.isArray(items)) return []
775
+ return items.map((item) => {
776
+ const it = unwrapRemote(item)
777
+ const folder = it.folder as Record<string, unknown> | undefined
778
+ // The `folder` facet is the counterpart of `file`: present on folders only.
779
+ const isFolder = folder != null && typeof folder === 'object'
780
+ const count = folder?.childCount
781
+ return {
782
+ ...shapeFileRef(it),
783
+ isFolder,
784
+ child_count: isFolder && typeof count === 'number' && Number.isFinite(count) ? count : null,
785
+ }
786
+ })
787
+ }
788
+
789
+ export interface GraphFileSearchResult {
790
+ /** The KQL the app composed, so the model can see how its arguments were
791
+ * read — and correct them — instead of guessing why a filter didn't bite. */
792
+ query: string
793
+ /** Graph's reported match count; null when Search didn't report one. */
794
+ total: number | null
795
+ results: GraphFileHit[]
796
+ /** Steering for the model when `total` exceeds what was returned: the raw
797
+ * number alone wasn't acted on (observed: a 4,502-match search answered by
798
+ * raising `limit`). Present only when triggered. */
799
+ hint?: string
800
+ }
801
+
802
+ export interface GraphFileListResult {
803
+ /** Which place was listed, echoed back because the arguments select it
804
+ * implicitly. */
805
+ location: 'onedrive-root' | 'folder'
806
+ items: GraphFileEntry[]
807
+ }
808
+
809
+ /**
810
+ * Browse a known location. Two modes — the person's OneDrive root, or one named
811
+ * folder in any drive they can reach.
812
+ *
813
+ * ## Why there is no `recent` mode
814
+ * The obvious third mode would be `/me/drive/recent` (and its sibling
815
+ * `/me/drive/sharedWithMe`), but both are **deprecated and already degrading**:
816
+ * `sharedWithMe` is currently clamped to roughly one result by a live Microsoft
817
+ * mitigation, and both stop returning data in **November 2026**, with no
818
+ * replacement endpoint. Shipping a tool mode on top of that would build a
819
+ * capability with a known expiry date and no migration path — a model would learn
820
+ * to reach for it and then quietly get nothing back. "Files I touched lately"
821
+ * is its own tool instead — `graph_files_recent`, on the non-deprecated Office
822
+ * Graph insights surface (`/me/insights/used`).
823
+ */
824
+
825
+ // ----------------------------------------------------------------------------
826
+ // Recent files (Office Graph insights)
827
+ // ----------------------------------------------------------------------------
828
+
829
+ /** One recently-used item as the model sees it. */
830
+ export interface GraphRecentFile {
831
+ name: string | null
832
+ /** Human word from insights ("Word", "Excel", "Whiteboard", …) — not a MIME. */
833
+ type: string | null
834
+ /** When the item was last changed / last opened by this user. */
835
+ modified: string | null
836
+ accessed: string | null
837
+ /** Handoff pair, parsed from the insight's resourceReference; null when the
838
+ * insight didn't point at an addressable driveItem. */
839
+ drive_id: string | null
840
+ item_id: string | null
841
+ webUrl: string | null
842
+ }
843
+
844
+ export interface GraphRecentFilesResult {
845
+ items: GraphRecentFile[]
846
+ /** Present when insights were unavailable (tenant policy) — tells the model
847
+ * where to go instead rather than failing the run. */
848
+ note?: string
849
+ }
850
+
851
+ /** Shape one /me/insights/used row; null when it isn't a driveItem. */
852
+ export function shapeUsedInsight(raw: unknown): GraphRecentFile | null {
853
+ const it = (raw ?? {}) as Record<string, unknown>
854
+ const ref = (it.resourceReference ?? {}) as Record<string, unknown>
855
+ if (ref.type !== 'microsoft.graph.driveItem') return null
856
+ const vis = (it.resourceVisualization ?? {}) as Record<string, unknown>
857
+ const used = (it.lastUsed ?? {}) as Record<string, unknown>
858
+ const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v : null)
859
+ // resourceReference.id is "drives/{driveId}/items/{itemId}" — the same
860
+ // handoff pair search hits carry. An unparseable id keeps the row (name and
861
+ // dates still inform) with null ids.
862
+ const ids = /^drives\/([^/]+)\/items\/(.+)$/.exec(str(ref.id) ?? '')
863
+ return {
864
+ name: str(vis.title),
865
+ type: str(vis.type),
866
+ modified: str(used.lastModifiedDateTime),
867
+ accessed: str(used.lastAccessedDateTime),
868
+ drive_id: ids ? ids[1] : null,
869
+ item_id: ids ? ids[2] : null,
870
+ webUrl: str(ref.webUrl),
871
+ }
872
+ }
873
+
874
+ // ----------------------------------------------------------------------------
875
+ // Shared with me (Office Graph insights)
876
+ // ----------------------------------------------------------------------------
877
+
878
+ /** How something reached the user — the distinction `how` fails to make. */
879
+ export type GraphSharedVia = 'email' | 'teams' | 'link'
880
+
881
+ export const GRAPH_SHARED_VIA: readonly GraphSharedVia[] = ['email', 'teams', 'link']
882
+
883
+ /** One thing shared with the signed-in user. */
884
+ export interface GraphSharedFile {
885
+ /** For an email attachment this is the true filename recovered from the
886
+ * message, which carries the extension the insights title drops (measured
887
+ * 2026-08-03: 14 of 15 attachment rows, e.g. "20260802-07346747"). */
888
+ name: string | null
889
+ /** "file" (a driveItem — carries the handoff pair) or "attachment" (an email
890
+ * attachment — lives in a mailbox, so there are no drive ids to hand on). */
891
+ kind: 'file' | 'attachment'
892
+ shared_by: string | null
893
+ shared_when: string | null
894
+ /** Graph's own `lastShared.sharingType` — "Link", "Attachment", "Direct".
895
+ * It does NOT identify the mechanism: measured 2026-08-03 (N=25), 23 rows
896
+ * said "Attachment" while at least three unrelated URL shapes hid behind
897
+ * that single label. Reason about `via` instead. Kept because "Link" is the
898
+ * one signal separating a Share-dialog link from a drive file sent as an
899
+ * attachment — a distinction `via` deliberately does not draw. */
900
+ how: string | null
901
+ /** How it actually reached the user. See `deriveVia`. */
902
+ via: GraphSharedVia
903
+ drive_id: string | null
904
+ item_id: string | null
905
+ webUrl: string | null
906
+ /** Set only when this response carries several attachments from the SAME
907
+ * email: every row of one message shares an ordinal, so an answer can cite
908
+ * the message once instead of repeating an identical link per file. Absent
909
+ * on a row that is the only one from its message. */
910
+ email_group?: number
911
+ }
912
+
913
+ export interface GraphSharedFilesResult {
914
+ items: GraphSharedFile[]
915
+ /** Present when insights were unavailable (tenant policy) or when the
916
+ * filters matched nothing — steers instead of failing. */
917
+ note?: string
918
+ }
919
+
920
+ /**
921
+ * Does this webUrl point at a file pasted into a Teams chat?
922
+ *
923
+ * Teams uploads a chat attachment to the SENDER's OneDrive, into a folder whose
924
+ * name is LOCALIZED ("Microsoft Teams Chat Files"; observed in this tenant as
925
+ * the French "Fichiers de conversation Microsoft Teams"). Enumerating those
926
+ * would need one entry per Microsoft UI language, so match the product name
927
+ * instead — Microsoft translates the words around it but never "Teams" itself.
928
+ *
929
+ * Only FOLDER segments are scanned, so a Share-dialog file named
930
+ * "Teams rollout plan.docx" is not misread as a chat paste.
931
+ *
932
+ * No decodeURIComponent: it throws on a lone `%`, and it buys nothing here —
933
+ * "Microsoft%20Teams" already lowercases to something containing "teams".
934
+ */
935
+ function looksLikeTeamsChatPath(webUrl: string): boolean {
936
+ const folders = webUrl.split('?')[0].split('/').slice(0, -1).join('/').toLowerCase()
937
+ return folders.includes('/personal/') && folders.includes('teams')
938
+ }
939
+
940
+ /**
941
+ * Classify how a shared item reached the user. First match wins.
942
+ *
943
+ * `kind` is checked FIRST and comes from `resourceReference.type`, which Graph
944
+ * contracts — so a mailbox attachment can never fall through into the URL
945
+ * heuristic below.
946
+ *
947
+ * Degradation is deliberately one-directional. A Teams paste this misses
948
+ * (Microsoft renames the folder, or the file sits in a Teams *channel* under
949
+ * /sites/…) reads as "link", which is true and is exactly the information this
950
+ * tool carried before `via` existed. A false positive — a personal-OneDrive
951
+ * folder genuinely named "Teams Migration" — adds one row to a via="teams"
952
+ * query, with a visible OneDrive path in the answer. Neither ever yields
953
+ * "email", and the result is never null.
954
+ */
955
+ export function deriveVia(kind: GraphSharedFile['kind'], webUrl: string | null): GraphSharedVia {
956
+ if (kind === 'attachment') return 'email'
957
+ if (webUrl && looksLikeTeamsChatPath(webUrl)) return 'teams'
958
+ return 'link'
959
+ }
960
+
961
+ /**
962
+ * Pull the message out of an OWA attachment-popout URL.
963
+ *
964
+ * Insights links an attachment row to the ATTACHMENT VIEWER, not to the email:
965
+ *
966
+ * https://outlook.office.com/owa/?viewmodel=IAttachmentViewModelPopoutFactory
967
+ * &AttachmentId=<message id + attachment discriminator>
968
+ * &ItemId=<message id>
969
+ * &AttachmentName=invoice.pdf
970
+ *
971
+ * All three parameters were present on 15 of 15 attachment rows (measured
972
+ * 2026-08-03). `ItemId` is the message's ordinary Graph id — verified
973
+ * character-for-character against `id` from /me/messages for the same message
974
+ * — so swapping the viewmodel reproduces the link Graph itself puts in
975
+ * `message.webLink`, observed the same day as:
976
+ *
977
+ * https://outlook.office365.com/owa/?ItemID=<id>&exvsurl=1&viewmodel=ReadMessageItem
978
+ *
979
+ * Origin and pathname are carried over from the input rather than hard-coded,
980
+ * so a sovereign/GCC cloud — or the office365.com host Graph itself emits —
981
+ * keeps working.
982
+ */
983
+ export function parseOwaAttachmentUrl(
984
+ webUrl: string | null,
985
+ ): { itemId: string; attachmentName: string | null; messageUrl: string } | null {
986
+ if (!webUrl) return null
987
+ let url: URL
988
+ try {
989
+ url = new URL(webUrl)
990
+ } catch {
991
+ return null
992
+ }
993
+ // Case-INSENSITIVE lookup: insights spells it `ItemId`, Graph's own webLink
994
+ // spells it `ItemID`, and URLSearchParams.get() is case-sensitive — reading
995
+ // one spelling is the easiest way to ship a silently dead rewrite. Values
996
+ // come back through searchParams (lenient on a malformed escape) rather than
997
+ // decodeURIComponent (throws URIError).
998
+ const param = (want: string): string | null => {
999
+ for (const [k, v] of url.searchParams) {
1000
+ if (k.toLowerCase() === want && v.trim()) return v
1001
+ }
1002
+ return null
1003
+ }
1004
+ const itemId = param('itemid')
1005
+ if (!itemId) return null
1006
+ return {
1007
+ itemId,
1008
+ attachmentName: param('attachmentname'),
1009
+ messageUrl:
1010
+ `${url.origin}${url.pathname}` +
1011
+ `?ItemID=${encodeURIComponent(itemId)}&exvsurl=1&viewmodel=ReadMessageItem`,
1012
+ }
1013
+ }
1014
+
1015
+ /** Epoch ms for sorting; an unparseable date sorts last rather than first. */
1016
+ function sharedAt(r: GraphSharedFile): number {
1017
+ const t = Date.parse(r.shared_when ?? '')
1018
+ return Number.isNaN(t) ? -Infinity : t
1019
+ }
1020
+
1021
+ /**
1022
+ * Reject-don't-drop for a narrowing string filter (#314). A filter the model
1023
+ * sent in a wrong shape used to degrade to "no filter" and the tool answered
1024
+ * about EVERYONE — e.g. `person: ["Thibault"]` returned the newest attachment
1025
+ * mail to any recipient, narrated as "the files you sent Thibault". Same rule
1026
+ * as the `since` date args and `via`: refuse loudly instead of silently
1027
+ * widening. `undefined`/`null` mean absent (models send explicit nulls for
1028
+ * "unspecified"); anything else non-string is refused.
1029
+ */
1030
+ function filterString(args: Record<string, unknown>, key: string): string {
1031
+ const raw = args[key]
1032
+ if (raw === undefined || raw === null) return ''
1033
+ if (typeof raw !== 'string') {
1034
+ const got = Array.isArray(raw) ? 'array' : typeof raw
1035
+ throw new Error(`${key} must be a string (got ${got}). Resend it as a plain string.`)
1036
+ }
1037
+ return raw.trim()
1038
+ }
1039
+
1040
+ /**
1041
+ * Read the `via` argument, throwing on a value outside the set.
1042
+ *
1043
+ * Unlike `sort` on graph_files_search — which only reorders, so a silently
1044
+ * ignored value is merely unhelpful — `via` NARROWS. A dropped filter returns
1045
+ * every source and the model then narrates mail attachments as Teams pastes:
1046
+ * the same class of silently-wrong answer the date arguments throw to prevent.
1047
+ * Case is normalized first, so the likeliest mistake ("Teams") costs nothing.
1048
+ */
1049
+ function parseVia(raw: unknown): GraphSharedVia | null {
1050
+ if (typeof raw !== 'string' || !raw.trim()) return null
1051
+ const want = raw.trim().toLowerCase()
1052
+ const hit = GRAPH_SHARED_VIA.find((v) => v === want)
1053
+ if (!hit) {
1054
+ throw new Error(`Unknown via "${raw}". Valid values: ${GRAPH_SHARED_VIA.join(', ')}.`)
1055
+ }
1056
+ return hit
1057
+ }
1058
+
1059
+ /**
1060
+ * Shape one /me/insights/shared row; null for rows that aren't shareable
1061
+ * content (bare `entity` references arrive with no title and no address —
1062
+ * noise a model would only trip on).
1063
+ *
1064
+ * PREDOMINANTLY, BUT NOT EXCLUSIVELY, INBOUND. `lastShared.sharedBy` is the
1065
+ * ACTOR of the share, and that actor is sometimes the signed-in user: measured
1066
+ * 2026-08-03 (N=25) it was them on 3 rows — a OneDrive file they had made a
1067
+ * Link for, plus two files they had sent as attachments, all three carrying a
1068
+ * normal drive_id + item_id pair.
1069
+ *
1070
+ * That contradicts an earlier reading of a 50-row sample as "zero shared by the
1071
+ * signed-in user", recorded during #110 and repeated here until 2026-08-15. The
1072
+ * two samples disagree and the reason is not established, so claim neither
1073
+ * direction: the feed leans heavily inbound, some of the user's own outbound
1074
+ * shares surface, and whether outbound coverage is anywhere near complete is
1075
+ * unknown. Do NOT present it as a record of what the user shared with others.
1076
+ * See docs/graph-api-notes.md ("Not verified").
1077
+ */
1078
+ export function shapeSharedInsight(raw: unknown): GraphSharedFile | null {
1079
+ const it = (raw ?? {}) as Record<string, unknown>
1080
+ const ref = (it.resourceReference ?? {}) as Record<string, unknown>
1081
+ const kind =
1082
+ ref.type === 'microsoft.graph.driveItem'
1083
+ ? ('file' as const)
1084
+ : ref.type === 'microsoft.graph.fileAttachment'
1085
+ ? ('attachment' as const)
1086
+ : null
1087
+ if (!kind) return null
1088
+ const vis = (it.resourceVisualization ?? {}) as Record<string, unknown>
1089
+ const last = (it.lastShared ?? {}) as Record<string, unknown>
1090
+ const by = (last.sharedBy ?? {}) as Record<string, unknown>
1091
+ const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v : null)
1092
+ const ids = kind === 'file' ? /^drives\/([^/]+)\/items\/(.+)$/.exec(str(ref.id) ?? '') : null
1093
+ const webUrl = str(ref.webUrl)
1094
+ // An attachment row points at the attachment popout, and its insights title
1095
+ // has had the extension stripped. Both are recoverable from that same URL, so
1096
+ // rewrite to the email and take the real filename. A URL we cannot parse
1097
+ // leaves both fields exactly as they were before this existed.
1098
+ const owa = kind === 'attachment' ? parseOwaAttachmentUrl(webUrl) : null
1099
+ return {
1100
+ name: owa?.attachmentName ?? str(vis.title),
1101
+ kind,
1102
+ shared_by: str(by.displayName),
1103
+ shared_when: str(last.sharedDateTime),
1104
+ how: str(last.sharingType),
1105
+ via: deriveVia(kind, webUrl),
1106
+ drive_id: ids ? ids[1] : null,
1107
+ item_id: ids ? ids[2] : null,
1108
+ webUrl: owa?.messageUrl ?? webUrl,
1109
+ }
1110
+ }
1111
+
1112
+ // ----------------------------------------------------------------------------
1113
+ // Mail with attachments (sent or received)
1114
+ // ----------------------------------------------------------------------------
1115
+
1116
+ /** One attachment on a message — name/size/type only, never content bytes. */
1117
+ export interface GraphMailAttachment {
1118
+ name: string | null
1119
+ size: number | null
1120
+ contentType: string | null
1121
+ }
1122
+
1123
+ export interface GraphMailAttachmentMessage {
1124
+ subject: string | null
1125
+ /** The other side of the exchange: recipients for sent mail, sender for
1126
+ * received mail. */
1127
+ with: string[]
1128
+ date: string | null
1129
+ attachments: GraphMailAttachment[]
1130
+ webLink: string | null
1131
+ }
1132
+
1133
+ export interface GraphMailAttachmentsResult {
1134
+ direction: 'sent' | 'received'
1135
+ messages: GraphMailAttachmentMessage[]
1136
+ }
1137
+
1138
+ /** Case-insensitive person match against a display name or address. */
1139
+ function personMatches(needle: string, name: unknown, address: unknown): boolean {
1140
+ const n = needle.toLowerCase()
1141
+ return (
1142
+ (typeof name === 'string' && name.toLowerCase().includes(n)) ||
1143
+ (typeof address === 'string' && address.toLowerCase().includes(n))
1144
+ )
1145
+ }
1146
+
1147
+ /** Shape one message row from the attachments query. */
1148
+ export function shapeAttachmentMessage(
1149
+ raw: unknown,
1150
+ direction: 'sent' | 'received',
1151
+ ): GraphMailAttachmentMessage {
1152
+ const m = (raw ?? {}) as Record<string, unknown>
1153
+ const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v : null)
1154
+ const recips = (Array.isArray(m.toRecipients) ? m.toRecipients : []) as Array<
1155
+ Record<string, unknown>
1156
+ >
1157
+ const from = ((m.from ?? {}) as Record<string, unknown>).emailAddress as
1158
+ Record<string, unknown> | undefined
1159
+ const withNames =
1160
+ direction === 'sent'
1161
+ ? recips
1162
+ .map((r) => str((r.emailAddress as Record<string, unknown> | undefined)?.name))
1163
+ .filter((n): n is string => n !== null)
1164
+ : [str(from?.name)].filter((n): n is string => n !== null)
1165
+ const atts = (Array.isArray(m.attachments) ? m.attachments : []) as Array<Record<string, unknown>>
1166
+ return {
1167
+ subject: str(m.subject),
1168
+ with: withNames,
1169
+ date: str(m.sentDateTime) ?? str(m.receivedDateTime),
1170
+ attachments: atts.map((a) => ({
1171
+ name: str(a.name),
1172
+ size: typeof a.size === 'number' && Number.isFinite(a.size) ? a.size : null,
1173
+ contentType: str(a.contentType),
1174
+ })),
1175
+ webLink: str(m.webLink),
1176
+ }
1177
+ }
1178
+
1179
+ // ============================================================================
1180
+ // Registration — an explicit factory call, not an import side effect
1181
+ // ============================================================================
1182
+
1183
+ /** Required-supplier check (PR-2 doctrine: missing suppliers throw, never
1184
+ * degrade — no silent default, no env fallback). The check is
1185
+ * `typeof value !== 'function'`, aligned with the registry's own
1186
+ * `requireSupplier` (PR-C1 review finding F1): a present-but-wrong-typed
1187
+ * supplier (e.g. `graphFetch: 42`) is refused AT FACTORY CALL, not at first
1188
+ * tool use. */
1189
+ function requireGraphSupplier<T>(bag: unknown, field: string): T {
1190
+ const value = (bag as Record<string, unknown> | null | undefined)?.[field]
1191
+ if (typeof value !== 'function') {
1192
+ throw new Error(
1193
+ `registerGraphConnectorTools: missing required supplier "${field}" — the Graph ` +
1194
+ 'tools refuse to compose without it. No silent default, no env fallback.',
1195
+ )
1196
+ }
1197
+ return value as T
1198
+ }
1199
+
1200
+ /** A required supplier BAG (an object whose members are themselves required
1201
+ * function suppliers) — same doctrine as {@link requireGraphSupplier}, one
1202
+ * level down: the bag must be a present object and every named member must
1203
+ * be a function. */
1204
+ function requireGraphBag<T extends object>(deps: unknown, field: string): T {
1205
+ const value = (deps as Record<string, unknown> | null | undefined)?.[field]
1206
+ if (typeof value !== 'object' || value === null) {
1207
+ throw new Error(
1208
+ `registerGraphConnectorTools: missing required supplier "${field}" — the Graph ` +
1209
+ 'tools refuse to compose without it. No silent default, no env fallback.',
1210
+ )
1211
+ }
1212
+ return value as T
1213
+ }
1214
+
1215
+ /**
1216
+ * Register every Microsoft Graph tool against the supplied registry.
1217
+ *
1218
+ * Called once by the host's composition root (`app-tools/index.server.ts`)
1219
+ * with its own `graphFetch`, content classifier and Data Stash bridge;
1220
+ * importing this module alone registers nothing. Tool bodies are unchanged
1221
+ * from the import-time registration the host app used before the peel
1222
+ * (PR-C1, #225 PR-3): same definitions, same executors, same order.
1223
+ */
1224
+ export function registerGraphConnectorTools(deps: GraphConnectorDeps): void {
1225
+ const registerAppTool = requireGraphSupplier<(def: AppToolDefinition) => void>(
1226
+ deps,
1227
+ 'registerAppTool',
1228
+ )
1229
+ const graphFetch = requireGraphSupplier<GraphFetchFn>(deps, 'graphFetch')
1230
+ const content = requireGraphBag<GraphContentClassifier>(deps, 'content')
1231
+ requireGraphSupplier(content, 'conversionEnabled')
1232
+ requireGraphSupplier(content, 'isConvertible')
1233
+ requireGraphSupplier(content, 'guessMimeType')
1234
+ requireGraphSupplier(content, 'isTextMime')
1235
+ const stash = requireGraphBag<GraphStashBridge>(deps, 'stash')
1236
+ requireGraphSupplier(stash, 'loadStore')
1237
+ requireGraphSupplier(stash, 'ingest')
1238
+ const { conversionEnabled, isConvertible, guessMimeType, isTextMime } = content
1239
+
1240
+ registerAppTool({
1241
+ name: 'graph_calendar_today',
1242
+ namespace: 'graph',
1243
+ description:
1244
+ "List the signed-in user's own calendar events for today (or another day via " +
1245
+ 'day_offset: 0=today, 1=tomorrow, -1=yesterday). Returns subject, start/end, ' +
1246
+ 'location and organizer. Expands recurring meetings. Acts as the current user.',
1247
+ inputSchema: {
1248
+ type: 'object',
1249
+ properties: {
1250
+ day_offset: {
1251
+ type: 'integer',
1252
+ description: 'Days from today. 0=today (default), 1=tomorrow, -1=yesterday.',
1253
+ },
1254
+ },
1255
+ additionalProperties: false,
1256
+ },
1257
+ execute: async (args, { userId }) => {
1258
+ const offset = Number.isFinite(Number(args.day_offset)) ? Number(args.day_offset) : 0
1259
+ const tz = graphTimeZone()
1260
+ const { start, end } = localDayBounds(new Date(), offset)
1261
+
1262
+ // calendarView (not /events) so recurring series are expanded into
1263
+ // occurrences within the window.
1264
+ const raw = await graphFetch(
1265
+ userId,
1266
+ `/me/calendarView?startDateTime=${start}&endDateTime=${end}` +
1267
+ `&$select=subject,start,end,isAllDay,location,organizer,onlineMeetingUrl` +
1268
+ `&$orderby=start/dateTime&$top=50`,
1269
+ {
1270
+ scopes: ['Calendars.ReadWrite'],
1271
+ headers: { Prefer: `outlook.timezone="${tz}"` },
1272
+ },
1273
+ )
1274
+ return { timeZone: tz, day: start.slice(0, 10), events: shapeEvents(raw) }
1275
+ },
1276
+ })
1277
+
1278
+ registerAppTool({
1279
+ name: 'graph_mail_recent',
1280
+ namespace: 'graph',
1281
+ description:
1282
+ "List recent messages from the signed-in user's inbox, newest first. Set " +
1283
+ 'unread_only=true for just unread mail. Returns sender, subject, received ' +
1284
+ 'time and a short preview — not full bodies. Acts as the current user.',
1285
+ inputSchema: {
1286
+ type: 'object',
1287
+ properties: {
1288
+ unread_only: {
1289
+ type: 'boolean',
1290
+ description: 'Only unread messages (default false).',
1291
+ },
1292
+ limit: {
1293
+ type: 'integer',
1294
+ description: 'How many messages to return, 1-25 (default 10).',
1295
+ },
1296
+ },
1297
+ additionalProperties: false,
1298
+ },
1299
+ execute: async (args, { userId }) => {
1300
+ const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 25)
1301
+ const unreadOnly = args.unread_only === true
1302
+
1303
+ // Inbox specifically (not all folders), so Sent/Archive don't pollute
1304
+ // "recent mail". $filter + $orderby together is supported on messages.
1305
+ const raw = await graphFetch(
1306
+ userId,
1307
+ `/me/mailFolders/inbox/messages?$top=${limit}` +
1308
+ `&$select=subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview,webLink` +
1309
+ `&$orderby=receivedDateTime desc` +
1310
+ (unreadOnly ? `&$filter=isRead eq false` : ''),
1311
+ { scopes: ['Mail.Read'] },
1312
+ )
1313
+ return { unreadOnly, messages: shapeMessages(raw) }
1314
+ },
1315
+ })
1316
+
1317
+ registerAppTool({
1318
+ name: 'graph_me',
1319
+ namespace: 'graph',
1320
+ description:
1321
+ "Get the signed-in user's own Microsoft 365 profile (name, work email/UPN, " +
1322
+ 'job title, office, language). Acts as the current user — no user or token ' +
1323
+ 'argument is accepted or needed.',
1324
+ // No parameters at all: the identity is the request's authenticated user.
1325
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
1326
+ execute: async (_args, { userId }) => {
1327
+ const raw = await graphFetch(userId, `/me?$select=${ME_FIELDS.join(',')}`, {
1328
+ scopes: ['User.Read'],
1329
+ })
1330
+ return shapeMe(raw)
1331
+ },
1332
+ })
1333
+
1334
+ registerAppTool({
1335
+ name: 'graph_file_ingest',
1336
+ namespace: 'graph',
1337
+ description:
1338
+ "Copy one of the signed-in person's own Microsoft 365 files (OneDrive or " +
1339
+ "SharePoint) into this conversation's Data Stash, so later turns can search " +
1340
+ 'it, read it or hand it to the sandbox. Identify the file by item_id, ' +
1341
+ 'optionally with drive_id for a shared/SharePoint drive. Text files become ' +
1342
+ 'searchable automatically; other formats are stored as-is. Returns the stash ' +
1343
+ 'document id and metadata — never the file contents. Acts as the current ' +
1344
+ 'signed-in person.',
1345
+ inputSchema: {
1346
+ type: 'object',
1347
+ properties: {
1348
+ item_id: {
1349
+ type: 'string',
1350
+ description: 'Microsoft Graph driveItem id of the file to copy.',
1351
+ },
1352
+ drive_id: {
1353
+ type: 'string',
1354
+ description: "Drive holding the item. Omit for the signed-in person's own OneDrive.",
1355
+ },
1356
+ filename: {
1357
+ type: 'string',
1358
+ description: 'Override the stored filename. Defaults to the name in Microsoft 365.',
1359
+ },
1360
+ },
1361
+ required: ['item_id'],
1362
+ additionalProperties: false,
1363
+ },
1364
+ execute: async (args, { userId, sessionId }): Promise<GraphFileIngestResult> => {
1365
+ // Fail closed: the Data Stash is keyed by conversation, so without one in
1366
+ // scope there is no correct place to put the file — and guessing would mean
1367
+ // writing one person's file into another conversation's stash.
1368
+ if (!sessionId) {
1369
+ throw new Error(
1370
+ "graph_file_ingest stores the file in the current conversation's Data Stash, " +
1371
+ 'and no conversation is in scope for this call. Run it from a chat turn or ' +
1372
+ 'a triggered action run.',
1373
+ )
1374
+ }
1375
+ const itemId = typeof args.item_id === 'string' ? args.item_id.trim() : ''
1376
+ if (!itemId) {
1377
+ throw new Error('item_id is required — the Microsoft Graph driveItem id of the file.')
1378
+ }
1379
+ const driveId = typeof args.drive_id === 'string' ? args.drive_id.trim() || null : null
1380
+ const base = driveItemPath(itemId, driveId)
1381
+
1382
+ // Metadata first — and separately from the download — because it carries the
1383
+ // size, which is how an oversized file is refused BEFORE its bytes are in
1384
+ // this process's heap. It also gives the real filename and MIME type.
1385
+ const meta = shapeDriveItem(
1386
+ await graphFetch(userId, `${base}?$select=${DRIVE_ITEM_SELECT}`, {
1387
+ scopes: FILE_SCOPES,
1388
+ }).catch((err) => {
1389
+ throw translateIngestDenial(err, itemId)
1390
+ }),
1391
+ )
1392
+ if (!meta.isFile) {
1393
+ throw new Error(
1394
+ `Microsoft 365 item ${itemId} has no file content — it is probably a folder. ` +
1395
+ 'Pass the id of a file.',
1396
+ )
1397
+ }
1398
+
1399
+ // The Data Stash layer is injected as a LAZY supplier (the stash bridge
1400
+ // seam): in the host it pulls in ioredis and the whole chunk/embed/vector
1401
+ // stack, and nothing else in this module needs it — so it is only
1402
+ // resolved here, on ingest, never at composition time.
1403
+ const store = await stash.loadStore()
1404
+ // A missing size (Graph reports one for every file in practice) is not
1405
+ // treated as oversized; the store re-checks the limit on the decoded
1406
+ // bytes, so an unreported giant still can't be stored.
1407
+ if (meta.size != null && meta.size > store.maxContentBytes) {
1408
+ throw new Error(
1409
+ `"${meta.name ?? itemId}" is ${meta.size} bytes, above the Data Stash limit of ` +
1410
+ `${store.maxContentBytes} bytes, so it was not downloaded. Use a smaller file or ` +
1411
+ 'an extract of this one.',
1412
+ )
1413
+ }
1414
+
1415
+ const override = typeof args.filename === 'string' ? args.filename.trim() : ''
1416
+ const filename = override || meta.name || `driveitem-${itemId}`
1417
+ const mimeType = meta.mimeType ?? guessMimeType(filename)
1418
+
1419
+ // Always download bytes, then decide how to STORE them — mirroring the
1420
+ // upload route's intake: text formats go in as UTF-8 (the chunker reads
1421
+ // `content` directly), anything else keeps its exact bytes as base64 so the
1422
+ // `/work` round-trip and `?download` still serve the real file.
1423
+ const encoded = await graphFetch(userId, `${base}/content`, {
1424
+ scopes: FILE_SCOPES,
1425
+ responseType: 'base64',
1426
+ }).catch((err) => {
1427
+ throw translateIngestDenial(err, itemId)
1428
+ })
1429
+ if (typeof encoded !== 'string') {
1430
+ throw new Error(`Microsoft 365 returned no content for "${filename}".`)
1431
+ }
1432
+ const isText = isTextMime(mimeType)
1433
+ const content = isText ? Buffer.from(encoded, 'base64').toString('utf8') : encoded
1434
+
1435
+ // Same gate as the upload route: a binary is only worth ingesting when we can
1436
+ // turn it into text; otherwise `ingestStashDocument` would only mark it
1437
+ // failed. Unlike that route we do NOT also require the agent to compose a
1438
+ // redis retriever — calling this tool is an explicit request to make the file
1439
+ // usable, and a retriever added later reads an already-indexed corpus.
1440
+ const ingesting = isText || (conversionEnabled() && isConvertible(mimeType))
1441
+
1442
+ const doc = await store.storeDocument({
1443
+ sessionId,
1444
+ filename,
1445
+ mimeType,
1446
+ content,
1447
+ ...(isText ? {} : { encoding: 'base64' as const }),
1448
+ // Persist 'pending' in the FIRST write (as the upload route does) so a
1449
+ // status poll can never read a doc with no ingest status and flicker.
1450
+ ...(ingesting ? { ingestStatus: 'pending' as const } : {}),
1451
+ })
1452
+
1453
+ if (ingesting) {
1454
+ // Fire-and-forget, mirroring `POST /api/stash/upload`: embedding is slow
1455
+ // and the tool result must come back inside the turn. Failures are
1456
+ // recorded in the document's `ingestStatus`, which is why the rejection is
1457
+ // swallowed here rather than surfaced.
1458
+ void stash.ingest(sessionId, doc.id).catch(() => {})
1459
+ }
1460
+
1461
+ return {
1462
+ documentId: doc.id,
1463
+ filename,
1464
+ mimeType,
1465
+ size: doc.size,
1466
+ ingesting,
1467
+ webUrl: meta.webUrl,
1468
+ }
1469
+ },
1470
+ })
1471
+
1472
+ registerAppTool({
1473
+ name: 'graph_files_search',
1474
+ namespace: 'graph',
1475
+ description:
1476
+ 'Search the files the signed-in person can open — their own OneDrive and ' +
1477
+ 'every SharePoint site they have access to. Pass plain words in `query`: the ' +
1478
+ 'app builds the search expression, so search syntax is neither needed nor ' +
1479
+ 'honoured. Narrow with `site` (a SharePoint site URL), `file_type` (an ' +
1480
+ "extension like docx or pdf), `author` (a person's name), or " +
1481
+ '`modified_after` / `modified_before` (dates). Set sort="newest" for ' +
1482
+ "most-recently-modified first. Returns each file's name, folder, site, " +
1483
+ 'modified date, size and a snippet of the matched text, plus the drive_id + ' +
1484
+ 'item_id pair that identifies a file to the tools that act on one. Acts as ' +
1485
+ 'the current signed-in person.',
1486
+ inputSchema: {
1487
+ type: 'object',
1488
+ properties: {
1489
+ query: {
1490
+ type: 'string',
1491
+ description:
1492
+ 'Words to look for, e.g. "q3 budget forecast". Plain terms only — ' +
1493
+ 'operators and field:value syntax are stripped, not interpreted.',
1494
+ },
1495
+ site: {
1496
+ type: 'string',
1497
+ description:
1498
+ 'Restrict to one SharePoint site, given as its URL ' +
1499
+ '(https://contoso.sharepoint.com/sites/Finance).',
1500
+ },
1501
+ file_type: {
1502
+ type: 'string',
1503
+ description: 'Restrict to one file extension, e.g. docx, xlsx, pdf.',
1504
+ },
1505
+ author: {
1506
+ type: 'string',
1507
+ description: 'Restrict to files authored by this person, e.g. "Jane Smith".',
1508
+ },
1509
+ modified_after: {
1510
+ type: 'string',
1511
+ description: 'Only files modified on/after this date, e.g. 2026-07-01.',
1512
+ },
1513
+ modified_before: {
1514
+ type: 'string',
1515
+ description: 'Only files modified on/before this date, e.g. 2026-07-31.',
1516
+ },
1517
+ sort: {
1518
+ type: 'string',
1519
+ enum: ['relevance', 'newest'],
1520
+ description:
1521
+ 'Result order: best match first (relevance, default) or ' +
1522
+ 'most-recently-modified first (newest).',
1523
+ },
1524
+ limit: {
1525
+ type: 'integer',
1526
+ description: 'How many files to return, 1-25 (default 10).',
1527
+ },
1528
+ },
1529
+ required: ['query'],
1530
+ additionalProperties: false,
1531
+ },
1532
+ execute: async (args, { userId }): Promise<GraphFileSearchResult> => {
1533
+ const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 25)
1534
+ const query = composeFileQuery({
1535
+ query: typeof args.query === 'string' ? args.query : '',
1536
+ site: filterString(args, 'site') || null,
1537
+ fileType: filterString(args, 'file_type') || null,
1538
+ author: filterString(args, 'author') || null,
1539
+ modifiedAfter: typeof args.modified_after === 'string' ? args.modified_after : null,
1540
+ modifiedBefore: typeof args.modified_before === 'string' ? args.modified_before : null,
1541
+ })
1542
+ if (!query) {
1543
+ throw new Error(
1544
+ 'query is required — the words to look for, e.g. "q3 budget". ' +
1545
+ 'Nothing searchable was left after the arguments were parsed.',
1546
+ )
1547
+ }
1548
+
1549
+ const raw = await graphFetch(userId, '/search/query', {
1550
+ method: 'POST',
1551
+ scopes: FILE_SEARCH_SCOPES,
1552
+ body: {
1553
+ requests: [
1554
+ {
1555
+ // driveItem covers OneDrive *and* SharePoint document libraries in
1556
+ // one request. `listItem` and `site` are combinable with it here,
1557
+ // but they'd fold list rows and site pages into a *file* search.
1558
+ entityTypes: ['driveItem'],
1559
+ query: { queryString: query },
1560
+ from: 0,
1561
+ size: limit,
1562
+ // Deliberately no `fields`: unlike `$select` it *replaces* the
1563
+ // returned resource properties, and a hit stripped of
1564
+ // `parentReference` loses `drive_id` — the handoff this tool exists
1565
+ // to produce. `shapeSearchHits` is the allowlist instead, so no raw
1566
+ // Graph payload reaches the model either way.
1567
+ //
1568
+ // `isDescending` is the STRING "true" — the shape verified live
1569
+ // against this tenant. Microsoft's docs type it Boolean; do not
1570
+ // "correct" it untested.
1571
+ ...(args.sort === 'newest'
1572
+ ? { sortProperties: [{ name: 'lastModifiedDateTime', isDescending: 'true' }] }
1573
+ : {}),
1574
+ },
1575
+ ],
1576
+ },
1577
+ })
1578
+
1579
+ const { total, results } = shapeSearchHits(raw)
1580
+ const hint =
1581
+ total != null && total > results.length
1582
+ ? `Showing ${results.length} of ${total} matches. Prefer narrowing ` +
1583
+ `(modified_after, file_type, site, author, sort="newest") over raising limit.`
1584
+ : undefined
1585
+ return { query, total, results, ...(hint ? { hint } : {}) }
1586
+ },
1587
+ })
1588
+
1589
+ registerAppTool({
1590
+ name: 'graph_files_list',
1591
+ namespace: 'graph',
1592
+ description:
1593
+ "Browse the signed-in person's files instead of searching them. With no " +
1594
+ 'arguments, lists the top level of their own OneDrive; pass folder_item_id ' +
1595
+ "(plus drive_id for a SharePoint or shared drive) to list that folder's " +
1596
+ 'contents. Entries carry the same drive_id + item_id pair as a search result, ' +
1597
+ 'and folders report isFolder + child_count so you can walk down into them. ' +
1598
+ 'Use graph_files_search to find a file by its words instead. Acts as the ' +
1599
+ 'current signed-in person.',
1600
+ inputSchema: {
1601
+ type: 'object',
1602
+ properties: {
1603
+ folder_item_id: {
1604
+ type: 'string',
1605
+ description:
1606
+ 'driveItem id of the folder to list. Omit for the top level of the ' +
1607
+ "person's own OneDrive.",
1608
+ },
1609
+ drive_id: {
1610
+ type: 'string',
1611
+ description: "Drive holding that folder. Omit for the person's own OneDrive.",
1612
+ },
1613
+ limit: {
1614
+ type: 'integer',
1615
+ description: 'How many entries to return, 1-50 (default 20).',
1616
+ },
1617
+ },
1618
+ additionalProperties: false,
1619
+ },
1620
+ execute: async (args, { userId }): Promise<GraphFileListResult> => {
1621
+ const limit = Math.min(Math.max(Number(args.limit) || 20, 1), 50)
1622
+ const folderId = typeof args.folder_item_id === 'string' ? args.folder_item_id.trim() : ''
1623
+ const driveId = typeof args.drive_id === 'string' ? args.drive_id.trim() || null : null
1624
+
1625
+ const location: GraphFileListResult['location'] = folderId ? 'folder' : 'onedrive-root'
1626
+
1627
+ const base = folderId
1628
+ ? // Same encoded path builder as the ingest tool, so a crafted id can't
1629
+ // escape its segment and address an unrelated resource.
1630
+ `${driveItemPath(folderId, driveId)}/children`
1631
+ : '/me/drive/root/children'
1632
+
1633
+ // No `$orderby`: children come back name-ordered already, and it is not
1634
+ // supported on every drive type — a 400 here would break browsing outright.
1635
+ const raw = await graphFetch(
1636
+ userId,
1637
+ `${base}?$select=${DRIVE_ITEM_LIST_SELECT}&$top=${limit}`,
1638
+ { scopes: FILE_SCOPES },
1639
+ )
1640
+ return { location, items: shapeFileEntries(raw) }
1641
+ },
1642
+ })
1643
+
1644
+ registerAppTool({
1645
+ name: 'graph_files_recent',
1646
+ namespace: 'graph',
1647
+ description:
1648
+ 'List the files the signed-in person recently used — opened or edited — ' +
1649
+ "newest first, from Microsoft 365's insights. No query needed; this is the " +
1650
+ 'right tool for "my recent files" or "what did I work on lately". Each ' +
1651
+ 'item carries the drive_id + item_id pair the other file tools accept. Use ' +
1652
+ 'graph_files_search (optionally with sort="newest") to find files by ' +
1653
+ 'words or by other people. Acts as the current signed-in person.',
1654
+ inputSchema: {
1655
+ type: 'object',
1656
+ properties: {
1657
+ limit: {
1658
+ type: 'integer',
1659
+ description: 'How many files to return, 1-25 (default 10).',
1660
+ },
1661
+ },
1662
+ additionalProperties: false,
1663
+ },
1664
+ execute: async (args, { userId }): Promise<GraphRecentFilesResult> => {
1665
+ const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 25)
1666
+ let raw: unknown
1667
+ try {
1668
+ // `$top` applies BEFORE our driveItem filter and insights mixes in
1669
+ // non-file rows (sites, …), so the request is inflated and the shaped
1670
+ // list sliced back down to `limit`.
1671
+ raw = await graphFetch(userId, `/me/insights/used?$top=${Math.min(limit * 2, 50)}`, {
1672
+ scopes: ['Sites.Read.All'],
1673
+ })
1674
+ } catch (err) {
1675
+ // A 403 here is almost always itemInsights disabled by tenant policy —
1676
+ // not a sign-in problem, so a re-auth prompt would be wrong AND the
1677
+ // agent can still answer via search. Degrade to a successful, steerable
1678
+ // result. 401/acquisition failures keep the sign-in path.
1679
+ if (err instanceof GraphAuthRequiredError && err.status === 403) {
1680
+ return {
1681
+ items: [],
1682
+ note:
1683
+ 'Item insights are disabled by tenant policy (or this account lacks ' +
1684
+ 'consent for them) — use graph_files_search with sort="newest" instead.',
1685
+ }
1686
+ }
1687
+ throw err
1688
+ }
1689
+ const rows = ((raw as { value?: unknown[] })?.value ?? [])
1690
+ .map(shapeUsedInsight)
1691
+ .filter((r): r is GraphRecentFile => r !== null)
1692
+ .slice(0, limit)
1693
+ return { items: rows }
1694
+ },
1695
+ })
1696
+
1697
+ registerAppTool({
1698
+ name: 'graph_files_shared',
1699
+ namespace: 'graph',
1700
+ description:
1701
+ 'List what was recently shared WITH the signed-in person — OneDrive/' +
1702
+ 'SharePoint links, files pasted into a Teams chat, and email attachments — ' +
1703
+ 'newest first, with who shared it, when and through which channel (via). ' +
1704
+ "Filter to one sharer with shared_by (a person's name) and/or one channel " +
1705
+ 'with via. This ' +
1706
+ 'answers "what was shared with me" and "what did X share with me". ' +
1707
+ 'shared_by names whoever performed the share, which is usually someone else ' +
1708
+ 'but is sometimes the signed-in person — a few of their own outbound shares ' +
1709
+ 'do surface. It is NOT a reliable record of what they shared with others, so ' +
1710
+ 'do not answer that question from it alone. ' +
1711
+ 'Files carry the drive_id + item_id pair the other ' +
1712
+ 'file tools accept; email attachments do not (they live in the mailbox) and ' +
1713
+ 'link to the message rather than to the file. Several attachments from one ' +
1714
+ 'email share an email_group number, so cite that message once. ' +
1715
+ 'Acts as the current signed-in person.',
1716
+ inputSchema: {
1717
+ type: 'object',
1718
+ properties: {
1719
+ shared_by: {
1720
+ type: 'string',
1721
+ description: 'Only items shared by this person, e.g. "Jan" or "Jan Van Damme".',
1722
+ },
1723
+ via: {
1724
+ type: 'string',
1725
+ enum: ['email', 'teams', 'link'],
1726
+ description:
1727
+ 'Only items that arrived this way: "email" (attached to a message), ' +
1728
+ '"teams" (pasted into a Teams chat), "link" (a OneDrive or SharePoint ' +
1729
+ 'link). Omit to list every channel.',
1730
+ },
1731
+ limit: {
1732
+ type: 'integer',
1733
+ description: 'How many items to return, 1-25 (default 10).',
1734
+ },
1735
+ },
1736
+ additionalProperties: false,
1737
+ },
1738
+ execute: async (args, { userId }): Promise<GraphSharedFilesResult> => {
1739
+ const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 25)
1740
+ const sharedBy = filterString(args, 'shared_by')
1741
+ const via = parseVia(args.via)
1742
+ // Same inflation rationale as graph_files_recent ($top precedes our row
1743
+ // filter), amplified when a filter will discard most rows — a via filter
1744
+ // over a narrow window would report "no Teams files" when they were merely
1745
+ // outside the slice.
1746
+ const top = sharedBy || via ? 50 : Math.min(limit * 2, 50)
1747
+ let raw: unknown
1748
+ try {
1749
+ raw = await graphFetch(userId, `/me/insights/shared?$top=${top}`, {
1750
+ scopes: ['Sites.Read.All'],
1751
+ })
1752
+ } catch (err) {
1753
+ if (err instanceof GraphAuthRequiredError && err.status === 403) {
1754
+ return {
1755
+ items: [],
1756
+ note:
1757
+ 'Item insights are disabled by tenant policy (or this account lacks ' +
1758
+ 'consent for them) — use graph_files_search with sort="newest" instead.',
1759
+ }
1760
+ }
1761
+ throw err
1762
+ }
1763
+ const needle = sharedBy.toLowerCase()
1764
+ const seen = new Set<string>()
1765
+ const rows = ((raw as { value?: unknown[] })?.value ?? [])
1766
+ .map(shapeSharedInsight)
1767
+ .filter((r): r is GraphSharedFile => r !== null)
1768
+ .filter((r) => !needle || (r.shared_by ?? '').toLowerCase().includes(needle))
1769
+ .filter((r) => !via || r.via === via)
1770
+ // The insights order is empirically newest-first, but nothing contracts it
1771
+ // — no $orderby is sent, and adding one to this surface is unverified (an
1772
+ // unsupported-query-option 400 would take out the whole tool rather than
1773
+ // one field). Sorting the shaped rows makes the "newest first" this tool
1774
+ // advertises true by construction instead of by luck.
1775
+ .sort((a, b) => sharedAt(b) - sharedAt(a))
1776
+ // The same message AND the same filename is one attachment arriving
1777
+ // twice; the newest copy survives because the sort already ran. Done
1778
+ // before the slice so a duplicate never costs a slot.
1779
+ .filter((r) => {
1780
+ const key = `${r.webUrl ?? ''}\0${r.name ?? ''}`
1781
+ if (seen.has(key)) return false
1782
+ seen.add(key)
1783
+ return true
1784
+ })
1785
+ .slice(0, limit)
1786
+
1787
+ // Several attachments from ONE email arrive as several rows carrying the
1788
+ // identical rewritten message URL, so that URL *is* the message key — no
1789
+ // opaque 150-char mailbox id has to enter the shaped row or the prompt.
1790
+ // Only a group of two or more earns an ordinal; a lone attachment needs no
1791
+ // cross-reference. Deliberately NOT collapsed into one row: those really
1792
+ // are different files, and their names are the useful part.
1793
+ const byMessage = new Map<string, GraphSharedFile[]>()
1794
+ for (const r of rows) {
1795
+ if (r.via !== 'email' || !r.webUrl) continue
1796
+ const group = byMessage.get(r.webUrl)
1797
+ if (group) group.push(r)
1798
+ else byMessage.set(r.webUrl, [r])
1799
+ }
1800
+ let ordinal = 0
1801
+ for (const group of byMessage.values()) {
1802
+ if (group.length < 2) continue
1803
+ ordinal += 1
1804
+ for (const r of group) r.email_group = ordinal
1805
+ }
1806
+
1807
+ if (rows.length === 0 && (sharedBy || via)) {
1808
+ const filters = [
1809
+ ...(sharedBy ? [`by "${sharedBy}"`] : []),
1810
+ ...(via ? [`via ${via}`] : []),
1811
+ ].join(' and ')
1812
+ return {
1813
+ items: [],
1814
+ note:
1815
+ `Nothing in the recent sharing activity was shared ${filters}. ` +
1816
+ 'The window covers recent items only — try graph_files_search with ' +
1817
+ 'author for older files.',
1818
+ }
1819
+ }
1820
+ return { items: rows }
1821
+ },
1822
+ })
1823
+
1824
+ registerAppTool({
1825
+ name: 'graph_mail_attachments',
1826
+ namespace: 'graph',
1827
+ description:
1828
+ "List the signed-in person's emails that carry file attachments — what was " +
1829
+ 'SENT (default) or RECEIVED, newest first, with the attachment names. ' +
1830
+ 'Filter to one person and/or a start date. Useful for "what files did I ' +
1831
+ 'send X" — but note it only sees files that travelled through email: ' +
1832
+ 'OneDrive/SharePoint shares made from the Share dialog do not appear in ' +
1833
+ 'sent mail. Returns attachment names and sizes, not their contents. Acts ' +
1834
+ 'as the current signed-in person.',
1835
+ inputSchema: {
1836
+ type: 'object',
1837
+ properties: {
1838
+ person: {
1839
+ type: 'string',
1840
+ description:
1841
+ 'Only exchanges with this person (name or email), e.g. "Thibault". ' +
1842
+ 'Matches recipients for sent mail, the sender for received mail.',
1843
+ },
1844
+ direction: {
1845
+ type: 'string',
1846
+ enum: ['sent', 'received'],
1847
+ description: 'Look in sent mail (default) or received mail.',
1848
+ },
1849
+ since: {
1850
+ type: 'string',
1851
+ description: 'Only messages on/after this date, e.g. 2026-07-01.',
1852
+ },
1853
+ limit: {
1854
+ type: 'integer',
1855
+ description: 'How many messages to return, 1-25 (default 10).',
1856
+ },
1857
+ },
1858
+ additionalProperties: false,
1859
+ },
1860
+ execute: async (args, { userId }): Promise<GraphMailAttachmentsResult> => {
1861
+ const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 25)
1862
+ const direction = args.direction === 'received' ? ('received' as const) : ('sent' as const)
1863
+ const person = filterString(args, 'person')
1864
+
1865
+ // Same reject-don't-drop rule as the search date args: a silently ignored
1866
+ // `since` is a silently wrong answer.
1867
+ let sinceClause = ''
1868
+ if (typeof args.since === 'string' && args.since.trim()) {
1869
+ const d = new Date(args.since.trim())
1870
+ if (Number.isNaN(d.getTime())) {
1871
+ throw new Error(`since must be a date like 2026-07-01 (got "${args.since}").`)
1872
+ }
1873
+ sinceClause = ` and receivedDateTime ge ${d.toISOString().slice(0, 10)}T00:00:00Z`
1874
+ }
1875
+
1876
+ const folder = direction === 'sent' ? 'sentitems' : 'inbox'
1877
+ // The person filter runs app-side (recipient matching in OData is awkward
1878
+ // and unindexed), so the request is inflated and sliced after filtering.
1879
+ const top = person ? 50 : Math.min(limit * 2, 50)
1880
+ // No $orderby: combined with $filter Graph requires the sort property to
1881
+ // lead the filter, and the default order is already newest-first.
1882
+ // Attachments are expanded WITHOUT contentBytes — names and sizes only.
1883
+ const raw = await graphFetch(
1884
+ userId,
1885
+ `/me/mailFolders/${folder}/messages` +
1886
+ `?$filter=hasAttachments eq true${sinceClause}` +
1887
+ `&$select=subject,toRecipients,from,sentDateTime,receivedDateTime,webLink` +
1888
+ `&$expand=attachments($select=name,size,contentType)` +
1889
+ `&$top=${top}`,
1890
+ { scopes: ['Mail.Read'] },
1891
+ )
1892
+
1893
+ const messages = ((raw as { value?: unknown[] })?.value ?? [])
1894
+ .map((m) => ({ raw: m, shaped: shapeAttachmentMessage(m, direction) }))
1895
+ .filter(({ raw: m }) => {
1896
+ if (!person) return true
1897
+ const msg = (m ?? {}) as Record<string, unknown>
1898
+ if (direction === 'received') {
1899
+ const from = ((msg.from ?? {}) as Record<string, unknown>).emailAddress as
1900
+ Record<string, unknown> | undefined
1901
+ return personMatches(person, from?.name, from?.address)
1902
+ }
1903
+ const recips = (Array.isArray(msg.toRecipients) ? msg.toRecipients : []) as Array<
1904
+ Record<string, unknown>
1905
+ >
1906
+ return recips.some((r) => {
1907
+ const ea = (r.emailAddress ?? {}) as Record<string, unknown>
1908
+ return personMatches(person, ea.name, ea.address)
1909
+ })
1910
+ })
1911
+ .map(({ shaped }) => shaped)
1912
+ // Real attachments only: inline images and signature logos also set
1913
+ // hasAttachments, but arrive with isInline — Graph still lists them, so
1914
+ // an empty attachments array can slip through for filtered $selects.
1915
+ .filter((m) => m.attachments.length > 0)
1916
+ .slice(0, limit)
1917
+
1918
+ return { direction, messages }
1919
+ },
1920
+ })
1921
+ }