@zackbart/connecta 0.28.1 → 0.30.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 (332) hide show
  1. package/CHANGELOG.md +855 -5
  2. package/README.md +125 -116
  3. package/bin/connecta.mjs +121 -139
  4. package/bin/meta-tool-names.d.mts +2 -0
  5. package/bin/meta-tool-names.mjs +9 -0
  6. package/bin/version.mjs +3 -0
  7. package/dist/access-tokens.js +53 -63
  8. package/dist/activity-request.js +8 -0
  9. package/dist/activity.d.ts +76 -42
  10. package/dist/activity.js +110 -42
  11. package/dist/argument-redaction.js +230 -0
  12. package/dist/auth/clerk-sdk/client.d.ts +65 -0
  13. package/dist/auth/clerk-sdk/client.js +5 -0
  14. package/dist/auth/clerk-sdk/generated.js +1845 -0
  15. package/dist/auth/clerk-transport.js +19 -0
  16. package/dist/auth/clerk.d.ts +17 -7
  17. package/dist/auth/clerk.js +189 -107
  18. package/dist/auth/cloudflare-access.js +25 -26
  19. package/dist/auth/downstream-client-metadata.d.ts +29 -0
  20. package/dist/auth/downstream-client-metadata.js +133 -0
  21. package/dist/auth/downstream-oauth.js +1584 -1580
  22. package/dist/auth/downstream-revocation.js +106 -0
  23. package/dist/auth/oauth-token-response.js +231 -0
  24. package/dist/auth/oauth-v2-migration.js +116 -0
  25. package/dist/auth/static-oauth.js +93 -71
  26. package/dist/auth-elicitation.js +288 -0
  27. package/dist/authorization-handoff.js +111 -0
  28. package/dist/branding.d.ts +8 -16
  29. package/dist/branding.js +26 -59
  30. package/dist/byte-read-response.js +39 -0
  31. package/dist/call-admission.js +12 -16
  32. package/dist/catalog-cache.js +739 -0
  33. package/dist/catalog-drift.d.ts +50 -15
  34. package/dist/catalog-drift.js +365 -128
  35. package/dist/catalog-fingerprint.js +2 -4
  36. package/dist/catalog-freshness.js +13 -0
  37. package/dist/catalog-limits.js +2 -2
  38. package/dist/catalog-service.js +409 -225
  39. package/dist/catalog.js +43 -84
  40. package/dist/concurrency.js +3 -4
  41. package/dist/config-defaults.d.ts +39 -0
  42. package/dist/config-defaults.js +46 -0
  43. package/dist/config-schema.d.ts +181 -0
  44. package/dist/config-schema.js +435 -0
  45. package/dist/config-value-sources.js +102 -0
  46. package/dist/config.d.ts +633 -0
  47. package/dist/config.js +508 -0
  48. package/dist/connector-access.d.ts +1 -0
  49. package/dist/connector-access.js +13 -15
  50. package/dist/connector-caller.d.ts +8 -0
  51. package/dist/connector-caller.js +15 -6
  52. package/dist/connector-scope.js +1 -1
  53. package/dist/connectors/api-connector.d.ts +59 -7
  54. package/dist/connectors/api-connector.js +106 -19
  55. package/dist/connectors/api.js +4 -1
  56. package/dist/connectors/guarded-fetch.d.ts +144 -0
  57. package/dist/connectors/guarded-fetch.js +87 -38
  58. package/dist/connectors/negotiation-cache.js +46 -0
  59. package/dist/connectors/option-shapes.js +27 -0
  60. package/dist/connectors/remote-mcp.d.ts +30 -4
  61. package/dist/connectors/remote-mcp.js +1774 -320
  62. package/dist/connectors/resource-uri.js +335 -0
  63. package/dist/credential-contract.d.ts +10 -0
  64. package/dist/credential-rules.js +1 -3
  65. package/dist/credential-url.js +108 -0
  66. package/dist/credentials.d.ts +6 -1
  67. package/dist/credentials.js +27 -18
  68. package/dist/d1.d.ts +130 -0
  69. package/dist/d1.js +290 -0
  70. package/dist/describe-config.d.ts +165 -0
  71. package/dist/describe-config.js +315 -0
  72. package/dist/described.js +106 -0
  73. package/dist/downstream-input-context.js +128 -0
  74. package/dist/downstream-input.js +325 -0
  75. package/dist/errors.d.ts +132 -31
  76. package/dist/errors.js +175 -87
  77. package/dist/execute.js +798 -318
  78. package/dist/executor-admission.d.ts +1 -0
  79. package/dist/executor-admission.js +11 -15
  80. package/dist/executor-contract.d.ts +20 -0
  81. package/dist/executor-contract.js +48 -0
  82. package/dist/executor-result.js +30 -13
  83. package/dist/executors/quickjs-child.js +15 -9
  84. package/dist/executors/quickjs-protocol.js +5 -6
  85. package/dist/executors/quickjs-runtime.js +152 -59
  86. package/dist/executors/quickjs.js +49 -39
  87. package/dist/favicon.js +1 -2
  88. package/dist/fix-prompt.js +1 -3
  89. package/dist/guest-runtime.js +238 -0
  90. package/dist/guest-types.d.ts +218 -0
  91. package/dist/guest-types.js +1 -0
  92. package/dist/html-security.js +24 -0
  93. package/dist/identity.js +1 -3
  94. package/dist/inbound-credential.js +52 -0
  95. package/dist/index.d.ts +24 -256
  96. package/dist/index.js +65 -478
  97. package/dist/invocation-auth.js +69 -0
  98. package/dist/invocation.js +262 -79
  99. package/dist/json-schema.d.ts +1 -1
  100. package/dist/mcp-client-context.js +35 -0
  101. package/dist/mcp-result.js +11 -7
  102. package/dist/meta-output.js +61 -0
  103. package/dist/meta-tool-names.d.ts +2 -0
  104. package/dist/meta-tool-names.js +3 -0
  105. package/dist/meta-tools.js +419 -272
  106. package/dist/module-contracts.d.ts +8 -23
  107. package/dist/node.d.ts +0 -2
  108. package/dist/node.js +2 -2
  109. package/dist/oauth-callback-outcome.js +1 -3
  110. package/dist/oauth-handoff.js +194 -0
  111. package/dist/oauth-sealing.js +20 -0
  112. package/dist/operator-record.d.ts +137 -0
  113. package/dist/operator-record.js +420 -0
  114. package/dist/operator-ui/assets.js +31 -0
  115. package/dist/operator-ui/contract.d.ts +72 -0
  116. package/dist/operator-ui/contract.js +1 -0
  117. package/dist/operator-ui/fix-prompts.d.ts +18 -0
  118. package/dist/operator-ui/generated.js +5 -4
  119. package/dist/operator-ui/model.d.ts +12 -87
  120. package/dist/operator-ui/model.js +2 -9
  121. package/dist/operator-ui/setup-commands.js +1 -3
  122. package/dist/operator-ui/view.d.ts +323 -0
  123. package/dist/operator-ui/view.js +62 -189
  124. package/dist/page-styles.js +2 -3
  125. package/dist/program-result.js +5 -0
  126. package/dist/program-source.js +14 -2
  127. package/dist/{exempt-writes.js → program-writes.js} +60 -19
  128. package/dist/provider.d.ts +142 -0
  129. package/dist/provider.js +187 -0
  130. package/dist/providers/_shared/google/result-size.js +57 -0
  131. package/dist/providers/_shared/google/service-account.d.ts +58 -0
  132. package/dist/providers/_shared/google/service-account.js +500 -0
  133. package/dist/providers/_shared/google/workspace.d.ts +276 -0
  134. package/dist/providers/_shared/google/workspace.js +518 -0
  135. package/dist/providers/_shared/request-token.js +83 -0
  136. package/dist/providers/{basecamp.d.ts → basecamp/index.d.ts} +6 -28
  137. package/dist/providers/basecamp/index.js +997 -0
  138. package/dist/providers/basecamp/skill.generated.js +9 -0
  139. package/dist/providers/{breeze.d.ts → breeze/index.d.ts} +2 -2
  140. package/dist/providers/{breeze.js → breeze/index.js} +184 -140
  141. package/dist/providers/breeze/skill.generated.js +9 -0
  142. package/dist/providers/{ccb.d.ts → ccb/index.d.ts} +2 -2
  143. package/dist/providers/{ccb.js → ccb/index.js} +271 -116
  144. package/dist/providers/ccb/skill.generated.js +9 -0
  145. package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
  146. package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
  147. package/dist/providers/cloudflare/skill.generated.js +10 -0
  148. package/dist/providers/docs/index.d.ts +54 -0
  149. package/dist/providers/docs/index.js +1237 -0
  150. package/dist/providers/docs/skill.generated.js +9 -0
  151. package/dist/providers/drive/index.d.ts +53 -0
  152. package/dist/providers/drive/index.js +1523 -0
  153. package/dist/providers/drive/skill.generated.js +9 -0
  154. package/dist/providers/forms/index.d.ts +54 -0
  155. package/dist/providers/forms/index.js +1217 -0
  156. package/dist/providers/forms/skill.generated.js +11 -0
  157. package/dist/providers/github/auth.d.ts +32 -0
  158. package/dist/providers/github/auth.js +361 -0
  159. package/dist/providers/github/hosted.js +218 -0
  160. package/dist/providers/github/index.d.ts +13 -0
  161. package/dist/providers/github/index.js +140 -0
  162. package/dist/providers/github/key.js +100 -0
  163. package/dist/providers/github/rest.js +321 -0
  164. package/dist/providers/github/scope.d.ts +29 -0
  165. package/dist/providers/github/scope.js +124 -0
  166. package/dist/providers/github/skill.generated.js +8 -0
  167. package/dist/providers/gmail/index.d.ts +51 -0
  168. package/dist/providers/gmail/index.js +1436 -0
  169. package/dist/providers/gmail/skill.generated.js +9 -0
  170. package/dist/providers/infisical/index.d.ts +7 -0
  171. package/dist/providers/infisical/index.js +713 -0
  172. package/dist/providers/infisical/skill.generated.js +8 -0
  173. package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
  174. package/dist/providers/linear/index.js +352 -0
  175. package/dist/providers/linear/skill.generated.js +8 -0
  176. package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
  177. package/dist/providers/mixpanel/index.js +416 -0
  178. package/dist/providers/mixpanel/skill.generated.js +9 -0
  179. package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
  180. package/dist/providers/{notion.js → notion/index.js} +226 -239
  181. package/dist/providers/notion/skill.generated.js +10 -0
  182. package/dist/providers/{overflow.d.ts → overflow/index.d.ts} +2 -2
  183. package/dist/providers/{overflow.js → overflow/index.js} +74 -111
  184. package/dist/providers/overflow/skill.generated.js +8 -0
  185. package/dist/providers/{planning-center.d.ts → planning-center/index.d.ts} +2 -2
  186. package/dist/providers/{planning-center.js → planning-center/index.js} +264 -104
  187. package/dist/providers/planning-center/skill.generated.js +10 -0
  188. package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
  189. package/dist/providers/revenuecat/index.js +557 -0
  190. package/dist/providers/revenuecat/skill.generated.js +11 -0
  191. package/dist/providers/sheets/index.d.ts +54 -0
  192. package/dist/providers/sheets/index.js +1237 -0
  193. package/dist/providers/sheets/skill.generated.js +10 -0
  194. package/dist/providers/slides/index.d.ts +53 -0
  195. package/dist/providers/slides/index.js +2645 -0
  196. package/dist/providers/slides/skill.generated.js +9 -0
  197. package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
  198. package/dist/providers/stripe/index.js +266 -0
  199. package/dist/providers/stripe/skill.generated.js +11 -0
  200. package/dist/providers/{tithely.d.ts → tithely/index.d.ts} +2 -2
  201. package/dist/providers/{tithely.js → tithely/index.js} +60 -91
  202. package/dist/providers/tithely/skill.generated.js +8 -0
  203. package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
  204. package/dist/providers/vercel/index.js +1038 -0
  205. package/dist/providers/vercel/mcp-ownership.js +212 -0
  206. package/dist/providers/vercel/skill.generated.js +11 -0
  207. package/dist/registry.d.ts +110 -171
  208. package/dist/registry.js +588 -927
  209. package/dist/request-state.js +21 -0
  210. package/dist/resource-read.js +103 -0
  211. package/dist/result-shapes.js +17 -49
  212. package/dist/retired-toolkits.js +1 -1
  213. package/dist/routes/access-tokens.js +3 -6
  214. package/dist/routes/activity-context.js +21 -0
  215. package/dist/routes/activity-disclosure.js +40 -0
  216. package/dist/routes/activity.js +59 -21
  217. package/dist/routes/actor-labels.js +3 -50
  218. package/dist/routes/connect.js +221 -0
  219. package/dist/routes/credentials.js +15 -27
  220. package/dist/routes/mcp.js +365 -130
  221. package/dist/routes/oauth-client-metadata.js +15 -0
  222. package/dist/routes/oauth-management.js +45 -107
  223. package/dist/routes/oauth.js +110 -57
  224. package/dist/routes/operator.js +3 -3
  225. package/dist/routes/shared.d.ts +25 -64
  226. package/dist/routes/shared.js +160 -80
  227. package/dist/routes/skills.js +30 -0
  228. package/dist/routes/ui-config.js +225 -0
  229. package/dist/routes/ui-data.js +40 -35
  230. package/dist/routes/ui.js +66 -56
  231. package/dist/runtime/admission.js +4 -2
  232. package/dist/runtime/call-admission.js +3 -1
  233. package/dist/runtime/connector-scope.js +1 -4
  234. package/dist/runtime/run.js +10 -39
  235. package/dist/runtime/services.js +12 -117
  236. package/dist/runtime/shared-read.js +1 -3
  237. package/dist/runtime/storage.js +8 -1
  238. package/dist/sent-secrets.d.ts +93 -0
  239. package/dist/sent-secrets.js +1013 -0
  240. package/dist/server.js +28 -71
  241. package/dist/skills.js +416 -174
  242. package/dist/sqlite.d.ts +48 -0
  243. package/dist/sqlite.js +181 -0
  244. package/dist/storage/keys.js +443 -0
  245. package/dist/storage/memory.js +16 -4
  246. package/dist/storage/sql.d.ts +66 -0
  247. package/dist/storage/sql.js +543 -0
  248. package/dist/timeout.js +4 -2
  249. package/dist/tool-name.js +17 -0
  250. package/dist/tool-safety.d.ts +9 -35
  251. package/dist/tool-safety.js +20 -40
  252. package/dist/types.d.ts +336 -56
  253. package/dist/types.js +0 -1
  254. package/dist/ui.d.ts +7 -19
  255. package/dist/ui.js +76 -90
  256. package/dist/url-safety.js +3 -6
  257. package/dist/usage-guide.js +146 -0
  258. package/dist/validate.d.ts +7 -0
  259. package/dist/validate.js +309 -47
  260. package/dist/version.d.ts +1 -7
  261. package/dist/version.js +3 -7
  262. package/dist/worker.d.ts +11 -0
  263. package/dist/worker.js +435 -0
  264. package/documentation/README.md +63 -0
  265. package/documentation/architecture.md +696 -231
  266. package/documentation/auth.md +1069 -546
  267. package/documentation/code-mode.md +662 -356
  268. package/documentation/deploying.md +146 -0
  269. package/documentation/integrating.md +159 -0
  270. package/documentation/meta-tools.md +570 -216
  271. package/documentation/operating.md +135 -0
  272. package/documentation/operator-tests.md +97 -0
  273. package/documentation/operator-ui.md +132 -0
  274. package/documentation/provider-migration-0.29.md +65 -0
  275. package/examples/worker/AGENTS.md +34 -12
  276. package/examples/worker/README.md +293 -255
  277. package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
  278. package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
  279. package/examples/worker/src/connecta.config.ts +151 -0
  280. package/examples/worker/src/index.ts +14 -197
  281. package/examples/worker/wrangler.jsonc +15 -35
  282. package/package.json +126 -54
  283. package/templates/node/.dockerignore +1 -2
  284. package/templates/node/.env.example +20 -21
  285. package/templates/node/AGENTS.md +21 -17
  286. package/templates/node/Dockerfile +3 -5
  287. package/templates/node/README.md +140 -58
  288. package/templates/node/docker-compose.yml +11 -12
  289. package/templates/node/package.json +4 -2
  290. package/templates/node/src/connecta.config.ts +119 -0
  291. package/templates/node/src/index.ts +9 -154
  292. package/templates/node/src/provision-token.ts +17 -0
  293. package/AGENTS.md +0 -170
  294. package/dist/artifacts/connector.d.ts +0 -58
  295. package/dist/artifacts/connector.js +0 -730
  296. package/dist/artifacts/document.js +0 -86
  297. package/dist/artifacts/guide.js +0 -140
  298. package/dist/artifacts/html-scan.js +0 -329
  299. package/dist/artifacts/json.js +0 -78
  300. package/dist/artifacts/kv-store.d.ts +0 -20
  301. package/dist/artifacts/kv-store.js +0 -140
  302. package/dist/artifacts/markdown.js +0 -478
  303. package/dist/artifacts/module.d.ts +0 -37
  304. package/dist/artifacts/module.js +0 -66
  305. package/dist/artifacts/operations.d.ts +0 -197
  306. package/dist/artifacts/operations.js +0 -1002
  307. package/dist/artifacts/refresh.d.ts +0 -55
  308. package/dist/artifacts/refresh.js +0 -236
  309. package/dist/artifacts/routes.js +0 -206
  310. package/dist/artifacts/types.d.ts +0 -214
  311. package/dist/artifacts/types.js +0 -22
  312. package/dist/artifacts/validate.d.ts +0 -62
  313. package/dist/artifacts/validate.js +0 -651
  314. package/dist/artifacts.d.ts +0 -6
  315. package/dist/artifacts.js +0 -9
  316. package/dist/auth/bearer.d.ts +0 -11
  317. package/dist/auth/bearer.js +0 -50
  318. package/dist/providers/basecamp.js +0 -445
  319. package/dist/providers/linear.js +0 -217
  320. package/dist/providers/mixpanel.js +0 -247
  321. package/dist/providers/revenuecat.js +0 -317
  322. package/dist/providers/stripe.js +0 -253
  323. package/dist/providers/vercel.js +0 -1253
  324. package/dist/storage/file.d.ts +0 -15
  325. package/dist/storage/file.js +0 -455
  326. package/ethos.md +0 -93
  327. package/examples/worker/src/cloudflare-kv.ts +0 -43
  328. package/examples/worker/src/d1-activity-row.ts +0 -155
  329. package/examples/worker/src/d1-activity.ts +0 -147
  330. package/examples/worker/src/d1-storage.ts +0 -138
  331. package/examples/worker/src/r2-artifact-blobs.ts +0 -29
  332. package/templates/node/src/file-activity.ts +0 -182
