@open-everywhere/provider-notion 0.1.0

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.
Files changed (186) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +48 -0
  4. package/agent/channels/notion-acting-identity.ts +88 -0
  5. package/agent/channels/notion-ancestry-trust.ts +458 -0
  6. package/agent/channels/notion-api.ts +1290 -0
  7. package/agent/channels/notion-bot-user.ts +250 -0
  8. package/agent/channels/notion-catch-up.ts +339 -0
  9. package/agent/channels/notion-credentials.ts +417 -0
  10. package/agent/channels/notion-engagement.ts +372 -0
  11. package/agent/channels/notion-event-normalizer.ts +587 -0
  12. package/agent/channels/notion-identity.ts +271 -0
  13. package/agent/channels/notion-ingress-drain.ts +289 -0
  14. package/agent/channels/notion-ingress.ts +182 -0
  15. package/agent/channels/notion-message-ops.ts +140 -0
  16. package/agent/channels/notion-origin-codec.ts +38 -0
  17. package/agent/channels/notion-post-arm.ts +189 -0
  18. package/agent/channels/notion-question-return.ts +223 -0
  19. package/agent/channels/notion-read.ts +283 -0
  20. package/agent/channels/notion-scope.ts +560 -0
  21. package/agent/channels/notion-stop.ts +169 -0
  22. package/agent/channels/notion-supervisor.ts +856 -0
  23. package/agent/channels/notion-webhook-timing.ts +131 -0
  24. package/agent/channels/notion-webhook-verifier.ts +55 -0
  25. package/agent/channels/notion-webhook.ts +963 -0
  26. package/agent/channels/notion-work-search.ts +74 -0
  27. package/agent/channels/notion-work.ts +144 -0
  28. package/agent/channels/notion.ts +1242 -0
  29. package/agent/contributions.ts +39 -0
  30. package/agent/docs-generated.ts +15 -0
  31. package/agent/memory/blocks.ts +295 -0
  32. package/agent/memory/memory.ts +622 -0
  33. package/agent/provider-events.ts +198 -0
  34. package/agent/register.ts +144 -0
  35. package/agent/schedules/notion-ingress.ts +20 -0
  36. package/agent/surface-parity.ts +105 -0
  37. package/agent/tools/notion_search.ts +110 -0
  38. package/agent/tools/read_notion_comments.ts +250 -0
  39. package/agent/tools/read_notion_page.ts +351 -0
  40. package/dist/agent/channels/notion-acting-identity.d.ts +23 -0
  41. package/dist/agent/channels/notion-acting-identity.d.ts.map +1 -0
  42. package/dist/agent/channels/notion-acting-identity.js +85 -0
  43. package/dist/agent/channels/notion-acting-identity.js.map +1 -0
  44. package/dist/agent/channels/notion-ancestry-trust.d.ts +208 -0
  45. package/dist/agent/channels/notion-ancestry-trust.d.ts.map +1 -0
  46. package/dist/agent/channels/notion-ancestry-trust.js +331 -0
  47. package/dist/agent/channels/notion-ancestry-trust.js.map +1 -0
  48. package/dist/agent/channels/notion-api.d.ts +655 -0
  49. package/dist/agent/channels/notion-api.d.ts.map +1 -0
  50. package/dist/agent/channels/notion-api.js +938 -0
  51. package/dist/agent/channels/notion-api.js.map +1 -0
  52. package/dist/agent/channels/notion-bot-user.d.ts +107 -0
  53. package/dist/agent/channels/notion-bot-user.d.ts.map +1 -0
  54. package/dist/agent/channels/notion-bot-user.js +192 -0
  55. package/dist/agent/channels/notion-bot-user.js.map +1 -0
  56. package/dist/agent/channels/notion-catch-up.d.ts +64 -0
  57. package/dist/agent/channels/notion-catch-up.d.ts.map +1 -0
  58. package/dist/agent/channels/notion-catch-up.js +289 -0
  59. package/dist/agent/channels/notion-catch-up.js.map +1 -0
  60. package/dist/agent/channels/notion-credentials.d.ts +210 -0
  61. package/dist/agent/channels/notion-credentials.d.ts.map +1 -0
  62. package/dist/agent/channels/notion-credentials.js +360 -0
  63. package/dist/agent/channels/notion-credentials.js.map +1 -0
  64. package/dist/agent/channels/notion-engagement.d.ts +219 -0
  65. package/dist/agent/channels/notion-engagement.d.ts.map +1 -0
  66. package/dist/agent/channels/notion-engagement.js +283 -0
  67. package/dist/agent/channels/notion-engagement.js.map +1 -0
  68. package/dist/agent/channels/notion-event-normalizer.d.ts +194 -0
  69. package/dist/agent/channels/notion-event-normalizer.d.ts.map +1 -0
  70. package/dist/agent/channels/notion-event-normalizer.js +542 -0
  71. package/dist/agent/channels/notion-event-normalizer.js.map +1 -0
  72. package/dist/agent/channels/notion-identity.d.ts +119 -0
  73. package/dist/agent/channels/notion-identity.d.ts.map +1 -0
  74. package/dist/agent/channels/notion-identity.js +205 -0
  75. package/dist/agent/channels/notion-identity.js.map +1 -0
  76. package/dist/agent/channels/notion-ingress-drain.d.ts +66 -0
  77. package/dist/agent/channels/notion-ingress-drain.d.ts.map +1 -0
  78. package/dist/agent/channels/notion-ingress-drain.js +185 -0
  79. package/dist/agent/channels/notion-ingress-drain.js.map +1 -0
  80. package/dist/agent/channels/notion-ingress.d.ts +64 -0
  81. package/dist/agent/channels/notion-ingress.d.ts.map +1 -0
  82. package/dist/agent/channels/notion-ingress.js +130 -0
  83. package/dist/agent/channels/notion-ingress.js.map +1 -0
  84. package/dist/agent/channels/notion-message-ops.d.ts +3 -0
  85. package/dist/agent/channels/notion-message-ops.d.ts.map +1 -0
  86. package/dist/agent/channels/notion-message-ops.js +126 -0
  87. package/dist/agent/channels/notion-message-ops.js.map +1 -0
  88. package/dist/agent/channels/notion-origin-codec.d.ts +7 -0
  89. package/dist/agent/channels/notion-origin-codec.d.ts.map +1 -0
  90. package/dist/agent/channels/notion-origin-codec.js +32 -0
  91. package/dist/agent/channels/notion-origin-codec.js.map +1 -0
  92. package/dist/agent/channels/notion-post-arm.d.ts +3 -0
  93. package/dist/agent/channels/notion-post-arm.d.ts.map +1 -0
  94. package/dist/agent/channels/notion-post-arm.js +185 -0
  95. package/dist/agent/channels/notion-post-arm.js.map +1 -0
  96. package/dist/agent/channels/notion-question-return.d.ts +77 -0
  97. package/dist/agent/channels/notion-question-return.d.ts.map +1 -0
  98. package/dist/agent/channels/notion-question-return.js +183 -0
  99. package/dist/agent/channels/notion-question-return.js.map +1 -0
  100. package/dist/agent/channels/notion-read.d.ts +62 -0
  101. package/dist/agent/channels/notion-read.d.ts.map +1 -0
  102. package/dist/agent/channels/notion-read.js +257 -0
  103. package/dist/agent/channels/notion-read.js.map +1 -0
  104. package/dist/agent/channels/notion-scope.d.ts +188 -0
  105. package/dist/agent/channels/notion-scope.d.ts.map +1 -0
  106. package/dist/agent/channels/notion-scope.js +433 -0
  107. package/dist/agent/channels/notion-scope.js.map +1 -0
  108. package/dist/agent/channels/notion-stop.d.ts +48 -0
  109. package/dist/agent/channels/notion-stop.d.ts.map +1 -0
  110. package/dist/agent/channels/notion-stop.js +141 -0
  111. package/dist/agent/channels/notion-stop.js.map +1 -0
  112. package/dist/agent/channels/notion-supervisor.d.ts +462 -0
  113. package/dist/agent/channels/notion-supervisor.d.ts.map +1 -0
  114. package/dist/agent/channels/notion-supervisor.js +482 -0
  115. package/dist/agent/channels/notion-supervisor.js.map +1 -0
  116. package/dist/agent/channels/notion-webhook-timing.d.ts +35 -0
  117. package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -0
  118. package/dist/agent/channels/notion-webhook-timing.js +99 -0
  119. package/dist/agent/channels/notion-webhook-timing.js.map +1 -0
  120. package/dist/agent/channels/notion-webhook-verifier.d.ts +5 -0
  121. package/dist/agent/channels/notion-webhook-verifier.d.ts.map +1 -0
  122. package/dist/agent/channels/notion-webhook-verifier.js +53 -0
  123. package/dist/agent/channels/notion-webhook-verifier.js.map +1 -0
  124. package/dist/agent/channels/notion-webhook.d.ts +242 -0
  125. package/dist/agent/channels/notion-webhook.d.ts.map +1 -0
  126. package/dist/agent/channels/notion-webhook.js +675 -0
  127. package/dist/agent/channels/notion-webhook.js.map +1 -0
  128. package/dist/agent/channels/notion-work-search.d.ts +9 -0
  129. package/dist/agent/channels/notion-work-search.d.ts.map +1 -0
  130. package/dist/agent/channels/notion-work-search.js +69 -0
  131. package/dist/agent/channels/notion-work-search.js.map +1 -0
  132. package/dist/agent/channels/notion-work.d.ts +68 -0
  133. package/dist/agent/channels/notion-work.d.ts.map +1 -0
  134. package/dist/agent/channels/notion-work.js +126 -0
  135. package/dist/agent/channels/notion-work.js.map +1 -0
  136. package/dist/agent/channels/notion.d.ts +95 -0
  137. package/dist/agent/channels/notion.d.ts.map +1 -0
  138. package/dist/agent/channels/notion.js +1081 -0
  139. package/dist/agent/channels/notion.js.map +1 -0
  140. package/dist/agent/contributions.d.ts +32 -0
  141. package/dist/agent/contributions.d.ts.map +1 -0
  142. package/dist/agent/contributions.js +37 -0
  143. package/dist/agent/contributions.js.map +1 -0
  144. package/dist/agent/docs-generated.d.ts +3 -0
  145. package/dist/agent/docs-generated.d.ts.map +1 -0
  146. package/dist/agent/docs-generated.js +13 -0
  147. package/dist/agent/docs-generated.js.map +1 -0
  148. package/dist/agent/memory/blocks.d.ts +58 -0
  149. package/dist/agent/memory/blocks.d.ts.map +1 -0
  150. package/dist/agent/memory/blocks.js +264 -0
  151. package/dist/agent/memory/blocks.js.map +1 -0
  152. package/dist/agent/memory/memory.d.ts +157 -0
  153. package/dist/agent/memory/memory.d.ts.map +1 -0
  154. package/dist/agent/memory/memory.js +471 -0
  155. package/dist/agent/memory/memory.js.map +1 -0
  156. package/dist/agent/provider-events.d.ts +3 -0
  157. package/dist/agent/provider-events.d.ts.map +1 -0
  158. package/dist/agent/provider-events.js +196 -0
  159. package/dist/agent/provider-events.js.map +1 -0
  160. package/dist/agent/register.d.ts +5 -0
  161. package/dist/agent/register.d.ts.map +1 -0
  162. package/dist/agent/register.js +128 -0
  163. package/dist/agent/register.js.map +1 -0
  164. package/dist/agent/schedules/notion-ingress.d.ts +6 -0
  165. package/dist/agent/schedules/notion-ingress.d.ts.map +1 -0
  166. package/dist/agent/schedules/notion-ingress.js +16 -0
  167. package/dist/agent/schedules/notion-ingress.js.map +1 -0
  168. package/dist/agent/surface-parity.d.ts +3 -0
  169. package/dist/agent/surface-parity.d.ts.map +1 -0
  170. package/dist/agent/surface-parity.js +99 -0
  171. package/dist/agent/surface-parity.js.map +1 -0
  172. package/dist/agent/tools/notion_search.d.ts +56 -0
  173. package/dist/agent/tools/notion_search.d.ts.map +1 -0
  174. package/dist/agent/tools/notion_search.js +100 -0
  175. package/dist/agent/tools/notion_search.js.map +1 -0
  176. package/dist/agent/tools/read_notion_comments.d.ts +57 -0
  177. package/dist/agent/tools/read_notion_comments.d.ts.map +1 -0
  178. package/dist/agent/tools/read_notion_comments.js +236 -0
  179. package/dist/agent/tools/read_notion_comments.js.map +1 -0
  180. package/dist/agent/tools/read_notion_page.d.ts +53 -0
  181. package/dist/agent/tools/read_notion_page.d.ts.map +1 -0
  182. package/dist/agent/tools/read_notion_page.js +327 -0
  183. package/dist/agent/tools/read_notion_page.js.map +1 -0
  184. package/package.json +231 -0
  185. package/tsconfig.build.json +22 -0
  186. package/tsconfig.json +15 -0
