@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/transcript.mjs ADDED
@@ -0,0 +1,190 @@
1
+ import { ProviderError } from "./core.mjs"
2
+
3
+ function invalid() {
4
+ return new ProviderError(502, "invalid_transcript", "Notion AI returned an unsupported transcript update.")
5
+ }
6
+
7
+ function messageText(entity) {
8
+ if (!Array.isArray(entity.content)) throw invalid()
9
+ return entity.content.filter((part) => part?.type === "text").map((part) => {
10
+ if (typeof part.text !== "string") throw invalid()
11
+ return part.text
12
+ }).join("")
13
+ }
14
+
15
+ function append(entity, operation) {
16
+ if (operation.op !== "append" || typeof operation.value !== "string") throw invalid()
17
+ const match = /^\/content\/(\d+)\/text$/.exec(operation.path)
18
+ if (!match) throw invalid()
19
+ const part = entity.content?.[Number(match[1])]
20
+ if (part?.type !== "text" || typeof part.text !== "string") throw invalid()
21
+ part.text += operation.value
22
+ return operation.value
23
+ }
24
+
25
+ // One frame of its own may not be larger than this. The limit is per frame and
26
+ // not per network read: a reader may hand over a whole batch of small frames at
27
+ // once, which used to be refused as one oversized frame.
28
+ const MAX_FRAME = 4 * 1024 * 1024
29
+
30
+ function tooLarge() {
31
+ return new ProviderError(502, "transcript_frame_too_large", "Notion AI returned an oversized transcript frame.")
32
+ }
33
+
34
+ function frameLine(raw) {
35
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw
36
+ if (line.length > MAX_FRAME) throw tooLarge()
37
+ return line.trim()
38
+ }
39
+
40
+ export function sessionError(session) {
41
+ const allowance = session?.last_turn_allowance
42
+ if (allowance?.notion_exhausted === true) throw new ProviderError(402, "credits_exhausted", "The Notion workspace has exhausted its Notion AI credits.")
43
+ if (allowance?.chatgpt_exhausted === true) throw new ProviderError(402, "connected_plan_exhausted", "The connected ChatGPT plan's allowance is exhausted.")
44
+ if (session?.pending_input) throw new ProviderError(409, "notion_action_required", "The Notion agent requires approval or input in the Notion client.")
45
+ }
46
+
47
+ export async function* readNDJSON(body, signal) {
48
+ if (!body) throw invalid()
49
+ const reader = body.getReader()
50
+ const abort = () => { void reader.cancel(signal.reason).catch(() => {}) }
51
+ if (signal?.aborted) abort()
52
+ else signal?.addEventListener("abort", abort, { once: true })
53
+ const decoder = new TextDecoder()
54
+ let pending = ""
55
+ try {
56
+ while (true) {
57
+ signal?.throwIfAborted()
58
+ const next = await reader.read()
59
+ signal?.throwIfAborted()
60
+ if (next.done) break
61
+ pending += decoder.decode(next.value, { stream: true })
62
+ let newline
63
+ let start = 0
64
+ while ((newline = pending.indexOf("\n", start)) >= 0) {
65
+ const line = frameLine(pending.slice(start, newline))
66
+ start = newline + 1
67
+ if (!line) continue
68
+ try { yield JSON.parse(line) } catch (error) {
69
+ if (error instanceof SyntaxError) throw invalid()
70
+ throw error
71
+ }
72
+ }
73
+ pending = start ? pending.slice(start) : pending
74
+ // A frame that never ends must not grow without bound either.
75
+ // A trailing CR may be the first half of a CRLF delimiter.
76
+ if (pending.length > MAX_FRAME + (pending.endsWith("\r") ? 1 : 0)) throw tooLarge()
77
+ }
78
+ pending += decoder.decode()
79
+ const line = frameLine(pending)
80
+ if (line) {
81
+ let value
82
+ try { value = JSON.parse(line) } catch { throw invalid() }
83
+ yield value
84
+ }
85
+ } finally {
86
+ signal?.removeEventListener("abort", abort)
87
+ await reader.cancel().catch(() => {})
88
+ reader.releaseLock()
89
+ }
90
+ }
91
+
92
+ // Real Notion transcript deltas replace provisional messages with committed
93
+ // entities. Apply the whole patch batch before comparing the visible text.
94
+ export async function* transcriptEvents(frames, signal, { afterSequence = 0 } = {}) {
95
+ const entities = new Map()
96
+ let emitted = ""
97
+ // The visible text is kept in step with the frames instead of being rebuilt
98
+ // from every entity each time: an answer arrives as thousands of appended
99
+ // fragments, and re-joining the whole message for each one of them is
100
+ // quadratic work for a linear change. A put, a remove, or an append to a
101
+ // message that is not the last one falls back to a rebuild, and `aligned`
102
+ // records whether what has been sent is still exactly the visible text.
103
+ let visible = ""
104
+ let tail
105
+ let appended = null
106
+ let rebuild = true
107
+ let aligned = true
108
+ const render = () => {
109
+ const messages = [...entities.values()].filter((entity) => entity.kind === "assistant_message").sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
110
+ visible = messages.map(messageText).join("")
111
+ tail = messages.at(-1)
112
+ }
113
+ for await (const frame of frames) {
114
+ signal?.throwIfAborted()
115
+ if (!["agent_service_transcript_delta", "agent_service_transcript_page"].includes(frame?.type)) continue
116
+ if (!Array.isArray(frame.patches)) throw invalid()
117
+ const checkSession = (session) => {
118
+ if (afterSequence === 0 || session?.sequence > afterSequence || session?.last_turn_allowance?.sequence > afterSequence) sessionError(session)
119
+ }
120
+ checkSession(frame.session)
121
+ let completed
122
+ appended = null
123
+ for (const patch of frame.patches) {
124
+ if (patch.op === "put") {
125
+ const entity = patch.entity
126
+ if (!entity || typeof entity.id !== "string") throw invalid()
127
+ if (entity.kind === "turn_completed" && (!Number.isSafeInteger(entity.sequence) || entity.sequence <= 0 || (entity.source !== undefined && entity.source !== "committed"))) throw invalid()
128
+ if (entity.sequence <= afterSequence) continue
129
+ entities.set(entity.id, structuredClone(entity))
130
+ rebuild = true
131
+ if (entity.kind === "error") throw new ProviderError(502, "notion_agent_error", "The Notion agent reported an error. Open its thread in Notion for details.")
132
+ if (entity.kind === "client_update_required") throw new ProviderError(502, "notion_client_update_required", "Notion requires a newer client protocol.")
133
+ if (entity.kind === "status" && entity.status === "per_user_connection_required") throw new ProviderError(409, "connection_required", "Complete the requested account connection in the Notion client.")
134
+ if (entity.kind === "turn_completed") completed = entity
135
+ } else if (patch.op === "remove") {
136
+ if (entities.delete(patch.id)) rebuild = true
137
+ } else if (patch.op === "patch") {
138
+ const entity = entities.get(patch.id)
139
+ if (entity?.kind !== "assistant_message") continue
140
+ if (!Array.isArray(patch.ops)) throw invalid()
141
+ let added = ""
142
+ for (const operation of patch.ops) added += append(entity, operation)
143
+ // Multipart edits can insert before text already sent to the client.
144
+ if (entity === tail && entity.content.length === 1) appended = (appended ?? "") + added
145
+ else rebuild = true
146
+ } else if (["session_status", "session"].includes(patch.op)) checkSession(patch.session)
147
+ else if (patch.op !== "committed") throw invalid()
148
+ }
149
+ if (rebuild) {
150
+ render()
151
+ appended = null
152
+ rebuild = false
153
+ }
154
+ if (appended !== null && aligned) {
155
+ if (appended) {
156
+ emitted += appended
157
+ visible = emitted
158
+ yield { type: "text", text: appended }
159
+ }
160
+ } else if (visible.startsWith(emitted)) {
161
+ const delta = visible.slice(emitted.length)
162
+ emitted = visible
163
+ aligned = true
164
+ if (delta) yield { type: "text", text: delta }
165
+ } else if (!emitted.startsWith(visible)) {
166
+ throw new ProviderError(502, "transcript_rewritten", "Notion AI rewrote text already sent to the client.")
167
+ } else {
168
+ // A committed message can be shorter than provisional text that was
169
+ // already sent. That text cannot be taken back, so it stands, and the next
170
+ // append continues from the committed one.
171
+ aligned = false
172
+ }
173
+ if (completed) {
174
+ if (completed.stop_reason === "requires_action") throw new ProviderError(409, "notion_action_required", "The Notion agent requires approval or input in the Notion client.")
175
+ if (["runtime_error", "retries_exhausted"].includes(completed.stop_reason)) throw new ProviderError(502, "notion_agent_error", "The Notion agent could not complete this turn.")
176
+ if (completed.stop_reason !== "end_turn") {
177
+ throw new ProviderError(502, "unsupported_stop_reason", "Notion AI ended with an unverified stop reason.")
178
+ }
179
+ if (!visible) throw new ProviderError(502, "empty_answer", "Notion AI completed the turn without any text to send.")
180
+ // Streaming text cannot be taken back. Report exactly what was yielded,
181
+ // so buffered clients, streaming clients and the next-turn cache all see
182
+ // the same assistant message.
183
+ yield { type: "done", finishReason: "stop", sequence: completed.sequence, text: emitted }
184
+ return
185
+ }
186
+ }
187
+ throw new ProviderError(502, "incomplete_upstream", "Notion AI ended the transcript before turn completion.")
188
+ }
189
+
190
+ export const _internal = { append, messageText, sessionError }