@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.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/app-tools/registry.ts +185 -0
- package/graph/graph-auth.ts +29 -0
- package/graph/graph-tools.server.ts +1921 -0
- package/index.ts +40 -0
- package/mcp-catalog.ts +149 -0
- package/neo4j/client.ts +112 -0
- package/neo4j/graph-edit.server.ts +127 -0
- package/neo4j/index.ts +34 -0
- package/neo4j/plain.ts +153 -0
- package/neo4j/queries.ts +369 -0
- package/neo4j/transform.ts +437 -0
- package/package.json +66 -0
|
@@ -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
|
+
}
|