@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 resucdueff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,105 @@
1
- # Temporary Holding Version
1
+ # magpie-notion
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 版本:1.0.6
4
+
5
+ `magpie-notion` 是一个把 Notion AI 接入 Magpie / OpenCode 的非官方插件。它使用
6
+ Notion 网页客户端所调用的内部接口,不是 Notion 的公开 Integration API;Notion
7
+ 更新网页端后,插件可能失效。
8
+
9
+ 本仓库是基于上游 `resucdueff/magpie-notion` 修改的维护版本,原始 MIT 许可证和
10
+ 版权声明保留在 `LICENSE` 中。
11
+
12
+ ## 风险与账号建议
13
+
14
+ - 插件调用 Notion 网页端的非官方内部接口。创建对话线程时,它使用 `impit` 模拟
15
+ Chrome 的 TLS/HTTP 指纹。
16
+ - 插件默认不限制同一账号的并发请求;如果 Magpie 中为 Notion 单独设置了并发,
17
+ 则以该设置为准。
18
+ - 上述做法可能违反 Notion 服务条款,并导致账号被限制或封禁。请不要使用重要的
19
+ 工作账号,或任何不能承受异常风险的账号。
20
+ - 每段对话都会在 Notion AI 聊天记录中留下一个线程。系统提示发生变化、切换模型或
21
+ reasoning effort,或插件进程重启后,后续对话可能会新建线程,而不是继续原来的
22
+ 线程;插件会把有界近期上下文重新发送到新线程。
23
+
24
+ 插件图标使用 Notion 官方黑白 N 方块标志,源文件保存在 `notion.svg`,
25
+ 并内嵌到包元数据里。名称与标志属于 Notion;本项目是非官方插件。
26
+
27
+ ## 安装
28
+
29
+ ```sh
30
+ magpie plugin add @tonysbb/magpie-notion
31
+ magpie plugin login notion
32
+ ```
33
+
34
+ 插件发布在公开 npm registry,安装和检查更新不需要 GitHub 登录。源码维护在
35
+ [tonysbb/Magpie-Notion](https://github.com/tonysbb/Magpie-Notion)。
36
+
37
+ Magpie 添加插件时会安装运行依赖。运行时 provider id 仍是 `notion`,插件包名是
38
+ `@tonysbb/magpie-notion`。已经用 Git 地址安装过的版本,需要先移除旧插件,再用上面的
39
+ npm 包名重新添加,之后才能通过 Magpie 的「检查更新」升级。
40
+
41
+ ## 发布
42
+
43
+ 维护者推送与 `package.json` 版本一致的 Git tag 时,GitHub Actions 会把插件发布到
44
+ 公开 npm。例如版本 `1.0.5` 使用 tag `v1.0.5`。发布前需要在 GitHub 仓库的 Actions
45
+ secrets 中设置 `NPM_TOKEN`,该 Token 只授予发布此包所需的最小权限。
46
+
47
+ ## 登录
48
+
49
+ 登录只有一个输入框:粘贴你自己的 Notion `token_v2`。
50
+
51
+ - 可以只粘贴 `token_v2` 的值。
52
+ - 也可以误贴整段含 `token_v2=` 的 Cookie;插件只提取 `token_v2`,不会使用其中
53
+ 的其他 Cookie。
54
+ - 空内容、找不到 `token_v2`、带换行的输入都会被拒绝。
55
+ - 登录失败时提示:`请粘贴 Notion 的 token_v2。`
56
+ - 不打开网页,也不轮询浏览器。
57
+
58
+ 登录不需要填写工作区 UUID。插件会调用 `getSpaces` 自动发现账号可用的工作区,
59
+ 再通过模型目录确认当前工作区。`token_v2` 只交给宿主自己的认证存储保存,不要
60
+ 写进源码、命令参数、日志或提交记录。
61
+
62
+ ### 获取 `token_v2`
63
+
64
+ `token_v2` 是 Notion 网页登录后保存在你自己浏览器里的会话值。只使用你自己的账号。
65
+
66
+ 1. 用浏览器打开 https://www.notion.so 并登录,进入任意工作区。
67
+ 2. 打开开发者工具。Windows 和 Linux 按 F12,macOS 按 Option-Command-I。
68
+ 3. 选择「应用」或 Application,左侧展开 Cookie,点选 `https://www.notion.so`。
69
+ 4. 找到名称正好是 `token_v2` 的一行,只复制它的值;不要复制名称,也不要复制整段 Cookie。
70
+ 5. 回到 Magpie 的 Notion 登录页,把值粘贴进「粘贴 token_v2」。
71
+
72
+ 如果没有「应用」面板,可以打开「网络」或 Network,刷新页面,点开任意一个发往
73
+ `www.notion.so` 的请求,在请求头 Cookie 中找到 `token_v2=`,复制等号后面、下一个
74
+ 分号前面的内容。
75
+
76
+ 这个值等同于登录状态。只粘贴到自己的 Magpie,不要发到聊天、工单或 Git 仓库。
77
+ 失效后按同样的步骤重新复制。
78
+
79
+ 复制时要整段选中。以 `v03:` 开头的值中间有好几段,用点号分开;少了最后一段时 Notion 会直接拒绝。会话被拒绝后,插件只能显示内置的一个占位模型,用量也是空的。请重新复制完整的 `token_v2`,不要把这个值发到聊天里。
80
+
81
+ 每个账号同时发出的请求不限制。如果在 Magpie 里给 Notion 单独设了并发,以那个设置为准。
82
+
83
+ ## 工具调用
84
+
85
+ 插件不执行客户端附带的工具。Codex 等代理每次请求都会带上工具定义;这些定义会被去掉,对话仍按纯文本发给 Notion,并返回文字回答。
86
+
87
+ 已经发生过的工具调用和工具结果会折成文字留在上下文里,Notion 不会再次执行它们。图片仍然拒绝。因此 Notion 模型可以正常对话,但不能替 Codex 运行 shell、改文件或调用其他工具。
88
+
89
+ ## 长上下文
90
+
91
+ 模型目录中的 `limit.context` 是 **44,000 token** 的保守安全元数据窗口,用于告诉
92
+ Magpie 这个适配器的可用上下文规模;这不是 Notion 官方公布的上限,也不等于实际的
93
+ 字节门限。真正发送前另以 **44,000 UTF-8 bytes** 作为不会低估的硬上限,token 估算
94
+ 只用于显示,不参与放行判断。
95
+
96
+ 历史较长时,插件会完整保留 system/developer 指令和最新一条 user 消息,从新到旧
97
+ 整条装入近期消息,并用明确的省略标记记录未发送的旧消息数量、估算 token 和 UTF-8
98
+ 字节数。完整历史首次超过窗口后会改用有界近期历史新建线程,后续轮次优先续写该线程;
99
+ 每个远端线程还会累计已发送的用户消息、assistant 回答和每轮保守协议开销,累计达到
100
+ 44,000 bytes 时不再向其发送,而是用当前客户端历史重新生成本地有界上下文并自动
101
+ 建立新线程。
102
+
103
+ system/developer 指令单独超过硬上限、最新 user 单独超过硬上限,或两者分别未超限
104
+ 但合在一起超限时,请求都会在调用 Notion 前返回 `context_length_exceeded`,错误
105
+ 信息会分别说明原因;请缩短对应内容或新开会话。
package/check.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // Loads this package the way OpenCode and magpie do, then checks the shape of
2
+ // the hooks it returns and the metadata magpie reads from package.json. It
3
+ // signs in to nothing and sends no request.
4
+ import { existsSync, readFileSync } from "node:fs"
5
+
6
+ const here = new URL("./", import.meta.url)
7
+ const pkg = JSON.parse(readFileSync(new URL("package.json", here), "utf8"))
8
+ const problems = []
9
+ const note = (m) => problems.push(m)
10
+
11
+ // package.json: the parts magpie reads when listing and loading a plugin
12
+ if (!/^\d+\.\d+\.\d+(?:[-+].+)?$/.test(String(pkg.version ?? ""))) note("version is not semver")
13
+ const haystack = [pkg.name, ...(pkg.keywords ?? [])].filter(Boolean).join(" ").toLowerCase()
14
+ if (!haystack.includes("opencode")) note("name and keywords do not mention opencode")
15
+ if (!/(auth|plugin|provider)/.test(haystack)) note("name and keywords hold none of auth, plugin, provider")
16
+ if (!Array.isArray(pkg.keywords) || !pkg.keywords.includes("magpie-plugin")) note("keywords should carry magpie-plugin")
17
+ const main = pkg.main ?? "index.mjs"
18
+ if (!existsSync(new URL(main, here))) note(`main ${main} is missing`)
19
+ if (!(pkg.files ?? []).includes(main.replace(/^\.\//, ""))) note(`files does not include ${main}`)
20
+ if (!existsSync(new URL("README.md", here))) note("README.md is missing")
21
+ const icon = pkg.magpie?.icon
22
+ if (typeof icon !== "string" || !/^(https:\/\/|data:image\/)/.test(icon)) note("magpie.icon is not an https: URL or a data:image/ URI")
23
+ else if (Buffer.byteLength(icon) > 1024 * 1024) note("magpie.icon is over 1 MB")
24
+ const concurrency = pkg.magpie?.maxConcurrency
25
+ if (concurrency !== undefined && (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 1000)) {
26
+ note("magpie.maxConcurrency should be a whole number from 1 to 1000")
27
+ }
28
+
29
+ // The plugin itself: one exported factory returning the hooks magpie expects
30
+ try {
31
+ const mod = await import(new URL(main, here).href)
32
+ const fns = Object.values(mod).filter((v) => typeof v === "function")
33
+ if (!fns.length) note("exports no plugin function")
34
+ const client = { app: { log: async () => {} }, auth: { set: async () => {} }, tui: { showToast: async () => {} }, config: { get: async () => ({ data: {} }) } }
35
+ for (const fn of fns) {
36
+ const hooks = await fn({ client, directory: process.cwd(), worktree: process.cwd(), $: undefined, project: {} })
37
+ const auth = hooks?.auth
38
+ if (!auth) {
39
+ note(`${fn.name}: no auth hook`)
40
+ continue
41
+ }
42
+ if (typeof auth.provider !== "string" || !auth.provider) note(`${fn.name}: auth.provider`)
43
+ if (typeof auth.loader !== "function") note(`${fn.name}: auth.loader`)
44
+ if (typeof auth.usage !== "function") note(`${fn.name}: auth.usage is the plan-usage hook`)
45
+ if (!Array.isArray(auth.methods) || !auth.methods.length) note(`${fn.name}: auth.methods`)
46
+ for (const m of auth.methods ?? []) {
47
+ if (!["oauth", "api"].includes(m.type)) note(`${fn.name}: method type ${m.type}`)
48
+ if (!m.label) note(`${fn.name}: a sign-in method has no label`)
49
+ if (m.type === "oauth" && typeof m.authorize !== "function") note(`${fn.name}: ${m.label} has no authorize`)
50
+ }
51
+ if (!hooks.config) note(`${fn.name}: no config hook`)
52
+ else {
53
+ const cfg = { provider: {} }
54
+ await hooks.config(cfg)
55
+ const p = cfg.provider[auth.provider]
56
+ if (!p) note(`${fn.name}: config does not declare ${auth.provider}`)
57
+ else {
58
+ if (!p.npm) note(`${fn.name}: ${auth.provider} has no npm`)
59
+ if (!p.models || !Object.keys(p.models).length) note(`${fn.name}: ${auth.provider} has no models`)
60
+ }
61
+ }
62
+ if (!hooks.provider?.models) note(`${fn.name}: no provider.models hook`)
63
+ }
64
+ } catch (e) {
65
+ note(String(e?.stack ?? e))
66
+ }
67
+
68
+ if (problems.length) {
69
+ console.error(`failed ${pkg.name}\n ${problems.join("\n ")}`)
70
+ process.exit(1)
71
+ }
72
+ console.log(`ok ${pkg.name} ${pkg.version}`)
package/core.mjs ADDED
@@ -0,0 +1,239 @@
1
+ import { randomUUID } from "node:crypto"
2
+
3
+ const ID = "notion"
4
+ const VIRTUAL_API = "https://notion.invalid/v1"
5
+ const encoder = new TextEncoder()
6
+
7
+ export class ProviderError extends Error {
8
+ constructor(status, code, message, options = {}) {
9
+ super(message)
10
+ this.status = status
11
+ this.code = code
12
+ if (status === 401) this.signIn = "expired"
13
+ if (options.retryAfter !== undefined) this.retryAfter = String(options.retryAfter)
14
+ }
15
+ }
16
+
17
+ function publicError(error) {
18
+ if (error instanceof ProviderError) return { message: error.message, type: "notion_error", code: error.code }
19
+ return { message: "Notion AI request failed. No upstream diagnostic or session data was included.", type: "notion_error", code: "upstream_error" }
20
+ }
21
+
22
+ function errorResponse(error) {
23
+ const headers = {}
24
+ if (error?.signIn === "expired") headers["X-Magpie-Sign-In"] = "expired"
25
+ if (error?.retryAfter !== undefined) headers["Retry-After"] = error.retryAfter
26
+ return Response.json({ error: publicError(error) }, {
27
+ status: error instanceof ProviderError ? error.status : 502,
28
+ ...(Object.keys(headers).length ? { headers } : {}),
29
+ })
30
+ }
31
+
32
+ function text(content) {
33
+ if (typeof content === "string") return content
34
+ if (content == null) return ""
35
+ if (Array.isArray(content) && content.every((part) => part?.type === "text" && typeof part.text === "string")) return content.map((part) => part.text).join("")
36
+ throw new ProviderError(400, "unsupported_content", "This Notion adapter currently accepts text messages only.")
37
+ }
38
+
39
+ function callText(call) {
40
+ const name = typeof call?.function?.name === "string" ? call.function.name : (typeof call?.name === "string" ? call.name : "tool")
41
+ const args = call?.function?.arguments ?? call?.arguments
42
+ const rendered = typeof args === "string" ? args : args == null ? "" : JSON.stringify(args)
43
+ return rendered ? name + " " + rendered : name
44
+ }
45
+
46
+ // Client tools are not executed. Agents attach them to every request, so they
47
+ // are removed and any earlier tool result is kept as plain text.
48
+ function parseChat(body) {
49
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new ProviderError(400, "invalid_request", "Expected a JSON chat request.")
50
+ if (typeof body.model !== "string" || !body.model.trim()) throw new ProviderError(400, "invalid_model", "A verified workspace model id is required.")
51
+ if (body.stream !== undefined && typeof body.stream !== "boolean") throw new ProviderError(400, "invalid_stream", "stream must be a boolean.")
52
+ if (body.n !== undefined && body.n !== 1) throw new ProviderError(400, "unsupported_parameter", "Only one completion is supported.")
53
+ if (!Array.isArray(body.messages) || !body.messages.length) throw new ProviderError(400, "invalid_messages", "At least one message is required.")
54
+ const messages = body.messages.map((message) => {
55
+ if (!message || typeof message.role !== "string") throw new ProviderError(400, "unsupported_message", "Only text system, developer, user and assistant messages are supported.")
56
+ if (message.role === "tool" || message.role === "function") {
57
+ const name = typeof message.name === "string" && message.name.trim() ? message.name.trim() : "tool"
58
+ const result = message.content != null && typeof message.content === "object" && !Array.isArray(message.content) ? JSON.stringify(message.content) : text(message.content)
59
+ return { role: "user", content: result ? "[" + name + "]\n" + result : "[" + name + "]" }
60
+ }
61
+ if (!["system", "developer", "user", "assistant"].includes(message.role)) {
62
+ throw new ProviderError(400, "unsupported_message", "Only text system, developer, user and assistant messages are supported.")
63
+ }
64
+ const calls = [...(Array.isArray(message.tool_calls) ? message.tool_calls : []), ...(message.function_call ? [message.function_call] : [])]
65
+ const note = calls.map(callText).filter(Boolean).join("\n")
66
+ const content = text(message.content)
67
+ return { role: message.role, content: content && note ? content + "\n" + note : content || note }
68
+ })
69
+ const { tools, functions, tool_choice, function_call, ...rest } = body
70
+ return { ...rest, messages }
71
+ }
72
+
73
+
74
+ // These events are an adapter contract, not a claim about Notion's wire format.
75
+ // A decoder must yield text deltas and one explicit done; EOF alone is a failure.
76
+ async function* completionChunks(events, model, signal) {
77
+ const id = "chatcmpl-" + randomUUID()
78
+ const created = Math.floor(Date.now() / 1000)
79
+ const chunk = (delta, finish_reason = null) => ({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason }] })
80
+ yield chunk({ role: "assistant", content: "" })
81
+ try {
82
+ for await (const event of events) {
83
+ signal.throwIfAborted()
84
+ if (event?.type === "text" && typeof event.text === "string") {
85
+ if (event.text) yield chunk({ content: event.text })
86
+ } else if (event?.type === "done" && ["stop", "length", "content_filter"].includes(event.finishReason ?? "stop")) {
87
+ yield chunk({}, event.finishReason ?? "stop")
88
+ return
89
+ } else {
90
+ throw new ProviderError(502, "invalid_upstream_event", "Notion AI returned an unsupported response event.")
91
+ }
92
+ }
93
+ } catch (error) {
94
+ if (signal.aborted) throw signal.reason
95
+ throw error
96
+ }
97
+ throw new ProviderError(502, "incomplete_upstream", "Notion AI ended the response without a verified completion event.")
98
+ }
99
+
100
+ function streamResponse(chunks, cancel) {
101
+ const iterator = chunks[Symbol.asyncIterator]()
102
+ const frame = (value) => encoder.encode("data: " + (typeof value === "string" ? value : JSON.stringify(value)) + "\n\n")
103
+ let ended = false
104
+ return new Response(new ReadableStream({
105
+ async pull(controller) {
106
+ if (ended) return
107
+ try {
108
+ const next = await iterator.next()
109
+ if (ended) return
110
+ if (next.done) {
111
+ ended = true
112
+ controller.enqueue(frame("[DONE]"))
113
+ controller.close()
114
+ cancel()
115
+ } else controller.enqueue(frame(next.value))
116
+ } catch (error) {
117
+ if (ended) return
118
+ ended = true
119
+ controller.enqueue(frame({ error: publicError(error) }))
120
+ controller.close()
121
+ cancel()
122
+ }
123
+ },
124
+ async cancel(reason) {
125
+ ended = true
126
+ cancel(reason)
127
+ await iterator.return?.()
128
+ },
129
+ }), { headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" } })
130
+ }
131
+
132
+ // bufferedResponse answers a non-streaming request from the same event
133
+ // contract the streaming path uses: text deltas, then one explicit done whose
134
+ // text is the committed answer. The done text is authoritative, because a
135
+ // provisional message that was streamed can be replaced by a shorter committed
136
+ // one, and nothing must be invented when the stream ends without it.
137
+ async function bufferedResponse(events, model, signal) {
138
+ const id = "chatcmpl-" + randomUUID()
139
+ const created = Math.floor(Date.now() / 1000)
140
+ let content = ""
141
+ let finishReason
142
+ for await (const event of events) {
143
+ signal.throwIfAborted()
144
+ if (event?.type === "text" && typeof event.text === "string") content += event.text
145
+ else if (event?.type === "done" && ["stop", "length", "content_filter"].includes(event.finishReason ?? "stop")) {
146
+ if (typeof event.text === "string") content = event.text
147
+ finishReason = event.finishReason ?? "stop"
148
+ break
149
+ } else {
150
+ throw new ProviderError(502, "invalid_upstream_event", "Notion AI returned an unsupported response event.")
151
+ }
152
+ }
153
+ if (finishReason === undefined) throw new ProviderError(502, "incomplete_upstream", "Notion AI ended the response without a verified completion event.")
154
+ return Response.json({ id, object: "chat.completion", created, model, choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: finishReason }] })
155
+ }
156
+
157
+ function scopedSignal(signal, timeoutMs) {
158
+ const controller = new AbortController()
159
+ const abort = () => controller.abort(new ProviderError(499, "request_cancelled", "The client cancelled the Notion AI request."))
160
+ const timer = setTimeout(() => controller.abort(new ProviderError(504, "request_timeout", "Notion AI did not finish before the deadline.")), timeoutMs)
161
+ if (signal?.aborted) abort()
162
+ else signal?.addEventListener("abort", abort, { once: true })
163
+ return { signal: controller.signal, close: (reason) => {
164
+ clearTimeout(timer)
165
+ signal?.removeEventListener("abort", abort)
166
+ controller.abort(reason)
167
+ } }
168
+ }
169
+
170
+ export function createPlugin(protocol, options = {}) {
171
+ const timeoutMs = options.timeoutMs ?? 900_000
172
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 3_600_000) throw new Error("Notion timeoutMs must be between 1000 and 3600000 milliseconds.")
173
+ return {
174
+ async config(config) {
175
+ config.provider ??= {}
176
+ config.provider[ID] ??= {
177
+ name: "Notion AI",
178
+ npm: "@ai-sdk/openai-compatible",
179
+ api: VIRTUAL_API,
180
+ models: { "omniberry-sundae": { name: "Notion AI(登录后刷新模型列表)", tool_call: false, attachment: false, modalities: { input: ["text"], output: ["text"] } } },
181
+ }
182
+ },
183
+ auth: {
184
+ provider: ID,
185
+ // 0 means Magpie does not limit how many requests one account has in flight.
186
+ maxConcurrency: 0,
187
+ methods: [
188
+ { type: "api", label: "粘贴 token_v2", placeholder: "只粘贴 token_v2" },
189
+ ],
190
+ async loader(getAuth) {
191
+ return {
192
+ baseURL: VIRTUAL_API,
193
+ apiKey: "notion-local-adapter",
194
+ async fetch(input, init = {}) {
195
+ let scope
196
+ try {
197
+ const request = new Request(input, init)
198
+ const url = new URL(request.url)
199
+ if (url.origin !== new URL(VIRTUAL_API).origin || url.pathname !== "/v1/chat/completions" || request.method !== "POST") {
200
+ throw new ProviderError(400, "unsupported_request", "Only chat completions are supported by this adapter.")
201
+ }
202
+ let body
203
+ try { body = await request.json() } catch { throw new ProviderError(400, "invalid_json", "Expected a JSON chat request.") }
204
+ const chat = parseChat(body)
205
+ scope = scopedSignal(request.signal, timeoutMs)
206
+ scope.signal.throwIfAborted()
207
+ const auth = await getAuth()
208
+ const events = await protocol.open({ auth, chat, signal: scope.signal })
209
+ if (chat.stream) return streamResponse(completionChunks(events, chat.model, scope.signal), scope.close)
210
+ try { return await bufferedResponse(events, chat.model, scope.signal) } finally { scope.close() }
211
+ } catch (error) {
212
+ if (scope?.signal.aborted && scope.signal.reason instanceof ProviderError) error = scope.signal.reason
213
+ scope?.close()
214
+ return errorResponse(error)
215
+ }
216
+ },
217
+ }
218
+ },
219
+ // Magpie's usage card: the workspace's rolling and billing allowance
220
+ // plus the premium credit balance. A missing auth or an unreadable
221
+ // response is an error to show, never an empty account.
222
+ async usage(getAuth) {
223
+ try {
224
+ return await protocol.usage({ auth: await getAuth() })
225
+ } catch {
226
+ return { error: "无法读取 Notion AI 额度。", windows: [] }
227
+ }
228
+ },
229
+ },
230
+ provider: {
231
+ id: ID,
232
+ async models(provider, { auth }) {
233
+ return await protocol.models({ provider, auth })
234
+ },
235
+ },
236
+ }
237
+ }
238
+
239
+ export const _internal = { parseChat, completionChunks, streamResponse, bufferedResponse, publicError, scopedSignal, errorResponse }
package/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ import { createPlugin } from "./core.mjs"
2
+ import protocol from "./protocol.mjs"
3
+
4
+ export const NotionPlugin = async (_input, options) => createPlugin(protocol, options)
package/notion.svg ADDED
@@ -0,0 +1,25 @@
1
+ <svg
2
+ width="335"
3
+ height="350"
4
+ viewBox="0 0 335 350"
5
+ fill="none"
6
+ xmlns="http://www.w3.org/2000/svg"
7
+ >
8
+ <g clip-path="url(#clip0_913_11)">
9
+ <path
10
+ d="M21.056 15.0565L214.701 0.796296C238.481 -1.24326 244.599 0.122938 259.545 10.9802L321.359 54.4257C331.558 61.8966 334.958 63.9307 334.958 72.0749V310.358C334.958 325.292 329.518 334.124 310.498 335.475L85.6192 349.054C71.3416 349.735 64.5464 347.701 57.0693 338.19L11.5487 279.13C3.3919 268.259 0 260.126 0 250.61V38.8077C0 26.5954 5.44153 16.4088 21.056 15.0565Z"
11
+ fill="white"
12
+ />
13
+ <path
14
+ fill-rule="evenodd"
15
+ clip-rule="evenodd"
16
+ d="M214.701 0.796296L21.056 15.0565C5.44153 16.4088 0 26.5954 0 38.8077V250.61C0 260.126 3.3919 268.259 11.5487 279.13L57.0693 338.19C64.5464 347.701 71.3416 349.735 85.6192 349.054L310.498 335.475C329.518 334.124 334.958 325.292 334.958 310.358V72.0749C334.958 64.3554 331.904 62.1254 322.899 55.5518C322.404 55.1901 321.891 54.8153 321.359 54.4257L259.545 10.9802C244.599 0.122938 238.481 -1.24326 214.701 0.796296ZM90.718 68.1664C72.3581 69.4062 68.1862 69.6879 57.7599 61.2177L31.2457 40.1737C28.5415 37.4525 29.8978 34.0579 36.6871 33.3847L222.855 19.8061C238.478 18.4455 246.629 23.8852 252.747 28.635L284.678 51.7183C286.038 52.3944 289.429 56.4624 285.351 56.4624L93.0882 68.0071C92.2729 68.0614 91.4837 68.1147 90.719 68.1664L90.718 68.1664ZM69.3031 308.318V106.024C69.3031 97.2006 72.021 93.1245 80.1694 92.4398L300.977 79.5458C308.466 78.867 311.857 83.6223 311.857 92.4398V293.376C311.857 302.208 310.495 309.686 298.261 310.358L86.9643 322.584C74.7361 323.257 69.3031 319.19 69.3031 308.318ZM277.894 116.876C279.249 122.992 277.894 129.102 271.768 129.789L261.586 131.817V281.164C252.747 285.915 244.596 288.631 237.804 288.631C226.929 288.631 224.206 285.234 216.06 275.056L149.467 170.513V271.662L170.539 276.417C170.539 276.417 170.539 288.631 153.538 288.631L106.67 291.349C105.308 288.631 106.67 281.848 111.424 280.488L123.654 277.099V143.362L106.672 142.001C105.311 135.886 108.703 127.068 118.221 126.383L168.501 122.994L237.804 228.898V135.212L220.135 133.184C218.778 125.707 224.206 120.278 231.001 119.605L277.894 116.876Z"
17
+ fill="black"
18
+ />
19
+ </g>
20
+ <defs>
21
+ <clipPath id="clip0_913_11">
22
+ <rect width="335" height="349.948" fill="white" />
23
+ </clipPath>
24
+ </defs>
25
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@tonysbb/magpie-notion",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.6",
4
+ "description": "Magpie / OpenCode 的 Notion AI 插件:登录时只粘贴自己的 token_v2,工作区自动发现。",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": "./index.mjs",
8
+ "license": "MIT",
9
+ "scripts": {
10
+ "check": "node check.mjs",
11
+ "secrets": "node scripts/check-secrets.mjs",
12
+ "smoke:impit": "node --input-type=module -e \"const { Impit } = await import('impit'); new Impit({ browser: 'chrome' })\"",
13
+ "test": "bun test"
14
+ },
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "dependencies": {
19
+ "impit": "0.14.6"
20
+ },
21
+ "keywords": [
22
+ "opencode",
23
+ "opencode-plugin",
24
+ "magpie-plugin",
25
+ "notion",
26
+ "notion-ai"
27
+ ],
28
+ "files": [
29
+ "README.md",
30
+ "check.mjs",
31
+ "core.mjs",
32
+ "index.mjs",
33
+ "protocol.mjs",
34
+ "session.mjs",
35
+ "transcript.mjs",
36
+ "notion.svg"
37
+ ],
38
+ "magpie": {
39
+ "icon": "data:image/svg+xml;base64,PHN2ZwoJd2lkdGg9IjMzNSIKCWhlaWdodD0iMzUwIgoJdmlld0JveD0iMCAwIDMzNSAzNTAiCglmaWxsPSJub25lIgoJeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgo+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF85MTNfMTEpIj4KPHBhdGgKCQkJZD0iTTIxLjA1NiAxNS4wNTY1TDIxNC43MDEgMC43OTYyOTZDMjM4LjQ4MSAtMS4yNDMyNiAyNDQuNTk5IDAuMTIyOTM4IDI1OS41NDUgMTAuOTgwMkwzMjEuMzU5IDU0LjQyNTdDMzMxLjU1OCA2MS44OTY2IDMzNC45NTggNjMuOTMwNyAzMzQuOTU4IDcyLjA3NDlWMzEwLjM1OEMzMzQuOTU4IDMyNS4yOTIgMzI5LjUxOCAzMzQuMTI0IDMxMC40OTggMzM1LjQ3NUw4NS42MTkyIDM0OS4wNTRDNzEuMzQxNiAzNDkuNzM1IDY0LjU0NjQgMzQ3LjcwMSA1Ny4wNjkzIDMzOC4xOUwxMS41NDg3IDI3OS4xM0MzLjM5MTkgMjY4LjI1OSAwIDI2MC4xMjYgMCAyNTAuNjFWMzguODA3N0MwIDI2LjU5NTQgNS40NDE1MyAxNi40MDg4IDIxLjA1NiAxNS4wNTY1WiIKCQkJZmlsbD0id2hpdGUiCgkJLz4KPHBhdGgKCQkJZmlsbC1ydWxlPSJldmVub2RkIgoJCQljbGlwLXJ1bGU9ImV2ZW5vZGQiCgkJCWQ9Ik0yMTQuNzAxIDAuNzk2Mjk2TDIxLjA1NiAxNS4wNTY1QzUuNDQxNTMgMTYuNDA4OCAwIDI2LjU5NTQgMCAzOC44MDc3VjI1MC42MUMwIDI2MC4xMjYgMy4zOTE5IDI2OC4yNTkgMTEuNTQ4NyAyNzkuMTNMNTcuMDY5MyAzMzguMTlDNjQuNTQ2NCAzNDcuNzAxIDcxLjM0MTYgMzQ5LjczNSA4NS42MTkyIDM0OS4wNTRMMzEwLjQ5OCAzMzUuNDc1QzMyOS41MTggMzM0LjEyNCAzMzQuOTU4IDMyNS4yOTIgMzM0Ljk1OCAzMTAuMzU4VjcyLjA3NDlDMzM0Ljk1OCA2NC4zNTU0IDMzMS45MDQgNjIuMTI1NCAzMjIuODk5IDU1LjU1MThDMzIyLjQwNCA1NS4xOTAxIDMyMS44OTEgNTQuODE1MyAzMjEuMzU5IDU0LjQyNTdMMjU5LjU0NSAxMC45ODAyQzI0NC41OTkgMC4xMjI5MzggMjM4LjQ4MSAtMS4yNDMyNiAyMTQuNzAxIDAuNzk2Mjk2Wk05MC43MTggNjguMTY2NEM3Mi4zNTgxIDY5LjQwNjIgNjguMTg2MiA2OS42ODc5IDU3Ljc1OTkgNjEuMjE3N0wzMS4yNDU3IDQwLjE3MzdDMjguNTQxNSAzNy40NTI1IDI5Ljg5NzggMzQuMDU3OSAzNi42ODcxIDMzLjM4NDdMMjIyLjg1NSAxOS44MDYxQzIzOC40NzggMTguNDQ1NSAyNDYuNjI5IDIzLjg4NTIgMjUyLjc0NyAyOC42MzVMMjg0LjY3OCA1MS43MTgzQzI4Ni4wMzggNTIuMzk0NCAyODkuNDI5IDU2LjQ2MjQgMjg1LjM1MSA1Ni40NjI0TDkzLjA4ODIgNjguMDA3MUM5Mi4yNzI5IDY4LjA2MTQgOTEuNDgzNyA2OC4xMTQ3IDkwLjcxOSA2OC4xNjY0TDkwLjcxOCA2OC4xNjY0Wk02OS4zMDMxIDMwOC4zMThWMTA2LjAyNEM2OS4zMDMxIDk3LjIwMDYgNzIuMDIxIDkzLjEyNDUgODAuMTY5NCA5Mi40Mzk4TDMwMC45NzcgNzkuNTQ1OEMzMDguNDY2IDc4Ljg2NyAzMTEuODU3IDgzLjYyMjMgMzExLjg1NyA5Mi40Mzk4VjI5My4zNzZDMzExLjg1NyAzMDIuMjA4IDMxMC40OTUgMzA5LjY4NiAyOTguMjYxIDMxMC4zNThMODYuOTY0MyAzMjIuNTg0Qzc0LjczNjEgMzIzLjI1NyA2OS4zMDMxIDMxOS4xOSA2OS4zMDMxIDMwOC4zMThaTTI3Ny44OTQgMTE2Ljg3NkMyNzkuMjQ5IDEyMi45OTIgMjc3Ljg5NCAxMjkuMTAyIDI3MS43NjggMTI5Ljc4OUwyNjEuNTg2IDEzMS44MTdWMjgxLjE2NEMyNTIuNzQ3IDI4NS45MTUgMjQ0LjU5NiAyODguNjMxIDIzNy44MDQgMjg4LjYzMUMyMjYuOTI5IDI4OC42MzEgMjI0LjIwNiAyODUuMjM0IDIxNi4wNiAyNzUuMDU2TDE0OS40NjcgMTcwLjUxM1YyNzEuNjYyTDE3MC41MzkgMjc2LjQxN0MxNzAuNTM5IDI3Ni40MTcgMTcwLjUzOSAyODguNjMxIDE1My41MzggMjg4LjYzMUwxMDYuNjcgMjkxLjM0OUMxMDUuMzA4IDI4OC42MzEgMTA2LjY3IDI4MS44NDggMTExLjQyNCAyODAuNDg4TDEyMy42NTQgMjc3LjA5OVYxNDMuMzYyTDEwNi42NzIgMTQyLjAwMUMxMDUuMzExIDEzNS44ODYgMTA4LjcwMyAxMjcuMDY4IDExOC4yMjEgMTI2LjM4M0wxNjguNTAxIDEyMi45OTRMMjM3LjgwNCAyMjguODk4VjEzNS4yMTJMMjIwLjEzNSAxMzMuMTg0QzIxOC43NzggMTI1LjcwNyAyMjQuMjA2IDEyMC4yNzggMjMxLjAwMSAxMTkuNjA1TDI3Ny44OTQgMTE2Ljg3NloiCgkJCWZpbGw9ImJsYWNrIgoJCS8+CjwvZz4KPGRlZnM+CjxjbGlwUGF0aCBpZD0iY2xpcDBfOTEzXzExIj4KPHJlY3Qgd2lkdGg9IjMzNSIgaGVpZ2h0PSIzNDkuOTQ4IiBmaWxsPSJ3aGl0ZSIgLz4KPC9jbGlwUGF0aD4KPC9kZWZzPgo8L3N2Zz4K"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/tonysbb/Magpie-Notion.git"
44
+ },
45
+ "homepage": "https://github.com/tonysbb/Magpie-Notion",
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "registry": "https://registry.npmjs.org/"
49
+ }
50
+ }