@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.
- package/README.md +62 -45
- package/instructions/skills/lanes-link/SKILL.md +25 -0
- package/package.json +3 -2
- package/src/audit/conformance.ts +1 -1
- package/src/cli/argv.ts +14 -1
- package/src/cli/commands/connect/acquire.ts +100 -0
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/declare.ts +19 -0
- package/src/cli/commands/connect/index.ts +54 -56
- package/src/cli/commands/connect/requirements.ts +23 -1
- package/src/cli/commands/connect/setup.ts +1 -1
- package/src/cli/commands/connect/variables.ts +220 -0
- package/src/cli/commands/knowledge/index.ts +22 -4
- package/src/cli/commands/knowledge/migrate.ts +61 -43
- package/src/cli/commands/operate/tools.ts +1 -1
- package/src/cli/commands/owner/entities.ts +244 -0
- package/src/cli/commands/owner/shared.ts +14 -0
- package/src/cli/commands/owner/vault.ts +2 -2
- package/src/cli/commands/owner.ts +13 -3
- package/src/cli/commands/setup.ts +12 -0
- package/src/cli/config-edit.ts +16 -6
- package/src/cli/config-repair.ts +10 -0
- package/src/cli/dispatch-owner.ts +30 -1
- package/src/cli/main.ts +2 -1
- package/src/cli/runtime/open.ts +14 -7
- package/src/cli/runtime/registry.ts +6 -1
- package/src/cli/selection.ts +11 -0
- package/src/cli/usage.ts +13 -2
- package/src/connectivity/auth/strategy/index.ts +1 -1
- package/src/connectivity/capability.ts +1 -1
- package/src/connectivity/context.ts +1 -1
- package/src/connectivity/index.ts +1 -1
- package/src/connectivity/mail/attachments.ts +42 -73
- package/src/connectivity/mail/content-type.ts +71 -0
- package/src/connectivity/mail/index.ts +1 -1
- package/src/connectivity/manifest/provider.ts +50 -0
- package/src/connectivity/manifest/variables.ts +173 -0
- package/src/connectivity/transports/factory.ts +74 -1
- package/src/connectivity/transports/http/index.ts +8 -2
- package/src/connectivity/transports/http/keys.ts +83 -0
- package/src/connectivity/transports/imap/attachment.ts +53 -10
- package/src/connectivity/transports/imap/capabilities.ts +28 -0
- package/src/connectivity/transports/imap/download.ts +111 -0
- package/src/connectivity/transports/imap/index.ts +13 -0
- package/src/connectivity/transports/imap/operations.ts +1 -0
- package/src/deployments/adapters/s3.ts +1 -1
- package/src/deployments/gcp/provision.ts +1 -1
- package/src/deployments/knowledge.ts +44 -18
- package/src/deployments/prepare.ts +1 -1
- package/src/deployments/serving.ts +1 -1
- package/src/dispatch/dispatch.ts +17 -10
- package/src/dispatch/staging.ts +76 -1
- package/src/profile/knowledge.ts +19 -11
- package/src/profile/schema.ts +2 -2
- package/src/providers/README.md +189 -0
- package/src/providers/airtable/index.ts +10 -0
- package/src/providers/algolia/index.ts +10 -0
- package/src/providers/amplitude/index.ts +10 -0
- package/src/providers/apify/index.ts +10 -0
- package/src/providers/asana/index.ts +10 -0
- package/src/providers/assets/provider.ts +11 -4
- package/src/providers/atlassian/index.ts +19 -0
- package/src/providers/attio/index.ts +10 -0
- package/src/providers/betterstack/index.ts +10 -0
- package/src/providers/box/index.ts +64 -0
- package/src/providers/brightdata/index.ts +10 -0
- package/src/providers/buildkite/index.ts +10 -0
- package/src/providers/bunq/index.ts +2 -2
- package/src/providers/calendly/index.ts +10 -0
- package/src/providers/canva/index.ts +10 -0
- package/src/providers/circleci/index.ts +10 -0
- package/src/providers/clickup/index.ts +10 -0
- package/src/providers/close/index.ts +10 -0
- package/src/providers/cloudflare_bindings/index.ts +14 -0
- package/src/providers/cloudflare_observability/index.ts +10 -0
- package/src/providers/contentful/index.ts +10 -0
- package/src/providers/custom/load.ts +1 -1
- package/src/providers/datadog/index.ts +10 -0
- package/src/providers/discord/index.ts +1 -1
- package/src/providers/discord/specs/discord.v10.json +0 -88
- package/src/providers/discord/specs/vendor.ts +1 -1
- package/src/providers/dropbox/index.ts +10 -0
- package/src/providers/entities/catalogue.ts +366 -0
- package/src/providers/entities/find.ts +279 -0
- package/src/providers/entities/provider.ts +334 -0
- package/src/providers/entities/render.ts +142 -0
- package/src/providers/entities/store.ts +276 -0
- package/src/providers/entities/writes.ts +279 -0
- package/src/providers/example/provider.ts +1 -1
- package/src/providers/expensify/index.ts +10 -0
- package/src/providers/fastmail/calendar/index.ts +21 -0
- package/src/providers/fastmail/contacts/index.ts +17 -0
- package/src/providers/fastmail/index.ts +4 -0
- package/src/providers/fastmail/mail/index.ts +38 -0
- package/src/providers/fastmail/shared/setup.ts +52 -0
- package/src/providers/figma/index.ts +10 -0
- package/src/providers/fireflies/index.ts +10 -0
- package/src/providers/flagsmith/index.ts +10 -0
- package/src/providers/gamma/index.ts +10 -0
- package/src/providers/github/index.ts +2 -2
- package/src/providers/google/gmail/attachment.ts +16 -1
- package/src/providers/google/gmail-imap/index.ts +7 -2
- package/src/providers/google/shared/oauth.ts +1 -1
- package/src/providers/google/shared/service-account.ts +1 -1
- package/src/providers/google/shared/setup.ts +1 -1
- package/src/providers/google/specs/calendar.v3.json +0 -32
- package/src/providers/google/specs/docs.v1.json +0 -76
- package/src/providers/google/specs/drive.v3.json +0 -32
- package/src/providers/google/specs/gmail.v1.json +0 -76
- package/src/providers/google/specs/people.v1.json +0 -77
- package/src/providers/google/specs/sheets.v4.json +0 -76
- package/src/providers/google/specs/tasks.v1.json +0 -76
- package/src/providers/grafana/index.ts +10 -0
- package/src/providers/heroku/index.ts +10 -0
- package/src/providers/hubspot/index.ts +75 -0
- package/src/providers/hygraph/index.ts +10 -0
- package/src/providers/icloud/mail/index.ts +5 -0
- package/src/providers/icloud/shared/setup.ts +1 -1
- package/src/providers/index.ts +255 -0
- package/src/providers/insightly/index.ts +10 -0
- package/src/providers/jam/index.ts +10 -0
- package/src/providers/klaviyo/index.ts +10 -0
- package/src/providers/mailbox/index.ts +87 -0
- package/src/providers/memory/provider.ts +3 -8
- package/src/providers/mercury/index.ts +10 -0
- package/src/providers/microsoft/calendar/index.ts +49 -0
- package/src/providers/microsoft/contacts/index.ts +50 -0
- package/src/providers/microsoft/drive/index.ts +62 -0
- package/src/providers/microsoft/index.ts +12 -0
- package/src/providers/microsoft/mail/index.ts +76 -0
- package/src/providers/microsoft/shared/oauth.ts +58 -0
- package/src/providers/microsoft/shared/scopes.ts +56 -0
- package/src/providers/microsoft/shared/setup.ts +45 -0
- package/src/providers/microsoft/specs/microsoft-todo.v1.json +755 -0
- package/src/providers/microsoft/specs/onedrive.v1.json +993 -0
- package/src/providers/microsoft/specs/outlook-calendar.v1.json +1743 -0
- package/src/providers/microsoft/specs/outlook-contacts.v1.json +430 -0
- package/src/providers/microsoft/specs/outlook-mail.v1.json +901 -0
- package/src/providers/microsoft/specs/vendor.ts +232 -0
- package/src/providers/microsoft/todo/index.ts +54 -0
- package/src/providers/miro/index.ts +10 -0
- package/src/providers/mixpanel/index.ts +10 -0
- package/src/providers/monday/index.ts +10 -0
- package/src/providers/mux/index.ts +10 -0
- package/src/providers/navan/index.ts +10 -0
- package/src/providers/neon/index.ts +10 -0
- package/src/providers/netlify/index.ts +10 -0
- package/src/providers/nextcloud/calendar/index.ts +28 -0
- package/src/providers/nextcloud/contacts/index.ts +18 -0
- package/src/providers/nextcloud/index.ts +3 -0
- package/src/providers/nextcloud/shared/setup.ts +59 -0
- package/src/providers/owner.ts +15 -3
- package/src/providers/paddle/index.ts +10 -0
- package/src/providers/paypal/index.ts +10 -0
- package/src/providers/posthog/index.ts +10 -0
- package/src/providers/prisma/index.ts +10 -0
- package/src/providers/ramp/index.ts +10 -0
- package/src/providers/recurly/index.ts +10 -0
- package/src/providers/reddit/index.ts +1 -1
- package/src/providers/remote/index.ts +10 -0
- package/src/providers/render/index.ts +35 -0
- package/src/providers/replicate/index.ts +10 -0
- package/src/providers/resend/index.ts +10 -0
- package/src/providers/riverside/index.ts +10 -0
- package/src/providers/rootly/index.ts +10 -0
- package/src/providers/rudderstack/index.ts +10 -0
- package/src/providers/salesloft/index.ts +10 -0
- package/src/providers/sanity/index.ts +10 -0
- package/src/providers/scopes.ts +2 -0
- package/src/providers/sentry/index.ts +10 -0
- package/src/providers/setup/plan.ts +11 -0
- package/src/providers/setup/provider.ts +12 -0
- package/src/providers/shared/openapi.ts +70 -0
- package/src/providers/shared/slug.ts +28 -0
- package/src/providers/shared/vendor-operations.ts +43 -1
- package/src/providers/shared/vendor-spec.ts +98 -17
- package/src/providers/shortcut/index.ts +10 -0
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/square/index.ts +10 -0
- package/src/providers/storyblok/index.ts +10 -0
- package/src/providers/stripe/index.ts +10 -0
- package/src/providers/supabase/index.ts +10 -0
- package/src/providers/tasks/store.ts +2 -7
- package/src/providers/tavily/index.ts +10 -0
- package/src/providers/todoist/index.ts +10 -0
- package/src/providers/untested.ts +105 -0
- package/src/providers/vault/provider.ts +1 -1
- package/src/providers/vercel/index.ts +10 -0
- package/src/providers/vimeo/index.ts +10 -0
- package/src/providers/webflow/index.ts +10 -0
- package/src/providers/whimsical/index.ts +10 -0
- package/src/providers/wix/index.ts +10 -0
- package/src/providers/workable/index.ts +10 -0
- package/src/providers/yahoo_mail/index.ts +73 -0
- package/src/providers/zapier/index.ts +10 -0
- package/src/providers/zoho_mail/index.ts +79 -0
- package/src/secrets/document.ts +1 -1
- package/src/server/attachments.ts +129 -5
- package/src/server/index.ts +3 -3
- package/src/server/mcp/instructions.ts +88 -18
- 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:
|
|
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:
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
61
|
+
throw new Error(`Message ${named} has no attachments.`);
|
|
44
62
|
}
|
|
45
63
|
|
|
46
|
-
const chosen = pick(attachments, attachmentId,
|
|
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/
|
|
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/
|
|
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 {
|
|
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
|
|
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:
|
|
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,
|
|
33
|
+
* One repository client, three stores.
|
|
33
34
|
*
|
|
34
|
-
* Memory and
|
|
35
|
-
* one client means one branch head, one tree, and one
|
|
36
|
-
* so the endpoint's two-second skill poll keeps
|
|
37
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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/
|
|
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/
|
|
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
|
package/src/dispatch/dispatch.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
/**
|