@@ -0,0 +1,283 @@
1
+ // THE NOTION AMBIENT ENGAGEMENT SEAM (chat-surface contract Β§2.5) β€” the Notion wiring over core's
2
+ // pure engagement policy (`packages/core/agent/channels/engagement.ts`). It gives Notion the middle
3
+ // posture Slack, Discord and Teams already have: addressing the app marks the PAGE ENGAGED, a later
4
+ // PLAIN comment on that page wakes the agent, and `stand_down` stores the stop.
5
+ //
6
+ // THE ANCHOR is `notionConversationKey(workspaceId, pageId)` β€” `<workspace>/<page>`, the same string
7
+ // the mapper publishes as `anchorChannelId` for the stop registry. The doc session is PAGE-scoped
8
+ // (`notionSupervisorToken` keys the standing session by page, aware of all the page's discussions),
9
+ // so the page is the conversation a person can address a stop to, and a per-discussion mark would
10
+ // leave the same session answering the discussion next door.
11
+ //
12
+ // πŸ”΄ THE ADDRESS IS A NAME-DROP, NOT AN @MENTION, AND THAT IS A VENDOR FACT. Notion lets no user
13
+ // @mention an internal integration at all, so `classifyNotionComment` promotes a name-drop (or the
14
+ // structurally-unreachable structured mention) to `direct` β€” that IS Notion's addressed leg, and it
15
+ // is therefore what enrols a page here. `notionNameDrop` owns the matching, including its
16
+ // word-boundary guards; this module never looks at comment text.
17
+ import { getOperationalStore, getThreadEngagement, markThreadEngaged, resolvePartition, shouldDeliverReply, } from "@open-everywhere/core/agent/adapter-api.js";
18
+ import { buildNotionAuth, notionConversationKey } from "./notion-identity.js";
19
+ /** The engagement-registry anchor for one Notion delivery, or `undefined` when either id is missing.
20
+ *
21
+ * πŸ”΄ IT IS THE ONE PREDICATE THE MAPPER AND THE SEAM SHARE. `bindNotion` publishes exactly this as
22
+ * `engagementKey`, so `stand_down`'s mark and this seam's read address the same row. */
23
+ export function notionEngagementAnchor(f) {
24
+ return notionConversationKey(f.workspaceId, f.pageId);
25
+ }
26
+ /** The partition one page's engagement row lives in β€” `notion/<workspace>/<page>`, which is what
27
+ * `resolvePartition` gives a bound Notion session and therefore what `stand_down` writes under. It
28
+ * is built through the same `buildNotionAuth` the wake dispatches, so the seam can never key a row
29
+ * the turn would not. Null on an unresolvable identity β€” the caller then keeps today's path. */
30
+ export function notionSeamPartition(f) {
31
+ if (!f.workspaceId || !f.pageId)
32
+ return null;
33
+ try {
34
+ const auth = buildNotionAuth({
35
+ workspaceId: f.workspaceId,
36
+ pageId: f.pageId,
37
+ ...(f.principalId ? { principalId: f.principalId } : {}),
38
+ ...(f.credentialId ? { credentialId: f.credentialId } : {}),
39
+ });
40
+ return resolvePartition({ channel: { kind: "notion" }, session: { auth: { current: auth } } });
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /**
47
+ * Resolve the seam for one NON-ADDRESSED comment β€” READ-ONLY. An addressed comment never reaches
48
+ * this function: it marks instead ({@link markNotionEngaged}), so exactly one caller writes the row.
49
+ *
50
+ * THE DECISION ITSELF IS CORE'S. `shouldDeliverReply` owns the disqualifier order and the reasons;
51
+ * this function only translates Notion facts into its input. Nothing here re-states the policy.
52
+ *
53
+ * FAIL-CLOSED on a store error: `getThreadEngagement` reads a failure as `null`, so an unreadable
54
+ * registry holds rather than delivers.
55
+ *
56
+ * `authorIsBot` is the caller's own loop-guard verdict. The classifier drops a self-authored or
57
+ * integration-authored comment before this runs, so it is `false` on every live call; it stays a
58
+ * parameter rather than a hard-coded literal so the guard is visible at the call site and a test can
59
+ * drive the other direction.
60
+ */
61
+ export async function resolveNotionEngagement(f, authorIsBot, deps = {}) {
62
+ const anchor = notionEngagementAnchor(f);
63
+ if (!anchor)
64
+ return { kind: "unanchored", reason: "the delivery names no workspace and page β€” no row to address" };
65
+ const partition = notionSeamPartition(f);
66
+ if (!partition)
67
+ return { kind: "unanchored", reason: "the delivery has no resolvable partition β€” no row to address" };
68
+ const read = deps.getEngagement ?? ((p, key) => getThreadEngagement(p, key));
69
+ const row = await read(partition, anchor);
70
+ const decision = shouldDeliverReply({
71
+ isEngaged: row !== null,
72
+ stopped: row?.stopped ?? false,
73
+ authorIsBot,
74
+ isAppMention: false, // an addressed comment never reaches this function
75
+ inThread: true, // an anchored Notion page IS the container engagement is kept per
76
+ });
77
+ if (decision.deliver)
78
+ return { kind: "deliver", anchor, reason: decision.reason };
79
+ return { kind: "hold", anchor, reason: decision.reason, stopped: row?.stopped === true };
80
+ }
81
+ /**
82
+ * Mark one page ENGAGED on an addressed (name-drop or structured-mention) comment. ONCE PER COMMENT.
83
+ *
84
+ * `participating: true` is the re-invitation: it promotes the row and CLEARS a previous stand-down,
85
+ * which is what "name the agent to bring it back" means here.
86
+ *
87
+ * πŸ”΄ THE MARK IS DEDUPED, AND THE STOP IS WHY β€” the same durable guard GitHub's and Linear's marks
88
+ * take. Clearing the flag is right for a person's second name-drop and WRONG for Notion redelivering
89
+ * the first one. THE ROUTE DOES NOT ALWAYS ACK, so the redelivery is reachable: `handleNotionDelivery`
90
+ * carries no top-level `catch`, and eve's channel dispatcher answers a throw with a 500 β€” while this
91
+ * mark sits ABOVE `getOrCreateSession` and the wake, either of which can throw on a store outage. The
92
+ * sequence that costs is then: name-drop marks the page, the mint throws, Notion is told 500, somebody
93
+ * asks the agent to stand down, and Notion's retry of the original comment lifts the stop.
94
+ * `commentId` is the trigger comment's own id, so the guard keys on the thing that must happen once.
95
+ * DURABLE layer only, and it expires with the store's TTL β€” a retry later than that window can still
96
+ * re-mark. FAIL-OPEN on a store error, the posture every dedupe in this repository takes.
97
+ *
98
+ * Best-effort throughout β€” `markThreadEngaged` swallows a store failure, because engagement
99
+ * bookkeeping must never fail a turn. Returns the anchor it marked, or null when it marked nothing.
100
+ */
101
+ export async function markNotionEngaged(f, commentId, deps = {}) {
102
+ const anchor = notionEngagementAnchor(f);
103
+ if (!anchor)
104
+ return null;
105
+ const partition = notionSeamPartition(f);
106
+ if (!partition)
107
+ return null;
108
+ const now = deps.now ?? (() => new Date().toISOString());
109
+ const atIso = now();
110
+ if (commentId !== undefined && commentId.length > 0) {
111
+ const seen = deps.markEventSeen ?? liveMarkEngageSeen;
112
+ if (!(await seen(partition.org, `notion-engage:${anchor}:${commentId}`, atIso)))
113
+ return null;
114
+ }
115
+ const mark = deps.markEngaged ?? ((p, key, at) => markThreadEngaged(p, key, at, { participating: true }));
116
+ await mark(partition, anchor, atIso);
117
+ return anchor;
118
+ }
119
+ /**
120
+ * ONE WAKE PER COMMENT (OPE-1906). TRUE = this comment has not woken the agent inside the window,
121
+ * so the caller should dispatch. FALSE = the same comment id already did, so the caller must not.
122
+ *
123
+ * πŸ”΄ THE OTHER HALF OF THE LOOP GUARD, AND THE CLASSIFIER CANNOT BE IT. `classifyNotionComment`
124
+ * reads one comment's author; a REDELIVERY of a person's comment carries the same author and the
125
+ * same text, so it classifies identically and dispatches again β€” a second model turn, a second
126
+ * `notion.dispatched` row, and a second reply into the discussion. Nothing else on this road is
127
+ * keyed on the delivery, so this is the only place that can say "again".
128
+ *
129
+ * REDELIVERY IS REACHABLE, and {@link markNotionEngaged} above already says why: the route does not
130
+ * always ack. `handleNotionDelivery` carries no top-level `catch`, and eve's channel dispatcher
131
+ * answers a throw with a 500, so Notion retries. A page shared with two subscriptions delivers the
132
+ * same comment twice by design (OPE-467), and a vendor retry needs no failure of ours at all.
133
+ *
134
+ * πŸ”΄ THE MARK IS TAKEN BEFORE THE WAKE, AND THAT TRADE IS DELIBERATE. Marking afterwards would let
135
+ * two concurrent deliveries of one comment both run a turn, which is the cost this guard exists to
136
+ * stop; `markEventSeen` is atomic across instances, so marking first stops them. The price is that
137
+ * a delivery whose dispatch THROWS is not retried inside the window β€” the same price the engage
138
+ * mark one step above already pays, and the reason this guard sits as LATE as it can, below every
139
+ * cheaper refusal, so only a delivery that was really about to wake a turn ever spends a mark.
140
+ *
141
+ * πŸ”΄ A SUITE THAT DRIVES `handleNotionDelivery` PAST THIS POINT MUST INJECT `markEventSeen`, or
142
+ * pin its own store path. The default is the DURABLE operational store, and an unpinned suite in
143
+ * this package shares one sqlite file (scripts/suite-store-isolation-lint.ts says why) β€” so a suite
144
+ * that replays a fixed comment id writes a mark that survives the run, and its NEXT run reads
145
+ * `duplicate-delivery` where it expected `dispatched`. `lint:suite-store` cannot catch that: its
146
+ * reach test is a source scan, and such a suite names no store entry point of its own. Five suites
147
+ * inject the stub today.
148
+ *
149
+ * FAIL-OPEN, three ways: a store error, an unresolvable partition and a delivery with no comment id
150
+ * all answer TRUE, so an outage costs a duplicate turn rather than a deaf deployment. The try/catch
151
+ * covers the INJECTED edge too, not just the live default (the shape webhooks/replay-dedupe.ts and
152
+ * webhooks/reject-audit.ts take) β€” which is how a test drives the fail-open arm without breaking a
153
+ * real store. `commentId` is the thing that must happen once, and a Notion comment id is globally
154
+ * unique, so the key needs no anchor.
155
+ */
156
+ export async function firstNotionDispatchOfComment(f, commentId, deps = {}) {
157
+ if (commentId === undefined || commentId.length === 0)
158
+ return true;
159
+ const partition = notionSeamPartition(f);
160
+ if (!partition)
161
+ return true;
162
+ const now = deps.now ?? (() => new Date().toISOString());
163
+ const seen = deps.markEventSeen ?? liveMarkDispatchSeen;
164
+ try {
165
+ return await seen(partition.org, `notion-dispatch:${commentId}`, now());
166
+ }
167
+ catch {
168
+ return true;
169
+ }
170
+ }
171
+ /**
172
+ * WHAT WOKE ONE ADMITTED DELIVERY, in the wake envelope's own vocabulary (core's `WakeAddressing`).
173
+ * It is the value the `notion.dispatched` audit row reports, and NO PRODUCT CODE BRANCHES ON IT.
174
+ * Every reader is an EVIDENCE reader outside the product: the journey pack's `reportedThreadFollow`
175
+ * (packages/core/scripts/e2e/engaged-rows.ts) gates `J-ENGAGED-notion`'s follow-up step on it,
176
+ * `drive-notion-namedrop.ts` prints and asserts it, and the test tiers read it. So changing the
177
+ * value moves what the pack can prove; it moves no delivery decision.
178
+ *
179
+ * πŸ”΄ IT IS DELIBERATELY NOT THE CLASSIFIER'S VALUE. The classifier answers one question β€” did this
180
+ * comment ADDRESS the agent β€” and an engaged follow-up did not, so it stays `channel-watch`. The
181
+ * supervisor core needs that: `channel-watch` is what makes the turn a JUDGED one, and promoting it
182
+ * would silence the very wake this seam exists to admit.
183
+ *
184
+ * πŸ”΄ BUT `channel-watch` ALSO NAMES UNOWNED ROOM CHATTER on an ambient page, and the audit row is
185
+ * the one place those two must not read alike. A row that reported `channel-watch` for both cannot
186
+ * tell an ENGAGED wake from an AMBIENT one, and that distinction is the whole evidentiary worth of
187
+ * the Β§2.5 seam on this surface: it is what `J-ENGAGED-notion` gates its follow-up step on
188
+ * (`reportedThreadFollow`, packages/core/scripts/e2e/engaged-rows.ts). Until OPE-1501 the row was
189
+ * unreachable on an addressed-only page β€” the ambient gate returned before the classifier ran β€” so
190
+ * the collision cost nothing. The gate now admits an engaged follow-up, and the collision costs the
191
+ * proof, which is what OPE-1510 fixed here.
192
+ *
193
+ * πŸ”΄ IT REPORTS THE REGISTRY, NOT THE DOOR. The seam is read for every plain comment, above and
194
+ * independently of the ambient gate, so on an ambient-ENABLED page a plain comment on an engaged
195
+ * page also reads `thread-follow` β€” the ambient flag would have admitted it either way. The value
196
+ * therefore separates an engaged wake from an ambient one only where the domain has ambient OFF,
197
+ * which is the addressed-only posture the journeys drive.
198
+ *
199
+ * `engagedFollowUp` is the webhook core's own seam verdict, and the `channel-watch` guard below is
200
+ * belt-and-braces: an ADDRESSED comment never asks the seam, so it can never carry the flag.
201
+ */
202
+ export function notionWakeAddressing(addressing, engagedFollowUp) {
203
+ return addressing === "channel-watch" && engagedFollowUp === true ? "thread-follow" : addressing;
204
+ }
205
+ /**
206
+ * THE SEAM'S WHOLE CONTRIBUTION TO THE AUDIT ROW, as one pure mapping.
207
+ *
208
+ * πŸ”΄ THE TWO FIELDS ANSWER DIFFERENT QUESTIONS, AND A ROW NEEDS BOTH. `addressing` says WHAT WOKE
209
+ * the delivery. `via` says HOW THE OPERATOR DECLARED THE PAGE, which is the domain origin the
210
+ * addressed door gated on (`notionPageDomain`, channels/notion-scope.ts). A `container` claim
211
+ * reaches pages NOBODY ENUMERATED β€” the whole workspace, or a same-level page glob, which are the
212
+ * two shapes a container entry may take (memory/domain.ts); a `roster` entry is a page an
213
+ * operator wrote down. Both reach the same door, and a row carrying only `addressing` reports them
214
+ * identically β€” which leaves the wider admission looking exactly like the narrower one in the
215
+ * audit trail. The row says WHICH KIND of claim, never how wide that claim is: read the domain's
216
+ * own container entry for the breadth.
217
+ *
218
+ * `via` is the webhook core's own read, carried on the dispatch. An absent field reads `none`: the
219
+ * conservative word, so a caller that omits it can never claim a declaration it did not make.
220
+ */
221
+ export function notionWakeAuditDetail(addressing, engagedFollowUp, via) {
222
+ return { addressing: notionWakeAddressing(addressing, engagedFollowUp), via: via ?? "none" };
223
+ }
224
+ /** How long this module remembers one comment id β€” the window both durable guards below key on.
225
+ * It bounds the vendor retry both of them answer, so ONE number serves both: a redelivery this
226
+ * module treats as "again" for the mark must be "again" for the wake too, or the two guards would
227
+ * disagree about the same delivery. */
228
+ const NOTION_DEDUPE_TTL_MS = 10 * 60_000;
229
+ /** The durable half of the mark dedupe. FAIL-OPEN: an unreachable store answers "not seen", so a
230
+ * store outage costs a redundant mark rather than a lost engagement. */
231
+ let warnedEngageDedupe = false;
232
+ async function liveMarkEngageSeen(org, key, atIso) {
233
+ try {
234
+ return await getOperationalStore().markEventSeen(org, key, atIso, NOTION_DEDUPE_TTL_MS);
235
+ }
236
+ catch (err) {
237
+ if (!warnedEngageDedupe) {
238
+ warnedEngageDedupe = true;
239
+ console.warn(`[open-everywhere:notion-engagement] engage dedupe unavailable β€” a redelivered name-drop may re-mark: ${String(err)}`);
240
+ }
241
+ return true;
242
+ }
243
+ }
244
+ /** The durable half of the WAKE once-guard ({@link firstNotionDispatchOfComment}). Same window as
245
+ * the mark above, and the same FAIL-OPEN posture: an unreachable store costs a duplicate turn,
246
+ * never a silenced comment. */
247
+ let warnedDispatchDedupe = false;
248
+ async function liveMarkDispatchSeen(org, key, atIso) {
249
+ try {
250
+ return await getOperationalStore().markEventSeen(org, key, atIso, NOTION_DEDUPE_TTL_MS);
251
+ }
252
+ catch (err) {
253
+ if (!warnedDispatchDedupe) {
254
+ warnedDispatchDedupe = true;
255
+ console.warn(`[open-everywhere:notion-engagement] dispatch dedupe unavailable β€” a redelivered comment may wake a second turn: ${String(err)}`);
256
+ }
257
+ return true;
258
+ }
259
+ }
260
+ /**
261
+ * Build the join ids for the row. Pure, and EVERY FIELD IS AN ID β€” never a comment body, an author
262
+ * name or a page title.
263
+ *
264
+ * πŸ”΄ WHY THE ROW NEEDS THEM. Before this, `notion.dispatched` recorded the discussion and the
265
+ * addressing but nothing that identified the DELIVERY, so a leg that forged one replay could only
266
+ * scan a time window for a verdict row and hope no other wake fell inside it. S-TAINT-DRYRUN Arm B
267
+ * did exactly that and the scan could not tell its own replay's verdict from an ambient one
268
+ * (OPE-1907). With the delivery id on the row, one forge joins one wake joins one session.
269
+ *
270
+ * An absent value is OMITTED rather than written as `undefined`: an audit row that carries a key
271
+ * with no value reads, to anyone querying it, like a value that was looked for and found empty.
272
+ */
273
+ export function notionDispatchJoinDetail(ids) {
274
+ const out = {};
275
+ if (ids.deliveryId)
276
+ out.deliveryId = ids.deliveryId;
277
+ if (ids.commentId)
278
+ out.commentId = ids.commentId;
279
+ if (ids.wakeId)
280
+ out.wakeId = ids.wakeId;
281
+ return out;
282
+ }
283
+ //# sourceMappingURL=notion-engagement.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notion-engagement.js","sourceRoot":"","sources":["../../../agent/channels/notion-engagement.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,194 @@
1
+ import { type Domain, type EventNormalizer, type EventScopeGrant, type NormalizedEvent, type ScopeStep } from "@open-everywhere/core/agent/adapter-api.js";
2
+ /**
3
+ * The registry key for BOTH halves of the event plane, and it is the SAME string the webhook
4
+ * verifier registers under β€” one contract, spelled once. (Since OPE-198 S2 this key is the
5
+ * delivery's SCHEME; on the native Notion route the scheme and the hook name coincide.)
6
+ */
7
+ export declare const NOTION_EVENT_SOURCE = "notion";
8
+ /** Event-type families that are WORK β€” routed into the event plane. Prefix-matched on the
9
+ * verbatim `type` discriminator (`page.properties_updated`, `data_source.schema_updated`, …). */
10
+ export declare const NOTION_STRUCTURAL_PREFIXES: readonly string[];
11
+ /** Event-type families that are CONVERSATION β€” the ambient supervisor's, never a routine's.
12
+ * Named explicitly (rather than falling into the unrecognised bucket) so the partition is a
13
+ * reviewed statement in code and a reader can see that the omission is deliberate. */
14
+ export declare const NOTION_CONVERSATIONAL_PREFIXES: readonly string[];
15
+ /** Notion's webhook envelope, probed defensively β€” a mis-shaped payload must yield `null`, never a
16
+ * throw (a normalizer that throws is caught by `normalizeDelivery`, but an ingress should not rely
17
+ * on that to avoid a 500). Mirrors `notion-api.ts`'s loose typing of the same envelope. */
18
+ export interface NotionEventEnvelope {
19
+ readonly id?: unknown;
20
+ readonly timestamp?: unknown;
21
+ readonly workspace_id?: unknown;
22
+ readonly type?: unknown;
23
+ readonly event_type?: unknown;
24
+ readonly authors?: unknown;
25
+ readonly entity?: {
26
+ readonly id?: unknown;
27
+ readonly type?: unknown;
28
+ };
29
+ readonly data?: {
30
+ readonly parent?: {
31
+ readonly id?: unknown;
32
+ readonly type?: unknown;
33
+ };
34
+ readonly updated_blocks?: unknown;
35
+ };
36
+ }
37
+ /** Is this event type WORK (routable), CONVERSATION (the supervisor's), or unrecognised? Exported
38
+ * so the disjointness can be asserted directly rather than inferred from `normalize` returning
39
+ * null for two different reasons. */
40
+ export declare function notionEventClass(type: string | undefined): "structural" | "conversational" | "unrecognised";
41
+ /**
42
+ * The delivery id Notion carries for one event β€” its top-level `id`, which is in the BODY (Notion
43
+ * publishes no delivery-id header). THE supported way for the Notion ingress to populate
44
+ * {@link TriggerDelivery.deliveryId}, which is what arms `admitTrigger`'s durable replay dedupe.
45
+ * Returns undefined for an unparseable body and for a blank id alike β€” both are "this delivery has
46
+ * no id", which the plane already models as not-deduped rather than dropped.
47
+ */
48
+ export declare function notionEventDeliveryId(rawBody: string): string | undefined;
49
+ /** The scope path one envelope proves. Exported for the unit test, which drives it directly. */
50
+ export declare function notionScopePath(ev: NotionEventEnvelope): ScopeStep[];
51
+ /** Lift one Notion envelope onto the v2 event model, or null when it is not ours to route.
52
+ * Exported for the unit test, which drives it directly rather than through the registry. */
53
+ export declare function liftNotionEvent(ev: NotionEventEnvelope, declaredSource: string): NormalizedEvent | null;
54
+ export declare const notionEventNormalizer: EventNormalizer;
55
+ /**
56
+ * ════════════════════════════════════════════════════════════════════════════════════════════════
57
+ * πŸ”΄ NOTION'S READ GRANT β€” AND THE BOUNDARY IT IS *NOT*.
58
+ * ════════════════════════════════════════════════════════════════════════════════════════════════
59
+ *
60
+ * WHAT OE ACTUALLY HOLDS, MEASURED (a design note Β§1, Β§6; `notion-api.ts`
61
+ * `resolveNotionCredential`): an INTERNAL INTEGRATION SECRET (`db:notion:channel-key`, broker `db`) β€”
62
+ * one for the whole deployment by default, or one per domain that binds its own (OPE-467). Each is
63
+ * bearer-token-flat: there is no scopes string, it is bound to a single workspace at creation, and
64
+ * what it can see is a SHARE LIST maintained on NOTION'S side, page by page, by workspace members.
65
+ *
66
+ * πŸ”΄ THEREFORE THE CREDENTIAL CANNOT BE THE BOUNDARY, AND THIS ADAPTER DOES NOT PRETEND IT IS.
67
+ * Three properties make that unavoidable, and each one alone would be enough:
68
+ * 1. A domain need not bind its own integration, and by default none does β€” ten domains then
69
+ * reach Notion with the same bytes, so nothing credential-derived can differ between them.
70
+ * A grant rule that held only for BOUND domains would be a rule with a config-shaped hole.
71
+ * 2. The share list is invisible to OE β€” it is not in any config we hold and there is no API that
72
+ * would let a PURE, SYNC `grantsRead` consult it even if it were.
73
+ * 3. `EffectiveAccess` has no Notion-shaped granted resource to express it over (`repos` is a
74
+ * hardcoded GitHub `string[]`), and inventing one for a credential nobody varies per domain
75
+ * would be schema for a distinction that does not exist.
76
+ * A `grantsRead: () => true` with those facts in a comment is the failure class this tree has a
77
+ * name for β€” a documented absence of a boundary that LOOKS like a boundary β€” so it is not what
78
+ * ships here.
79
+ *
80
+ * WHAT *IS* A REAL, PER-DOMAIN, DEPLOYER-OWNED NOTION BOUNDARY: which domain a Notion surface
81
+ * belongs to. `domainsForSurface` reads the static `domains` registry in the app tree β€” deployer
82
+ * config, merged by PR, validated at module load, with a surface belonging to at most ONE domain.
83
+ * It is the SAME authority, over the SAME `notion/<workspace>/<page>` surface key, that already
84
+ * gates whether the ambient supervisor may engage on that page at all (`notion.ts`
85
+ * `notionAmbientEnabled`). So the rule is:
86
+ *
87
+ * A routine may be triggered by a Notion event iff the routine's partition is in a domain the
88
+ * deployer put the affected Notion surface in.
89
+ *
90
+ * It REFUSES things that are real: a routine in domain `payments` cannot subscribe to a page the
91
+ * deployer put in `security`; a page in NO domain cannot trigger anything anywhere; a delivery
92
+ * proving only a workspace is refused outright, because a workspace is not a surface anybody
93
+ * configured.
94
+ *
95
+ * INNERMOST-CONFIGURED-WINS, AND IT NEVER FALLS THROUGH A REFUSAL. The walk goes inwards→outwards
96
+ * and the FIRST step the registry knows about is authoritative: it grants or it refuses, and the
97
+ * walk stops. A step the registry says nothing about is skipped (deployers configure pages and
98
+ * databases, not every descendant), which is what makes the rule usable β€” but continuing PAST a
99
+ * refusal would let a parent-database grant authorize a child page the deployer deliberately put
100
+ * somewhere else, which is the widening this ordering exists to prevent.
101
+ *
102
+ * ⚠️ TWO ASSUMPTIONS, STATED BECAUSE THEY ARE THE ONES MOST LIKELY TO SHIFT.
103
+ * β€’ `loadDomains` is explicitly a swappable seam ("the seam a DB/dashboard would swap behind",
104
+ * memory/config/domains.ts). This grant's authority class is TIED to that seam staying deployer-owned:
105
+ * the day a dashboard lets a non-deployer edit domain membership, this rule silently weakens to
106
+ * whatever authorizes that editor.
107
+ * β€’ Notion's own share list is a SECOND fence underneath this one, not a substitute for it: a
108
+ * subscription only delivers events for objects the connection was shared onto, so a delivery
109
+ * arriving at all already proves OE can see the object. That fact is OE-wide, never per-domain,
110
+ * which is exactly why it cannot be the grant.
111
+ *
112
+ * ⚠️ IT READS CONFIG, WHICH GITHUB'S GRANT DOES NOT NEED TO. `grantsRead` is documented PURE β€” no
113
+ * I/O, no ctx, no fetch β€” and this stays inside that: `getConfig()` is a registry read and
114
+ * `domainsForSurface` reads static, module-load-validated config. It is the SAME read
115
+ * `requestForPartition` (event-authz.ts) already performs on this exact fire path, so it introduces
116
+ * no new authority class and no new I/O. An unregistered config throws, `checkEventReadGrant`
117
+ * catches it as `unresolvable`, and the delivery fails CLOSED.
118
+ */
119
+ /**
120
+ * The scope steps that can DECIDE a Notion delivery, innermost first. The first step any domain owns
121
+ * is authoritative β€” it grants or it refuses, and the walk stops there.
122
+ *
123
+ * ⭐ ONE LIST, TWO READERS, ON PURPOSE. {@link notionEventScopeGrant} walks it to find the step that
124
+ * AUTHORIZES, and {@link notionTriggerScopeVerdict} walks it to find the step it must check the
125
+ * SIGNER against. If the two walked different orders, the fence would check one step and the grant
126
+ * would authorize another, which is the exact shape of a fence that looks applied and is not.
127
+ *
128
+ * πŸ”΄ `tenant` IS DELIBERATELY ABSENT. The grant's own comment holds the standing reason; the fence
129
+ * inherits it, and inherits the consequence too β€” a forged `workspace_id` is never checked directly.
130
+ * It does not need to be. The tenant only changes WHICH domain a step below resolves to, and the
131
+ * fence then reads that domain's credential.
132
+ */
133
+ export declare const NOTION_GRANT_STEPS: readonly ["sub", "entity", "container", "group"];
134
+ export declare const notionEventScopeGrant: EventScopeGrant;
135
+ /** {@link notionTriggerScopeVerdict}'s answer. `ok` is the ONLY field a caller may branch on; the
136
+ * `reason` is operator-facing prose for the log line and carries no control flow. */
137
+ export type NotionTriggerScopeVerdict = {
138
+ readonly ok: true;
139
+ readonly detail: string;
140
+ } | {
141
+ readonly ok: false;
142
+ readonly reason: string;
143
+ };
144
+ /**
145
+ * ════════════════════════════════════════════════════════════════════════════════════════════════
146
+ * πŸ”΄ THE INGRESS FENCE (OPE-500) β€” MAY THIS SIGNER ROUTE A DELIVERY ABOUT THIS SCOPE?
147
+ * ════════════════════════════════════════════════════════════════════════════════════════════════
148
+ *
149
+ * `channels/notion.ts` calls this on every VERIFIED structural delivery, BEFORE `admitTrigger`. A
150
+ * refusal drops the delivery out of the trigger plane. It never affects the ambient comment path,
151
+ * which has its own OPE-496 resolution and never reaches this function.
152
+ *
153
+ * THE RULE, in one sentence:
154
+ *
155
+ * A delivery may arm a routine on a scope only when the domain that owns that scope is a domain
156
+ * the SIGNING integration serves.
157
+ *
158
+ * WHY THAT IS THE RIGHT AUTHORITY. The signing integration is the ONE authenticated fact a delivery
159
+ * carries (`gateNotionDeliveryAcross` β€” the secret that verifies identifies the sender). The scope
160
+ * path is not authenticated at all. `notion-credentials.ts` already establishes which integration a
161
+ * domain binds, and that binding is deployer config merged by PR. So this asks the payload's claim
162
+ * one question the payload cannot answer for itself, and answers it from config.
163
+ *
164
+ * WORKED THROUGH, because the point is what it REFUSES. Domain `payments` binds integration
165
+ * `notion-payments` with its own inbound secret. A holder of `notion-payments`'s `verification_token`
166
+ * signs a body naming a `security` page. The granting step resolves to `security`, whose credential
167
+ * is `notion-security` (or the org default). That is not the signer, and the two do not share an
168
+ * inbound secret, so the delivery routes nothing.
169
+ *
170
+ * πŸ”΄ THE ONE THING IT CANNOT SEPARATE, STATED PLAINLY. Two integrations that share an INBOUND SECRET
171
+ * are indistinguishable to the HMAC gate β€” `connectorIntegrations` defaults a domain's
172
+ * `inboundSecret` to the deployment default, so a domain that binds a credential WITHOUT declaring
173
+ * its own inbound secret signs exactly like the org default. This function accepts that case rather
174
+ * than refusing it, because refusing would break a supported config in which the domain runs no
175
+ * subscription of its own. The honest statement of the property is therefore:
176
+ *
177
+ * A domain is fenced from another domain's forged deliveries IFF it runs its own inbound secret.
178
+ *
179
+ * That is the same condition `notion-credentials.ts` already states for the read boundary, and the
180
+ * runbook already tells operators to satisfy it. It is not a hole this function opens; it is the
181
+ * limit of what the HMAC can tell one sender from another.
182
+ *
183
+ * FAIL CLOSED, AND THE REFUSALS ARE REAL. No signer (the dev hatch), an unparseable body, and a path
184
+ * no domain owns all refuse. The last one costs nothing: {@link notionEventScopeGrant} would refuse
185
+ * the same delivery a moment later, so dropping it here only makes the refusal legible.
186
+ *
187
+ * PURE. `resolveSurface` defaults to the same `getConfig().domainsForSurface` the grant reads, and is
188
+ * injectable so the test drives every arm with no registry.
189
+ */
190
+ export declare function notionTriggerScopeVerdict(rawBody: string, signerCredentialId: string | undefined, domains: readonly Domain[], resolveSurface?: (p: {
191
+ workspaceKey: string;
192
+ surface: string;
193
+ }) => readonly string[]): NotionTriggerScopeVerdict;
194
+ //# sourceMappingURL=notion-event-normalizer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notion-event-normalizer.d.ts","sourceRoot":"","sources":["../../../agent/channels/notion-event-normalizer.ts"],"names":[],"mappings":"AAmHA,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,SAAS,EAEf,MAAM,4CAA4C,CAAC;AAKpD;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,WAAwB,CAAC;AAEzD;kGACkG;AAClG,eAAO,MAAM,0BAA0B,EAAE,SAAS,MAAM,EAA2C,CAAC;AAEpG;;uFAEuF;AACvF,eAAO,MAAM,8BAA8B,EAAE,SAAS,MAAM,EAAiB,CAAC;AAE9E;;4FAE4F;AAC5F,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;IAChC,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;QAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACrE,QAAQ,CAAC,IAAI,CAAC,EAAE;QACd,QAAQ,CAAC,MAAM,CAAC,EAAE;YAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;YAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;SAAE,CAAC;QACrE,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;KACnC,CAAC;CACH;AAsBD;;sCAEsC;AACtC,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY,GAAG,gBAAgB,GAAG,cAAc,CAK3G;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEzE;AAED,gGAAgG;AAChG,wBAAgB,eAAe,CAAC,EAAE,EAAE,mBAAmB,GAAG,SAAS,EAAE,CAiCpE;AAED;6FAC6F;AAC7F,wBAAgB,eAAe,CAAC,EAAE,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAwDvG;AAED,eAAO,MAAM,qBAAqB,EAAE,eA6BnC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AACH;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,kBAAkB,YAAI,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,OAAO,CAAU,CAAC;AAEnF,eAAO,MAAM,qBAAqB,EAAE,eAwCnC,CAAC;AAkBF;sFACsF;AACtF,MAAM,MAAM,yBAAyB,GACjC;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC9C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,MAAM,EACf,kBAAkB,EAAE,MAAM,GAAG,SAAS,EACtC,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,cAAc,GAAE,CAAC,CAAC,EAAE;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,KAAK,SAAS,MAAM,EAA4C,GAC5H,yBAAyB,CA6D3B"}