@zackbart/connecta 0.40.0 → 0.42.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 (195) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +32 -5
  7. package/dist/activity.js +32 -6
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-observation.js +24 -2
  10. package/dist/agent-routes.js +33 -44
  11. package/dist/auth/clerk.d.ts +6 -1
  12. package/dist/auth/clerk.js +16 -0
  13. package/dist/auth/cloudflare-access.js +49 -7
  14. package/dist/auth/downstream-oauth/consent.js +79 -29
  15. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  16. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  17. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  18. package/dist/auth/downstream-oauth.js +15 -4
  19. package/dist/auth/oauth-request-not-sent.js +4 -0
  20. package/dist/auth/static-oauth.js +77 -39
  21. package/dist/auth-elicitation.js +118 -61
  22. package/dist/authorization-handoff.js +36 -3
  23. package/dist/catalog-cache.js +352 -43
  24. package/dist/catalog-freshness.js +25 -0
  25. package/dist/catalog-service.js +408 -78
  26. package/dist/catalog.js +216 -62
  27. package/dist/config-defaults.d.ts +2 -1
  28. package/dist/config-defaults.js +4 -1
  29. package/dist/config.d.ts +53 -21
  30. package/dist/config.js +56 -17
  31. package/dist/connectors/remote-mcp/call.js +1 -1
  32. package/dist/connectors/remote-mcp/catalog.js +52 -28
  33. package/dist/connectors/remote-mcp/connection.js +113 -22
  34. package/dist/connectors/remote-mcp/oauth.js +2 -2
  35. package/dist/connectors/remote-mcp/resources.js +5 -2
  36. package/dist/connectors/remote-mcp/transport.js +24 -6
  37. package/dist/connectors/remote-mcp.js +14 -5
  38. package/dist/credential-contract.d.ts +3 -1
  39. package/dist/describe-config.d.ts +4 -2
  40. package/dist/describe-config.js +1 -0
  41. package/dist/discovery-health.js +26 -11
  42. package/dist/downstream-input.js +11 -2
  43. package/dist/errors.d.ts +60 -4
  44. package/dist/errors.js +88 -3
  45. package/dist/execute.js +491 -202
  46. package/dist/executor-admission.js +2 -0
  47. package/dist/executor-contract.js +6 -0
  48. package/dist/executor-result.js +3 -3
  49. package/dist/executors/quickjs-child.js +14 -5
  50. package/dist/executors/quickjs-runtime.d.ts +2 -0
  51. package/dist/executors/quickjs-runtime.js +37 -20
  52. package/dist/executors/quickjs.js +24 -7
  53. package/dist/guarded-transport.js +2 -0
  54. package/dist/guest-functions.js +5 -4
  55. package/dist/guest-runtime.js +37 -1
  56. package/dist/guest-types.d.ts +18 -0
  57. package/dist/http/client.js +14 -2
  58. package/dist/http/errors.js +9 -4
  59. package/dist/http/fields.js +17 -5
  60. package/dist/http/hatch.js +11 -3
  61. package/dist/http/node-transport.js +110 -4
  62. package/dist/http/oauth.js +17 -6
  63. package/dist/http/paging.js +1 -1
  64. package/dist/http/provider.js +2 -6
  65. package/dist/http/vendor.js +6 -1
  66. package/dist/index.d.ts +1 -1
  67. package/dist/index.js +15 -21
  68. package/dist/invocation-failure.js +28 -0
  69. package/dist/invocation.js +167 -64
  70. package/dist/membership.d.ts +1 -1
  71. package/dist/meta-output.js +24 -18
  72. package/dist/meta-tool-names.d.ts +1 -1
  73. package/dist/meta-tool-names.js +1 -1
  74. package/dist/meta-tools.js +286 -73
  75. package/dist/module-contracts.d.ts +3 -1
  76. package/dist/native-recovery.js +4 -1
  77. package/dist/native-tools.js +177 -55
  78. package/dist/node.d.ts +7 -0
  79. package/dist/node.js +85 -4
  80. package/dist/oauth-handoff.js +53 -43
  81. package/dist/operation-activity.js +104 -0
  82. package/dist/operation-vocabulary.js +6 -0
  83. package/dist/operator-record.d.ts +9 -1
  84. package/dist/operator-record.js +16 -0
  85. package/dist/operator-ui/generated.js +2 -2
  86. package/dist/program-result.js +2 -0
  87. package/dist/program-source.js +1 -1
  88. package/dist/program-writes.js +112 -19
  89. package/dist/provider.d.ts +1 -14
  90. package/dist/provider.js +0 -3
  91. package/dist/providers/_shared/google/kit.js +2 -2
  92. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  93. package/dist/providers/breeze/api.js +2 -2
  94. package/dist/providers/breeze/index.js +1 -1
  95. package/dist/providers/breeze/skill.generated.js +1 -1
  96. package/dist/providers/ccb/api.js +2 -2
  97. package/dist/providers/ccb/index.js +1 -1
  98. package/dist/providers/ccb/skill.generated.js +1 -1
  99. package/dist/providers/cloudflare/api.js +2 -2
  100. package/dist/providers/cloudflare/index.js +3 -1
  101. package/dist/providers/cloudflare/skill.generated.js +1 -1
  102. package/dist/providers/docs/index.js +1 -1
  103. package/dist/providers/docs/skill.generated.js +1 -1
  104. package/dist/providers/drive/index.js +1 -1
  105. package/dist/providers/drive/skill.generated.js +1 -1
  106. package/dist/providers/forms/index.js +2 -1
  107. package/dist/providers/forms/skill.generated.js +1 -1
  108. package/dist/providers/github/api.js +98 -6
  109. package/dist/providers/github/index.js +8 -3
  110. package/dist/providers/github/lib/auth.d.ts +3 -1
  111. package/dist/providers/github/lib/auth.js +43 -12
  112. package/dist/providers/github/lib/discovery.js +11 -1
  113. package/dist/providers/github/lib/operations.js +8 -0
  114. package/dist/providers/github/safety.js +39 -0
  115. package/dist/providers/github/skill.generated.js +1 -1
  116. package/dist/providers/github/tools.js +12 -5
  117. package/dist/providers/gmail/index.js +2 -1
  118. package/dist/providers/gmail/skill.generated.js +1 -1
  119. package/dist/providers/granola/index.js +1 -1
  120. package/dist/providers/granola/skill.generated.js +1 -1
  121. package/dist/providers/overflow/index.js +3 -1
  122. package/dist/providers/overflow/skill.generated.js +1 -1
  123. package/dist/providers/planning-center/index.js +12 -6
  124. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  125. package/dist/providers/planning-center/skill.generated.js +1 -1
  126. package/dist/providers/quickbooks/api.js +28 -25
  127. package/dist/providers/quickbooks/index.js +3 -6
  128. package/dist/providers/quickbooks/lib/writes.js +55 -0
  129. package/dist/providers/quickbooks/safety.js +17 -5
  130. package/dist/providers/quickbooks/skill.generated.js +1 -1
  131. package/dist/providers/sheets/index.js +2 -1
  132. package/dist/providers/sheets/skill.generated.js +1 -1
  133. package/dist/providers/slides/index.js +3 -3
  134. package/dist/providers/slides/skill.generated.js +1 -1
  135. package/dist/providers/stripe/index.js +2 -2
  136. package/dist/providers/stripe/skill.generated.js +1 -1
  137. package/dist/providers/tithely/index.js +2 -1
  138. package/dist/providers/tithely/skill.generated.js +1 -1
  139. package/dist/providers/vercel/api.js +3 -2
  140. package/dist/providers/vercel/skill.generated.js +1 -1
  141. package/dist/registry.d.ts +36 -4
  142. package/dist/registry.js +142 -30
  143. package/dist/resource-read.js +21 -2
  144. package/dist/result-preview.js +210 -0
  145. package/dist/result-seal.js +168 -0
  146. package/dist/route-names.js +53 -0
  147. package/dist/routes/activity-context.js +1 -0
  148. package/dist/routes/activity-disclosure.js +26 -4
  149. package/dist/routes/activity.js +2 -1
  150. package/dist/routes/connect.js +21 -13
  151. package/dist/routes/mcp.js +83 -35
  152. package/dist/routes/shared.d.ts +6 -0
  153. package/dist/routes/ui-config.js +1 -1
  154. package/dist/routes/ui-data.js +3 -2
  155. package/dist/runtime/call.js +15 -10
  156. package/dist/runtime/native-storage.js +12 -0
  157. package/dist/runtime/request-reads.js +66 -14
  158. package/dist/runtime/request-scope.js +38 -2
  159. package/dist/runtime/run.js +4 -1
  160. package/dist/sent-secrets.d.ts +20 -0
  161. package/dist/sent-secrets.js +49 -1
  162. package/dist/skill-origin.js +16 -0
  163. package/dist/skills.js +213 -57
  164. package/dist/storage/d1-driver.js +33 -5
  165. package/dist/storage/keys.js +16 -7
  166. package/dist/storage/sql-native.js +60 -3
  167. package/dist/storage/sql.js +6 -0
  168. package/dist/timing-trace.js +84 -0
  169. package/dist/tool-safety.d.ts +33 -0
  170. package/dist/tool-safety.js +51 -15
  171. package/dist/types.d.ts +36 -0
  172. package/dist/url-safety.js +38 -20
  173. package/dist/usage-guide.js +14 -13
  174. package/dist/utility-recovery.js +33 -0
  175. package/dist/validate.js +29 -10
  176. package/dist/version.d.ts +1 -1
  177. package/dist/version.js +1 -1
  178. package/dist/worker.js +60 -15
  179. package/documentation/architecture.md +152 -43
  180. package/documentation/auth.md +160 -53
  181. package/documentation/code-mode.md +396 -119
  182. package/documentation/deploying.md +35 -10
  183. package/documentation/hosted-mcp-migration.md +9 -3
  184. package/documentation/integrating.md +27 -5
  185. package/documentation/meta-tools.md +761 -157
  186. package/documentation/operating.md +91 -4
  187. package/documentation/operator-ui.md +51 -2
  188. package/documentation/providers.md +34 -20
  189. package/examples/worker/README.md +3 -3
  190. package/guides/how-it-works.md +2 -1
  191. package/guides/safety-and-trust.md +1 -3
  192. package/package.json +10 -8
  193. package/templates/node/AGENTS.md +3 -3
  194. package/templates/node/README.md +1 -1
  195. package/templates/node/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { isRecord } from "../_shared/json.js";
