@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/LICENSE +21 -0
- package/README.md +104 -2
- package/check.mjs +72 -0
- package/core.mjs +239 -0
- package/index.mjs +4 -0
- package/notion.svg +25 -0
- package/package.json +48 -4
- package/protocol.mjs +826 -0
- package/session.mjs +659 -0
- package/transcript.mjs +190 -0
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
|
+
}
|