@tonysbb/magpie-notion 0.0.0-stage → 1.0.6

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/protocol.mjs ADDED
@@ -0,0 +1,826 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { ProviderError } from "./core.mjs"
3
+ import { readNDJSON, transcriptEvents, sessionError } from "./transcript.mjs"
4
+
5
+ const ORIGIN = "https://app.notion.com"
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
7
+ const sessions = new Map()
8
+ const catalogs = new Map()
9
+ const TTL = 30 * 60 * 1000
10
+ const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex")
11
+ const moduleFetch = globalThis.fetch
12
+ const CONTROL = /[\x00-\x1f\x7f]/
13
+ const encoder = new TextEncoder()
14
+ const READ_RETRIES = new Set([
15
+ "getSpaces",
16
+ "getAvailableModels",
17
+ "authValidate",
18
+ "getThreadTranscript",
19
+ "getCreditRateLimitStatus",
20
+ "getAIUsageEligibilityV2",
21
+ ])
22
+ const MAX_READ_RETRIES = 2
23
+ const MAX_BACKWARD_PAGES = 8
24
+ const MAX_ERROR_DETAIL_BYTES = 16 * 1024
25
+ const TRANSCRIPT_PREAMBLE = "Conversation supplied by the client. Follow the system/developer instructions and answer the last user message."
26
+ // These are conservative adapter safety windows, not Notion-published limits.
27
+ // The observed ~51K-token failure boundary contains about 48.7K UTF-8 bytes for
28
+ // dense ASCII, so the byte ceiling stays below that even when tokenizer
29
+ // metadata is inaccurate. CONTEXT_BUDGET remains display-only metadata.
30
+ const CONTEXT_BUDGET = 44_000
31
+ const OUTPUT_BUDGET = 8192
32
+ const CONTEXT_BYTE_BUDGET = 44_000
33
+ const THREAD_BYTE_BUDGET = 44_000
34
+ const TURN_OVERHEAD_BYTES = 256
35
+ const TOKEN_ESTIMATE_BYTES = 3
36
+ let impitClient
37
+ let impitLoading
38
+ let fetchOverride
39
+
40
+ // A stable UUID for Notion's device cookie. The session token selects it, so
41
+ // repeated requests look like one browser without writing a new identifier
42
+ // back into the saved sign-in.
43
+ function deviceId(token) {
44
+ const bytes = createHash("sha256").update(token).digest().subarray(0, 16)
45
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
46
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
47
+ const hex = bytes.toString("hex")
48
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
49
+ }
50
+
51
+ function cookieFields(header) {
52
+ return header.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
53
+ const separator = part.indexOf("=")
54
+ if (separator < 1) return { name: part, value: undefined }
55
+ return { name: part.slice(0, separator).trim(), value: part.slice(separator + 1) }
56
+ })
57
+ }
58
+
59
+ function hasUser(value, activeUserId) {
60
+ if (typeof value !== "string" || !value) return false
61
+ try {
62
+ return decodeURIComponent(value).includes(activeUserId)
63
+ } catch {
64
+ return value.includes(activeUserId)
65
+ }
66
+ }
67
+
68
+ // The web client sends a device cookie and a notion_users cookie naming the
69
+ // active account. Derive the former from token_v2 and add the latter when the
70
+ // sign-in names a user, keeping every other cookie exactly as supplied.
71
+ function normalizeCookie(header, activeUserId) {
72
+ const fields = cookieFields(header)
73
+ if (!fields.some((field) => field.name === "device_id")) {
74
+ const token = fields.find((field) => field.name === "token_v2")?.value
75
+ if (token !== undefined && token !== "") fields.push({ name: "device_id", value: deviceId(token) })
76
+ }
77
+ if (activeUserId && !fields.some((field) => field.name === "notion_users" && hasUser(field.value, activeUserId))) {
78
+ const encoded = encodeURIComponent(JSON.stringify([activeUserId]))
79
+ const existing = fields.find((field) => field.name === "notion_users")
80
+ if (existing) existing.value = encoded
81
+ else fields.push({ name: "notion_users", value: encoded })
82
+ }
83
+ return fields.map((field) => field.value === undefined ? field.name : `${field.name}=${field.value}`).join("; ")
84
+ }
85
+
86
+ async function impit() {
87
+ if (impitClient) return impitClient
88
+ if (!impitLoading) {
89
+ impitLoading = import("impit")
90
+ .then(({ Impit }) => (impitClient = new Impit({ browser: "chrome", followRedirects: false })))
91
+ .catch(() => {
92
+ impitLoading = undefined
93
+ throw new ProviderError(503, "transport_unavailable", "Reinstall the plugin so Magpie can install its impit dependency.")
94
+ })
95
+ }
96
+ return impitLoading
97
+ }
98
+
99
+ async function request(name, url, init) {
100
+ if (fetchOverride) return fetchOverride(url, init)
101
+ // Only thread creation needs Chrome's browser fingerprint. Other calls keep
102
+ // the fetch captured at module load so a host replacing globalThis.fetch
103
+ // cannot silently change their transport.
104
+ if (name !== "createAgentThread") return moduleFetch(url, init)
105
+ const client = await impit()
106
+ try {
107
+ return await client.fetch(url, init)
108
+ } catch (error) {
109
+ // Some impit builds reject the request-level redirect option. The shared
110
+ // client still refuses to follow redirects, so omitting it is safe: a 3xx
111
+ // response reaches call() and becomes an error there.
112
+ if (!/redirect/i.test(String(error?.message ?? ""))) throw error
113
+ const { redirect, ...withoutRedirect } = init
114
+ return client.fetch(url, withoutRedirect)
115
+ }
116
+ }
117
+
118
+ function useFetch(fetch) {
119
+ if (fetch !== undefined && typeof fetch !== "function") throw new TypeError("The Notion fetch override must be a function.")
120
+ fetchOverride = fetch
121
+ }
122
+
123
+ function wait(ms, signal) {
124
+ if (!ms) return Promise.resolve()
125
+ return new Promise((resolve, reject) => {
126
+ const timer = setTimeout(done, ms)
127
+ function done() {
128
+ signal?.removeEventListener("abort", aborted)
129
+ resolve()
130
+ }
131
+ function aborted() {
132
+ clearTimeout(timer)
133
+ signal.removeEventListener("abort", aborted)
134
+ reject(signal.reason)
135
+ }
136
+ if (signal?.aborted) aborted()
137
+ else signal?.addEventListener("abort", aborted, { once: true })
138
+ })
139
+ }
140
+
141
+ // Accept either the token value by itself or a Cookie header the user pasted by
142
+ // mistake. Only token_v2 is kept, so unrelated pasted cookies never reach the
143
+ // upstream request.
144
+ function cookie(auth) {
145
+ const reject = () => new ProviderError(401, "session_required", "请粘贴 Notion 的 token_v2。")
146
+ if (auth?.type !== "api" || typeof auth.key !== "string" || CONTROL.test(auth.key)) throw reject()
147
+ const pasted = auth.key.trim().replace(/^Cookie\s*:\s*/i, "")
148
+ if (!pasted) throw reject()
149
+ const field = cookieFields(pasted).find((entry) => entry.name === "token_v2")
150
+ if (field) {
151
+ const token = field.value?.trim()
152
+ if (!token || CONTROL.test(token)) throw reject()
153
+ return `token_v2=${token}`
154
+ }
155
+ // A Cookie header without token_v2 is not a token, even when it has one field.
156
+ if (pasted.includes("=") || pasted.includes(";")) throw reject()
157
+ return `token_v2=${pasted}`
158
+ }
159
+
160
+ // A field the sign-in left blank is not a value: an unanswered optional prompt
161
+ // is saved as an empty string, and a blank workspace or user must mean
162
+ // "discover it" the same way an absent one does.
163
+ function optional(value) {
164
+ if (value === undefined || value === null) return undefined
165
+ if (typeof value === "string" && !value.trim()) return undefined
166
+ return value
167
+ }
168
+
169
+ // credentials reads the pasted token and the workspace/user metadata where the
170
+ // account carries them. The workspace is not required: an account without one
171
+ // has it resolved from the session itself, so nobody has to type a UUID.
172
+ function credentials(auth) {
173
+ const key = cookie(auth)
174
+ const metadata = auth?.metadata ?? {}
175
+ const spaceId = optional(metadata.spaceId)
176
+ if (spaceId !== undefined && (typeof spaceId !== "string" || !UUID.test(spaceId))) throw new ProviderError(400, "workspace_required", "The Notion workspace id must be a UUID.")
177
+ const activeUserId = optional(metadata.activeUserId)
178
+ if (activeUserId !== undefined && (typeof activeUserId !== "string" || !UUID.test(activeUserId))) throw new ProviderError(400, "invalid_active_user", "The Notion active user id must be a UUID.")
179
+ return { cookie: key, spaceId, activeUserId }
180
+ }
181
+
182
+ async function call(name, data, account, signal) {
183
+ const headers = {
184
+ Accept: name === "listenToThreadTranscript" ? "application/x-ndjson" : "application/json",
185
+ "Content-Type": "application/json",
186
+ Cookie: normalizeCookie(account.cookie, account.activeUserId),
187
+ Origin: ORIGIN,
188
+ "notion-audit-log-platform": "web",
189
+ "Sec-Fetch-Dest": "empty",
190
+ "Sec-Fetch-Mode": "cors",
191
+ "Sec-Fetch-Site": "same-origin",
192
+ }
193
+ if (account.spaceId) headers["x-notion-space-id"] = account.spaceId
194
+ if (account.activeUserId) headers["x-notion-active-user-header"] = account.activeUserId
195
+ for (let attempt = 0; ; attempt++) {
196
+ const response = await request(name, `${ORIGIN}/api/v3/${name}`, {
197
+ method: "POST",
198
+ headers,
199
+ body: JSON.stringify(data), signal, redirect: "error",
200
+ })
201
+ if (response.ok) return response
202
+ const retryAfter = response.headers.get("Retry-After")?.trim()
203
+ const seconds = retryAfter && /^\d+$/.test(retryAfter) ? Number(retryAfter) : undefined
204
+ if (response.status === 400 && ["createAgentThread", "sendEventToAgentThread"].includes(name)) {
205
+ // Read a bounded prefix only to classify the failure locally. The body is
206
+ // never logged or returned because it can contain account diagnostics.
207
+ const detail = await boundedBodyText(response, MAX_ERROR_DETAIL_BYTES)
208
+ const contextExceeded = /(?:context[\s_-]*(?:length|window|limit)|(?:maximum|max)[\s_-]*context|too\s+many\s+tokens|input[\s_-]*(?:too\s+long|length)|prompt[\s_-]*(?:too\s+long|length))/i.test(detail)
209
+ throw contextExceeded
210
+ ? new ProviderError(400, "context_length_exceeded", "The Notion conversation context is too large. Compress earlier tool results, shorten the message, or start a new conversation.")
211
+ : new ProviderError(400, "notion_request_rejected", "Notion rejected the personal-agent request. Compress the conversation or start a new thread and try again.")
212
+ }
213
+ await response.body?.cancel().catch(() => {})
214
+ if (response.status === 429 && READ_RETRIES.has(name) && attempt < MAX_READ_RETRIES && Number.isSafeInteger(seconds) && seconds <= 5) {
215
+ await wait(seconds * 1000, signal)
216
+ continue
217
+ }
218
+ const code = response.status === 401 ? "session_expired" : response.status === 403 ? "workspace_forbidden" : response.status === 429 ? "rate_limited" : "upstream_http_error"
219
+ throw new ProviderError(response.status, code, `Notion AI refused the request (HTTP ${response.status}).`, retryAfter ? { retryAfter } : undefined)
220
+ }
221
+ }
222
+
223
+ async function boundedBodyText(response, limit) {
224
+ if (!response.body?.getReader) return ""
225
+ const reader = response.body.getReader()
226
+ const chunks = []
227
+ let bytes = 0
228
+ try {
229
+ while (bytes < limit) {
230
+ const next = await reader.read()
231
+ if (next.done) break
232
+ const room = limit - bytes
233
+ const chunk = next.value.length > room ? next.value.subarray(0, room) : next.value
234
+ chunks.push(chunk)
235
+ bytes += chunk.length
236
+ if (chunk.length < next.value.length) break
237
+ }
238
+ } catch {
239
+ // Classification falls back to the generic rejection when a body cannot
240
+ // be read; no upstream diagnostic is surfaced either way.
241
+ } finally {
242
+ await reader.cancel().catch(() => {})
243
+ reader.releaseLock()
244
+ }
245
+ const joined = new Uint8Array(bytes)
246
+ let offset = 0
247
+ for (const chunk of chunks) {
248
+ joined.set(chunk, offset)
249
+ offset += chunk.length
250
+ }
251
+ return new TextDecoder().decode(joined)
252
+ }
253
+
254
+ const resolved = new Map()
255
+ const RESOLVED_TTL = 30 * 60 * 1000
256
+
257
+ // workspace asks the session which workspace its AI can be asked in: getSpaces
258
+ // names the ones the user belongs to, and the one that answers the model
259
+ // catalog is the account's. The answer is kept, so the question is asked once
260
+ // per session instead of on every request.
261
+ //
262
+ // One entry of getSpaces belongs to one user and lists that user's workspaces,
263
+ // so the entry's own key is the user those workspaces belong to. The
264
+ // notion_user table beside it can name other members of the same workspaces,
265
+ // and taking the first user named anywhere in the reply would send
266
+ // x-notion-active-user-header naming someone who is not the account.
267
+ async function workspace(read, signal) {
268
+ const key = digest([read.cookie, read.activeUserId])
269
+ const cached = resolved.get(key)
270
+ if (cached && Date.now() - cached.at < RESOLVED_TTL) return cached
271
+ const body = await json("getSpaces", {}, { cookie: read.cookie, activeUserId: read.activeUserId }, signal)
272
+ const owners = new Map()
273
+ const candidates = []
274
+ for (const [entryKey, entry] of Object.entries(body ?? {})) {
275
+ if (!entry || typeof entry !== "object") continue
276
+ const users = Object.keys(entry.notion_user ?? {}).filter((id) => UUID.test(id))
277
+ // The entry's key names its user; only an entry with exactly one listed
278
+ // user can stand in for it, because with several there is no telling which
279
+ // one the session is.
280
+ const owner = UUID.test(entryKey) ? entryKey : users.length === 1 ? users[0] : undefined
281
+ for (const spaceId of Object.keys(entry.space ?? {})) {
282
+ if (!UUID.test(spaceId) || owners.has(spaceId)) continue
283
+ owners.set(spaceId, owner)
284
+ candidates.push(spaceId)
285
+ }
286
+ }
287
+ if (!candidates.length) throw new ProviderError(404, "workspace_not_found", "This Notion session belongs to no workspace the AI can be asked in.")
288
+ let last
289
+ let readable
290
+ for (const spaceId of candidates) {
291
+ // A user the sign-in states is the account it meant; otherwise the entry
292
+ // that lists this workspace names the user it belongs to.
293
+ const user = read.activeUserId ?? owners.get(spaceId)
294
+ const answer = { cookie: read.cookie, spaceId, activeUserId: user }
295
+ try {
296
+ const probe = await call("getAvailableModels", { spaceId }, answer, signal)
297
+ // The answer that names the workspace is the catalog itself, so it is
298
+ // kept rather than asked for again by the request that follows.
299
+ const payload = await probe.json().catch(async () => {
300
+ await probe.body?.cancel().catch(() => {})
301
+ return undefined
302
+ })
303
+ if (!payload || !Array.isArray(payload.models)) {
304
+ await probe.body?.cancel().catch(() => {})
305
+ continue
306
+ }
307
+ const models = keepModels(digest([answer.cookie, spaceId, user]), payload)
308
+ readable ??= answer
309
+ if (!models.length) continue
310
+ while (resolved.size >= 256) resolved.delete(resolved.keys().next().value)
311
+ resolved.set(key, { spaceId, activeUserId: user, at: Date.now() })
312
+ return { spaceId, activeUserId: user, at: Date.now() }
313
+ } catch (error) {
314
+ last = error
315
+ }
316
+ }
317
+ if (readable) {
318
+ while (resolved.size >= 256) resolved.delete(resolved.keys().next().value)
319
+ resolved.set(key, { ...readable, at: Date.now() })
320
+ return { ...readable, at: Date.now() }
321
+ }
322
+ throw last instanceof ProviderError ? last : new ProviderError(502, "workspace_unreadable", "Notion did not answer for any of this session's workspaces.")
323
+ }
324
+
325
+ // accountOf is the account a request runs as, with the workspace resolved from
326
+ // the session when the sign-in did not state one.
327
+ async function accountOf(auth, signal) {
328
+ const read = credentials(auth)
329
+ if (read.spaceId) return read
330
+ const found = await workspace(read, signal)
331
+ return { cookie: read.cookie, spaceId: found.spaceId, activeUserId: read.activeUserId ?? found.activeUserId }
332
+ }
333
+
334
+ async function json(name, data, account, signal) {
335
+ const response = await call(name, data, account, signal)
336
+ try { return await response.json() } catch { throw new ProviderError(502, "invalid_upstream_json", "Notion AI returned invalid JSON.") }
337
+ }
338
+
339
+ function eligible(entry) {
340
+ return typeof entry?.model === "string" && entry.model.trim().length > 0 && typeof entry?.modelMessage === "string" && entry.agentService && !entry.agentService.isDisabled && !entry.isDisabled && !entry.restrictedForPersonalAgent
341
+ }
342
+
343
+ async function catalog(account, signal, refresh = false) {
344
+ const key = digest([account.cookie, account.spaceId, account.activeUserId])
345
+ const cached = catalogs.get(key)
346
+ if (!refresh && cached && Date.now() - cached.at < 60_000) return cached.models
347
+ const response = await json("getAvailableModels", { spaceId: account.spaceId }, account, signal)
348
+ return keepModels(key, response)
349
+ }
350
+
351
+ // keepModels reads the catalog the way the web client shows it — minus what the
352
+ // plan and the workspace don't serve — and keeps it for the next minute.
353
+ function keepModels(key, response) {
354
+ if (!Array.isArray(response.models)) throw new ProviderError(502, "invalid_model_catalog", "Notion AI returned an unsupported model catalog.")
355
+ const models = response.modelSelectionRestricted || response.restrictedGeoHiddenModelModes?.includes("agent_service") ? [] : response.models.filter(eligible)
356
+ while (catalogs.size >= 256) catalogs.delete(catalogs.keys().next().value)
357
+ catalogs.set(key, { models, at: Date.now() })
358
+ return models
359
+ }
360
+
361
+ function modelEntry(entry) {
362
+ const efforts = entry.modelConfiguration?.supportedReasoningEfforts ?? []
363
+ return {
364
+ id: entry.model, providerID: "notion", name: entry.modelMessage,
365
+ api: { id: entry.model, url: "https://notion.invalid/v1", npm: "@ai-sdk/openai-compatible" },
366
+ status: "active", options: {}, headers: {},
367
+ // Magpie reads limit.context as token metadata. The send gate is the
368
+ // independent UTF-8 byte budget below; neither value is a Notion limit.
369
+ limit: { context: CONTEXT_BUDGET, output: OUTPUT_BUDGET },
370
+ capabilities: {
371
+ temperature: false, reasoning: efforts.length > 0, attachment: false, toolcall: false,
372
+ input: { text: true, image: false, audio: false, video: false, pdf: false },
373
+ output: { text: true, image: false, audio: false, video: false, pdf: false }, interleaved: false,
374
+ },
375
+ variants: Object.fromEntries(efforts.filter((effort) => typeof effort === "string").map((effort) => [effort, { reasoningEffort: effort }])),
376
+ }
377
+ }
378
+
379
+ function estimateTokens(value) {
380
+ return Math.ceil(utf8Bytes(value) / TOKEN_ESTIMATE_BYTES)
381
+ }
382
+
383
+ function utf8Bytes(value) {
384
+ return encoder.encode(String(value)).length
385
+ }
386
+
387
+ function formattedMessage(message) {
388
+ return `[${message.role}]\n${message.content}`
389
+ }
390
+
391
+ function transcriptText(messages, omission) {
392
+ if (!omission && messages.length === 1 && messages[0].role === "user") return messages[0].content
393
+ const body = messages.map(formattedMessage)
394
+ if (omission) body.unshift(omission)
395
+ return `${TRANSCRIPT_PREAMBLE}\n\n${body.join("\n\n")}`
396
+ }
397
+
398
+ function omissionText(messages) {
399
+ const formatted = messages.map(formattedMessage)
400
+ const tokens = formatted.reduce((sum, value) => sum + estimateTokens(value), 0)
401
+ const bytes = formatted.reduce((sum, value) => sum + utf8Bytes(value), 0)
402
+ return `[Earlier history omitted / 早期历史已省略]\n${messages.length} earlier messages were omitted to fit the Magpie Notion adapter context budget (${tokens} estimated tokens, ${bytes} UTF-8 bytes).`
403
+ }
404
+
405
+ function contextLengthError(estimated, budget, kind = "history") {
406
+ const subject = kind === "protected"
407
+ ? "The protected system/developer instructions"
408
+ : kind === "latest"
409
+ ? "The latest user message"
410
+ : kind === "combined"
411
+ ? "The protected system/developer instructions and the latest user message together"
412
+ : "The Notion request"
413
+ const guidance = kind === "latest"
414
+ ? "Shorten the latest user message or start a new conversation."
415
+ : kind === "protected"
416
+ ? "Shorten the system/developer instructions or start a new conversation."
417
+ : kind === "combined"
418
+ ? "Shorten the system/developer instructions, shorten the latest user message, or start a new conversation."
419
+ : "Compress earlier tool results, shorten the message, or start a new conversation."
420
+ return new ProviderError(
421
+ 400,
422
+ "context_length_exceeded",
423
+ `${subject} exceeds the adapter context byte budget (${estimated} > ${budget} UTF-8 bytes). ${guidance}`,
424
+ )
425
+ }
426
+
427
+ // contextPlan keeps every system/developer instruction and the latest user
428
+ // message, then fills from the newest history backward in whole messages. The
429
+ // omission note is included in the same budget as the transcript it describes.
430
+ function contextPlan(messages, budget = CONTEXT_BYTE_BUDGET) {
431
+ if (!Array.isArray(messages) || !messages.length || messages.at(-1)?.role !== "user") {
432
+ throw new ProviderError(400, "user_message_required", "The conversation must end with a user message.")
433
+ }
434
+ const fullText = transcriptText(messages)
435
+ const full = { tokens: estimateTokens(fullText), bytes: utf8Bytes(fullText) }
436
+ const protectedIndexes = new Set(messages.flatMap((message, index) => message.role === "system" || message.role === "developer" || index === messages.length - 1 ? [index] : []))
437
+ const candidates = messages.flatMap((_message, index) => protectedIndexes.has(index) ? [] : [index])
438
+
439
+ const render = (historyIndexes, omittedIndexes) => {
440
+ const selected = [...protectedIndexes, ...historyIndexes].sort((a, b) => a - b)
441
+ const omission = omittedIndexes.length ? omissionText(omittedIndexes.map((index) => messages[index])) : undefined
442
+ return transcriptText(selected.map((index) => messages[index]), omission)
443
+ }
444
+
445
+ const baseline = render([], candidates)
446
+ if (utf8Bytes(baseline) > budget) {
447
+ const instructions = messages.filter((message) => message.role === "system" || message.role === "developer")
448
+ const instructionBytes = instructions.length ? utf8Bytes(transcriptText(instructions)) : 0
449
+ const latestBytes = utf8Bytes(transcriptText([messages.at(-1)]))
450
+ if (instructionBytes > budget) throw contextLengthError(instructionBytes, budget, "protected")
451
+ if (latestBytes > budget) throw contextLengthError(latestBytes, budget, "latest")
452
+ throw contextLengthError(utf8Bytes(baseline), budget, "combined")
453
+ }
454
+
455
+ let firstSelected = candidates.length
456
+ for (let index = candidates.length - 1; index >= 0; index--) {
457
+ const trial = render(candidates.slice(index), candidates.slice(0, index))
458
+ if (utf8Bytes(trial) > budget) break
459
+ firstSelected = index
460
+ }
461
+
462
+ const selectedHistory = candidates.slice(firstSelected)
463
+ const omittedIndexes = candidates.slice(0, firstSelected)
464
+ const text = render(selectedHistory, omittedIndexes)
465
+ const estimated = { tokens: estimateTokens(text), bytes: utf8Bytes(text) }
466
+ if (estimated.bytes > budget) throw contextLengthError(estimated.bytes, budget, "history")
467
+ const omittedFormatted = omittedIndexes.map((index) => formattedMessage(messages[index]))
468
+ return {
469
+ text,
470
+ messages: [...protectedIndexes, ...selectedHistory].sort((a, b) => a - b).map((index) => messages[index]),
471
+ full,
472
+ estimated,
473
+ budget,
474
+ fullOverBudget: full.bytes > budget,
475
+ omittedCount: omittedIndexes.length,
476
+ omittedTokens: omittedFormatted.reduce((sum, value) => sum + estimateTokens(value), 0),
477
+ omittedBytes: omittedFormatted.reduce((sum, value) => sum + utf8Bytes(value), 0),
478
+ }
479
+ }
480
+
481
+ function initialText(messages, budget = CONTEXT_BYTE_BUDGET) {
482
+ return contextPlan(messages, budget).text
483
+ }
484
+
485
+ function content(value) {
486
+ return [{ type: "text", text: [[value]] }]
487
+ }
488
+
489
+ function take(key) {
490
+ for (const [entry, value] of sessions) {
491
+ if (Date.now() - value.at > TTL) {
492
+ sessions.delete(entry)
493
+ claims.delete(entry)
494
+ }
495
+ }
496
+ const value = sessions.get(key)
497
+ if (!value || claims.has(key)) return undefined
498
+ claims.add(key)
499
+ return value
500
+ }
501
+
502
+ const claims = new Set()
503
+
504
+ function release(key) {
505
+ claims.delete(key)
506
+ }
507
+
508
+ function forget(key) {
509
+ sessions.delete(key)
510
+ claims.delete(key)
511
+ }
512
+
513
+ function remember(key, threadId, sequence, bounded, sentBytes) {
514
+ while (sessions.size >= 256) sessions.delete(sessions.keys().next().value)
515
+ sessions.set(key, { threadId, sequence, bounded, sentBytes, at: Date.now() })
516
+ }
517
+
518
+ function threadTurnBytes(sentBytes, incoming) {
519
+ if (!Number.isSafeInteger(sentBytes) || sentBytes < 0) return undefined
520
+ const projected = sentBytes + utf8Bytes(incoming) + TURN_OVERHEAD_BYTES
521
+ return projected <= THREAD_BYTE_BUDGET ? projected : undefined
522
+ }
523
+
524
+ function takeMatching(messages, accountId) {
525
+ const incoming = messages.at(-1)?.content
526
+ for (let count = messages.length; count > 0; count--) {
527
+ const key = digest([accountId, messages.slice(0, count)])
528
+ const value = sessions.get(key)
529
+ if (!value?.bounded) continue
530
+ if (!Number.isSafeInteger(value.sentBytes) || value.sentBytes < 0) {
531
+ sessions.delete(key)
532
+ claims.delete(key)
533
+ continue
534
+ }
535
+ if (threadTurnBytes(value.sentBytes, incoming) === undefined || claims.has(key)) continue
536
+ claims.add(key)
537
+ return { key, value }
538
+ }
539
+ return undefined
540
+ }
541
+
542
+ // ---- quota -----------------------------------------------------------------
543
+ // getCreditRateLimitStatus is the allowance card the web client shows: a
544
+ // rolling window and the billing period's, as percentages. Resets are only
545
+ // what the vendor states; a missing stop is unknown, never guessed. A bucket
546
+ // the vendor leaves unexposed (state not_exposed) is unknown too, not zero.
547
+ // getAIUsageEligibilityV2 adds the premium credit balance beside them.
548
+
549
+ const UNITS = { s: ["second", 1], m: ["minute", 60], h: ["hour", 3600], d: ["day", 86400] }
550
+
551
+ function percent(used, limit) {
552
+ const u = typeof used === "number" && Number.isFinite(used) && used >= 0 ? used : undefined
553
+ const l = typeof limit === "number" && Number.isFinite(limit) && limit > 0 ? limit : undefined
554
+ return u === undefined || l === undefined ? undefined : (100 * u) / l
555
+ }
556
+
557
+ function rollingWindow(entry, fallbackReset) {
558
+ const used = percent(entry?.used, entry?.limit)
559
+ if (used === undefined) return undefined
560
+ const label = typeof entry?.window === "string" ? entry.window.trim() : ""
561
+ const match = /^(\d+(?:\.\d+)?)\s*([smhd])$/i.exec(label)
562
+ const [unit, size] = match ? UNITS[match[2].toLowerCase()] : []
563
+ const count = match ? Number(match[1]) : undefined
564
+ const reset = [entry?.resetsInSeconds, fallbackReset].find((value) => typeof value === "number" && Number.isFinite(value) && value >= 0)
565
+ return {
566
+ name: match ? `${match[1]} ${unit}${count === 1 ? "" : "s"}` : label || "Rolling window",
567
+ used,
568
+ ...(count ? { span: count * size } : {}),
569
+ ...(reset !== undefined ? { resetSecs: reset } : {}),
570
+ }
571
+ }
572
+
573
+ function billingWindow(entry) {
574
+ const used = percent(entry?.used, entry?.limit)
575
+ if (used === undefined) return undefined
576
+ const end = typeof entry?.periodEndMs === "number" && Number.isFinite(entry.periodEndMs) && entry.periodEndMs > 0 ? entry.periodEndMs : undefined
577
+ return { name: "Month", used, ...(end !== undefined ? { resetsAt: new Date(end).toISOString() } : {}) }
578
+ }
579
+
580
+ function quotaWindows(data) {
581
+ if (!data || typeof data !== "object" || Array.isArray(data)) return undefined
582
+ if (data.status === "not_applicable") return []
583
+ const resolved = Array.isArray(data.allowances) ? data.allowances.filter((entry) => entry?.state === "resolved") : []
584
+ // A bucket is only skipped when the vendor left it unreadable: a window that
585
+ // does not read falls back to the allowance below it, and one that does reads
586
+ // even when the other does not.
587
+ const windows = [
588
+ (data.window ? rollingWindow(data.window, data.resetsInSeconds) : undefined) ?? rollingWindow(resolved.find((entry) => entry.cadence === "rolling"), data.resetsInSeconds),
589
+ (data.billingPeriodWindow ? billingWindow(data.billingPeriodWindow) : undefined) ?? billingWindow(resolved.find((entry) => entry.cadence === "billing_period")),
590
+ ].filter(Boolean)
591
+ if (windows.length) return windows
592
+ // Buckets the vendor itself marked unexposed are an understood answer - it
593
+ // names what it will not show. Anything else that reads no window is an
594
+ // unreadable response, which is an error to show, never an empty account.
595
+ const withheld = Array.isArray(data.allowances) && data.allowances.length > 0 && data.allowances.every((entry) => entry?.state === "not_exposed")
596
+ return withheld ? [] : undefined
597
+ }
598
+
599
+ const quotaError = (error) =>
600
+ error instanceof ProviderError ? error.message : "The Notion AI quota could not be read."
601
+
602
+ function transcriptPage(body) {
603
+ if (body?.type !== "agent_service_transcript_page" || !Array.isArray(body.patches)) throw new ProviderError(502, "invalid_transcript", "Notion AI returned an unsupported transcript page.")
604
+ return body
605
+ }
606
+
607
+ function crossedBoundary(page, afterSequence) {
608
+ return afterSequence > 0 && page.patches.some((patch) => {
609
+ const sequence = patch.op === "put" ? patch.entity?.sequence : undefined
610
+ return Number.isSafeInteger(sequence) && sequence <= afterSequence
611
+ })
612
+ }
613
+
614
+ async function transcriptPages(account, threadId, afterSequence, signal) {
615
+ const latest = transcriptPage(await json("getThreadTranscript", { spaceId: account.spaceId, threadId, direction: "backward", limit: 10 }, account, signal))
616
+ if (crossedBoundary(latest, afterSequence)) return [latest]
617
+ const pages = [latest]
618
+ const seen = new Set()
619
+ let page = latest
620
+ for (let count = 0; count < MAX_BACKWARD_PAGES; count++) {
621
+ const cursor = page.backward_cursor
622
+ if (typeof cursor !== "string" || !cursor || page.has_more_backward === false || seen.has(cursor)) break
623
+ seen.add(cursor)
624
+ page = transcriptPage(await json("getThreadTranscript", { spaceId: account.spaceId, threadId, direction: "backward", cursor, limit: 10 }, account, signal))
625
+ pages.unshift(page)
626
+ if (crossedBoundary(page, afterSequence)) break
627
+ }
628
+ return pages
629
+ }
630
+
631
+ async function quota({ auth }) {
632
+ const signal = AbortSignal.timeout(30_000)
633
+ let account
634
+ try {
635
+ account = await accountOf(auth, signal)
636
+ } catch (error) {
637
+ return { error: quotaError(error), windows: [], signIn: error?.status === 401 ? "expired" : "kept" }
638
+ }
639
+ let payload
640
+ try {
641
+ payload = await json("getCreditRateLimitStatus", { spaceId: account.spaceId }, account, signal)
642
+ } catch (error) {
643
+ return { error: quotaError(error), windows: [], signIn: error?.status === 401 ? "expired" : "kept" }
644
+ }
645
+ const windows = quotaWindows(payload)
646
+ if (windows === undefined) return { error: "Notion AI returned an unsupported quota response.", windows: [], signIn: "kept" }
647
+ let balance
648
+ try {
649
+ const eligibility = await json("getAIUsageEligibilityV2", { spaceId: account.spaceId }, account, signal)
650
+ const total = eligibility?.premiumCredits?.totalCreditBalance
651
+ if (typeof total === "number" && Number.isFinite(total) && total >= 0) balance = `${total} credits`
652
+ } catch {
653
+ // The premium credit line is optional; its failure must not hide the windows.
654
+ }
655
+ return { windows, ...(balance !== undefined ? { balance } : {}) }
656
+ }
657
+
658
+ export default {
659
+ async models({ auth }) {
660
+ const signal = AbortSignal.timeout(30_000)
661
+ const account = await accountOf(auth, signal)
662
+ const valid = await call("authValidate", {}, account, signal)
663
+ await valid.body?.cancel().catch(() => {})
664
+ const entries = await catalog(account, signal, true)
665
+ return Object.fromEntries(entries.map((entry) => [entry.model, modelEntry(entry)]))
666
+ },
667
+ async open({ auth, chat, signal }) {
668
+ const last = chat.messages?.at(-1)
669
+ if (!last || last.role !== "user") throw new ProviderError(400, "user_message_required", "The conversation must end with a user message.")
670
+ // Preflight the bounded transcript before auth/workspace/catalog access so
671
+ // an oversized latest message cannot consume a cached continuation.
672
+ const plan = contextPlan(chat.messages)
673
+ const account = await accountOf(auth, signal)
674
+ const models = await catalog(account, signal)
675
+ const model = models.find((entry) => entry.model === chat.model)
676
+ if (!model) throw new ProviderError(403, "model_unavailable", "This Notion workspace does not offer the requested model for personal agents. Refresh the model list after signing in.")
677
+ const effort = chat.reasoning_effort ?? model.modelConfiguration?.defaultReasoningEffort
678
+ if (effort !== undefined && !model.modelConfiguration?.supportedReasoningEfforts?.includes(effort)) {
679
+ throw new ProviderError(400, "reasoning_effort_unavailable", "The requested reasoning effort is not offered for this Notion model.")
680
+ }
681
+ const accountId = digest([account.cookie, account.spaceId, account.activeUserId, chat.model, effort])
682
+ const historyKey = (messages) => digest([accountId, messages])
683
+ const previousKey = historyKey(chat.messages.slice(0, -1))
684
+ // Once the complete client history is over budget, do not grow an old
685
+ // Notion thread. Rebuild a bounded recent window in a fresh thread; later
686
+ // oversized turns can slide that window and roll over again safely.
687
+ let previous = take(previousKey)
688
+ let previousEntryKey = previousKey
689
+ let continuedBytes
690
+ if (previous && !Number.isSafeInteger(previous.sentBytes)) {
691
+ forget(previousEntryKey)
692
+ previous = undefined
693
+ }
694
+ if (previous) {
695
+ continuedBytes = threadTurnBytes(previous.sentBytes, last.content)
696
+ if (continuedBytes === undefined) {
697
+ // The remote thread is at its cumulative budget. Never probe or send to
698
+ // it first: rebuild from the bounded client history in a fresh thread.
699
+ forget(previousEntryKey)
700
+ previous = undefined
701
+ }
702
+ }
703
+ if (plan.fullOverBudget) {
704
+ if (previous && !previous.bounded) {
705
+ release(previousEntryKey)
706
+ previous = undefined
707
+ continuedBytes = undefined
708
+ }
709
+ if (!previous) {
710
+ const boundedPrevious = takeMatching(chat.messages, accountId)
711
+ previous = boundedPrevious?.value
712
+ if (boundedPrevious) {
713
+ previousEntryKey = boundedPrevious.key
714
+ continuedBytes = threadTurnBytes(previous.sentBytes, last.content)
715
+ }
716
+ }
717
+ }
718
+ let claimedKey = previous ? previousEntryKey : undefined
719
+ let bounded = previous?.bounded ?? plan.fullOverBudget
720
+ let sentBytes = previous ? continuedBytes : plan.estimated.bytes + TURN_OVERHEAD_BYTES
721
+ let afterSequence = 0
722
+ if (previous) {
723
+ try {
724
+ const before = await json("getThreadTranscript", { spaceId: account.spaceId, threadId: previous.threadId, direction: "backward", limit: 10 }, account, signal)
725
+ // If the user advanced this thread in Notion, the client's history is
726
+ // no longer its history. Start a new thread instead of mixing them.
727
+ if (before.session?.sequence !== previous.sequence) {
728
+ forget(previousEntryKey)
729
+ claimedKey = undefined
730
+ previous = undefined
731
+ } else if (before.session?.status !== "idle") {
732
+ release(previousEntryKey)
733
+ claimedKey = undefined
734
+ previous = undefined
735
+ } else {
736
+ afterSequence = previous.sequence
737
+ await json("sendEventToAgentThread", { spaceId: account.spaceId, threadId: previous.threadId, event: { type: "user.message", content: content(last.content) }, surface: "full_page_chat", clientEventId: randomUUID() }, account, signal)
738
+ forget(previousEntryKey)
739
+ claimedKey = undefined
740
+ }
741
+ } catch (error) {
742
+ if (claimedKey) release(claimedKey)
743
+ throw error
744
+ }
745
+ }
746
+ let threadId
747
+ if (previous) {
748
+ threadId = previous.threadId
749
+ } else {
750
+ threadId = randomUUID()
751
+ const created = await json("createAgentThread", {
752
+ type: "personal_agent", spaceId: account.spaceId, threadId,
753
+ model: chat.model, ...(effort !== undefined ? { reasoningEffort: effort } : {}),
754
+ createdSource: "full_page_chat", content: content(plan.text),
755
+ policies: { approval_mode: "ask", disable_internet_access: true, disable_computer_file_downloads: true },
756
+ agentMemorySettings: { useMemories: false, excludeChatFromMemories: true },
757
+ browserEnabled: false, clientMessageId: randomUUID(),
758
+ }, account, signal)
759
+ if (typeof created.threadId !== "string" || !UUID.test(created.threadId)) throw new ProviderError(502, "unexpected_thread", "Notion AI returned an invalid thread id.")
760
+ threadId = created.threadId
761
+ bounded = plan.fullOverBudget
762
+ sentBytes = plan.estimated.bytes + TURN_OVERHEAD_BYTES
763
+ }
764
+ const pages = await transcriptPages(account, threadId, afterSequence, signal)
765
+ const newest = pages.at(-1)
766
+ const page = pages.length === 1 ? newest : { ...newest, patches: pages.flatMap((entry) => entry.patches) }
767
+ if (afterSequence === 0 || page.session?.sequence > afterSequence || page.session?.last_turn_allowance?.sequence > afterSequence) sessionError(page.session)
768
+ const completedOnPage = page.patches.some((patch) => patch.op === "put" && patch.entity?.kind === "turn_completed" && patch.entity.sequence > afterSequence)
769
+ let liveResponse
770
+ if (!completedOnPage) {
771
+ if (typeof page.forward_cursor !== "string") throw new ProviderError(502, "missing_cursor", "Notion AI did not return a transcript cursor.")
772
+ // Establish the HTTP stream before returning client headers, so an
773
+ // expired session at this endpoint can still be reported as HTTP 401.
774
+ liveResponse = await call("listenToThreadTranscript", { spaceId: account.spaceId, threadId, cursor: page.forward_cursor, includeProvisionalUserMessages: true, includeQueueChanges: false }, account, signal)
775
+ }
776
+ async function* frames() {
777
+ yield page
778
+ if (liveResponse) yield* readNDJSON(liveResponse.body, signal)
779
+ }
780
+ return (async function* () {
781
+ let answer = ""
782
+ try {
783
+ for await (const event of transcriptEvents(frames(), signal, { afterSequence })) {
784
+ if (event.type === "text") answer += event.text
785
+ else if (event.type === "done") {
786
+ const completedBytes = sentBytes + utf8Bytes(answer)
787
+ remember(historyKey([...chat.messages, { role: "assistant", content: answer }]), threadId, event.sequence, bounded, completedBytes)
788
+ }
789
+ yield event
790
+ }
791
+ } finally { await liveResponse?.body?.cancel().catch(() => {}) }
792
+ })()
793
+ },
794
+ usage: quota,
795
+ }
796
+
797
+ export const _internal = {
798
+ cookie,
799
+ credentials,
800
+ accountOf,
801
+ workspace,
802
+ resolved,
803
+ RESOLVED_TTL,
804
+ keepModels,
805
+ initialText,
806
+ contextPlan,
807
+ estimateTokens,
808
+ utf8Bytes,
809
+ transcriptText,
810
+ content,
811
+ sessions,
812
+ catalogs,
813
+ eligible,
814
+ modelEntry,
815
+ threadTurnBytes,
816
+ percent,
817
+ rollingWindow,
818
+ billingWindow,
819
+ quotaWindows,
820
+ useFetch,
821
+ CONTEXT_BUDGET,
822
+ CONTEXT_BYTE_BUDGET,
823
+ THREAD_BYTE_BUDGET,
824
+ TURN_OVERHEAD_BYTES,
825
+ OUTPUT_BUDGET,
826
+ }