@lanes-sh/link 0.6.11 → 0.7.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 (201) hide show
  1. package/README.md +62 -45
  2. package/instructions/skills/lanes-link/SKILL.md +25 -0
  3. package/package.json +3 -2
  4. package/src/audit/conformance.ts +1 -1
  5. package/src/cli/argv.ts +14 -1
  6. package/src/cli/commands/connect/acquire.ts +100 -0
  7. package/src/cli/commands/connect/custom/index.ts +1 -1
  8. package/src/cli/commands/connect/declare.ts +19 -0
  9. package/src/cli/commands/connect/index.ts +54 -56
  10. package/src/cli/commands/connect/requirements.ts +23 -1
  11. package/src/cli/commands/connect/setup.ts +1 -1
  12. package/src/cli/commands/connect/variables.ts +220 -0
  13. package/src/cli/commands/knowledge/index.ts +22 -4
  14. package/src/cli/commands/knowledge/migrate.ts +61 -43
  15. package/src/cli/commands/operate/tools.ts +1 -1
  16. package/src/cli/commands/owner/entities.ts +244 -0
  17. package/src/cli/commands/owner/shared.ts +14 -0
  18. package/src/cli/commands/owner/vault.ts +2 -2
  19. package/src/cli/commands/owner.ts +13 -3
  20. package/src/cli/commands/setup.ts +12 -0
  21. package/src/cli/config-edit.ts +16 -6
  22. package/src/cli/config-repair.ts +10 -0
  23. package/src/cli/dispatch-owner.ts +30 -1
  24. package/src/cli/main.ts +2 -1
  25. package/src/cli/runtime/open.ts +14 -7
  26. package/src/cli/runtime/registry.ts +6 -1
  27. package/src/cli/selection.ts +11 -0
  28. package/src/cli/usage.ts +13 -2
  29. package/src/connectivity/auth/strategy/index.ts +1 -1
  30. package/src/connectivity/capability.ts +1 -1
  31. package/src/connectivity/context.ts +1 -1
  32. package/src/connectivity/index.ts +1 -1
  33. package/src/connectivity/mail/attachments.ts +42 -73
  34. package/src/connectivity/mail/content-type.ts +71 -0
  35. package/src/connectivity/mail/index.ts +1 -1
  36. package/src/connectivity/manifest/provider.ts +50 -0
  37. package/src/connectivity/manifest/variables.ts +173 -0
  38. package/src/connectivity/transports/factory.ts +74 -1
  39. package/src/connectivity/transports/http/index.ts +8 -2
  40. package/src/connectivity/transports/http/keys.ts +83 -0
  41. package/src/connectivity/transports/imap/attachment.ts +53 -10
  42. package/src/connectivity/transports/imap/capabilities.ts +28 -0
  43. package/src/connectivity/transports/imap/download.ts +111 -0
  44. package/src/connectivity/transports/imap/index.ts +13 -0
  45. package/src/connectivity/transports/imap/operations.ts +1 -0
  46. package/src/deployments/adapters/s3.ts +1 -1
  47. package/src/deployments/gcp/provision.ts +1 -1
  48. package/src/deployments/knowledge.ts +44 -18
  49. package/src/deployments/prepare.ts +1 -1
  50. package/src/deployments/serving.ts +1 -1
  51. package/src/dispatch/dispatch.ts +17 -10
  52. package/src/dispatch/staging.ts +76 -1
  53. package/src/profile/knowledge.ts +19 -11
  54. package/src/profile/schema.ts +2 -2
  55. package/src/providers/README.md +189 -0
  56. package/src/providers/airtable/index.ts +10 -0
  57. package/src/providers/algolia/index.ts +10 -0
  58. package/src/providers/amplitude/index.ts +10 -0
  59. package/src/providers/apify/index.ts +10 -0
  60. package/src/providers/asana/index.ts +10 -0
  61. package/src/providers/assets/provider.ts +11 -4
  62. package/src/providers/atlassian/index.ts +19 -0
  63. package/src/providers/attio/index.ts +10 -0
  64. package/src/providers/betterstack/index.ts +10 -0
  65. package/src/providers/box/index.ts +64 -0
  66. package/src/providers/brightdata/index.ts +10 -0
  67. package/src/providers/buildkite/index.ts +10 -0
  68. package/src/providers/bunq/index.ts +2 -2
  69. package/src/providers/calendly/index.ts +10 -0
  70. package/src/providers/canva/index.ts +10 -0
  71. package/src/providers/circleci/index.ts +10 -0
  72. package/src/providers/clickup/index.ts +10 -0
  73. package/src/providers/close/index.ts +10 -0
  74. package/src/providers/cloudflare_bindings/index.ts +14 -0
  75. package/src/providers/cloudflare_observability/index.ts +10 -0
  76. package/src/providers/contentful/index.ts +10 -0
  77. package/src/providers/custom/load.ts +1 -1
  78. package/src/providers/datadog/index.ts +10 -0
  79. package/src/providers/discord/index.ts +1 -1
  80. package/src/providers/discord/specs/discord.v10.json +0 -88
  81. package/src/providers/discord/specs/vendor.ts +1 -1
  82. package/src/providers/dropbox/index.ts +10 -0
  83. package/src/providers/entities/catalogue.ts +366 -0
  84. package/src/providers/entities/find.ts +279 -0
  85. package/src/providers/entities/provider.ts +334 -0
  86. package/src/providers/entities/render.ts +142 -0
  87. package/src/providers/entities/store.ts +276 -0
  88. package/src/providers/entities/writes.ts +279 -0
  89. package/src/providers/example/provider.ts +1 -1
  90. package/src/providers/expensify/index.ts +10 -0
  91. package/src/providers/fastmail/calendar/index.ts +21 -0
  92. package/src/providers/fastmail/contacts/index.ts +17 -0
  93. package/src/providers/fastmail/index.ts +4 -0
  94. package/src/providers/fastmail/mail/index.ts +38 -0
  95. package/src/providers/fastmail/shared/setup.ts +52 -0
  96. package/src/providers/figma/index.ts +10 -0
  97. package/src/providers/fireflies/index.ts +10 -0
  98. package/src/providers/flagsmith/index.ts +10 -0
  99. package/src/providers/gamma/index.ts +10 -0
  100. package/src/providers/github/index.ts +2 -2
  101. package/src/providers/google/gmail/attachment.ts +16 -1
  102. package/src/providers/google/gmail-imap/index.ts +7 -2
  103. package/src/providers/google/shared/oauth.ts +1 -1
  104. package/src/providers/google/shared/service-account.ts +1 -1
  105. package/src/providers/google/shared/setup.ts +1 -1
  106. package/src/providers/google/specs/calendar.v3.json +0 -32
  107. package/src/providers/google/specs/docs.v1.json +0 -76
  108. package/src/providers/google/specs/drive.v3.json +0 -32
  109. package/src/providers/google/specs/gmail.v1.json +0 -76
  110. package/src/providers/google/specs/people.v1.json +0 -77
  111. package/src/providers/google/specs/sheets.v4.json +0 -76
  112. package/src/providers/google/specs/tasks.v1.json +0 -76
  113. package/src/providers/grafana/index.ts +10 -0
  114. package/src/providers/heroku/index.ts +10 -0
  115. package/src/providers/hubspot/index.ts +75 -0
  116. package/src/providers/hygraph/index.ts +10 -0
  117. package/src/providers/icloud/mail/index.ts +5 -0
  118. package/src/providers/icloud/shared/setup.ts +1 -1
  119. package/src/providers/index.ts +255 -0
  120. package/src/providers/insightly/index.ts +10 -0
  121. package/src/providers/jam/index.ts +10 -0
  122. package/src/providers/klaviyo/index.ts +10 -0
  123. package/src/providers/mailbox/index.ts +87 -0
  124. package/src/providers/memory/provider.ts +3 -8
  125. package/src/providers/mercury/index.ts +10 -0
  126. package/src/providers/microsoft/calendar/index.ts +49 -0
  127. package/src/providers/microsoft/contacts/index.ts +50 -0
  128. package/src/providers/microsoft/drive/index.ts +62 -0
  129. package/src/providers/microsoft/index.ts +12 -0
  130. package/src/providers/microsoft/mail/index.ts +76 -0
  131. package/src/providers/microsoft/shared/oauth.ts +58 -0
  132. package/src/providers/microsoft/shared/scopes.ts +56 -0
  133. package/src/providers/microsoft/shared/setup.ts +45 -0
  134. package/src/providers/microsoft/specs/microsoft-todo.v1.json +755 -0
  135. package/src/providers/microsoft/specs/onedrive.v1.json +993 -0
  136. package/src/providers/microsoft/specs/outlook-calendar.v1.json +1743 -0
  137. package/src/providers/microsoft/specs/outlook-contacts.v1.json +430 -0
  138. package/src/providers/microsoft/specs/outlook-mail.v1.json +901 -0
  139. package/src/providers/microsoft/specs/vendor.ts +232 -0
  140. package/src/providers/microsoft/todo/index.ts +54 -0
  141. package/src/providers/miro/index.ts +10 -0
  142. package/src/providers/mixpanel/index.ts +10 -0
  143. package/src/providers/monday/index.ts +10 -0
  144. package/src/providers/mux/index.ts +10 -0
  145. package/src/providers/navan/index.ts +10 -0
  146. package/src/providers/neon/index.ts +10 -0
  147. package/src/providers/netlify/index.ts +10 -0
  148. package/src/providers/nextcloud/calendar/index.ts +28 -0
  149. package/src/providers/nextcloud/contacts/index.ts +18 -0
  150. package/src/providers/nextcloud/index.ts +3 -0
  151. package/src/providers/nextcloud/shared/setup.ts +59 -0
  152. package/src/providers/owner.ts +15 -3
  153. package/src/providers/paddle/index.ts +10 -0
  154. package/src/providers/paypal/index.ts +10 -0
  155. package/src/providers/posthog/index.ts +10 -0
  156. package/src/providers/prisma/index.ts +10 -0
  157. package/src/providers/ramp/index.ts +10 -0
  158. package/src/providers/recurly/index.ts +10 -0
  159. package/src/providers/reddit/index.ts +1 -1
  160. package/src/providers/remote/index.ts +10 -0
  161. package/src/providers/render/index.ts +35 -0
  162. package/src/providers/replicate/index.ts +10 -0
  163. package/src/providers/resend/index.ts +10 -0
  164. package/src/providers/riverside/index.ts +10 -0
  165. package/src/providers/rootly/index.ts +10 -0
  166. package/src/providers/rudderstack/index.ts +10 -0
  167. package/src/providers/salesloft/index.ts +10 -0
  168. package/src/providers/sanity/index.ts +10 -0
  169. package/src/providers/scopes.ts +2 -0
  170. package/src/providers/sentry/index.ts +10 -0
  171. package/src/providers/setup/plan.ts +11 -0
  172. package/src/providers/setup/provider.ts +12 -0
  173. package/src/providers/shared/openapi.ts +70 -0
  174. package/src/providers/shared/slug.ts +28 -0
  175. package/src/providers/shared/vendor-operations.ts +43 -1
  176. package/src/providers/shared/vendor-spec.ts +98 -17
  177. package/src/providers/shortcut/index.ts +10 -0
  178. package/src/providers/slack/index.ts +2 -2
  179. package/src/providers/square/index.ts +10 -0
  180. package/src/providers/storyblok/index.ts +10 -0
  181. package/src/providers/stripe/index.ts +10 -0
  182. package/src/providers/supabase/index.ts +10 -0
  183. package/src/providers/tasks/store.ts +2 -7
  184. package/src/providers/tavily/index.ts +10 -0
  185. package/src/providers/todoist/index.ts +10 -0
  186. package/src/providers/untested.ts +105 -0
  187. package/src/providers/vault/provider.ts +1 -1
  188. package/src/providers/vercel/index.ts +10 -0
  189. package/src/providers/vimeo/index.ts +10 -0
  190. package/src/providers/webflow/index.ts +10 -0
  191. package/src/providers/whimsical/index.ts +10 -0
  192. package/src/providers/wix/index.ts +10 -0
  193. package/src/providers/workable/index.ts +10 -0
  194. package/src/providers/yahoo_mail/index.ts +73 -0
  195. package/src/providers/zapier/index.ts +10 -0
  196. package/src/providers/zoho_mail/index.ts +79 -0
  197. package/src/secrets/document.ts +1 -1
  198. package/src/server/attachments.ts +129 -5
  199. package/src/server/index.ts +3 -3
  200. package/src/server/mcp/instructions.ts +88 -18
  201. package/src/server/stdio.ts +1 -1