2
2
  // Vercel's pinned contract, authentication, failures and value-safety boundary.
3
3
  import { Effect, Schema } from "effect";
4
- import { ConnectorCallError } from "../../errors.js";
4
+ import { ConnectorCallError, tokenPrefix } from "../../errors.js";
5
5
  import { Refusal, StatusFailure, UnreadableBody } from "../../http/errors.js";
6
6
  import { restSafety } from "../../http/safety.js";
7
7
  import { Credentials, RequestSecrets } from "../../http/services.js";
@@ -93,7 +93,7 @@ export const vercelErrors = {
93
93
  const code = text(error["code"]);
94
94
  const message = text(error["message"])?.trim() ?? text(root["message"])?.trim() ?? `Vercel returned HTTP ${status}.`;
95
95
  const reviewed = withheld ? facts["code"] : reviewedCode(code);
96
- const detail = (code ? `Vercel ${code}: ${message}` : message).slice(0, 1_000);
96
+ const detail = tokenPrefix(code ? `Vercel ${code}: ${message}` : message, 1_000) || `Vercel returned HTTP ${status}.`;
97
97
  if (status >= 200 && status <= 299 && facts["envRejected"] === true)
98
98
  return `Vercel rejected the environment-variable write${reviewed ? ` (${reviewed})` : ""}; its message is withheld because this operation handles secret values.`;
99
99
  return withheld
@@ -414,6 +414,7 @@ export function usageGuide(options) {
414
414
  options = pinOptions(options);
415
415
  const team = defaultTeam(options);
416
416
  return {
417
+ summary: "Load for Vercel team scope, project/deployment ids, build events, runtime logs and guarded writes.",
417
418
  context: [
418
419
  `Vercel REST access token is already configured. Account purpose: ${options.purpose}`,
419
420
  options.pin
@@ -1,5 +1,5 @@
1
1
  // Generated by scripts/generate-providers.mjs; do not edit.
2
2
  export const skill = {
3
- "content": "## Projects, deployments and domains\n\nResolve a team by slug, a project by name, and deployments by the observed\nproject id. Team ids start with `team_`, project ids with `prj_`, and deployment\nids with `dpl_`. A deployment hostname and a deployment id identify the same\ndeployment; an alias can later point to a different deployment.\n\nSome project paths accept a project name, but a deployment list's projectId\nfilter requires the observed `prj_` id. Resolve the name before listing its\ndeployments; an empty result for a guessed name does not prove absence.\nComplete each identifier lookup before starting the reads that depend on it.\n\n<!-- when: unpinned -->\nThe configured team is a routing default, not a restriction. Explicit teamId\nor slug selects another team the token can reach; teamId: null selects personal\nscope. Vercel token permissions determine access. Prefer an explicit team when\njoining resources across several calls.\n<!-- end -->\n\n<!-- when: pinned -->\nThe deployment confines this connection to one team. A supplied team slug must\nbelong to that team; personal scope and foreign teams are refused. Project and\ndeployment references require ownership reads. Global/account and transfer\noperations without a verifiable team boundary remain unavailable. A team list\nreturns only the pinned team.\n<!-- end -->\n\nRead deployment state before its build events. Runtime logs diagnose requests\nafter a deployment built; build logs explain a failed build. Narrow time windows\nbefore raising collection limits. Generic reads refuse live follow streams;\nuse the bounded build/runtime readers. Deployment timestamps are milliseconds.\n\nProject domains and deployment aliases are different joins: a project domain\nbelongs to the project, while an alias identifies its assigned deployment.\nRead a pending domain's verification instructions before changing DNS.\n\n## Environment changes\n\nResolve environment-variable ids using metadata, including target, branch and\ncustom environment. A key can have distinct production and preview values.\nExisting values cannot be retrieved. Supply new values only when the task asks\nfor a change; confirm the returned id and metadata.\n\nEnvironment changes apply to future deployments. A new deployment is a separate\nwrite. Promoting an existing deployment does not rebuild it or update embedded\nenvironment values. After an uncertain update, reconcile observable metadata\nsuch as id, targets and comment; reading metadata cannot verify a secret value.\n\n## Explicit files and credentials\n\nDeployment files can reference previously uploaded digests. File uploads accept\nexplicit bytes and never read a path on the agent's machine. Decrypted-value,\ncredential-minting, protection-bypass-link, signing and domain move-out operations\nare refused; use Vercel's dashboard for those workflows.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `list_teams`, `vercel_api_details`, `vercel_api_search`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. For `vercel_api_read`, put it in `query[page.param]` or `body[page.param]`, as `page.in` specifies. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 20, at most 100). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `vercel_api_search` finds this API's operations and reads no account data, `vercel_api_details` shows one operation's contract, and `vercel_api_read` calls a reviewed read. Details' `response.fields` promises selection only; `withheld` hints are incomplete, and absence never means safe or readable. `vercel_api_write` calls a reviewed write; the host may ask the user to confirm it. An operation without a reviewed verdict is refused. 29 operations are refused by design; use `vercel_api_details` with the method and path to see each summary and refusal, even for methods outside the read/write schemas.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. `not_found` means the record does not exist; this API tells absence from a permission gap. It documents no conflict.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
3
+ "content": "## Projects, deployments and domains\n\nResolve a team by slug, a project by name, and deployments by the observed\nproject id. Team ids start with `team_`, project ids with `prj_`, and deployment\nids with `dpl_`. A deployment hostname and a deployment id identify the same\ndeployment; an alias can later point to a different deployment.\n\nSome project paths accept a project name, but a deployment list's projectId\nfilter requires the observed `prj_` id. Resolve the name before listing its\ndeployments; an empty result for a guessed name does not prove absence.\nComplete each identifier lookup before starting the reads that depend on it.\n\n<!-- when: unpinned -->\nThe configured team is a routing default, not a restriction. Explicit teamId\nor slug selects another team the token can reach; teamId: null selects personal\nscope. Vercel token permissions determine access. Prefer an explicit team when\njoining resources across several calls.\n<!-- end -->\n\n<!-- when: pinned -->\nThe deployment confines this connection to one team. A supplied team slug must\nbelong to that team; personal scope and foreign teams are refused. Project and\ndeployment references require ownership reads. Global/account and transfer\noperations without a verifiable team boundary remain unavailable. A team list\nreturns only the pinned team.\n<!-- end -->\n\nRead deployment state before its build events. Runtime logs diagnose requests\nafter a deployment built; build logs explain a failed build. Narrow time windows\nbefore raising collection limits. Generic reads refuse live follow streams;\nuse the bounded build/runtime readers. Deployment timestamps are milliseconds.\n\nProject domains and deployment aliases are different joins: a project domain\nbelongs to the project, while an alias identifies its assigned deployment.\nRead a pending domain's verification instructions before changing DNS.\n\n## Environment changes\n\nResolve environment-variable ids using metadata, including target, branch and\ncustom environment. A key can have distinct production and preview values.\nExisting values cannot be retrieved. Supply new values only when the task asks\nfor a change; confirm the returned id and metadata.\n\nEnvironment changes apply to future deployments. A new deployment is a separate\nwrite. Promoting an existing deployment does not rebuild it or update embedded\nenvironment values. After an uncertain update, reconcile observable metadata\nsuch as id, targets and comment; reading metadata cannot verify a secret value.\n\n## Explicit files and credentials\n\nDeployment files can reference previously uploaded digests. File uploads accept\nexplicit bytes and never read a path on the agent's machine. Decrypted-value,\ncredential-minting, protection-bypass-link, signing and domain move-out operations\nare refused; use Vercel's dashboard for those workflows.\n\n## Conventions\n\n- **Hatch:** `vercel_api_search` finds operations, `vercel_api_details` shows one contract, `vercel_api_read` calls a reviewed read and `vercel_api_write` a reviewed write. 29 operations are refused by design; use `vercel_api_details` with the method and path to see each summary and refusal, even for methods outside the read/write schemas.\n- **Errors:** `not_found` means the record does not exist; this API tells absence from a permission gap.",
4
4
  "instructionsHeading": "Account instructions"
5
5
  };
@@ -2,6 +2,7 @@ import type { CatalogDriftActivityContext } from "./activity.js";
2
2
  import { type CallAdmissionPermit, type ConnectorCallAdmissionSnapshot } from "./call-admission.js";
3
3
  import { type ConnectorCaller } from "./connector-caller.js";
4
4
  import type { CredentialVault } from "./credential-contract.js";
5
+ import { type ClassificationOverride } from "./tool-safety.js";
5
6
  import type { Connector, ConnectorContext, ConnectorStatus, KVStorage, Logger, ToolDef } from "./types.js";
6
7
  /**
7
8
  * Split `"<connectorId>.<toolName>"` on the first dot. Connector ids contain
@@ -45,7 +46,7 @@ export declare function resolveMaxResultBytes(value: number | undefined, inherit
45
46
  export interface RegistryOptions {
46
47
  publicUrl?: string | undefined;
47
48
  oauthClientName?: string | undefined;
48
- classification?: Readonly<Record<string, Readonly<Record<string, "read" | "write" | "hidden">>>> | undefined;
49
+ classification?: Readonly<Record<string, Readonly<Record<string, ClassificationOverride>>>> | undefined;
49
50
  storage: KVStorage;
50
51
  /** Internal deployment root, preserved when a personal registry partitions storage. */
51
52
  rootStorage?: KVStorage;
@@ -99,6 +100,13 @@ export interface ProgramResultDependency {
99
100
  tool?: string;
100
101
  classification: "read" | "write";
101
102
  }
103
+ /** A cached catalog as discovery may list it. */
104
+ export interface CatalogPeek {
105
+ tools: ToolDef[];
106
+ fetchedAt: number;
107
+ /** Whether the connector's own cache would serve this now, with no failure to report. */
108
+ fresh: boolean;
109
+ }
102
110
  export interface RegistryView {
103
111
  credentialUiAvailable(): boolean;
104
112
  /** Deployment-wide result-size cap threaded to the meta-tools. */
@@ -114,6 +122,12 @@ export interface RegistryView {
114
122
  toolName: string;
115
123
  } | null;
116
124
  getTools(id: string, baseUrl: string, requestScope?: object, callOptions?: ConnectorOperationOptions): Promise<ToolDef[]>;
125
+ /**
126
+ * The newest complete catalog this caller could be listed, classified and
127
+ * filtered as a listing is, from storage alone. For discovery; a call never
128
+ * resolves through it. Undefined for a static catalog or a miss.
129
+ */
130
+ peekCatalog(id: string, baseUrl: string, requestScope?: object, callOptions?: ConnectorOperationOptions): Promise<CatalogPeek | undefined>;
117
131
  contextFor(id: string, baseUrl: string, requestScope?: object, callOptions?: ConnectorOperationOptions): ConnectorContext;
118
132
  /** Acquire the connector's shared downstream-call permit. */
119
133
  admitCall(id: string, input: {
@@ -125,8 +139,19 @@ export interface RegistryView {
125
139
  resultIdentity(): ResultIdentity;
126
140
  /** One validation pass: admit once, then check every dependency under that scope. */
127
141
  recheckResultAccess(dependencies: readonly ProgramResultDependency[], classification: "read" | "write", signal?: AbortSignal): Promise<boolean>;
128
- /** Reserve deployment-wide capacity before writing a paging envelope's chunks. */
129
- stashResult(id: string, chunks: readonly string[], ttlSeconds: number): Promise<boolean>;
142
+ /**
143
+ * Reserve deployment-wide capacity before writing a paging envelope's chunks.
144
+ * `holdFirst` charges the whole envelope but leaves chunk 0, which makes the
145
+ * result readable, for the caller to write once the result is sealed.
146
+ */
147
+ stashResult(id: string, chunks: readonly string[], ttlSeconds: number, options?: {
148
+ readonly holdFirst?: boolean;
149
+ }): Promise<boolean>;
150
+ /**
151
+ * Give back a stashed result nobody can name any more. Best effort: a failure
152
+ * leaves it charged until its expiry.
153
+ */
154
+ releaseResult(id: string, chunkCount: number): Promise<void>;
130
155
  /**
131
156
  * Local declared-vs-stored credential mismatch, with no downstream I/O. With
132
157
  * `requestScope`, the read joins that request's read memo.
@@ -260,7 +285,11 @@ export declare class Registry implements RegistryView {
260
285
  * unconfirmed and retain their charge. Cleanup/release and settlement each
261
286
  * stop waiting after 15 seconds. Storage TTL reclaims the rows.
262
287
  */
263
- stashResult(id: string, chunks: readonly string[], ttlSeconds: number, partition?: string): Promise<boolean>;
288
+ stashResult(id: string, chunks: readonly string[], ttlSeconds: number, options?: {
289
+ readonly partition?: string;
290
+ readonly holdFirst?: boolean;
291
+ }): Promise<boolean>;
292
+ releaseResult(id: string, chunkCount: number, partition?: string): Promise<void>;
264
293
  /**
265
294
  * Storage namespaced to the root result partition, kept separate from any
266
295
  * connector's namespace. Backs connecta.result.
@@ -280,6 +309,8 @@ export declare class Registry implements RegistryView {
280
309
  /** Compare only the fingerprints accepted in this exact cache partition. */
281
310
  private recordCatalogDrift;
282
311
  private loadDownstreamTools;
312
+ /** See {@link RegistryView.peekCatalog}. Same classification and filtering as `loadTools`. */
313
+ private loadPeek;
283
314
  /** The served catalog excludes control-character names; raw caches stay complete. */
284
315
  private acceptToolNames;
285
316
  /**
@@ -292,6 +323,7 @@ export declare class Registry implements RegistryView {
292
323
  /** Construction-only view for the secret-free static config description. */
293
324
  describeStaticTools(id: string): ToolDef[] | undefined;
294
325
  private validateClassification;
326
+ peekCatalog(id: string, baseUrl: string, requestScope?: object, callOptions?: ConnectorOperationOptions, scope?: RegistryScope): Promise<CatalogPeek | undefined>;
295
327
  getTools(id: string, baseUrl: string, requestScope?: object, callOptions?: ConnectorOperationOptions, scope?: RegistryScope): Promise<ToolDef[]>;
296
328
  credentialDriftFor(id: string, requestScope?: object): Promise<string | undefined>;
297
329
  /** Age of a complete cache entry; reads never refresh it. */
package/dist/registry.js CHANGED
@@ -6,21 +6,21 @@ import { authCall, authInvoke } from "./auth/native.js";
6
6
  import { Clock, Duration, Effect, Random } from "effect";
7
7
  import { activityRequest } from "./activity-request.js";
8
8
  import { aggregateCallAdmissionSnapshots, ConnectorCallAdmissionController, } from "./call-admission.js";
9
- import { attachCatalogCache, catalogExpiry, catalogFetchedAt, customCatalogFallback, invalidateCatalogCache, observeUncachedCatalogRefresh, retainedCatalogIdentity, storeCustomCatalogFallback, } from "./catalog-cache.js";
10
- import { carryCatalogFreshness, markCatalogFreshness } from "./catalog-freshness.js";
9
+ import { attachCatalogCache, catalogExpiry, catalogFetchedAt, bindCustomCatalogListing, customCatalogFallback, customCatalogPeek, fenceCustomCatalog, invalidateCatalogCache, observeUncachedCatalogRefresh, retainedCatalogIdentity, storeCustomCatalogFallback, } from "./catalog-cache.js";
10
+ import { carryCatalogFreshness, markCatalogFreshness, markCatalogStale } from "./catalog-freshness.js";
11
11
  import { MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES } from "./catalog-limits.js";
12
12
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
13
13
  import { usesCallerIdentity } from "./connector-caller.js";
14
14
  import { forwardsPrincipal } from "./request-principal.js";
15
15
  import { canonicalJson } from "./canonical-json.js";
16
16
  import { storedCredentialShape } from "./credential-rules.js";
17
- import { ConnectorCallError } from "./errors.js";
17
+ import { ConnectorCallError, isAccessRefusal } from "./errors.js";
18
18
  import { attachOAuthPartition, oauthPartitionIdle } from "./oauth-partition.js";
19
19
  import { attachOAuthSealer, vaultOAuthSealer } from "./oauth-sealing.js";
20
20
  import { boundedStatus, failureRecord, failureStatus, logFailure, ownStatus } from "./operator-record.js";
21
21
  import { ObservedOutputSchemas } from "./result-shapes.js";
22
22
  import { ContinuationBinding, OperationCredentials, ContinuationStorage, operationView, setViewService, duringViewEffect, } from "./runtime/call.js";
23
- import { hasNativeStorage, registerNativeStorage } from "./runtime/native-storage.js";
23
+ import { getNativeStorage, hasNativeStorage, registerNativeStorage } from "./runtime/native-storage.js";
24
24
  import { observeStorageWrites } from "./runtime/request-reads.js";
25
25
  import { attachStorageDigestKey } from "./storage-digest.js";
26
26
  import { requestReads } from "./runtime/request-scope.js";
@@ -31,10 +31,10 @@ import { Storage } from "./runtime/services.js";
31
31
  import { SharedRead } from "./runtime/shared-read.js";
32
32
  import { runOnPartition, storageCompareAndSet, storageDelete, storageGet, storageGetMany, storageSet, storageList, } from "./runtime/storage.js";
33
33
  import { redactCatalog, sentSecretsFor, sentSecretsForRequest } from "./sent-secrets.js";
34
- import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary } from "./skills.js";
34
+ import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary, connectorGuide, connectorGuideSummary } from "./skills.js";
35
35
  import { jsonCodec, OAUTH_HANDOFF_TTL_SECONDS, oauthHandoffKeys, resultKeys, scopes, stashExpiryKeys, stashLedgerKeys, } from "./storage/keys.js";
36
36
  import { assertStaticToolNames, hasControlCharacters } from "./tool-name.js";
37
- import { carryToolClassification, classifyTool } from "./tool-safety.js";
37
+ import { carryToolClassification, classifyTool, sourceDeclaresWrite, } from "./tool-safety.js";
38
38
  import { isWorkspaceAddress } from "./workspace-address.js";
39
39
  import { workspaceSubjectOf } from "./workspace-subject.js";
40
40
  const ID_RE = /^[a-z0-9_-]+$/;
@@ -145,12 +145,18 @@ const STASH_WRITE_TIMEOUT_MS = 30_000;
145
145
  const STASH_COMPLETION_TIMEOUT_MS = 15_000;
146
146
  /** Reservation, chunk writes and completion share one finite outer budget. */
147
147
  const STASH_TIMEOUT_MS = STASH_WRITE_TIMEOUT_MS + STASH_COMPLETION_TIMEOUT_MS;
148
+ /** Chunks one release deletes at a time; a stash holds at most 32. */
149
+ const STASH_RELEASE_CONCURRENCY = 16;
148
150
  /** Stop waiting without starting background storage work after the deadline. */
149
151
  function completeStash(effect) {
150
152
  return effect.pipe(Effect.asVoid, Effect.timeoutOrElse({ duration: Duration.millis(STASH_COMPLETION_TIMEOUT_MS), orElse: () => Effect.void }),
151
153
  // onError finalizers are otherwise uninterruptible, including their I/O.
152
154
  Effect.interruptible);
153
155
  }
156
+ /** The reservation id of one result id, which is random and never reused. */
157
+ function stashCharge(id) {
158
+ return Effect.tryPromise({ try: () => sha256Hex(id), catch: (error) => error });
159
+ }
154
160
  /**
155
161
  * Live charges in a stored ledger. Anything malformed reads as empty: the
156
162
  * ledger bounds an advisory cache, and the next successful swap replaces it.
@@ -298,6 +304,13 @@ export class Registry {
298
304
  `normalization; the discovery bound is ${GUIDE_SUMMARY_LENGTH}. ` +
299
305
  "Shorten it or omit it to derive one.");
300
306
  }
307
+ // Advisory English-language heuristic: a task verb or operation noun
308
+ // distinguishes actionable guidance from credential/scope facts. Never
309
+ // log guide text, which may contain deployment-private information.
310
+ if (connectorGuide(c) !== undefined &&
311
+ !/\b(load|use|when|read|reads|write|writes|search|query|inspect|resolve|list|review|edit|create|update|deploy|paging|reconciliation|accounting)\b/i.test(connectorGuideSummary(c) ?? "")) {
312
+ opts.logger.warn(`Connector "${c.id}" guide needs an actionable summary; say when to load it with usageGuide.summary.`);
313
+ }
301
314
  this.connectors.set(c.id, c);
302
315
  if (c.callAdmission !== undefined) {
303
316
  this.callAdmission.set(c.id, new ConnectorCallAdmissionController(c.id, c.callAdmission));
@@ -310,8 +323,8 @@ export class Registry {
310
323
  Object.freeze(this.classification);
311
324
  for (const [id, overrides] of Object.entries(this.classification)) {
312
325
  for (const [name, verdict] of Object.entries(overrides)) {
313
- if (!name || (verdict !== "read" && verdict !== "write" && verdict !== "hidden")) {
314
- throw new Error(`ConnectaConfig.classification.${id}.${name}: expected "read", "write", or "hidden"`);
326
+ if (!name || !["read", "read-repeatable", "write", "hidden"].includes(verdict)) {
327
+ throw new Error(`ConnectaConfig.classification.${id}.${name}: expected "read", "read-repeatable", "write", or "hidden"`);
315
328
  }
316
329
  }
317
330
  const connector = this.connectors.get(id);
@@ -610,8 +623,11 @@ export class Registry {
610
623
  * unconfirmed and retain their charge. Cleanup/release and settlement each
611
624
  * stop waiting after 15 seconds. Storage TTL reclaims the rows.
612
625
  */
613
- stashResult(id, chunks, ttlSeconds, partition = scopes.results) {
614
- return runOnPartition(registryEdgeProgram(Registry.prototype, this, "stashResult", id, chunks, ttlSeconds, partition), this.opts);
626
+ stashResult(id, chunks, ttlSeconds, options = {}) {
627
+ return runOnPartition(registryEdgeProgram(Registry.prototype, this, "stashResult", id, chunks, ttlSeconds, options), this.opts);
628
+ }
629
+ releaseResult(id, chunkCount, partition = scopes.results) {
630
+ return runOnPartition(registryEdgeProgram(Registry.prototype, this, "releaseResult", id, chunkCount, partition), this.opts);
615
631
  }
616
632
  /**
617
633
  * Storage namespaced to the root result partition, kept separate from any
@@ -674,7 +690,16 @@ export class Registry {
674
690
  if (connector.staticTools)
675
691
  return markCatalogFreshness(connector.staticTools, Infinity);
676
692
  const ctx = this.contextFor(id, baseUrl, requestScope, callOptions, scope);
677
- const listed = yield* authInvoke(connector, "listTools", [ctx]).pipe(Effect.catch((error) => Effect.gen(function* () {
693
+ // `remoteMcp()` binds and fences its own attempts, knowing how each was made.
694
+ const customListing = connector.peekCatalog
695
+ ? undefined
696
+ : yield* authCall(bindCustomCatalogListing, [ctx, id]).pipe(Effect.catch((failure) => Effect.sync(() => {
697
+ logFailure(this.opts.logger, "catalog read failed", failureRecord({ connector: id }, failure));
698
+ return undefined;
699
+ })));
700
+ const listed = yield* authInvoke(connector, "listTools", [ctx]).pipe(Effect.catch((error) => Effect.gen({ self: this }, function* () {
701
+ if (customListing && isAccessRefusal(error))
702
+ yield* authCall(fenceCustomCatalog, [ctx, id, customListing]).pipe(Effect.catch((failure) => Effect.sync(() => logFailure(this.opts.logger, "catalog fence failed", failureRecord({ connector: id }, failure)))));
678
703
  const fallback = error instanceof ConnectorCallError && error.code === "unavailable"
679
704
  ? yield* authCall(customCatalogFallback, [ctx, id]).pipe(Effect.catch(() => Effect.succeed(undefined)))
680
705
  : undefined;
@@ -690,12 +715,18 @@ export class Registry {
690
715
  const facts = frozenFacts(structuredClone(tools));
691
716
  const accepted = this.acceptToolNames(facts);
692
717
  if (catalogFetchedAt(ctx) === undefined) {
693
- for (const observe of [storeCustomCatalogFallback, observeUncachedCatalogRefresh]) {
694
- yield* authCall(observe, [ctx, id, facts]).pipe(Effect.catch((error) => Effect.sync(() => logFailure(this.opts.logger, "catalog refresh observation failed", failureRecord({ connector: id }, error)))));
718
+ for (const observe of [
719
+ authCall(storeCustomCatalogFallback, [ctx, facts, customListing]),
720
+ authCall(observeUncachedCatalogRefresh, [ctx, id, facts]),
721
+ ]) {
722
+ yield* observe.pipe(Effect.catch((error) => Effect.sync(() => logFailure(this.opts.logger, "catalog refresh observation failed", failureRecord({ connector: id }, error)))));
695
723
  }
696
724
  }
697
725
  const provenance = catalogExpiry(ctx);
698
726
  const freshUntil = provenance && !provenance.staleFallback ? provenance.expiresAt : 0;
727
+ // A transient-failure fallback answers the listing; discovery still reports its age.
728
+ if (provenance?.staleFallback)
729
+ markCatalogStale(accepted, { code: "unavailable", ageMs: Math.max(0, Date.now() - provenance.fetchedAt) });
699
730
  // Principal-forwarded listings belong to one caller, never to the shared
700
731
  // operator observation store. The actual catalog cache is identity-bound.
701
732
  if (!forwardsPrincipal(connector)) {
@@ -708,6 +739,30 @@ export class Registry {
708
739
  return markCatalogFreshness(accepted, freshUntil);
709
740
  });
710
741
  }
742
+ /** See {@link RegistryView.peekCatalog}. Same classification and filtering as `loadTools`. */
743
+ loadPeek(id, baseUrl, requestScope, callOptions = {}, scope) {
744
+ return Effect.gen({ self: this }, function* () {
745
+ const connector = this.connectors.get(id);
746
+ if (!connector || connector.staticTools)
747
+ return undefined;
748
+ const ctx = this.contextFor(id, baseUrl, requestScope, callOptions, scope);
749
+ const peek = connector.peekCatalog
750
+ ? yield* authInvoke(connector, "peekCatalog", [ctx])
751
+ : yield* authCall(customCatalogPeek, [ctx, id]);
752
+ if (!peek)
753
+ return undefined;
754
+ const facts = redactCatalog(ctx, peek.tools).map(({ classification: _ignored, ...fact }) => fact);
755
+ const overrides = this.classification[id];
756
+ return {
757
+ tools: this.acceptToolNames(frozenFacts(structuredClone(facts)))
758
+ .filter((tool) => overrides?.[tool.name] !== "hidden")
759
+ .map((tool) => this.publishTool(id, tool)),
760
+ fetchedAt: peek.fetchedAt,
761
+ // A custom catalog is always listed anew, so its retained copy is never current.
762
+ fresh: connector.peekCatalog !== undefined && peek.expiresAt > Date.now(),
763
+ };
764
+ });
765
+ }
711
766
  /** The served catalog excludes control-character names; raw caches stay complete. */
712
767
  acceptToolNames(tools) {
713
768
  const accepted = tools.filter((tool) => !hasControlCharacters(tool.name));
@@ -734,19 +789,20 @@ export class Registry {
734
789
  }
735
790
  publishTool(id, tool) {
736
791
  const override = this.classification[id]?.[tool.name];
792
+ const classification = classifyTool(tool, override === "hidden" ? undefined : override);
737
793
  return carryToolClassification(tool, {
738
794
  ...structuredClone(tool),
739
- classification: classifyTool(tool, override === "hidden" ? undefined : override),
795
+ classification,
740
796
  ...(override !== undefined
741
797
  ? {
742
798
  annotations: {
743
799
  ...structuredClone(tool.annotations),
744
- readOnlyHint: override === "read",
745
- destructiveHint: override === "write",
800
+ readOnlyHint: override !== "hidden" && classification === "read",
801
+ destructiveHint: override !== "hidden" && classification === "write",
746
802
  },
747
803
  }
748
804
  : {}),
749
- });
805
+ }, override === "read-repeatable" || undefined);
750
806
  }
751
807
  /** Construction-only view for the secret-free static config description. */
752
808
  describeStaticTools(id) {
@@ -764,19 +820,30 @@ export class Registry {
764
820
  return;
765
821
  this.overrideWarningCatalogs.set(partition, catalog);
766
822
  }
767
- const known = new Set(tools.map((tool) => tool.name));
768
- for (const name of Object.keys(overrides ?? {})) {
769
- if (known.has(name))
770
- continue;
771
- if (this.connectors.get(id)?.staticTools) {
823
+ const known = new Map(tools.map((tool) => [tool.name, tool]));
824
+ const staticCatalog = this.connectors.get(id)?.staticTools !== undefined;
825
+ for (const [name, override] of Object.entries(overrides ?? {})) {
826
+ const tool = known.get(name);
827
+ if (!tool && staticCatalog) {
772
828
  throw new Error(`ConnectaConfig.classification: connector "${id}" has no tool "${name}"`);
773
829
  }
774
830
  // Only deployment-authored names, never downstream text. JSON quoting
775
831
  // prevents control characters from forging operator log lines.
776
832
  const quoted = JSON.stringify(`${id}.${name}`).replace(/[\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16)}`);
833
+ if (tool) {
834
+ // A static catalog cannot change, so the deployment's construction reports it once.
835
+ const reports = staticCatalog ? partition === undefined && this.opts.constructionChecks !== false : true;
836
+ if (override === "read" && reports && sourceDeclaresWrite(tool)) {
837
+ this.opts.logger.warn(`classification override ${quoted} to "read" is not applied because the connector declares this tool a write; it stays a write. "read-repeatable" serves it as a read and accepts that hosts may repeat it`);
838
+ }
839
+ continue;
840
+ }
777
841
  this.opts.logger.warn(`classification override ${quoted} is dormant because the runtime catalog lists no such tool`);
778
842
  }
779
843
  }
844
+ peekCatalog(id, baseUrl, requestScope, callOptions = {}, scope) {
845
+ return runEdge(registryEdgeProgram(Registry.prototype, this, "peekCatalog", id, baseUrl, requestScope, callOptions, scope));
846
+ }
780
847
  getTools(id, baseUrl, requestScope, callOptions = {}, scope) {
781
848
  return runEdge(registryEdgeProgram(Registry.prototype, this, "getTools", id, baseUrl, requestScope, callOptions, scope));
782
849
  }
@@ -811,7 +878,8 @@ export class Registry {
811
878
  }
812
879
  static {
813
880
  provideRegistryPrograms(Registry.prototype, {
814
- stashResult: (self, id, chunks, ttlSeconds, partition = scopes.results) => Effect.gen({ self }, function* () {
881
+ stashResult: (self, id, chunks, ttlSeconds, options = {}) => Effect.gen({ self }, function* () {
882
+ const partition = options.partition ?? scopes.results;
815
883
  const maxBytes = this.opts.results?.maxStashBytes ?? CONFIG_DEFAULTS.results.maxStashBytes;
816
884
  const maxEntries = this.opts.results?.maxStashEntries ?? CONFIG_DEFAULTS.results.maxStashEntries;
817
885
  // The paging envelope is ASCII, so its string length is its stored byte count.
@@ -819,8 +887,9 @@ export class Registry {
819
887
  if (bytes > maxBytes || maxEntries === 0)
820
888
  return false;
821
889
  const keys = chunks.map((_, index) => partition + resultKeys.chunk(id, index));
822
- // IDs belong to reservations, not reusable result IDs or subjects.
823
- const charge = crypto.randomUUID();
890
+ // A digest finds this reservation again on release without putting
891
+ // the result's ID in the deployment-wide ledger.
892
+ const charge = yield* stashCharge(id);
824
893
  const ttlMs = ttlSeconds * 1000;
825
894
  if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0)
826
895
  return false;
@@ -874,16 +943,22 @@ export class Registry {
874
943
  // ID after failure or timeout, including a late header commit.
875
944
  const remaining = Math.max(0, writeDeadline - (yield* Clock.currentTimeMillis));
876
945
  const written = yield* Effect.gen(function* () {
877
- for (let index = keys.length - 1; index >= 0; index--) {
946
+ const multi = getNativeStorage(self.opts.storage)?.setMany;
947
+ const lowest = options.holdFirst ? 1 : 0;
948
+ for (let index = keys.length - 1; index >= lowest;) {
949
+ const first = multi && index > 0 ? Math.max(1, index - 63) : index;
878
950
  const before = yield* Clock.currentTimeMillis;
879
951
  if (before >= writeDeadline || before >= chunkExpiry)
880
952
  return false;
881
953
  pendingWrite = true;
882
- yield* storageSet(keys[index], chunks[index], { expiresAtMs: chunkExpiry }).pipe(Effect.tap(() => Effect.sync(() => {
954
+ yield* (multi && index > 0
955
+ ? multi(Array.from({ length: index - first + 1 }, (_, offset) => [keys[index - offset], chunks[index - offset]]), { expiresAtMs: chunkExpiry })
956
+ : storageSet(keys[index], chunks[index], { expiresAtMs: chunkExpiry })).pipe(Effect.tap(() => Effect.sync(() => {
883
957
  pendingWrite = false;
884
958
  })));
885
959
  if ((yield* Clock.currentTimeMillis) >= writeDeadline)
886
960
  return false;
961
+ index = first - 1;
887
962
  }
888
963
  return true;
889
964
  }).pipe(Effect.timeoutOrElse({ duration: Duration.millis(remaining), orElse: () => Effect.succeed(false) }), Effect.onError(() => release.pipe(Effect.ignore)));
@@ -893,6 +968,15 @@ export class Registry {
893
968
  yield* completeStash(settle);
894
969
  return written;
895
970
  }).pipe(Effect.timeoutOrElse({ duration: Duration.millis(STASH_TIMEOUT_MS), orElse: () => Effect.succeed(false) }), Effect.provideService(Storage, self.opts.storage)),
971
+ releaseResult: (self, id, chunkCount, partition = scopes.results) => completeStash(Effect.gen(function* () {
972
+ const charge = yield* stashCharge(id);
973
+ // Bytes first: a failed delete keeps its charge, so the ledger never
974
+ // undercounts what storage holds.
975
+ yield* Effect.forEach(Array.from({ length: chunkCount }, (_, index) => partition + resultKeys.chunk(id, index)), (key) => storageDelete(key), { concurrency: STASH_RELEASE_CONCURRENCY, discard: true });
976
+ yield* swapStashLedger((live) => live.some((entry) => entry[0] === charge)
977
+ ? { entries: live.filter((entry) => entry[0] !== charge), result: undefined }
978
+ : { result: undefined });
979
+ })).pipe(Effect.ignore, Effect.provideService(Storage, self.opts.storage)),
896
980
  recheckResultAccess: () => Effect.succeed(true),
897
981
  storeOAuthHandoff: (self, connectorId, state, principalKey) => Effect.gen(function* () {
898
982
  const key = oauthHandoffKeys.handoff(connectorId, yield* Effect.tryPromise({ try: () => sha256Hex(state), catch: (error) => error }));
@@ -961,6 +1045,12 @@ export class Registry {
961
1045
  return yield* load.join(signal);
962
1046
  }));
963
1047
  }),
1048
+ peekCatalog: (self, id, baseUrl, requestScope, callOptions = {}, scope) => Effect.suspend(() => {
1049
+ if (!self.connectors.has(id))
1050
+ return Effect.succeed(undefined);
1051
+ const owner = self.contextFor(id, baseUrl, requestScope, callOptions, scope);
1052
+ return duringViewEffect(owner, (signal) => self.loadPeek(id, baseUrl, owner.requestScope, { ...callOptions, signal }, scope));
1053
+ }),
964
1054
  credentialDriftFor: (self, id, requestScope) => Effect.gen(function* () {
965
1055
  const credential = self.connectors.get(id)?.credential;
966
1056
  const vault = self.opts.credentialVault;
@@ -1085,6 +1175,9 @@ class ScopedRegistryView {
1085
1175
  getTools(...args) {
1086
1176
  return runEdge(registryEdgeProgram(ScopedRegistryView.prototype, this, "getTools", ...args));
1087
1177
  }
1178
+ peekCatalog(...args) {
1179
+ return runEdge(registryEdgeProgram(ScopedRegistryView.prototype, this, "peekCatalog", ...args));
1180
+ }
1088
1181
  contextFor(...args) {
1089
1182
  const registry = this.registryFor(args[0]);
1090
1183
  if (!registry)
@@ -1098,8 +1191,14 @@ class ScopedRegistryView {
1098
1191
  }
1099
1192
  return registry.admitCall(...args);
1100
1193
  }
1101
- stashResult(id, chunks, ttlSeconds) {
1102
- return this.root.stashResult(id, chunks, ttlSeconds, this.scope.subjectKey ? scopes.subject(this.scope.subjectKey) : scopes.results);
1194
+ stashResult(id, chunks, ttlSeconds, options = {}) {
1195
+ return this.root.stashResult(id, chunks, ttlSeconds, {
1196
+ ...options,
1197
+ partition: this.scope.subjectKey ? scopes.subject(this.scope.subjectKey) : scopes.results,
1198
+ });
1199
+ }
1200
+ releaseResult(id, chunkCount) {
1201
+ return this.root.releaseResult(id, chunkCount, this.scope.subjectKey ? scopes.subject(this.scope.subjectKey) : scopes.results);
1103
1202
  }
1104
1203
  resultIdentity() {
1105
1204
  return {
@@ -1146,7 +1245,11 @@ class ScopedRegistryView {
1146
1245
  }
1147
1246
  static {
1148
1247
  provideRegistryPrograms(ScopedRegistryView.prototype, {
1149
- stashResult: (self, id, chunks, ttlSeconds) => registryProgram(self.root, "stashResult", id, chunks, ttlSeconds, self.scope.subjectKey ? scopes.subject(self.scope.subjectKey) : scopes.results),
1248
+ releaseResult: (self, id, chunkCount) => registryProgram(self.root, "releaseResult", id, chunkCount, self.scope.subjectKey ? scopes.subject(self.scope.subjectKey) : scopes.results),
1249
+ stashResult: (self, id, chunks, ttlSeconds, options = {}) => registryProgram(self.root, "stashResult", id, chunks, ttlSeconds, {
1250
+ ...options,
1251
+ partition: self.scope.subjectKey ? scopes.subject(self.scope.subjectKey) : scopes.results,
1252
+ }),
1150
1253
  recheckResultAccess: (self, dependencies, classification, signal) => Effect.tryPromise({
1151
1254
  try: () => self.scope.currentResultAccess?.(dependencies, classification, signal) ?? Promise.resolve(true),
1152
1255
  catch: (error) => error,
@@ -1173,6 +1276,15 @@ class ScopedRegistryView {
1173
1276
  }
1174
1277
  return carryCatalogFreshness(tools, visible);
1175
1278
  }),
1279
+ peekCatalog: (self, ...args) => Effect.gen(function* () {
1280
+ const registry = self.registryFor(args[0]);
1281
+ if (!registry)
1282
+ return undefined;
1283
+ const peek = yield* registryProgram(registry, "peekCatalog", args[0], args[1], args[2], args[3], self.scope);
1284
+ const granted = self.scope.toolAccess?.get(args[0]);
1285
+ // A pool grant binds a stale listing exactly as it binds a live one.
1286
+ return peek && granted ? { ...peek, tools: peek.tools.filter((tool) => granted.has(tool.name)) } : peek;
1287
+ }),
1176
1288
  credentialDriftFor: (self, id, requestScope) => Effect.suspend(() => {
1177
1289
  const registry = self.registryFor(id);
1178
1290
  return registry
@@ -1,8 +1,10 @@
1
+ import { utilityRecovery } from "./utility-recovery.js";
1
2
  import { Effect } from "effect";
2
3
  import { classifyCallError, ConnectorCallError } from "./errors.js";
3
4
  import { InvocationFailure, timed } from "./invocation.js";
4
5
  import { withDeadlineEffect } from "./runtime/run.js";
5
6
  import { sentSecretsFor } from "./sent-secrets.js";
7
+ import { downstreamOrigin, isSkillResource } from "./skill-origin.js";
6
8
  import { hasControlCharacters } from "./tool-name.js";
7
9
  const encoder = new TextEncoder();
8
10
  const URI_HINT = "Use resource://<connectorId>/<encodeURIComponent(downstreamUri)> with a whole-connector grant.";
@@ -26,13 +28,26 @@ function resourceTarget(registry, raw) {
26
28
  const connector = registry.getResourceConnector(match[1]);
27
29
  if (!connector?.readResource) {
28
30
  throw new InvocationFailure({
29
- code: "unknown_address",
31
+ ...utilityRecovery(connector ? "unsupported_capability" : "unknown_address", "No resource reader is available."),
32
+ code: connector ? "unsupported_capability" : "unknown_address",
30
33
  message: `No resource reader is available for this address. ${URI_HINT}`,
31
34
  retryable: false,
32
35
  });
33
36
  }
34
37
  return { connector, uri };
35
38
  }
39
+ /** Frame skill text for a program; native resources/read keeps exact bytes and never comes here. */
40
+ function labelSkillText(contents, connectorId, uri) {
41
+ const items = contents?.contents;
42
+ if (!Array.isArray(items))
43
+ return contents;
44
+ return {
45
+ ...contents,
46
+ contents: items.map((item) => typeof item?.text === "string" && (isSkillResource(uri) || isSkillResource(String(item.uri ?? "")))
47
+ ? { ...item, text: `${downstreamOrigin(connectorId)}\n\n${item.text}` }
48
+ : item),
49
+ };
50
+ }
36
51
  /** A protocol read, under the same request scope, call permit and deadline as tools. */
37
52
  export function readResource(registry, raw, options) {
38
53
  let connector;
@@ -68,7 +83,7 @@ export function readResource(registry, raw, options) {
68
83
  },
69
84
  catch: (error) => error,
70
85
  }));
71
- return options.sentSecrets.redact(contents);
86
+ return labelSkillText(options.sentSecrets.redact(contents), target.connector.id, target.uri);
72
87
  })), {
73
88
  timeoutMs: options.timeoutMs,
74
89
  ...(options.signal ? { signal: options.signal } : {}),
@@ -98,6 +113,10 @@ export function readResource(registry, raw, options) {
98
113
  retry: "Retry connecta.read with the same resource URI after the operator completes recovery.",
99
114
  };
100
115
  }
116
+ if (["unavailable", "unsupported_capability", "input_required_unsupported"].includes(details.code) &&
117
+ !details.nextAction) {
118
+ details = { ...details, nextAction: utilityRecovery(details.code, details.message).nextAction };
119
+ }
101
120
  return Effect.fail(new InvocationFailure(options.sentSecrets.redact(details)));
102
121
  }));
103
122
  }