@@ -0,0 +1,1237 @@
1
+ import { skill } from "./skill.generated.js";
2
+ /**
3
+ * Google Docs as the signed-in Workspace user: read a document as readable
4
+ * text with the indexes an edit needs, create one, append, insert, or replace
5
+ * text, and send raw `documents.batchUpdate` requests behind approval.
6
+ * Hand-written against the Docs API v1 reference
7
+ * (https://developers.google.com/workspace/docs/api/reference/rest).
8
+ *
9
+ * Whose documents. Access is a service account with domain-wide delegation
10
+ * (`src/providers/_shared/google/workspace.ts`): deployment config maps the admitted
11
+ * identity to a Workspace address, and each call mints, or reuses, a token as
12
+ * that user. Docs has no `users/me` path to confine beneath — a document id
13
+ * addresses a Drive file — so the token's subject is the confinement: a
14
+ * request reaches exactly the documents Drive shares with that person, and no
15
+ * argument names anyone else.
16
+ *
17
+ * Why no search. Finding a document is Drive's job (`files.list` with a Docs
18
+ * MIME type), and this connection requests only `documents`, which cannot
19
+ * list anything. Duplicating a listing here would need a Drive scope this
20
+ * connector deliberately does not ask for.
21
+ *
22
+ * Drift. Google publishes a credential-free Discovery document per API.
23
+ * `src/providers/docs/drift.json` records the three methods the tools
24
+ * call, and `npm run providers:check -- --provider docs` reports a touched
25
+ * contract that moved or a method that stopped accepting the scope below.
26
+ */
27
+ import { apiConnector as api, defined } from "../../connectors/api-connector.js";
28
+ import { ConnectorCallError } from "../../errors.js";
29
+ import { googleOutcomeOf, GOOGLE_WORKSPACE_OPTIONS, googleWorkspaceClient, workspaceConnection, } from "../_shared/google/workspace.js";
30
+ import { RESULT_BUDGET_BYTES, clampText, jsonBytes } from "../_shared/google/result-size.js";
31
+ import { asProviderFactory } from "../../provider.js";
32
+ /**
33
+ * The Docs API root. Unlike Gmail there is no per-user path segment: the
34
+ * delegated token's subject decides which documents a request can reach.
35
+ */
36
+ export const DOCS_API_BASE_URL = "https://docs.googleapis.com/v1";
37
+ /**
38
+ * Exactly the scope this connection requests, and exactly what the Admin
39
+ * console's domain-wide delegation entry must list for it. `documents` reads
40
+ * and edits Docs the user can reach; it lists nothing and touches no other
41
+ * Drive file type.
42
+ */
43
+ export const DOCS_SCOPES = ["https://www.googleapis.com/auth/documents"];
44
+ /**
45
+ * Rendered characters kept across a document's tabs by default: at most about
46
+ * 60 KB of JSON even in a three-byte script, so a default read crosses the
47
+ * 256 KiB execute_code bridge with room for its index page and tab rows.
48
+ */
49
+ const DEFAULT_MAX_CHARS = 20_000;
50
+ /**
51
+ * Connecta's ceiling on one rendering; Google caps a document near 1.02M
52
+ * characters. At most about 3 MB of JSON, inside what a direct call can stash
53
+ * and page with connecta.result; past 256 KiB it cannot cross into execute_code.
54
+ */
55
+ const MAX_CHARS = 1_000_000;
56
+ /**
57
+ * Connecta's ceiling on a raw document, in UTF-8 bytes of JSON: styles ride
58
+ * on every run, so this is a long document's whole structure, and past it one
59
+ * tab at a time is the readable answer.
60
+ */
61
+ const MAX_RAW_BYTES = 4 * 1024 * 1024;
62
+ /**
63
+ * Serialized bytes of index rows in one page. Rows are a few hundred bytes at
64
+ * most, so a page is hundreds of them; a long document pages by cursor rather
65
+ * than returning more than the execute_code bridge carries.
66
+ */
67
+ const ELEMENT_PAGE_BYTES = 64 * 1024;
68
+ /** Characters of each index row's text preview. */
69
+ const PREVIEW_CHARS = 80;
70
+ /** One text argument; Google caps a whole document near 1.02M characters. */
71
+ const MAX_TEXT_CHARS = 1_000_000;
72
+ /** Raw requests in one batch_update_document call; connecta's cap. */
73
+ const MAX_REQUESTS = 100;
74
+ /**
75
+ * The Request kinds batch_update_document forwards: every generally available
76
+ * member of the Docs API's `Request` union. The Developer Preview comment and
77
+ * suggestion kinds are left out — they answer only for enrolled projects, and
78
+ * comments are Drive's surface. A kind is checked, its contents are not:
79
+ * Google owns their shape, and a drift check reports a new or retired kind.
80
+ */
81
+ const REQUEST_KINDS = [
82
+ "addDocumentTab",
83
+ "createDropdownDefinition",
84
+ "createFooter",
85
+ "createFootnote",
86
+ "createHeader",
87
+ "createNamedRange",
88
+ "createParagraphBullets",
89
+ "deleteContentRange",
90
+ "deleteDropdownDefinition",
91
+ "deleteFooter",
92
+ "deleteHeader",
93
+ "deleteNamedRange",
94
+ "deleteParagraphBullets",
95
+ "deletePositionedObject",
96
+ "deleteTab",
97
+ "deleteTableColumn",
98
+ "deleteTableRow",
99
+ "insertDate",
100
+ "insertDropdown",
101
+ "insertInlineImage",
102
+ "insertPageBreak",
103
+ "insertPerson",
104
+ "insertRichLink",
105
+ "insertSectionBreak",
106
+ "insertTable",
107
+ "insertTableColumn",
108
+ "insertTableRow",
109
+ "insertText",
110
+ "mergeTableCells",
111
+ "pinTableHeaderRows",
112
+ "replaceAllText",
113
+ "replaceImage",
114
+ "replaceNamedRangeContent",
115
+ "unmergeTableCells",
116
+ "updateDocumentStyle",
117
+ "updateDocumentTabProperties",
118
+ "updateDropdownDefinitionProperties",
119
+ "updateDropdownProperties",
120
+ "updateNamedStyle",
121
+ "updateParagraphStyle",
122
+ "updateSectionStyle",
123
+ "updateTableCellStyle",
124
+ "updateTableColumnProperties",
125
+ "updateTableRowStyle",
126
+ "updateTextStyle",
127
+ ];
128
+ /**
129
+ * A document with every tab, as JSON. Styles ride on every text run, so the
130
+ * JSON is many times the text; this admits documents near Google's own size
131
+ * limit without letting one exhaust a Worker.
132
+ */
133
+ const DOCS_MAX_RESPONSE_BYTES = 24 * 1024 * 1024;
134
+ function asRecord(value) {
135
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
136
+ }
137
+ function asArray(value) {
138
+ return Array.isArray(value) ? value : [];
139
+ }
140
+ function compact(value) {
141
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
142
+ }
143
+ function text(value) {
144
+ return typeof value === "string" && value !== "" ? value : undefined;
145
+ }
146
+ function integer(value) {
147
+ return typeof value === "number" && Number.isInteger(value) ? value : undefined;
148
+ }
149
+ /**
150
+ * Ids Google sends back are copied into results only if a tool could take
151
+ * them as input again — the same pattern and length the input schemas
152
+ * enforce. Anything else is dropped whole and named in the result's
153
+ * `dropped`, never cut: a shortened id addresses something else.
154
+ */
155
+ const ID_RULES = {
156
+ documentId: { pattern: /^[A-Za-z0-9_-]+$/, max: 256 },
157
+ revisionId: { pattern: /^\S+$/, max: 512 },
158
+ tabId: { pattern: /^[A-Za-z0-9._-]+$/, max: 128 },
159
+ };
160
+ function keptId(value, kind, name, dropped) {
161
+ const id = text(value);
162
+ if (id === undefined)
163
+ return undefined;
164
+ const rule = ID_RULES[kind];
165
+ if (id.length <= rule.max && rule.pattern.test(id))
166
+ return id;
167
+ dropped.push(name);
168
+ return undefined;
169
+ }
170
+ /** Titles are prose: cut to this many JSON bytes with a marker, never dropped. */
171
+ const TITLE_BYTES = 4 * 1024;
172
+ function boundedTitle(value) {
173
+ const title = text(value);
174
+ return title === undefined ? undefined : clampText(title, TITLE_BYTES, (more) => `… [${more} more characters]`);
175
+ }
176
+ function droppedField(dropped) {
177
+ return dropped.length > 0 ? [...new Set(dropped)].slice(0, 20) : undefined;
178
+ }
179
+ function documentUrl(documentId) {
180
+ return `https://docs.google.com/document/d/${encodeURIComponent(documentId)}/edit`;
181
+ }
182
+ /**
183
+ * What a tab holds that its text does not show, named so a reader never
184
+ * mistakes the rendering for the whole document: headers, footers, floating
185
+ * (positioned) images, in the inline view which text is a suggestion, and any
186
+ * element of a kind Google added after this rendering was written. Every
187
+ * paragraph element kind Docs v1 defines renders as something.
188
+ */
189
+ const NOT_RENDERED = ["headers", "footers", "positioned_objects", "suggestion_marks", "unknown_elements"];
190
+ const AUTO_TEXT = {
191
+ PAGE_NUMBER: "[page number]",
192
+ PAGE_COUNT: "[page count]",
193
+ };
194
+ function suggested(part) {
195
+ return asArray(part["suggestedInsertionIds"]).length > 0 || asArray(part["suggestedDeletionIds"]).length > 0;
196
+ }
197
+ const HEADINGS = {
198
+ TITLE: "# ",
199
+ SUBTITLE: "## ",
200
+ HEADING_1: "# ",
201
+ HEADING_2: "## ",
202
+ HEADING_3: "### ",
203
+ HEADING_4: "#### ",
204
+ HEADING_5: "##### ",
205
+ HEADING_6: "###### ",
206
+ };
207
+ /** Whether a list level numbers its items, from the tab's list definitions. */
208
+ function ordered(sources, bullet) {
209
+ const list = asRecord(sources.lists[String(bullet["listId"] ?? "")]);
210
+ const levels = asArray(asRecord(list["listProperties"])["nestingLevels"]);
211
+ const level = asRecord(levels[integer(bullet["nestingLevel"]) ?? 0]);
212
+ const glyph = text(level["glyphType"]);
213
+ return glyph !== undefined && glyph !== "NONE" && glyph !== "GLYPH_TYPE_UNSPECIFIED";
214
+ }
215
+ /**
216
+ * One paragraph element as text: a run's content, a link's text and target,
217
+ * a person's name, a rich link's title, and a bracketed marker for what has
218
+ * no text of its own.
219
+ */
220
+ function elementText(element, state) {
221
+ for (const part of Object.values(element)) {
222
+ if (part && typeof part === "object" && suggested(asRecord(part)))
223
+ state.marks.suggested = true;
224
+ }
225
+ const run = asRecord(element["textRun"]);
226
+ if (typeof run["content"] === "string") {
227
+ const url = text(asRecord(asRecord(run["textStyle"])["link"])["url"]);
228
+ return url ? { text: run["content"], url } : { text: run["content"] };
229
+ }
230
+ if (element["person"]) {
231
+ const person = asRecord(asRecord(element["person"])["personProperties"]);
232
+ return { text: text(person["name"]) ?? text(person["email"]) ?? "[person]" };
233
+ }
234
+ if (element["richLink"]) {
235
+ const link = asRecord(asRecord(element["richLink"])["richLinkProperties"]);
236
+ const uri = text(link["uri"]);
237
+ const title = text(link["title"]) ?? uri ?? "link";
238
+ return uri ? { text: title, url: uri } : { text: title };
239
+ }
240
+ if (element["footnoteReference"]) {
241
+ const reference = asRecord(element["footnoteReference"]);
242
+ const id = text(reference["footnoteId"]);
243
+ const number = text(reference["footnoteNumber"]) ?? String(state.footnotes.length + 1);
244
+ if (id && !state.footnotes.some((entry) => entry.id === id))
245
+ state.footnotes.push({ id, number });
246
+ return { text: `[^${number}]` };
247
+ }
248
+ if (element["inlineObjectElement"]) {
249
+ const id = String(asRecord(element["inlineObjectElement"])["inlineObjectId"] ?? "");
250
+ const embedded = asRecord(asRecord(asRecord(state.sources.inlineObjects[id])["inlineObjectProperties"])["embeddedObject"]);
251
+ const label = text(embedded["title"]) ?? text(embedded["description"]);
252
+ return { text: label ? `[image: ${label}]` : "[image]" };
253
+ }
254
+ if (element["dateElement"]) {
255
+ const date = asRecord(asRecord(element["dateElement"])["dateElementProperties"]);
256
+ return { text: text(date["displayText"]) ?? "[date]" };
257
+ }
258
+ if (element["dropdown"]) {
259
+ const dropdown = asRecord(asRecord(element["dropdown"])["dropdownProperties"]);
260
+ return { text: text(dropdown["displayValue"]) ?? "[dropdown: no selection]" };
261
+ }
262
+ if (element["autoText"]) {
263
+ return { text: AUTO_TEXT[String(asRecord(element["autoText"])["type"] ?? "")] ?? "[auto text]" };
264
+ }
265
+ if (element["horizontalRule"])
266
+ return { text: "---" };
267
+ // The API exposes no equation content, only that one is there.
268
+ if (element["equation"])
269
+ return { text: "[equation]" };
270
+ if (element["pageBreak"])
271
+ return { text: "[page break]" };
272
+ if (element["columnBreak"])
273
+ return { text: "[column break]" };
274
+ if (Object.keys(element).some((key) => key !== "startIndex" && key !== "endIndex")) {
275
+ state.marks.unknown = true;
276
+ }
277
+ return { text: "" };
278
+ }
279
+ /** Docs' soft line break (Shift+Enter) inside one paragraph. */
280
+ const VERTICAL_TAB = String.fromCharCode(0x0b);
281
+ /**
282
+ * A paragraph's text with its links as `[text](url)`. Adjacent runs that
283
+ * share a link become one link, and the paragraph's own closing newline is
284
+ * dropped; a soft line break (U+000B) becomes a newline.
285
+ */
286
+ function paragraphText(paragraph, state) {
287
+ const pieces = [];
288
+ for (const element of asArray(paragraph["elements"])) {
289
+ const piece = elementText(asRecord(element), state);
290
+ const last = pieces[pieces.length - 1];
291
+ if (last && last.url === piece.url)
292
+ last.text += piece.text;
293
+ else
294
+ pieces.push(piece);
295
+ }
296
+ const lastPiece = pieces[pieces.length - 1];
297
+ if (lastPiece)
298
+ lastPiece.text = lastPiece.text.replace(/\n$/, "");
299
+ return pieces
300
+ .map((piece) => (piece.url && piece.text.trim() ? `[${piece.text}](${piece.url})` : piece.text))
301
+ .join("")
302
+ .replaceAll(VERTICAL_TAB, "\n");
303
+ }
304
+ /** Where to cut `value` near `max` UTF-16 units without splitting a surrogate pair. */
305
+ function safeCut(value, max) {
306
+ const code = value.charCodeAt(max - 1);
307
+ return max > 0 && code >= 0xd800 && code <= 0xdbff ? max - 1 : max;
308
+ }
309
+ function preview(value) {
310
+ const flat = value.replace(/\s+/g, " ").trim();
311
+ return flat.length > PREVIEW_CHARS ? `${flat.slice(0, safeCut(flat, PREVIEW_CHARS))}…` : flat;
312
+ }
313
+ /** Render structural elements as markdown-ish lines, collecting index rows. */
314
+ function renderContent(content, state, cell) {
315
+ const lines = [];
316
+ for (const value of asArray(content)) {
317
+ const element = asRecord(value);
318
+ const start = integer(element["startIndex"]) ?? 0;
319
+ const end = integer(element["endIndex"]) ?? start;
320
+ if (element["paragraph"]) {
321
+ const paragraph = asRecord(element["paragraph"]);
322
+ const style = text(asRecord(paragraph["paragraphStyle"])["namedStyleType"]);
323
+ const body = paragraphText(paragraph, state);
324
+ const bullet = paragraph["bullet"] ? asRecord(paragraph["bullet"]) : undefined;
325
+ let prefix = "";
326
+ if (bullet) {
327
+ prefix = `${" ".repeat(integer(bullet["nestingLevel"]) ?? 0)}${ordered(state.sources, bullet) ? "1." : "-"} `;
328
+ }
329
+ else if (style && HEADINGS[style] && body.trim()) {
330
+ prefix = HEADINGS[style];
331
+ }
332
+ lines.push(`${prefix}${body}`);
333
+ state.rows?.push(compact({
334
+ type: "paragraph",
335
+ startIndex: start,
336
+ endIndex: end,
337
+ style: style && style !== "NORMAL_TEXT" ? style : undefined,
338
+ row: cell?.row,
339
+ column: cell?.column,
340
+ text: preview(body),
341
+ }));
342
+ }
343
+ else if (element["table"]) {
344
+ state.rows?.push(compact({ type: "table", startIndex: start, endIndex: end, row: cell?.row, column: cell?.column }));
345
+ const rows = asArray(asRecord(element["table"])["tableRows"]);
346
+ rows.forEach((rowValue, rowIndex) => {
347
+ const cells = asArray(asRecord(rowValue)["tableCells"]).map((cellValue, columnIndex) => renderContent(asRecord(cellValue)["content"], state, { row: rowIndex, column: columnIndex })
348
+ .join("<br>")
349
+ .replace(/\n/g, "<br>")
350
+ .replace(/\|/g, "\\|")
351
+ .trim());
352
+ lines.push(`| ${cells.join(" | ")} |`);
353
+ if (rowIndex === 0)
354
+ lines.push(`|${cells.map(() => " --- |").join("")}`);
355
+ });
356
+ }
357
+ else if (element["tableOfContents"]) {
358
+ state.rows?.push(compact({ type: "table_of_contents", startIndex: start, endIndex: end }));
359
+ // The headings it lists are rendered where they stand.
360
+ lines.push("[Table of contents]");
361
+ }
362
+ else if (!element["sectionBreak"] && Object.keys(element).some((key) => !key.endsWith("Index"))) {
363
+ state.marks.unknown = true;
364
+ }
365
+ // A section break has no text and is not an edit target.
366
+ }
367
+ return lines;
368
+ }
369
+ /** One tab's body as text, with its footnotes after it. */
370
+ function renderTab(tab, sources, rows) {
371
+ const marks = { suggested: false, unknown: false };
372
+ const state = { sources, footnotes: [], rows, marks };
373
+ const lines = renderContent(asRecord(tab["body"])["content"], state);
374
+ const notes = state.footnotes.map(({ id, number }) => {
375
+ const footnote = asRecord(sources.footnotes[id]);
376
+ // Footnote content is its own segment: rendered, never indexed.
377
+ const inner = { sources, footnotes: [], rows: undefined, marks };
378
+ return `[^${number}]: ${renderContent(footnote["content"], inner).join(" ").trim()}`;
379
+ });
380
+ const present = (key) => Object.keys(asRecord(tab[key])).length > 0;
381
+ const notRendered = NOT_RENDERED.filter((part) => part === "suggestion_marks"
382
+ ? marks.suggested
383
+ : part === "unknown_elements"
384
+ ? marks.unknown
385
+ : present(part === "positioned_objects" ? "positionedObjects" : part));
386
+ return {
387
+ text: [...lines, ...(notes.length > 0 ? ["", ...notes] : [])]
388
+ .join("\n")
389
+ .replace(/\n{3,}/g, "\n\n")
390
+ .trim(),
391
+ notRendered,
392
+ };
393
+ }
394
+ /**
395
+ * Every tab, depth first in the order the document shows them. A response
396
+ * without tabs — a document read without `includeTabsContent` — is one
397
+ * untitled tab holding the legacy top-level body.
398
+ */
399
+ function flattenTabs(document) {
400
+ const tabs = [];
401
+ const visit = (value, depth) => {
402
+ const tab = asRecord(value);
403
+ const properties = asRecord(tab["tabProperties"]);
404
+ tabs.push({
405
+ tabId: text(properties["tabId"]),
406
+ title: text(properties["title"]),
407
+ parentTabId: text(properties["parentTabId"]),
408
+ nestingLevel: integer(properties["nestingLevel"]),
409
+ documentTab: asRecord(tab["documentTab"]),
410
+ });
411
+ if (depth > 20)
412
+ return;
413
+ for (const child of asArray(tab["childTabs"]))
414
+ visit(child, depth + 1);
415
+ };
416
+ for (const tab of asArray(document["tabs"]))
417
+ visit(tab, 0);
418
+ if (tabs.length === 0) {
419
+ tabs.push({
420
+ tabId: undefined,
421
+ title: undefined,
422
+ parentTabId: undefined,
423
+ nestingLevel: undefined,
424
+ documentTab: document,
425
+ });
426
+ }
427
+ return tabs;
428
+ }
429
+ /** The index just past a body's last element: insert before `endIndex - 1`. */
430
+ function endIndexOf(body) {
431
+ const content = asArray(asRecord(body)["content"]);
432
+ return integer(asRecord(content[content.length - 1])["endIndex"]);
433
+ }
434
+ /** Cut text at `max` characters, never inside a surrogate pair, and say so. */
435
+ function capped(value, max) {
436
+ if (value.length <= max)
437
+ return { text: value, textTruncated: false };
438
+ const cut = safeCut(value, max);
439
+ const rest = value.length - cut;
440
+ return {
441
+ text: `${value.slice(0, cut)}${cut > 0 ? "\n" : ""}[… ${rest} more characters truncated; raise maxChars or pass tabId to read them]`,
442
+ textTruncated: true,
443
+ };
444
+ }
445
+ /** One tab as Google sent it, child tabs included, found at any depth. */
446
+ function findRawTab(tabs, tabId, depth = 0) {
447
+ for (const value of asArray(tabs)) {
448
+ const tab = asRecord(value);
449
+ if (asRecord(tab["tabProperties"])["tabId"] === tabId)
450
+ return tab;
451
+ if (depth < 20) {
452
+ const found = findRawTab(tab["childTabs"], tabId, depth + 1);
453
+ if (found)
454
+ return found;
455
+ }
456
+ }
457
+ return undefined;
458
+ }
459
+ /**
460
+ * The untouched resource, narrowed to one tab when asked, under a ceiling on
461
+ * the whole result as delivered — envelope included — that names its way out
462
+ * rather than handing back a partial tree.
463
+ */
464
+ function rawDocument(document, documentId, tabId) {
465
+ const resource = tabId === undefined ? document : { ...document, tabs: [findRawTab(document["tabs"], tabId)] };
466
+ const dropped = [];
467
+ const result = compact({
468
+ documentId,
469
+ title: boundedTitle(document["title"]),
470
+ revisionId: keptId(document["revisionId"], "revisionId", "revisionId", dropped),
471
+ url: documentUrl(documentId),
472
+ dropped: droppedField(dropped),
473
+ raw: resource,
474
+ });
475
+ const bytes = jsonBytes(result);
476
+ if (bytes > MAX_RAW_BYTES) {
477
+ throw new ConnectorCallError("invalid_args", `The raw document${tabId === undefined ? "" : ` tab ${tabId}`} is ${bytes} bytes as a result, past connecta's ${MAX_RAW_BYTES}-byte raw ceiling. ${tabId === undefined ? "Pass tabId for one tab, or" : "Read"} the rendering instead.`);
478
+ }
479
+ return result;
480
+ }
481
+ const SUGGESTION_MODES = {
482
+ inline: "SUGGESTIONS_INLINE",
483
+ accepted: "PREVIEW_SUGGESTIONS_ACCEPTED",
484
+ rejected: "PREVIEW_WITHOUT_SUGGESTIONS",
485
+ };
486
+ // --- Editing -----------------------------------------------------------------------
487
+ function batchPath(documentId) {
488
+ return `/documents/${encodeURIComponent(documentId)}:batchUpdate`;
489
+ }
490
+ /** The revision a batchUpdate left the document at, when Google reports a usable one. */
491
+ function revisionAfter(reply, dropped) {
492
+ return keptId(asRecord(reply["writeControl"])["requiredRevisionId"], "revisionId", "revisionId", dropped);
493
+ }
494
+ // No cause: what it would carry is a transport, stream, or parser error that can quote the reply (#695).
495
+ function uncertain(message) {
496
+ return new ConnectorCallError("connector_call_failed", message, { retryable: false });
497
+ }
498
+ function writeOutcome(error) {
499
+ const outcome = googleOutcomeOf(error);
500
+ switch (outcome?.phase) {
501
+ case "before-send":
502
+ return "not-sent";
503
+ case "refused": {
504
+ const status = outcome.status;
505
+ return status !== undefined && status >= 400 && status < 500 ? "refused" : "unknown";
506
+ }
507
+ case "reading-body":
508
+ return "probably-applied";
509
+ case "awaiting-response":
510
+ case "server-error":
511
+ case "redirected":
512
+ default:
513
+ return "unknown";
514
+ }
515
+ }
516
+ function detailOf(error) {
517
+ return error instanceof Error ? error.message : String(error);
518
+ }
519
+ /**
520
+ * Send one write and read its reply as text, parsing it here, so a reply that
521
+ * will not parse is a 2xx the client accepted, never a refusal. The shared
522
+ * client already makes an uncertain write non-retryable; this only replaces
523
+ * its generic words with the tool's own recovery advice, keyed on outcome.
524
+ */
525
+ async function sendWrite(client, ctx, request, revisionGuarded, words) {
526
+ let reply;
527
+ try {
528
+ reply = await client.text(request, ctx, "application/json", revisionGuarded ? { revisionGuarded: true } : undefined);
529
+ }
530
+ catch (error) {
531
+ // A cancelled call is the caller's own decision; its reason stays whole.
532
+ if (ctx.signal?.aborted)
533
+ throw error;
534
+ const outcome = writeOutcome(error);
535
+ if (outcome === "not-sent" || outcome === "refused")
536
+ throw error;
537
+ throw uncertain(outcome === "probably-applied" ? words.applied : words.unknown(detailOf(error)));
538
+ }
539
+ if (reply.text.trim() === "")
540
+ return {};
541
+ try {
542
+ return asRecord(JSON.parse(reply.text));
543
+ }
544
+ catch {
545
+ throw uncertain(words.applied);
546
+ }
547
+ }
548
+ /**
549
+ * One `documents.batchUpdate`, atomic on Google's side: every request applies
550
+ * or none does. A write naming `requiredRevisionId` is sent revision-guarded,
551
+ * so the shared client reads Google's own reason (FAILED_PRECONDITION or
552
+ * ABORTED on a 400 or 409) and answers a stale revision with `conflict`.
553
+ * Nothing here infers one: a revision read afterwards would prove nothing,
554
+ * since a collaborator may have edited in between. See {@link writeOutcome}
555
+ * for what every other failure means.
556
+ */
557
+ async function batchUpdate(client, ctx, documentId, requests, requiredRevisionId) {
558
+ return sendWrite(client, ctx, {
559
+ method: "POST",
560
+ path: batchPath(documentId),
561
+ body: compact({
562
+ requests,
563
+ writeControl: requiredRevisionId ? { requiredRevisionId } : undefined,
564
+ }),
565
+ }, requiredRevisionId !== undefined, {
566
+ unknown: (detail) => `The outcome of this edit is unknown: ${detail} Google may or may not have applied it. Re-read the document with get_document before doing anything else; repeating an insert blindly can duplicate it.${requiredRevisionId
567
+ ? " Repeating it with the same requiredRevisionId is safe: if the first attempt applied, Google refuses the repeat."
568
+ : ""}`,
569
+ applied: "Google Docs accepted this edit (HTTP 2xx), so it was applied, but its reply could not be read, so the new revisionId and any counts are unknown. Do not repeat it; re-read the document with get_document.",
570
+ });
571
+ }
572
+ /** Characters of one projected reply string: an id or a short value, never prose. */
573
+ const REPLY_STRING_CHARS = 512;
574
+ /**
575
+ * One batchUpdate reply kept to what a later request needs: its kind, and
576
+ * the ids and counts beneath it to two levels. A string too long to be an id
577
+ * is dropped whole, never cut into a different one; `repliesTruncated` says
578
+ * the replies were projected. Most replies are `{}` or one id; this only
579
+ * bites on the rare kind that echoes more.
580
+ */
581
+ function projectReply(value, depth = 0) {
582
+ const kept = {};
583
+ for (const [key, entry] of Object.entries(value)) {
584
+ if (typeof entry === "string") {
585
+ if (entry.length <= REPLY_STRING_CHARS)
586
+ kept[key] = entry;
587
+ }
588
+ else if (typeof entry === "number" || typeof entry === "boolean") {
589
+ kept[key] = entry;
590
+ }
591
+ else if (entry && typeof entry === "object" && !Array.isArray(entry) && depth < 2) {
592
+ kept[key] = projectReply(entry, depth + 1);
593
+ }
594
+ }
595
+ return kept;
596
+ }
597
+ /**
598
+ * A successful batch's result, inside the delivery budget whatever Google
599
+ * echoed. Replies come back whole when they fit; otherwise projected to ids
600
+ * and counts; otherwise the longest prefix that fits, so `replies[i]` still
601
+ * answers `requests[i]`. A cut result still says the batch applied — it did.
602
+ */
603
+ function boundedReplies(saved, replies) {
604
+ const whole = { ...saved, replies, replyCount: replies.length };
605
+ if (jsonBytes(whole) <= RESULT_BUDGET_BYTES)
606
+ return whole;
607
+ const projected = replies.map((reply) => projectReply(reply));
608
+ const slim = { ...saved, replies: projected, replyCount: replies.length, repliesTruncated: true };
609
+ const notice = (shown) => `The batch applied. ${shown === replies.length ? "Every" : `${shown} of ${replies.length}`} replies ${shown === replies.length ? "is" : "are"} shown, projected to ids and counts, to stay inside the result budget. Do not repeat it; re-read the document with get_document for anything else.`;
610
+ if (jsonBytes({ ...slim, notice: notice(replies.length) }) <= RESULT_BUDGET_BYTES) {
611
+ return { ...slim, notice: notice(replies.length) };
612
+ }
613
+ // The longest prefix whose complete result — its own notice included —
614
+ // fits. JSON adds each element's bytes plus one comma between elements,
615
+ // so a candidate's size is exact without re-serializing it.
616
+ const sizes = projected.map((reply) => jsonBytes(reply));
617
+ const sizeWith = (count) => jsonBytes({ ...slim, replies: [], notice: notice(count) }) +
618
+ sizes.slice(0, count).reduce((sum, size) => sum + size, 0) +
619
+ Math.max(0, count - 1);
620
+ let count = 0;
621
+ while (count < projected.length && sizeWith(count + 1) <= RESULT_BUDGET_BYTES)
622
+ count += 1;
623
+ return { ...slim, replies: projected.slice(0, count), notice: notice(count) };
624
+ }
625
+ /** An edit's result: the document it addressed, and the revision it left, if usable. */
626
+ function savedEdit(documentId, reply) {
627
+ const dropped = [];
628
+ return compact({ documentId, revisionId: revisionAfter(reply, dropped), dropped: droppedField(dropped) });
629
+ }
630
+ // --- Schemas -----------------------------------------------------------------------
631
+ function input(properties, required) {
632
+ return { type: "object", properties, required, additionalProperties: false };
633
+ }
634
+ const DOCUMENT_ID = {
635
+ type: "string",
636
+ minLength: 1,
637
+ maxLength: 256,
638
+ pattern: "^[A-Za-z0-9_-]+$",
639
+ description: "Document id: the part after /d/ in its URL, or a Drive file id for a Google Doc.",
640
+ };
641
+ const TAB_ID_PATTERN = "^[A-Za-z0-9._-]+$";
642
+ function tabIdProperty(description) {
643
+ return { type: "string", minLength: 1, maxLength: 128, pattern: TAB_ID_PATTERN, description };
644
+ }
645
+ const REVISION_PROPERTY = {
646
+ type: "string",
647
+ minLength: 1,
648
+ maxLength: 512,
649
+ pattern: "^\\S+$",
650
+ description: "revisionId from get_document or a previous edit. If the document has changed since, the edit fails conflict and nothing is applied.",
651
+ };
652
+ const TEXT_PROPERTY = {
653
+ type: "string",
654
+ minLength: 1,
655
+ maxLength: MAX_TEXT_CHARS,
656
+ description: "Plain text; \\n starts a new paragraph. Google caps a document near 1.02 million characters.",
657
+ };
658
+ /**
659
+ * Google's Document resource: every field Docs v1's Discovery document
660
+ * declares on it. With `includeTabsContent` the tabs carry the content and
661
+ * the legacy top-level content fields stay empty, but each is declared so
662
+ * nothing a raw read can emit goes undeclared. Nothing is required, because
663
+ * ProtoJSON omits every empty field.
664
+ */
665
+ const RAW_OUTPUT = {
666
+ type: "object",
667
+ properties: {
668
+ documentId: { type: "string" },
669
+ title: { type: "string" },
670
+ revisionId: { type: "string" },
671
+ suggestionsViewMode: { type: "string" },
672
+ commentsViewMode: { type: "string" },
673
+ body: { type: "object" },
674
+ headers: { type: "object" },
675
+ footers: { type: "object" },
676
+ footnotes: { type: "object" },
677
+ documentStyle: { type: "object" },
678
+ namedStyles: { type: "object" },
679
+ lists: { type: "object" },
680
+ namedRanges: { type: "object" },
681
+ inlineObjects: { type: "object" },
682
+ positionedObjects: { type: "object" },
683
+ suggestedDocumentStyleChanges: { type: "object" },
684
+ suggestedNamedStylesChanges: { type: "object" },
685
+ comments: { type: "array", items: { type: "object" } },
686
+ suggestions: { type: "array", items: { type: "object" } },
687
+ tabs: {
688
+ type: "array",
689
+ items: {
690
+ type: "object",
691
+ properties: {
692
+ tabProperties: { type: "object" },
693
+ documentTab: { type: "object" },
694
+ childTabs: { type: "array", items: { type: "object" } },
695
+ },
696
+ },
697
+ },
698
+ },
699
+ };
700
+ const PAGE_SCHEMA = {
701
+ type: "object",
702
+ properties: {
703
+ hasMore: { type: "boolean" },
704
+ nextCursor: { type: ["string", "null"] },
705
+ },
706
+ required: ["hasMore", "nextCursor"],
707
+ };
708
+ /** Names of ids Google sent that were dropped rather than cut; see `keptId`. */
709
+ const DROPPED_OUTPUT = { type: "array", items: { type: "string" } };
710
+ const EDIT_OUTPUT = {
711
+ type: "object",
712
+ properties: {
713
+ documentId: { type: "string" },
714
+ revisionId: { type: "string" },
715
+ dropped: DROPPED_OUTPUT,
716
+ notice: { type: "string" },
717
+ },
718
+ required: ["documentId"],
719
+ };
720
+ const TAB_OUTPUT = {
721
+ type: "object",
722
+ properties: {
723
+ tabId: { type: "string" },
724
+ title: { type: "string" },
725
+ parentTabId: { type: "string" },
726
+ nestingLevel: { type: "integer" },
727
+ endIndex: { type: "integer" },
728
+ text: { type: "string" },
729
+ textTruncated: { type: "boolean" },
730
+ notRendered: { type: "array", items: { type: "string", enum: [...NOT_RENDERED] } },
731
+ elements: {
732
+ type: "array",
733
+ items: {
734
+ type: "object",
735
+ properties: {
736
+ type: { type: "string", enum: ["paragraph", "table", "table_of_contents"] },
737
+ startIndex: { type: "integer" },
738
+ endIndex: { type: "integer" },
739
+ style: { type: "string" },
740
+ row: { type: "integer" },
741
+ column: { type: "integer" },
742
+ text: { type: "string" },
743
+ },
744
+ },
745
+ },
746
+ },
747
+ required: ["text", "textTruncated"],
748
+ };
749
+ // --- Tools -------------------------------------------------------------------------
750
+ /**
751
+ * Connecta's ceiling on a rendered or raw read: inside the 8 MiB stash a
752
+ * direct call pages from, with room for its envelope.
753
+ */
754
+ const MAX_READ_RESULT_BYTES = 4 * 1024 * 1024;
755
+ /**
756
+ * The one check every result passes on its way out, whatever built it. The
757
+ * tools bound what they copy, so this is a backstop, not a plan. A read
758
+ * stopped here refuses with its way out; a write was already applied, so it
759
+ * is acknowledged with only its smallest facts and told not to repeat.
760
+ */
761
+ function delivered(name, args, result) {
762
+ const read = name === "get_document";
763
+ // A default read must also cross the execute_code bridge; an explicit
764
+ // maxChars or raw read is for a direct call, which the stash pages.
765
+ const explicit = read && (args["maxChars"] !== undefined || args["raw"] === true);
766
+ const limit = explicit ? MAX_READ_RESULT_BYTES : RESULT_BUDGET_BYTES;
767
+ const bytes = jsonBytes(result);
768
+ if (bytes <= limit)
769
+ return result;
770
+ if (read) {
771
+ throw new ConnectorCallError("invalid_args", `This document's result is ${bytes} bytes, past connecta's ${limit}-byte ceiling for it. Pass tabId for one tab, or a smaller maxChars.`);
772
+ }
773
+ const full = asRecord(result);
774
+ const small = {};
775
+ for (const key of ["documentId", "url", "occurrencesChanged", "replyCount"]) {
776
+ const value = full[key];
777
+ if (typeof value === "number" || (typeof value === "string" && value.length <= 1024))
778
+ small[key] = value;
779
+ }
780
+ if (name === "batch_update_document") {
781
+ small["replies"] = [];
782
+ small["repliesTruncated"] = true;
783
+ }
784
+ small["notice"] =
785
+ `Google Docs applied this ${name === "create_document" ? "create" : "edit"}, but its full result (${bytes} bytes) is too large to return. Do not repeat it; re-read the document with get_document.`;
786
+ return small;
787
+ }
788
+ function tools(client) {
789
+ return toolDefinitions(client).map((tool) => ({
790
+ ...tool,
791
+ handler: async (args, ctx) => delivered(tool.name, args, await tool.handler(args, ctx)),
792
+ }));
793
+ }
794
+ function toolDefinitions(client) {
795
+ const revision = (args) => typeof args["requiredRevisionId"] === "string" ? args["requiredRevisionId"] : undefined;
796
+ return [
797
+ {
798
+ name: "get_document",
799
+ description: "Get one Google Doc as readable markdown-ish text per tab, with its title and revisionId; withIndexes adds the start/end indexes edits target. Cannot search or list documents.",
800
+ annotations: { readOnlyHint: true },
801
+ inputSchema: input({
802
+ documentId: DOCUMENT_ID,
803
+ tabId: tabIdProperty("Render only this tab (from a previous get_document); omit for every tab."),
804
+ maxChars: {
805
+ type: "integer",
806
+ minimum: 0,
807
+ maximum: MAX_CHARS,
808
+ description: `Characters of text kept across all rendered tabs, 0 to ${MAX_CHARS}; defaults to ${DEFAULT_MAX_CHARS}. Connecta's cap. Longer text ends with a truncation marker. Above the default, call directly (call_tool, connecta.result to page): execute_code carries at most 256 KiB per result.`,
809
+ },
810
+ withIndexes: {
811
+ type: "boolean",
812
+ description: `Add each tab's elements: every paragraph, table, and table of contents with UTF-16 startIndex and endIndex, ${ELEMENT_PAGE_BYTES / 1024} KiB per page (connecta's cap); page with cursor.`,
813
+ },
814
+ cursor: {
815
+ type: "string",
816
+ minLength: 3,
817
+ maxLength: 12,
818
+ pattern: "^e:[0-9]{1,9}$",
819
+ description: "Opaque page.nextCursor from a withIndexes read; pass it back unchanged with the same arguments, and maxChars: 0 to skip the text again.",
820
+ },
821
+ raw: {
822
+ type: "boolean",
823
+ description: `Return Google's document resource (every style, header, footer, named range, list and segment id) for the tabs instead of the rendering; not with maxChars or withIndexes. Connecta caps it at ${MAX_RAW_BYTES} bytes.`,
824
+ },
825
+ suggestions: {
826
+ type: "string",
827
+ enum: ["inline", "accepted", "rejected"],
828
+ description: "How suggested edits read: inline (editors' default; the indexes edits use), or a preview with all accepted or all rejected (indexes not valid for edits).",
829
+ },
830
+ }, ["documentId"]),
831
+ outputSchema: {
832
+ type: "object",
833
+ properties: {
834
+ documentId: { type: "string" },
835
+ title: { type: "string" },
836
+ revisionId: { type: "string" },
837
+ url: { type: "string" },
838
+ tabs: { type: "array", items: TAB_OUTPUT },
839
+ page: PAGE_SCHEMA,
840
+ dropped: DROPPED_OUTPUT,
841
+ raw: RAW_OUTPUT,
842
+ },
843
+ required: ["documentId"],
844
+ },
845
+ handler: async (args, ctx) => {
846
+ const documentId = String(args["documentId"]);
847
+ const raw = args["raw"] === true;
848
+ if (raw &&
849
+ (args["maxChars"] !== undefined || args["withIndexes"] !== undefined || args["cursor"] !== undefined)) {
850
+ throw new ConnectorCallError("invalid_args", "raw returns Google's resource untouched, so maxChars, withIndexes, and cursor do not apply; send raw alone, or read the rendering.");
851
+ }
852
+ if (args["cursor"] !== undefined && args["withIndexes"] !== true) {
853
+ throw new ConnectorCallError("invalid_args", "cursor pages index rows; send it with withIndexes: true.");
854
+ }
855
+ const suggestions = typeof args["suggestions"] === "string" ? SUGGESTION_MODES[args["suggestions"]] : undefined;
856
+ const document = asRecord(await client.json({
857
+ method: "GET",
858
+ path: `/documents/${encodeURIComponent(documentId)}`,
859
+ query: { includeTabsContent: true, suggestionsViewMode: suggestions },
860
+ }, ctx));
861
+ let tabs = flattenTabs(document);
862
+ const wanted = typeof args["tabId"] === "string" ? args["tabId"] : undefined;
863
+ if (wanted !== undefined) {
864
+ tabs = tabs.filter((tab) => tab.tabId === wanted);
865
+ if (tabs.length === 0) {
866
+ const known = flattenTabs(document)
867
+ .map((tab) => keptId(tab.tabId, "tabId", "tabId", []))
868
+ .filter((id) => id !== undefined);
869
+ throw new ConnectorCallError("not_found", `This document has no tab ${wanted}. Its tabs are: ${known.slice(0, 20).join(", ") || "none listed"}${known.length > 20 ? ", …" : ""}.`);
870
+ }
871
+ }
872
+ if (raw)
873
+ return rawDocument(document, documentId, wanted);
874
+ const withIndexes = args["withIndexes"] === true;
875
+ let budget = typeof args["maxChars"] === "number" ? args["maxChars"] : DEFAULT_MAX_CHARS;
876
+ // Index rows form one sequence across tabs; a page is the rows from
877
+ // the cursor's ordinal until the byte budget is spent.
878
+ const from = typeof args["cursor"] === "string" ? Number(args["cursor"].slice(2)) : 0;
879
+ let ordinal = 0;
880
+ let pageBytes = 0;
881
+ let next;
882
+ const dropped = [];
883
+ const rendered = tabs.map((tab, index) => {
884
+ const rows = withIndexes ? [] : undefined;
885
+ const sources = {
886
+ lists: asRecord(tab.documentTab["lists"]),
887
+ footnotes: asRecord(tab.documentTab["footnotes"]),
888
+ inlineObjects: asRecord(tab.documentTab["inlineObjects"]),
889
+ };
890
+ const { text: full, notRendered } = renderTab(tab.documentTab, sources, rows);
891
+ const cut = capped(full, budget);
892
+ budget = Math.max(0, budget - full.length);
893
+ let elements;
894
+ if (rows) {
895
+ elements = [];
896
+ for (const row of rows) {
897
+ const at = ordinal;
898
+ ordinal += 1;
899
+ if (at < from || next !== undefined)
900
+ continue;
901
+ const size = jsonBytes(row) + 1;
902
+ if (pageBytes + size > ELEMENT_PAGE_BYTES) {
903
+ next = at;
904
+ continue;
905
+ }
906
+ pageBytes += size;
907
+ elements.push(row);
908
+ }
909
+ }
910
+ return compact({
911
+ tabId: keptId(tab.tabId, "tabId", `tabs[${index}].tabId`, dropped),
912
+ title: boundedTitle(tab.title),
913
+ parentTabId: keptId(tab.parentTabId, "tabId", `tabs[${index}].parentTabId`, dropped),
914
+ nestingLevel: tab.nestingLevel,
915
+ endIndex: endIndexOf(tab.documentTab["body"]),
916
+ ...cut,
917
+ notRendered: notRendered.length > 0 ? notRendered : undefined,
918
+ elements,
919
+ });
920
+ });
921
+ return compact({
922
+ documentId,
923
+ title: boundedTitle(document["title"]),
924
+ revisionId: keptId(document["revisionId"], "revisionId", "revisionId", dropped),
925
+ url: documentUrl(documentId),
926
+ dropped: droppedField(dropped),
927
+ tabs: rendered,
928
+ page: withIndexes
929
+ ? { hasMore: next !== undefined, nextCursor: next === undefined ? null : `e:${next}` }
930
+ : undefined,
931
+ });
932
+ },
933
+ },
934
+ {
935
+ name: "create_document",
936
+ description: "Create a new Google Doc in the user's My Drive root with a title and optional plain-text body. Cannot choose a folder, apply a template, or copy a document.",
937
+ // Additive: a new document changes nothing that existed. Not read-only,
938
+ // so program calls require a trusted pool; other writes use
939
+ // call_destructive_tool. The host controls approval.
940
+ annotations: { readOnlyHint: false, destructiveHint: false },
941
+ inputSchema: input({
942
+ title: {
943
+ type: "string",
944
+ minLength: 1,
945
+ maxLength: 1000,
946
+ pattern: "^[^\\r\\n]*$",
947
+ description: "Document title, one line. Connecta's 1,000-character cap.",
948
+ },
949
+ text: { ...TEXT_PROPERTY, description: "Optional plain-text body; \\n starts a new paragraph." },
950
+ }, ["title"]),
951
+ outputSchema: {
952
+ type: "object",
953
+ properties: {
954
+ documentId: { type: "string" },
955
+ title: { type: "string" },
956
+ revisionId: { type: "string" },
957
+ url: { type: "string" },
958
+ dropped: DROPPED_OUTPUT,
959
+ notice: { type: "string" },
960
+ },
961
+ required: ["documentId", "url"],
962
+ },
963
+ handler: async (args, ctx) => {
964
+ // Google's create takes a title and ignores any content, so a body is
965
+ // a second, separate edit. Creating is not idempotent: every failure
966
+ // that may have left a document behind says so and is not retryable.
967
+ const title = String(args["title"]);
968
+ const createdButUnknown = `Google Docs created the document (HTTP 2xx), but its reply could not be read, so its id is unknown. Do not create it again; find "${title}" in Drive (a Google Drive connection's search) and use that id.`;
969
+ const created = await sendWrite(client, ctx, { method: "POST", path: "/documents", body: { title } }, false, {
970
+ unknown: (detail) => `Whether the document was created is unknown: ${detail} Google may have created "${title}". Do not create it again until a Drive search for that title shows it is missing.`,
971
+ applied: createdButUnknown,
972
+ });
973
+ const dropped = [];
974
+ // An id that could not be passed back is no id at all.
975
+ const documentId = keptId(created["documentId"], "documentId", "documentId", dropped);
976
+ if (!documentId)
977
+ throw uncertain(createdButUnknown);
978
+ let revisionId = keptId(created["revisionId"], "revisionId", "revisionId", dropped);
979
+ if (typeof args["text"] === "string") {
980
+ try {
981
+ const reply = await batchUpdate(client, ctx, documentId, [{ insertText: { text: args["text"], endOfSegmentLocation: {} } }], undefined);
982
+ revisionId = revisionAfter(reply, dropped) ?? revisionId;
983
+ }
984
+ catch (cause) {
985
+ // The document exists now. A retry of this call would make a
986
+ // second one, so the failure names it and is not retryable. Only
987
+ // an explicit refusal proves the text is absent; anything else
988
+ // sends the caller to look before appending.
989
+ const detail = detailOf(cause);
990
+ throw new ConnectorCallError("connector_call_failed", ["refused", "not-sent"].includes(writeOutcome(cause))
991
+ ? `Created document ${documentId}, but Google refused its initial text: ${detail} Do not create it again; add the text with append_text on ${documentId}.`
992
+ : `Created document ${documentId}, but whether its initial text was written is unknown: ${detail} Do not create it again; read ${documentId} with get_document, and append the text with append_text only if it is missing.`, { retryable: false, cause });
993
+ }
994
+ }
995
+ return compact({
996
+ documentId,
997
+ // The title this call sent, not Google's echo of it.
998
+ title,
999
+ revisionId,
1000
+ url: documentUrl(documentId),
1001
+ dropped: droppedField(dropped),
1002
+ });
1003
+ },
1004
+ },
1005
+ {
1006
+ name: "append_text",
1007
+ description: "Append plain text to the end of a Google Doc's body, or of one tab. It joins the last paragraph; start with \\n for a new one. Never removes or restyles text.",
1008
+ // Additive: the text lands after everything that was there.
1009
+ annotations: { readOnlyHint: false, destructiveHint: false },
1010
+ inputSchema: input({
1011
+ documentId: DOCUMENT_ID,
1012
+ text: TEXT_PROPERTY,
1013
+ tabId: tabIdProperty("Tab to append to; omit for the first tab."),
1014
+ requiredRevisionId: REVISION_PROPERTY,
1015
+ }, ["documentId", "text"]),
1016
+ outputSchema: EDIT_OUTPUT,
1017
+ handler: async (args, ctx) => {
1018
+ const reply = await batchUpdate(client, ctx, args["documentId"], [{ insertText: { text: args["text"], endOfSegmentLocation: compact({ tabId: args["tabId"] }) } }], revision(args));
1019
+ return savedEdit(args["documentId"], reply);
1020
+ },
1021
+ },
1022
+ {
1023
+ name: "insert_text",
1024
+ description: "Insert plain text at one UTF-16 index of a Google Doc's body, from get_document withIndexes. Inserts only; nothing existing is removed or restyled.",
1025
+ // Additive: text goes in at a point; nothing that was there is lost.
1026
+ annotations: { readOnlyHint: false, destructiveHint: false },
1027
+ inputSchema: input({
1028
+ documentId: DOCUMENT_ID,
1029
+ index: {
1030
+ type: "integer",
1031
+ minimum: 1,
1032
+ maximum: 10_000_000,
1033
+ description: "UTF-16 index inside an existing paragraph, from get_document withIndexes; 1 is the body's start. Not a table's start index.",
1034
+ },
1035
+ text: TEXT_PROPERTY,
1036
+ tabId: tabIdProperty("Tab the index belongs to; omit for the first tab."),
1037
+ requiredRevisionId: REVISION_PROPERTY,
1038
+ }, ["documentId", "index", "text"]),
1039
+ outputSchema: EDIT_OUTPUT,
1040
+ handler: async (args, ctx) => {
1041
+ const reply = await batchUpdate(client, ctx, args["documentId"], [{ insertText: { text: args["text"], location: compact({ index: args["index"], tabId: args["tabId"] }) } }], revision(args));
1042
+ return savedEdit(args["documentId"], reply);
1043
+ },
1044
+ },
1045
+ {
1046
+ name: "replace_all_text",
1047
+ description: "Replace every occurrence of a string (or regex) in a Google Doc, in all tabs or the listed ones, and report how many changed. Overwrites text; no preview.",
1048
+ // Destructive: the matched text is gone, and a broad match can rewrite
1049
+ // far more of the document than was meant.
1050
+ annotations: { readOnlyHint: false, destructiveHint: true },
1051
+ inputSchema: input({
1052
+ documentId: DOCUMENT_ID,
1053
+ find: {
1054
+ type: "string",
1055
+ minLength: 1,
1056
+ maxLength: 10_000,
1057
+ description: "Text to find. With regex: true, a regular expression in Google's RE2 syntax.",
1058
+ },
1059
+ replaceWith: {
1060
+ type: "string",
1061
+ maxLength: MAX_TEXT_CHARS,
1062
+ description: "Replacement text; an empty string deletes every match.",
1063
+ },
1064
+ matchCase: { type: "boolean", description: "Match case exactly. Defaults to false." },
1065
+ regex: { type: "boolean", description: "Treat find as an RE2 regular expression. Defaults to false." },
1066
+ tabIds: {
1067
+ type: "array",
1068
+ minItems: 1,
1069
+ maxItems: 100,
1070
+ items: tabIdProperty("Tab id from get_document."),
1071
+ description: "Only these tabs; omit for every tab.",
1072
+ },
1073
+ requiredRevisionId: REVISION_PROPERTY,
1074
+ }, ["documentId", "find", "replaceWith"]),
1075
+ outputSchema: {
1076
+ type: "object",
1077
+ properties: {
1078
+ documentId: { type: "string" },
1079
+ occurrencesChanged: { type: "integer" },
1080
+ revisionId: { type: "string" },
1081
+ dropped: DROPPED_OUTPUT,
1082
+ notice: { type: "string" },
1083
+ },
1084
+ required: ["documentId", "occurrencesChanged"],
1085
+ },
1086
+ handler: async (args, ctx) => {
1087
+ const reply = await batchUpdate(client, ctx, args["documentId"], [
1088
+ {
1089
+ replaceAllText: compact({
1090
+ containsText: compact({
1091
+ text: args["find"],
1092
+ matchCase: args["matchCase"] === true,
1093
+ searchByRegex: args["regex"] === true ? true : undefined,
1094
+ }),
1095
+ replaceText: args["replaceWith"],
1096
+ tabsCriteria: Array.isArray(args["tabIds"]) ? { tabIds: args["tabIds"] } : undefined,
1097
+ }),
1098
+ },
1099
+ ], revision(args));
1100
+ const changed = integer(asRecord(asRecord(asArray(reply["replies"])[0])["replaceAllText"])["occurrencesChanged"]);
1101
+ // Google omits a zero count from the reply.
1102
+ return { ...savedEdit(args["documentId"], reply), occurrencesChanged: changed ?? 0 };
1103
+ },
1104
+ },
1105
+ {
1106
+ name: "batch_update_document",
1107
+ description: "Apply raw Docs API documents.batchUpdate requests to one Google Doc at a required revision, atomically: all apply or none. Always destructive; prefer named edits.",
1108
+ // Destructive by construction: a raw request can delete ranges, restyle
1109
+ // the whole document, or drop tables, and no schema here can tell which.
1110
+ annotations: { readOnlyHint: false, destructiveHint: true },
1111
+ inputSchema: input({
1112
+ documentId: DOCUMENT_ID,
1113
+ requests: {
1114
+ type: "array",
1115
+ minItems: 1,
1116
+ maxItems: MAX_REQUESTS,
1117
+ items: {
1118
+ type: "object",
1119
+ minProperties: 1,
1120
+ maxProperties: 1,
1121
+ propertyNames: { enum: [...REQUEST_KINDS] },
1122
+ description: 'One Docs API Request, exactly one kind (insertText, deleteContentRange, updateTextStyle, …), e.g. {"deleteContentRange":{"range":{"startIndex":5,"endIndex":9}}}. Indexes are UTF-16, from get_document withIndexes.',
1123
+ },
1124
+ description: `Requests in order, 1 to ${MAX_REQUESTS} (connecta's cap). Later requests see earlier ones' index shifts.`,
1125
+ },
1126
+ requiredRevisionId: {
1127
+ ...REVISION_PROPERTY,
1128
+ description: "revisionId from get_document or a previous edit; required, so raw edits are planned against what was read. If the document has changed, it fails conflict and nothing is applied.",
1129
+ },
1130
+ }, ["documentId", "requests", "requiredRevisionId"]),
1131
+ outputSchema: {
1132
+ type: "object",
1133
+ properties: {
1134
+ documentId: { type: "string" },
1135
+ revisionId: { type: "string" },
1136
+ replies: { type: "array", items: { type: "object" } },
1137
+ dropped: DROPPED_OUTPUT,
1138
+ replyCount: { type: "integer" },
1139
+ repliesTruncated: { type: "boolean" },
1140
+ notice: { type: "string" },
1141
+ },
1142
+ required: ["documentId", "replies", "replyCount"],
1143
+ },
1144
+ handler: async (args, ctx) => {
1145
+ const reply = await batchUpdate(client, ctx, args["documentId"], args["requests"], revision(args));
1146
+ return boundedReplies(savedEdit(args["documentId"], reply), asArray(reply["replies"]).map(asRecord));
1147
+ },
1148
+ },
1149
+ ];
1150
+ }
1151
+ // --- Guide -------------------------------------------------------------------------
1152
+ function usageGuide(purpose, instructions) {
1153
+ const extra = instructions?.trim();
1154
+ return `${skill.fragments.guide_0}${purpose}${skill.fragments.guide_1}${extra ? `\n## ${skill.instructionsHeading}\n\n${extra}\n` : ""}`;
1155
+ }
1156
+ // --- Construction ------------------------------------------------------------------
1157
+ /**
1158
+ * A maintained Google Docs connection acting as each signed-in Workspace user
1159
+ * through a delegated service account.
1160
+ *
1161
+ * Setup, once per Workspace (shared with every Workspace provider):
1162
+ *
1163
+ * 1. In a Google Cloud project, enable the **Google Docs API**.
1164
+ * 2. Create a service account. Grant it no IAM roles: delegation, not project
1165
+ * permission, is what reaches documents.
1166
+ * 3. Create a JSON key for it. If key creation is refused, the organization
1167
+ * policy `iam.disableServiceAccountKeyCreation` is enforced; an org policy
1168
+ * administrator can override it for this project alone.
1169
+ * 4. Copy the service account's numeric **client ID** (the key's `client_id`).
1170
+ * 5. As a Workspace super admin: Admin console → Security → Access and data
1171
+ * control → API controls → Manage Domain Wide Delegation → Add new (or edit
1172
+ * the service account's existing entry). Paste the client ID and add
1173
+ * exactly this scope ({@link DOCS_SCOPES}):
1174
+ * `https://www.googleapis.com/auth/documents`. One entry lists every
1175
+ * Workspace provider's scopes, comma-separated. A new grant can take up to
1176
+ * 24 hours to apply.
1177
+ * 6. Configure inbound auth: a `subject` function is never asked about an
1178
+ * open deployment's anonymous requests, which fail `auth_required`.
1179
+ * Then give the deployment the key as a secret and map callers to accounts:
1180
+ *
1181
+ * ```ts
1182
+ * docs("docs", {
1183
+ * purpose: "Staff meeting notes and sermon drafts",
1184
+ * serviceAccount: env.GOOGLE_SERVICE_ACCOUNT_JSON,
1185
+ * subject: (identity) => accounts[identity.principal?.id ?? ""],
1186
+ * });
1187
+ * ```
1188
+ *
1189
+ * Reads run in programs. `create_document`, `append_text`, and `insert_text`
1190
+ * are additive writes the host approves through call_destructive_tool or a
1191
+ * trusted program; `replace_all_text` and `batch_update_document` are
1192
+ * destructive.
1193
+ */
1194
+ export const docs = asProviderFactory({
1195
+ name: "docs",
1196
+ title: "Google Docs",
1197
+ kind: "api",
1198
+ readme: "Google Docs",
1199
+ bundle: {
1200
+ "baselineGzip": 27149,
1201
+ "maxGzip": 87149,
1202
+ "note": "./providers/docs starts at 27,149 B gzip (#681): the same class as ./providers/gmail — a hand-written api() surface over the shared Workspace delegation layer, with no OAuth client and no MCP SDK. The cap uses the existing baseline + 60,000 B policy.",
1203
+ },
1204
+ skill,
1205
+ options: GOOGLE_WORKSPACE_OPTIONS,
1206
+ create: docsConnector,
1207
+ });
1208
+ function docsConnector(id, options) {
1209
+ const connection = workspaceConnection("docs", options);
1210
+ const client = googleWorkspaceClient({
1211
+ provider: "Google Docs",
1212
+ api: "Google Docs API",
1213
+ baseUrl: options.baseUrl?.trim() || DOCS_API_BASE_URL,
1214
+ scopes: DOCS_SCOPES,
1215
+ maxResponseBytes: DOCS_MAX_RESPONSE_BYTES,
1216
+ // A document is a Drive file: Google answers 404 both for an id that does
1217
+ // not exist and for one this person cannot see (H11).
1218
+ notFound: "ambiguous",
1219
+ connection,
1220
+ });
1221
+ return api(id, {
1222
+ ...defined({
1223
+ callAdmission: options.callAdmission,
1224
+ maxResultBytes: options.maxResultBytes,
1225
+ }),
1226
+ title: options.title ?? "Google Docs",
1227
+ description: `Google Docs as the signed-in Workspace user: read, create, and edit documents by id — ${connection.purpose}`,
1228
+ usageGuide: {
1229
+ content: usageGuide(connection.purpose, options.instructions),
1230
+ summary: "Each caller's own Docs by id: readable text with edit indexes, append/insert/replace, raw batchUpdate.",
1231
+ // Required: whose documents they are, that ids come from elsewhere, and
1232
+ // that indexes shift and go stale are conventions no schema can carry.
1233
+ required: true,
1234
+ },
1235
+ tools: tools(client),
1236
+ });
1237
+ }