@@ -1,4 +1,5 @@
1
1
  import type { McpOpenAPITool, ParameterMapper } from 'mcp-from-openapi';
2
+ import { withLegalKeys } from './keys.ts';
2
3
  import { shortenName } from '../mcp/index.ts';
3
4
  import {
4
5
  READ_BUNDLE,
@@ -186,17 +187,22 @@ export function createHttpConnector(options: HttpConnectorOptions): Connector {
186
187
  tool.description || `${tool.metadata.method.toUpperCase()} ${tool.metadata.path}`;
187
188
  const hint = context.manifest.hints?.[name];
188
189
 
190
+ const legal = withLegalKeys(
191
+ tool.inputSchema as unknown as Record<string, unknown>,
192
+ tool.mapper as unknown as ParameterMapper[],
193
+ );
194
+
189
195
  return {
190
196
  name,
191
197
  description: hint ? `${described}\n\n${hint}` : described,
192
- inputSchema: tool.inputSchema as unknown as Record<string, unknown>,
198
+ inputSchema: legal.schema,
193
199
  bundle: bundleForMethod(tool.metadata.method),
194
200
  // Everything needed to rebuild the request without re-reading the spec,
195
201
  // so a cold instance serves from the cache alone.
196
202
  target: {
197
203
  path: tool.metadata.path,
198
204
  method: tool.metadata.method,
199
- mapper: tool.mapper as unknown as Record<string, unknown>[],
205
+ mapper: legal.mapper as unknown as Record<string, unknown>[],
200
206
  },
201
207
  };
202
208
  });
