@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/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 }
|