@@ -0,0 +1,83 @@
1
+ import type { ParameterMapper } from 'mcp-from-openapi';
2
+
3
+ /**
4
+ * Property keys the Anthropic API will accept.
5
+ *
6
+ * It enforces `^[a-zA-Z0-9_.-]{1,64}$` on every key in a tool's input schema and
7
+ * rejects **the whole `tools` array** when one fails — so a single argument named
8
+ * `$top` takes down every provider on the endpoint at once, not just its own.
9
+ * That has happened: Google's `$.xgafv` produced 107 tools, one 400, and nothing
10
+ * working.
11
+ *
12
+ * Guarding it in the vendoring script was the first answer and it is the wrong
13
+ * layer. A script only protects the specs it runs over, and the constraint
14
+ * belongs to every `http` provider, including a YAML manifest in `providers.d/`
15
+ * pointing at a document nobody here has read. Microsoft Graph makes that
16
+ * concrete: OData names every query parameter `$top`, `$select`, `$filter`,
17
+ * `$orderby`, and dropping them would leave an agent unable to ask for less than
18
+ * a whole mailbox.
19
+ *
20
+ * So the key is renamed and the *wire name is not*. `ParameterMapper` already
21
+ * carries the two separately — `inputKey` is what the caller passes, `key` is
22
+ * what the request sends — and nothing but this function has ever needed them to
23
+ * differ.
24
+ */
25
+ export const LEGAL_KEY = /^[a-zA-Z0-9_.-]{1,64}$/;
26
+
27
+ function legalKey(name: string, taken: ReadonlySet<string>): string {
28
+ const stripped = name.replace(/[^a-zA-Z0-9_.-]/g, '').slice(0, 64);
29
+ // Everything illegal, or a name that was only its illegal characters.
30
+ const base = stripped === '' ? 'argument' : stripped;
31
+
32
+ if (!taken.has(base)) return base;
33
+ for (let suffix = 2; ; suffix += 1) {
34
+ const candidate = `${base.slice(0, 61)}_${suffix}`;
35
+ if (!taken.has(candidate)) return candidate;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Rename what the caller passes, leaving what the request sends alone.
41
+ *
42
+ * Returns the schema unchanged when every key is already legal, which is every
43
+ * provider but Microsoft today — so this costs nothing where it is not needed
44
+ * and cannot quietly reshape a schema that was fine.
45
+ */
46
+ export function withLegalKeys(
47
+ schema: Record<string, unknown>,
48
+ mapper: readonly ParameterMapper[],
49
+ ): { schema: Record<string, unknown>; mapper: readonly ParameterMapper[] } {
50
+ const properties = schema['properties'] as Record<string, unknown> | undefined;
51
+ if (!properties) return { schema, mapper };
52
+
53
+ const illegal = Object.keys(properties).filter((key) => !LEGAL_KEY.test(key));
54
+ if (illegal.length === 0) return { schema, mapper };
55
+
56
+ const taken = new Set(Object.keys(properties).filter((key) => LEGAL_KEY.test(key)));
57
+ const renamed = new Map<string, string>();
58
+ for (const key of illegal) {
59
+ const legal = legalKey(key, taken);
60
+ taken.add(legal);
61
+ renamed.set(key, legal);
62
+ }
63
+
64
+ const nextProperties: Record<string, unknown> = {};
65
+ for (const [key, value] of Object.entries(properties)) {
66
+ nextProperties[renamed.get(key) ?? key] = value;
67
+ }
68
+
69
+ const required = schema['required'];
70
+ return {
71
+ schema: {
72
+ ...schema,
73
+ properties: nextProperties,
74
+ ...(Array.isArray(required)
75
+ ? { required: required.map((key) => renamed.get(String(key)) ?? key) }
76
+ : {}),
77
+ },
78
+ mapper: mapper.map((entry) =>
79
+ renamed.has(entry.inputKey) ? { ...entry, inputKey: renamed.get(entry.inputKey)! } : entry,
80
+ ),
81
+ };
82
+ }
83
+
@@ -12,11 +12,20 @@ import { decodeMailboxName, encodeMailboxName } from './utf7.ts';
12
12
  * the bytes to make a round trip through the model. Here they never leave the
13
13
  * process.
14
14
  *
15
- * A message is named by its RFC 2822 `Message-ID` rather than by UID, because
16
- * that is what `get_message` and `search_messages` already report and it does not
17
- * oblige the caller to also remember which folder the message was in. The cost is
18
- * a search: INBOX first, since that is where it almost always is, then the rest
19
- * of the folders in the order the server lists them.
15
+ * Two ways to name the message, and the order of preference is the opposite of
16
+ * the order they were built in.
17
+ *
18
+ * A **uid** with the mailbox it belongs to — is what IMAP itself uses, and it
19
+ * is already in front of the caller: `search_messages` reports both and
20
+ * `get_message` takes both. One `EXAMINE` and one `UID FETCH`, no searching.
21
+ *
22
+ * A **Message-ID** obliges a search, because the caller has not said which folder
23
+ * the message is in: INBOX first, since that is where it almost always is, then
24
+ * the rest in the order the server lists them, matching on `HEADER MESSAGE-ID`.
25
+ * That is a substring match the server implements however it likes, and a server
26
+ * that implements it poorly answers "no such message" while holding the message.
27
+ * It stays because a Message-ID survives being moved between folders and a uid
28
+ * does not — but a caller holding a uid should send the uid.
20
29
  *
21
30
  * Kept out of `commands.ts` because that file is near the size budget and this is
22
31
  * a self-contained question — which bytes, given a Message-ID — rather than
@@ -27,12 +36,21 @@ import { decodeMailboxName, encodeMailboxName } from './utf7.ts';
27
36
  const SKIP_FLAGS = new Set(['\\trash', '\\junk']);
28
37
 
29
38
  export function mailboxAttachments(client: ImapClient): MailboxAttachmentSource {
30
- return async ({ messageId, attachmentId }) =>
39
+ return async ({ messageId, mailbox, uid, attachmentId }) =>
31
40
  client.run(async (session) => {
32
- const bytes = await findMessageBytes(session, messageId);
41
+ const named = uid === undefined ? messageId : `uid ${uid} in ${mailbox ?? 'INBOX'}`;
42
+ const bytes =
43
+ uid === undefined
44
+ ? await findMessageBytes(session, messageId!)
45
+ : await fetchByUid(session, mailbox ?? 'INBOX', uid);
46
+
33
47
  if (!bytes) {
34
48
  throw new Error(
35
- `No message with Message-ID ${messageId} in this account, so its attachment cannot be re-attached.`,
49
+ uid === undefined
50
+ ? `No message with Message-ID ${messageId} was found in this account. Every folder but Trash and Junk was searched, ` +
51
+ `and some servers match the Message-ID header poorly — if search_messages or get_message reported a uid and a ` +
52
+ `mailbox for this message, name those instead: { "uid": 1234, "mailbox": "INBOX" }.`
53
+ : `No message with ${named} — a uid is only valid in the mailbox it was reported for, and stops being valid if the message moves.`,
36
54
  );
37
55
  }
38
56
 
@@ -40,10 +58,10 @@ export function mailboxAttachments(client: ImapClient): MailboxAttachmentSource
40
58
  const attachments = mail.attachments;
41
59
 
42
60
  if (attachments.length === 0) {
43
- throw new Error(`Message ${messageId} has no attachments.`);
61
+ throw new Error(`Message ${named} has no attachments.`);
44
62
  }
45
63
 
46
- const chosen = pick(attachments, attachmentId, messageId);
64
+ const chosen = pick(attachments, attachmentId, named!);
47
65
 
48
66
  return {
49
67
  bytes:
@@ -155,6 +173,31 @@ async function fetchByMessageId(
155
173
 
156
174
  if (uid === undefined) return null;
157
175
 
176
+ return await fetchBody(session, uid);
177
+ }
178
+
179
+ /**
180
+ * The direct route: the caller already knows which mailbox and which uid.
181
+ *
182
+ * `EXAMINE` rather than `SELECT`, and `BODY.PEEK[]` rather than `BODY[]`, for the
183
+ * same reason as every other read here — fetching something to attach must not
184
+ * mark it as read.
185
+ */
186
+ async function fetchByUid(
187
+ session: ImapSession,
188
+ mailbox: string,
189
+ uid: number,
190
+ ): Promise<Uint8Array | null> {
191
+ try {
192
+ await session.command(`EXAMINE ${quoted(encodeMailboxName(mailbox))}`);
193
+ } catch {
194
+ return null;
195
+ }
196
+
197
+ return await fetchBody(session, uid);
198
+ }
199
+
200
+ async function fetchBody(session: ImapSession, uid: number): Promise<Uint8Array | null> {
158
201
  const fetched = await session.command(`UID FETCH ${uid} (BODY.PEEK[])`);
159
202
  const record = fetched.untagged.find(
160
203
  (tokens) => asText(tokens[2]) === 'FETCH' && tokens[3]?.kind === 'list',
@@ -69,6 +69,34 @@ export function imapCapabilities(input: {
69
69
  ),
70
70
  target: { operation: OPERATIONS.getMessage },
71
71
  },
72
+ {
73
+ name: OPERATIONS.getAttachment,
74
+ description:
75
+ 'Take one attachment out of a message and hold it on the endpoint, returning a handle rather than the file. Fetch the bytes with GET /attachments?connection=<provider>.<account>&handle=<handle>, or name the handle in a later send_message. get_message lists what a message carries; this is how you get one.',
76
+ bundle: READ_BUNDLE,
77
+ inputSchema: object(
78
+ {
79
+ mailbox: mailboxArgument,
80
+ uid: {
81
+ type: 'integer',
82
+ minimum: 1,
83
+ description: 'UID as search_messages and get_message report it. Preferred — it names the message directly.',
84
+ },
85
+ message_id: {
86
+ type: 'string',
87
+ description:
88
+ 'RFC 2822 Message-ID, if you do not have a uid. Slower and less reliable: it has no mailbox attached, so every folder is searched.',
89
+ },
90
+ attachment_id: {
91
+ type: 'string',
92
+ description:
93
+ 'Which attachment: its filename, or its 1-based position. Omit when the message has exactly one.',
94
+ },
95
+ },
96
+ [],
97
+ ),
98
+ target: { operation: OPERATIONS.getAttachment },
99
+ },
72
100
  {
73
101
  name: OPERATIONS.markMessages,
74
102
  description: 'Add or remove flags on messages — read, flagged, answered, draft.',
@@ -0,0 +1,111 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ newHandle,
4
+ putStaged,
5
+ sweepStaged,
6
+ STAGED_TTL_MS,
7
+ type MailboxAttachmentSource,
8
+ } from '#connectivity/mail';
9
+ import type { ConnectionInfo, ToolResult } from '#connectivity';
10
+ import type { AuditLogger } from '#audit';
11
+ import type { BlobStore } from '#stores/blobs';
12
+
13
+ /**
14
+ * Taking an attachment *out* of a mailbox.
15
+ *
16
+ * The direction that was missing. `get_message` reports what a message carries
17
+ * — filename, type, size — and `send_message` can attach one back out, so an
18
+ * attachment could be described and forwarded but never simply had. The two
19
+ * routes people tried instead are both dead ends: storing it as an asset, which
20
+ * cannot resolve a mailbox reference because a provider's store is scoped to
21
+ * its own connection; and mailing it to yourself, which needs the message found
22
+ * again by a header search some servers do badly.
23
+ *
24
+ * So this is the same staging the upload route already uses, run the other way.
25
+ * The bytes are read here, put in *this connection's* namespace under a handle,
26
+ * and the handle is what comes back. Nothing crosses between connections and no
27
+ * provider gains reach it did not have — the bytes were already in this
28
+ * account's area — and the client collects them over HTTP, out of band, exactly
29
+ * as it hands them in.
30
+ *
31
+ * **The bytes never enter the tool result.** That is the whole rule of ADR-017
32
+ * and it does not stop applying because the direction reversed: a 239 KB PDF is
33
+ * about 320,000 characters of base64, and a model that has to read the file in
34
+ * order to have it has not been handed anything.
35
+ */
36
+ export async function getAttachment(
37
+ attachments: MailboxAttachmentSource,
38
+ args: Record<string, unknown>,
39
+ audit: AuditLogger,
40
+ storage: BlobStore,
41
+ connection: ConnectionInfo,
42
+ ): Promise<ToolResult> {
43
+ const uid = typeof args['uid'] === 'number' ? args['uid'] : undefined;
44
+ const messageId = typeof args['message_id'] === 'string' ? args['message_id'] : undefined;
45
+
46
+ if (uid === undefined && messageId === undefined) {
47
+ throw new Error(
48
+ 'Name the message: uid (with its mailbox, as search_messages reports both) or message_id.',
49
+ );
50
+ }
51
+ if (uid !== undefined && messageId !== undefined) {
52
+ throw new Error('Name the message by uid or by message_id, not both — they are alternatives.');
53
+ }
54
+
55
+ const mailbox = typeof args['mailbox'] === 'string' ? args['mailbox'] : undefined;
56
+ const attachmentId =
57
+ typeof args['attachment_id'] === 'string' ? args['attachment_id'] : undefined;
58
+
59
+ const found = await attachments({ messageId, mailbox, uid, attachmentId });
60
+
61
+ const filename = found.filename ?? 'attachment';
62
+ const contentType = found.contentType ?? 'application/octet-stream';
63
+ const sha256 = createHash('sha256').update(found.bytes).digest('hex');
64
+
65
+ // Swept before writing, for the same reason the upload path sweeps: there is
66
+ // no scheduler here, and staging is the only thing that makes this garbage.
67
+ await sweepStaged(storage).catch(() => 0);
68
+
69
+ const handle = newHandle();
70
+ const expiresAt = Date.now() + STAGED_TTL_MS;
71
+
72
+ await putStaged(storage, {
73
+ handle,
74
+ bytes: found.bytes,
75
+ metadata: { filename, content_type: contentType, sha256, expires_at: expiresAt },
76
+ });
77
+
78
+ // The resolved facts rather than the arguments — identifiers, not content, the
79
+ // same annotation the send path records. What makes "which file left this
80
+ // mailbox" answerable is the name and the digest, not the bytes.
81
+ audit.annotate({
82
+ filename,
83
+ bytes: found.bytes.byteLength,
84
+ content_type: contentType,
85
+ sha256,
86
+ handle,
87
+ });
88
+
89
+ return {
90
+ content: [
91
+ {
92
+ type: 'text',
93
+ text: JSON.stringify(
94
+ {
95
+ handle,
96
+ filename,
97
+ bytes: found.bytes.byteLength,
98
+ content_type: contentType,
99
+ sha256,
100
+ expires_at: new Date(expiresAt).toISOString(),
101
+ connection: connection.key,
102
+ fetch: `GET /attachments?connection=${connection.key}&handle=${handle}`,
103
+ hint: `Download it with the GET above, or attach it to a message: { "handle": "${handle}" }. It is kept for 24 hours.`,
104
+ },
105
+ null,
106
+ 2,
107
+ ),
108
+ },
109
+ ],
110
+ };
111
+ }
@@ -44,6 +44,8 @@ import type { SocketFactory } from './socket.ts';
44
44
  */
45
45
 
46
46
  import { imapCapabilities } from './capabilities.ts';
47
+ import { mailboxAttachments } from './attachment.ts';
48
+ import { getAttachment } from './download.ts';
47
49
  import { OPERATIONS } from './operations.ts';
48
50
  import { error, json } from './result.ts';
49
51
  import {
@@ -114,6 +116,17 @@ export function createImapConnector(options: ImapConnectorOptions): Connector {
114
116
  return await client.run((session) => searchMessages(session, args));
115
117
  case OPERATIONS.getMessage:
116
118
  return await client.run((session) => getMessage(session, args, options.maxBodyBytes));
119
+ case OPERATIONS.getAttachment:
120
+ // Handed the context for the same reason `send_message` is: what
121
+ // came out is worth recording as resolved facts, and the raw
122
+ // argument cannot say what the file turned out to be.
123
+ return await getAttachment(
124
+ mailboxAttachments(client),
125
+ args as Record<string, unknown>,
126
+ context.provider.audit,
127
+ context.provider.storage,
128
+ context.provider.connection,
129
+ );
117
130
  case OPERATIONS.markMessages:
118
131
  // Never retried. A repeated flag change is harmless, but keeping the
119
132
  // rule uniform is what stops the exceptions from multiplying.
@@ -11,6 +11,7 @@ export const OPERATIONS = {
11
11
  listMailboxes: 'list_mailboxes',
12
12
  searchMessages: 'search_messages',
13
13
  getMessage: 'get_message',
14
+ getAttachment: 'get_attachment',
14
15
  markMessages: 'mark_messages',
15
16
  moveMessages: 'move_messages',
16
17
  sendMessage: 'send_message',
@@ -15,7 +15,7 @@ import type { BlobMetadata, BlobStore } from '#stores/blobs';
15
15
  *
16
16
  * Named for the protocol rather than the vendor, which is ADR-008's rule
17
17
  * applied to an adapter: Supabase Storage, Cloudflare R2, MinIO, and AWS differ
18
- * only in the endpoint. Supabase is what `docs/detailed/deployment-cloudrun.md`
18
+ * only in the endpoint. Supabase is what `https://lanes.sh/docs/link/deployment-cloudrun`
19
19
  * documents, because it is already the Postgres host and a second vendor for
20
20
  * the blobs would be one more thing to hold.
21
21
  *
@@ -8,7 +8,7 @@ import type { PolicyReader } from './iam.ts';
8
8
  /**
9
9
  * The project-level things a Cloud Run deploy needs to already exist.
10
10
  *
11
- * These used to be a numbered list in `docs/detailed/deployment-cloudrun.md` that the
11
+ * These used to be a numbered list in `https://lanes.sh/docs/link/deployment-cloudrun` that the
12
12
  * operator worked through by hand before their first deploy could get past its
13
13
  * first step. Everything here is derivable from what the target already
14
14
  * declares, so asking someone to transcribe it into a console was work with no
@@ -1,6 +1,7 @@
1
1
  import type { SecretStore } from '#secrets';
2
2
  import type { BlobStore } from '#stores/blobs';
3
- import { knowledgeRoot, type KnowledgeConfig } from '#profile';
3
+ import type { BlobRoute } from '#stores/blobs/route.ts';
4
+ import { KNOWLEDGE_LAYOUT, knowledgeRoot, type KnowledgeArea, type KnowledgeConfig } from '#profile';
4
5
  import { requireSecret, type TargetInput } from './target.ts';
5
6
  import type { FetchLike } from './adapters/github-api.ts';
6
7
  // Type-only, so a target with no `knowledge` block never loads the adapter.
@@ -14,13 +15,13 @@ import type { GithubRepository } from './adapters/github-repo.ts';
14
15
  export type { FetchLike } from './adapters/github-api.ts';
15
16
 
16
17
  /**
17
- * Opening the place a profile keeps its memory and its skills, when that is not
18
- * the place it keeps everything else.
18
+ * Opening the place a profile keeps its memory, skills and entities, when that
19
+ * is not the place it keeps everything else.
19
20
  *
20
21
  * Its own file rather than a fifth case in `target.ts`, because it is not the
21
22
  * same kind of decision. `target.ts` answers "where does this target run" —
22
23
  * credentials here, bytes there — and every consumer of a `BlobStore` rides
23
- * that one answer. This answers a narrower one: two directories of documents
24
+ * that one answer. This answers a narrower one: three directories of documents
24
25
  * the owner wrote have somewhere else to be, and nothing else moves with them.
25
26
  *
26
27
  * ADR-041 has the argument. `src/profile/knowledge.ts` has the contract, and
@@ -29,19 +30,22 @@ export type { FetchLike } from './adapters/github-api.ts';
29
30
  */
30
31
 
31
32
  /**
32
- * One repository client, two stores.
33
+ * One repository client, three stores.
33
34
  *
34
- * Memory and skills are two directories in one repository, and pointing both at
35
- * one client means one branch head, one tree, and one blob cache between them —
36
- * so the endpoint's two-second skill poll keeps memory's view current for free.
37
- * Two clients would each poll, each cache, and could disagree about which
38
- * commit is current.
35
+ * Memory, skills and entities are three directories in one repository, and
36
+ * pointing all of them at one client means one branch head, one tree, and one
37
+ * blob cache between them — so the endpoint's two-second skill poll keeps the
38
+ * other two views current for free. Three clients would each poll, each cache,
39
+ * and could disagree about which commit is current. The argument got stronger
40
+ * with a third area rather than weaker, which is why adding one is a line here.
39
41
  */
40
42
  export interface KnowledgeStores {
41
43
  readonly repository: GithubRepository;
42
44
  readonly skills: BlobStore;
43
45
  /** Rooted at the repository's memory directory; keys are `<connection>/<id>.md`. */
44
46
  readonly memory: BlobStore;
47
+ /** The same shape again, plus the derived `<connection>/_index.json`. */
48
+ readonly entities: BlobStore;
45
49
  /** One line for `target show` and `doctor`, so the config file is not the only witness. */
46
50
  readonly describe: string;
47
51
  }
@@ -88,7 +92,7 @@ export async function openKnowledge(
88
92
  }
89
93
 
90
94
  /**
91
- * The two stores a knowledge repository holds.
95
+ * The three stores a knowledge repository holds.
92
96
  *
93
97
  * Separate from `openKnowledge` because `lanes link knowledge use` builds these
94
98
  * against a repository it is still probing — before any of it has been written
@@ -98,21 +102,43 @@ export async function openKnowledge(
98
102
  export async function knowledgeStores(
99
103
  repository: GithubRepository,
100
104
  knowledge: KnowledgeConfig,
101
- ): Promise<{ skills: BlobStore; memory: BlobStore }> {
105
+ ): Promise<{ skills: BlobStore; memory: BlobStore; entities: BlobStore }> {
102
106
  const { createGithubBlobStore } = await import('./adapters/github.ts');
103
107
 
104
- const build = (area: 'memory' | 'skills'): BlobStore =>
108
+ // What the commit says it did. Named per area rather than left to the
109
+ // adapter's default, because "Store main/note.md" in a repository holding
110
+ // three of them is a line that does not say which one changed.
111
+ const noun: Record<KnowledgeArea, string> = {
112
+ memory: 'memory',
113
+ skills: 'skill',
114
+ entities: 'entity',
115
+ };
116
+
117
+ const build = (area: KnowledgeArea): BlobStore =>
105
118
  createGithubBlobStore({
106
119
  repository,
107
120
  root: knowledgeRoot(knowledge, area),
108
- // What the commit says it did. Named per area rather than left to the
109
- // adapter's default, because "Store main/note.md" in a repository holding
110
- // both is a line that does not say which of them changed.
111
121
  message: (operation, key) =>
112
- `${operation === 'store' ? 'Store' : 'Remove'} ${area === 'skills' ? 'skill' : 'memory'} ${key}`,
122
+ `${operation === 'store' ? 'Store' : 'Remove'} ${noun[area]} ${key}`,
113
123
  });
114
124
 
115
- return { skills: build('skills'), memory: build('memory') };
125
+ return { skills: build('skills'), memory: build('memory'), entities: build('entities') };
126
+ }
127
+
128
+ /**
129
+ * Which key prefixes of the profile's blob root lead to the repository.
130
+ *
131
+ * Here rather than at the call site because it is the same fact
132
+ * `KNOWLEDGE_LAYOUT` states: a directory name in the repository *is* the
133
+ * provider namespace that routes into it, and spelling the pair in two files is
134
+ * how they would come to disagree. `skills` is absent because it is not a
135
+ * prefix of that root — it is a store of its own, handed over whole.
136
+ */
137
+ export function knowledgeRoutes(stores: KnowledgeStores): BlobRoute[] {
138
+ return [
139
+ { prefix: `${KNOWLEDGE_LAYOUT.memory}/`, store: stores.memory },
140
+ { prefix: `${KNOWLEDGE_LAYOUT.entities}/`, store: stores.entities },
141
+ ];
116
142
  }
117
143
 
118
144
  /** `github:owner/name#branch/path`, in one place so every reader agrees. */
@@ -208,7 +208,7 @@ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult
208
208
  *
209
209
  * Minted here for the same reason the profile token is: it is a random string
210
210
  * this process generates correctly and nobody can usefully choose. It used to be
211
- * three manual commands in `docs/detailed/deployment-cloudrun.md` — generate, store,
211
+ * three manual commands in `https://lanes.sh/docs/link/deployment-cloudrun` — generate, store,
212
212
  * mount — whose only failure mode was forgetting them and finding out when the
213
213
  * first `vault.*` call failed against a revision that had booted healthy.
214
214
  *
@@ -102,7 +102,7 @@ async function choosePrimary(
102
102
  *
103
103
  * References are flat — `gmail/main`, not `personal/gmail/main` — and a target
104
104
  * has one credential store, so two profiles deployed to the same project share
105
- * a namespace. `docs/detailed/configuration.md` admits this in an aside about
105
+ * a namespace. `https://lanes.sh/docs/link/configuration` admits this in an aside about
106
106
  * removing a profile; deploying both at once is where it stops being an aside.
107
107
  *
108
108
  * The failure is silent and it is the bad kind: `personal`'s Gmail refresh
@@ -16,7 +16,8 @@ import type {
16
16
  import { isToolResult, strategyContextFrom, strategyFor } from '#connectivity';
17
17
  import type { Config } from '#profile';
18
18
  import { buildProviderContext, createProviderLogger } from './context.ts';
19
- import { stageAttachment, type StagedAttachment, type StageRequest } from './staging.ts';
19
+ import { fetchStaged, stageAttachment } from './staging.ts';
20
+ import type { FetchStagedRequest, StagedAttachment, StageRequest } from './staging.ts';
20
21
  import type { ProviderRegistry } from '#registry';
21
22
 
22
23
  /**
@@ -104,16 +105,22 @@ export class Dispatcher {
104
105
  * operator's file inside the endpoint, where a later send can post it outward
105
106
  * — so it is audited on the same path as the send rather than beside it.
106
107
  */
108
+ /** What both halves of staging take. One place, so the two cannot diverge. */
109
+ get #staging() {
110
+ return {
111
+ storage: this.#deps.storage,
112
+ audit: this.#deps.audit,
113
+ profile: this.#deps.config.instance.profile,
114
+ };
115
+ }
116
+
107
117
  async stageAttachment(request: StageRequest): Promise<StagedAttachment> {
108
- return stageAttachment(
109
- {
110
- storage: this.#deps.storage,
111
- audit: this.#deps.audit,
112
- profile: this.#deps.config.instance.profile,
113
- now: this.#now,
114
- },
115
- request,
116
- );
118
+ return stageAttachment({ ...this.#staging, now: this.#now }, request);
119
+ }
120
+
121
+ /** The same door, outward — bytes back to the client. See `fetchStaged`. */
122
+ async fetchStagedAttachment(request: FetchStagedRequest) {
123
+ return fetchStaged(this.#staging, request);
117
124
  }
118
125
 
119
126
  /**