@lanes-sh/link 0.4.0 → 0.5.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 +20 -9
- package/instructions/agents/lanes-link-scout.md +14 -3
- package/instructions/skills/lanes-link/SKILL.md +80 -3
- package/package.json +2 -2
- package/src/cli/argv.ts +7 -0
- package/src/cli/commands/connect/index.ts +9 -6
- package/src/cli/commands/connection.ts +298 -0
- package/src/cli/commands/mcp/list.ts +123 -29
- package/src/cli/commands/operate/inspect.ts +37 -20
- package/src/cli/commands/operate/serve.ts +21 -0
- package/src/cli/commands/owner/assets.ts +132 -0
- package/src/cli/commands/owner/shared.ts +28 -4
- package/src/cli/commands/owner/tasks.ts +194 -0
- package/src/cli/commands/owner.ts +9 -4
- package/src/cli/config-edit.ts +33 -7
- package/src/cli/config-repair.ts +115 -11
- package/src/cli/dispatch-owner.ts +49 -8
- package/src/cli/lanes.ts +1 -1
- package/src/cli/main.ts +26 -3
- package/src/cli/provider-marks.ts +1 -1
- package/src/cli/runtime/registry.ts +10 -2
- package/src/cli/selection.ts +14 -0
- package/src/cli/usage.ts +18 -2
- package/src/connectivity/mail/attachments.ts +5 -1
- package/src/connectivity/mail/index.ts +6 -1
- package/src/connectivity/manifest/provider.ts +15 -2
- package/src/deployments/deploy.ts +3 -2
- package/src/deployments/prepare.ts +1 -1
- package/src/deployments/servable.ts +1 -1
- package/src/deployments/upload.ts +0 -53
- package/src/profile/load.ts +46 -0
- package/src/providers/assets/provider.ts +337 -0
- package/src/providers/assets/store.ts +167 -0
- package/src/providers/bunq/hints.ts +3 -1
- package/src/providers/bunq/redact.ts +13 -2
- package/src/providers/bunq/specs/bunq.v1.json +20 -1
- package/src/providers/bunq/specs/vendor.ts +59 -1
- package/src/providers/google/index.ts +1 -1
- package/src/providers/google/tasks/index.ts +3 -3
- package/src/providers/google/tasks/redact.ts +21 -11
- package/src/providers/index.ts +3 -3
- package/src/providers/owner.ts +39 -19
- package/src/providers/setup/plan.ts +17 -1
- package/src/providers/shared/vendor-operations.ts +81 -0
- package/src/providers/tasks/provider.ts +370 -0
- package/src/providers/tasks/store.ts +248 -0
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/instructions.ts +67 -8
package/src/cli/selection.ts
CHANGED
|
@@ -111,6 +111,11 @@ export const SELECTION: Record<string, Requires> = {
|
|
|
111
111
|
'identity list': 'profile',
|
|
112
112
|
|
|
113
113
|
connect: 'profile+target',
|
|
114
|
+
// Both edit the profile config, and `disconnect` also opens the target's
|
|
115
|
+
// credential store to delete from it. Same requirement as `connect` for the
|
|
116
|
+
// same reasons.
|
|
117
|
+
disconnect: 'profile+target',
|
|
118
|
+
relabel: 'profile+target',
|
|
114
119
|
// Its own row rather than an inheritance from `connect`. Both need the same
|
|
115
120
|
// two things, but the row is what makes `selectionKey` return the two-word
|
|
116
121
|
// key — and that is what keeps thirty declaration flags off
|
|
@@ -153,6 +158,8 @@ export const SELECTION: Record<string, Requires> = {
|
|
|
153
158
|
'mcp add': 'profile+target',
|
|
154
159
|
'mcp stdio': 'profile+target',
|
|
155
160
|
memory: 'profile+target',
|
|
161
|
+
tasks: 'profile+target',
|
|
162
|
+
assets: 'profile+target',
|
|
156
163
|
skills: 'profile+target',
|
|
157
164
|
vault: 'profile+target',
|
|
158
165
|
// Both halves open the target's adapters — `show` counts what is in the
|
|
@@ -181,6 +188,8 @@ const SUBCOMMANDS: Record<string, readonly string[]> = {
|
|
|
181
188
|
config: ['show'],
|
|
182
189
|
setup: ['plan'],
|
|
183
190
|
memory: ['list', 'get', 'write', 'forget'],
|
|
191
|
+
tasks: ['list', 'get', 'add', 'update', 'remove'],
|
|
192
|
+
assets: ['list', 'get', 'add', 'remove'],
|
|
184
193
|
skills: ['list', 'show', 'add', 'remove'],
|
|
185
194
|
vault: ['list', 'get', 'set', 'remove', 'key'],
|
|
186
195
|
mcp: ['skill', 'add', 'stdio', 'list'],
|
|
@@ -310,6 +319,8 @@ const ACCEPTS: Record<string, readonly string[]> = {
|
|
|
310
319
|
// `removalPlan`, and was refused here — the flag existed everywhere except in
|
|
311
320
|
// the list that decides whether it may be typed.
|
|
312
321
|
'profile remove': ['dry-run', 'yes', 'target'],
|
|
322
|
+
disconnect: ['yes', 'keep-credential'],
|
|
323
|
+
relabel: [],
|
|
313
324
|
'target list': ['urls', 'target'],
|
|
314
325
|
'target show': ['target'],
|
|
315
326
|
'token show': ['show', 'raw'],
|
|
@@ -332,6 +343,9 @@ const ACCEPTS: Record<string, readonly string[]> = {
|
|
|
332
343
|
update: ['check'],
|
|
333
344
|
'identity add': ['note'],
|
|
334
345
|
memory: ['connection', 'title', 'description', 'file', 'tag'],
|
|
346
|
+
// `--yes` on both, because both have a delete that asks first.
|
|
347
|
+
tasks: ['connection', 'title', 'status', 'due', 'tag', 'yes'],
|
|
348
|
+
assets: ['connection', 'name', 'content-type', 'yes'],
|
|
335
349
|
skills: ['connection', 'title', 'description', 'file'],
|
|
336
350
|
vault: ['connection'],
|
|
337
351
|
// `no-migrate` is listed beside `migrate` because they are three states
|
package/src/cli/usage.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { style } from './output.ts';
|
|
|
16
16
|
/** How this CLI is invoked — the `link` area of the `lanes` command. */
|
|
17
17
|
export const PROGRAM = 'lanes link';
|
|
18
18
|
|
|
19
|
-
export const USAGE = `${style.bold(PROGRAM)} — a self-hostable MCP gateway for all your connections, memory,
|
|
19
|
+
export const USAGE = `${style.bold(PROGRAM)} — a self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets
|
|
20
20
|
|
|
21
21
|
${style.bold('Everyday')}
|
|
22
22
|
${PROGRAM} setup plan [--json] what each provider needs, and which are connected
|
|
@@ -26,6 +26,9 @@ ${style.bold('Everyday')}
|
|
|
26
26
|
${PROGRAM} connect <...> --replace ask for the stored password or key again
|
|
27
27
|
${PROGRAM} connect <...> --auth <method> pick how, where there is a choice
|
|
28
28
|
${PROGRAM} connect <...> --non-interactive [--json]
|
|
29
|
+
${PROGRAM} disconnect <provider>.<id> remove an account, and delete its credential
|
|
30
|
+
${PROGRAM} disconnect <...> --keep-credential leave the credential in the store
|
|
31
|
+
${PROGRAM} relabel <provider>.<id> <name> rename what an account is called
|
|
29
32
|
${PROGRAM} connect custom <id> --connector <kind> --auth <method>
|
|
30
33
|
declare a service that is not built in, and connect it.
|
|
31
34
|
kinds: mcp, http, imap, dav, fs. Omit a value and it is
|
|
@@ -78,6 +81,18 @@ ${style.bold('Your own context')}
|
|
|
78
81
|
${PROGRAM} memory write <id> --title <t> [--tag t] body on stdin
|
|
79
82
|
${PROGRAM} memory forget <id>
|
|
80
83
|
|
|
84
|
+
${PROGRAM} tasks list [--status s] what is outstanding; --status all for everything
|
|
85
|
+
${PROGRAM} tasks get <id>
|
|
86
|
+
${PROGRAM} tasks add <title> [--status s] [--due d] [--tag t] notes on stdin
|
|
87
|
+
${PROGRAM} tasks update <id> --status <s> closing one is an update, not a remove
|
|
88
|
+
${PROGRAM} tasks remove <id>
|
|
89
|
+
statuses: in_progress open blocked muted done dropped
|
|
90
|
+
|
|
91
|
+
${PROGRAM} assets list files kept in this profile
|
|
92
|
+
${PROGRAM} assets get <name> the bytes, to stdout — redirect them
|
|
93
|
+
${PROGRAM} assets add <file> [--name n] [--content-type t]
|
|
94
|
+
${PROGRAM} assets remove <name>
|
|
95
|
+
|
|
81
96
|
${PROGRAM} skills list the procedures agents can invoke
|
|
82
97
|
${PROGRAM} skills show <name>
|
|
83
98
|
${PROGRAM} skills add <name> [--file f] document on stdin
|
|
@@ -131,7 +146,8 @@ ${style.bold('Naming what a command acts on')}
|
|
|
131
146
|
command that names neither refuses and lists what exists.
|
|
132
147
|
|
|
133
148
|
${style.bold('Other flags')}
|
|
134
|
-
--connection <id> which memory/skills/vault connection,
|
|
149
|
+
--connection <id> which memory/tasks/assets/skills/vault connection, where
|
|
150
|
+
a profile has several of one kind
|
|
135
151
|
--yes skip the confirmation a destructive command would ask for
|
|
136
152
|
--json machine-readable output, where a command offers it
|
|
137
153
|
--non-interactive never prompt: connect refuses with what to store,
|
|
@@ -362,7 +362,11 @@ const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
|
362
362
|
// opens by extension anyway. Adding them back will fail the suite.
|
|
363
363
|
};
|
|
364
364
|
|
|
365
|
-
|
|
365
|
+
/**
|
|
366
|
+
* Exported because `assets` needs exactly this and the table above must not be
|
|
367
|
+
* copied — its `.pages`/`.numbers` note is a rule the copy would not carry.
|
|
368
|
+
*/
|
|
369
|
+
export function guessContentType(filename: string): string {
|
|
366
370
|
const extension = filename.split('.').pop()?.toLowerCase();
|
|
367
371
|
return (extension ? CONTENT_TYPES[extension] : undefined) ?? 'application/octet-stream';
|
|
368
372
|
}
|
|
@@ -21,7 +21,12 @@ export { receiptFor } from './message.ts';
|
|
|
21
21
|
export { composeMime } from './compose.ts';
|
|
22
22
|
|
|
23
23
|
export type { AttachmentRef, MailboxAttachmentSource, ResolveOptions } from './attachments.ts';
|
|
24
|
-
export {
|
|
24
|
+
export {
|
|
25
|
+
attachmentRefSchema,
|
|
26
|
+
attachmentsJsonSchema,
|
|
27
|
+
guessContentType,
|
|
28
|
+
resolveAttachments,
|
|
29
|
+
} from './attachments.ts';
|
|
25
30
|
|
|
26
31
|
export type { StagedFile, StagedMetadata } from './staging.ts';
|
|
27
32
|
export {
|
|
@@ -64,8 +64,21 @@ export const providerManifestSchema = z.object({
|
|
|
64
64
|
|
|
65
65
|
export type ProviderManifest = z.infer<typeof providerManifestSchema>;
|
|
66
66
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Provider ids reserved for the owner layer.
|
|
69
|
+
*
|
|
70
|
+
* The order is read: `#server/mcp`'s instructions emit one paragraph per
|
|
71
|
+
* reachable id in this sequence, so it is the order an agent meets them in.
|
|
72
|
+
*/
|
|
73
|
+
export const RESERVED_PROVIDER_IDS: readonly string[] = [
|
|
74
|
+
'memory',
|
|
75
|
+
'tasks',
|
|
76
|
+
'assets',
|
|
77
|
+
'skills',
|
|
78
|
+
'vault',
|
|
79
|
+
'setup',
|
|
80
|
+
'identity',
|
|
81
|
+
];
|
|
69
82
|
|
|
70
83
|
/**
|
|
71
84
|
* Validate a manifest, with the cross-field rules the schema alone cannot
|
|
@@ -7,7 +7,8 @@ import { resolveTarget, vaultEnv } from './bootstrap.ts';
|
|
|
7
7
|
import { printSteps, runSteps } from './steps.ts';
|
|
8
8
|
import { driverFor } from './drivers.ts';
|
|
9
9
|
import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
|
|
10
|
-
import {
|
|
10
|
+
import { repairOwnerLayer } from '#cli/config-repair.ts';
|
|
11
|
+
import { deployedWorkspace, uploadWorkspace } from './upload.ts';
|
|
11
12
|
import { unservableProfiles, unservableRefusal } from './servable.ts';
|
|
12
13
|
import { collidingRefs, collisionRefusal, servingProfiles } from './serving.ts';
|
|
13
14
|
import { healthLine, reachability, registerLine, reportUnauthorised } from './report.ts';
|
|
@@ -255,7 +256,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
255
256
|
throw new ConfigError(unservableRefusal(unservable, target));
|
|
256
257
|
}
|
|
257
258
|
|
|
258
|
-
await
|
|
259
|
+
await repairOwnerLayer(resolution.workspaceRoot, serving);
|
|
259
260
|
|
|
260
261
|
// Before the rollout, so the revision that comes up finds a config to read.
|
|
261
262
|
// Uploading after would leave a window where the service is serving and the
|
|
@@ -46,7 +46,7 @@ export interface PrepareResult {
|
|
|
46
46
|
* path derives from the manifest and has never read config for. Sharing one list
|
|
47
47
|
* would silently pick one answer for both.
|
|
48
48
|
*
|
|
49
|
-
* **Scoped exactly as the upload is**, for the reason `
|
|
49
|
+
* **Scoped exactly as the upload is**, for the reason `repairOwnerLayer`
|
|
50
50
|
* states and one more: a profile this deploy sends is a profile the endpoint may
|
|
51
51
|
* serve, and a connection whose secret nobody bound fails an hour after the
|
|
52
52
|
* revision reports healthy. The asymmetry decides it — an extra binding is a
|
|
@@ -30,7 +30,7 @@ export interface Unservable {
|
|
|
30
30
|
/**
|
|
31
31
|
* The profiles this deploy would send that the revision could not open.
|
|
32
32
|
*
|
|
33
|
-
* Scoped exactly as `uploadWorkspace` and `
|
|
33
|
+
* Scoped exactly as `uploadWorkspace` and `repairOwnerLayer` are — by the
|
|
34
34
|
* `--profile` flag, absent meaning the whole workspace — because the set that
|
|
35
35
|
* gets uploaded is the set that gets served, and checking a different one would
|
|
36
36
|
* be checking the wrong question.
|
|
@@ -7,8 +7,6 @@ import {
|
|
|
7
7
|
type Config,
|
|
8
8
|
type TargetConfig,
|
|
9
9
|
} from '#profile';
|
|
10
|
-
import { ConfigDocument } from '#cli/config-edit.ts';
|
|
11
|
-
import { ensureSetupConnection, repairLines, repaired } from '#cli/config-repair.ts';
|
|
12
10
|
import { ok, print, style, warn } from '#cli/output.ts';
|
|
13
11
|
|
|
14
12
|
/**
|
|
@@ -100,57 +98,6 @@ function authoredAreaOwner(key: string): string | null {
|
|
|
100
98
|
return area === layout.skills(owner) || area === layout.providers(owner) ? owner : null;
|
|
101
99
|
}
|
|
102
100
|
|
|
103
|
-
/**
|
|
104
|
-
* Give every profile about to be uploaded its setup surface.
|
|
105
|
-
*
|
|
106
|
-
* **Scoped exactly as the upload is**, because a profile this deploy sends is a
|
|
107
|
-
* profile the endpoint will serve: repairing a narrower set would leave a served
|
|
108
|
-
* profile without the surface, which is this bug one profile over. Note what
|
|
109
|
-
* `flags.profile` does not mean — it is the flag alone, so a profile resolved
|
|
110
|
-
* from `LANES_LINK_PROFILE` leaves it undefined and both this and the upload
|
|
111
|
-
* read that as the whole workspace. Surprising, pre-existing in the upload, and
|
|
112
|
-
* fixed there rather than here so the two cannot drift apart.
|
|
113
|
-
*
|
|
114
|
-
* *Which files are profiles* comes from `listProfiles`, never from
|
|
115
|
-
* `isWorkspaceConfig`: the allowlist decides what is safe to *copy*, so it
|
|
116
|
-
* happily sends a committed `personal.example.yaml` and a nested
|
|
117
|
-
* `profiles/archive/old.yaml` as bytes, while this opens and validates what it
|
|
118
|
-
* is handed — which turned that template into a `ConfigError` aborting the
|
|
119
|
-
* deploy after provisioning had already made cloud resources.
|
|
120
|
-
*
|
|
121
|
-
* A profile that cannot be read is warned about rather than fatal: the repair is
|
|
122
|
-
* a courtesy on the way past, and the upload still sends the file. Not silent,
|
|
123
|
-
* though — nothing else here widens a policy without being asked.
|
|
124
|
-
*/
|
|
125
|
-
export async function repairSetupSurface(
|
|
126
|
-
workspaceRoot: string,
|
|
127
|
-
profiles: readonly string[] | undefined,
|
|
128
|
-
): Promise<void> {
|
|
129
|
-
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
130
|
-
|
|
131
|
-
for (const name of await listProfiles(workspaceRoot)) {
|
|
132
|
-
if (wanted !== undefined && !wanted.has(name)) continue;
|
|
133
|
-
|
|
134
|
-
try {
|
|
135
|
-
const document = await ConfigDocument.open(workspaceRoot, name);
|
|
136
|
-
const repair = ensureSetupConnection(document);
|
|
137
|
-
if (!repaired(repair)) continue;
|
|
138
|
-
|
|
139
|
-
await document.save();
|
|
140
|
-
|
|
141
|
-
print(ok(`gave ${style.bold(name)} the setup surface`));
|
|
142
|
-
for (const change of repairLines(repair)) print(` ${style.dim(change)}`);
|
|
143
|
-
print(` ${style.dim('an agent can now see what is connected here, instead of guessing')}`);
|
|
144
|
-
} catch (error) {
|
|
145
|
-
print(
|
|
146
|
-
warn(
|
|
147
|
-
`could not give ${name} the setup surface: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
|
|
148
|
-
),
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
101
|
/**
|
|
155
102
|
* Copy the workspace's config up.
|
|
156
103
|
*/
|
package/src/profile/load.ts
CHANGED
|
@@ -107,6 +107,49 @@ function formatZodIssues(error: z.ZodError): string {
|
|
|
107
107
|
* left to resolve silently it grants nothing, which looks identical to a
|
|
108
108
|
* working rule until the day someone relies on it.
|
|
109
109
|
*/
|
|
110
|
+
/**
|
|
111
|
+
* A connection naming a provider whose id has moved out from under it.
|
|
112
|
+
*
|
|
113
|
+
* There is exactly one, and it is the reason this function exists: `tasks` was
|
|
114
|
+
* Google Tasks until the built-in task list took the plain noun (ADR-051). A row
|
|
115
|
+
* left saying `provider: tasks` does not fail — it resolves to the *built-in*,
|
|
116
|
+
* `reconcile` marks it active because a provider needing no credential is
|
|
117
|
+
* authorized by construction, and the operator is left with their Google Tasks
|
|
118
|
+
* tools gone, a task list wearing their old label, and nothing anywhere saying
|
|
119
|
+
* why. Refusing is the only outcome that names the fix.
|
|
120
|
+
*
|
|
121
|
+
* **The rule is a positive assertion, not a guess at what a vendor row looks
|
|
122
|
+
* like.** The built-in's row is written in exactly one spelling, by
|
|
123
|
+
* `newProfileTemplate` and by `ensureReservedConnection`: `account: Tasks`. So
|
|
124
|
+
* any other label on a `tasks` row is either a pre-rename Google Tasks row or a
|
|
125
|
+
* hand-edited built-in one, and the message names both fixes because either is
|
|
126
|
+
* one word.
|
|
127
|
+
*
|
|
128
|
+
* It was very nearly a guess, and the guess was wrong. The first version keyed
|
|
129
|
+
* on an `@` in the account, reasoning that `connect tasks` recorded the address
|
|
130
|
+
* the operator typed — Google Tasks publishes no identity, so `connect` asks.
|
|
131
|
+
* But what it asks for is a *label*: the real profile this was written for holds
|
|
132
|
+
* `account: personal`, so the check would have passed it and rebound their Google
|
|
133
|
+
* Tasks to the built-in in silence. That is the exact failure this exists to
|
|
134
|
+
* prevent, missed by one heuristic.
|
|
135
|
+
*
|
|
136
|
+
* Deliberately not a check on the connection *id*. Several task lists in one
|
|
137
|
+
* profile is a legitimate thing to want, exactly as several memory connections
|
|
138
|
+
* are, and keying on `id !== 'main'` would refuse a valid profile forever to
|
|
139
|
+
* catch a one-release migration. Labelling both `Tasks` is consistent with what
|
|
140
|
+
* the accountless providers already do — every memory connection is `Memory`.
|
|
141
|
+
*/
|
|
142
|
+
function renamedProvider(connection: { provider: string; account: string }): string | null {
|
|
143
|
+
if (connection.provider !== 'tasks' || connection.account === 'Tasks') return null;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
`"tasks" is now the built-in task list, and this row is labelled ` +
|
|
147
|
+
`"${connection.account}" rather than "Tasks".\n` +
|
|
148
|
+
' If it was Google Tasks: set provider to google_tasks here, and rename any "tasks.*" policy rule.\n' +
|
|
149
|
+
' If it is your own task list: set account to Tasks.'
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
110
153
|
function assertReferentialIntegrity(config: Config, source: string): void {
|
|
111
154
|
const problems: string[] = [];
|
|
112
155
|
|
|
@@ -142,6 +185,9 @@ function assertReferentialIntegrity(config: Config, source: string): void {
|
|
|
142
185
|
problems.push(`connections[${index}]: duplicate connection "${key}"`);
|
|
143
186
|
}
|
|
144
187
|
connectionKeys.add(key);
|
|
188
|
+
|
|
189
|
+
const renamed = renamedProvider(connection);
|
|
190
|
+
if (renamed) problems.push(`connections[${index}]: ${renamed}`);
|
|
145
191
|
});
|
|
146
192
|
|
|
147
193
|
// Same reason as a duplicate connection: two entries with the same kind and
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
attachmentRefSchema,
|
|
4
|
+
guessContentType,
|
|
5
|
+
receiptFor,
|
|
6
|
+
resolveAttachments,
|
|
7
|
+
} from '#connectivity/mail';
|
|
8
|
+
import { defineLocalProvider, keepKeys, type ProviderDefinition } from '#connectivity';
|
|
9
|
+
import {
|
|
10
|
+
allAssets,
|
|
11
|
+
assertAssetName,
|
|
12
|
+
describeAsset,
|
|
13
|
+
digestOf,
|
|
14
|
+
findAsset,
|
|
15
|
+
humanBytes,
|
|
16
|
+
isTextual,
|
|
17
|
+
} from './store.ts';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `assets` — files the owner wants kept.
|
|
21
|
+
*
|
|
22
|
+
* **This exists because memory holds Markdown.** A PDF, an image, an export had
|
|
23
|
+
* nowhere to live except the owner's own filesystem — which a deployed endpoint
|
|
24
|
+
* cannot reach at all, so on any target but `local` the answer was that there
|
|
25
|
+
* was no answer. An asset is a file in the profile's own store, named, listed,
|
|
26
|
+
* and served from wherever that profile runs. ADR-051.
|
|
27
|
+
*
|
|
28
|
+
* **Bytes never pass through the model, in either direction.** A write names one
|
|
29
|
+
* of the five sources `#connectivity/mail` already resolves — a path on the
|
|
30
|
+
* endpoint's machine, an HTTPS URL, a staged handle, an attachment on a message,
|
|
31
|
+
* or inline base64 as the last resort — and the endpoint reads them itself. That
|
|
32
|
+
* is not a new rule: it is ADR-017's, reused rather than restated, which also
|
|
33
|
+
* buys the size ceiling, the refusal when two sources are named, and the
|
|
34
|
+
* SHA-256 receipt that makes "what exactly was stored" answerable.
|
|
35
|
+
*
|
|
36
|
+
* A read is the same rule the other way. `ResourceContents` carries text and
|
|
37
|
+
* nothing else (`#connectivity`'s `capability.ts`), so a text asset comes back
|
|
38
|
+
* as text and a binary one comes back *described* — name, type, size, digest.
|
|
39
|
+
* A 239 KB PDF is roughly 320,000 characters of base64, and handing that to a
|
|
40
|
+
* model is the cost the five sources exist to avoid; there is no reason to pay
|
|
41
|
+
* it on the way in that would not also apply on the way out.
|
|
42
|
+
*
|
|
43
|
+
* **Getting an asset into a mail is not done here**, and that is a boundary
|
|
44
|
+
* rather than a gap. A staged handle is scoped to `<provider>/<connection>`
|
|
45
|
+
* (`#dispatch`'s `stageAttachment`), so a handle minted under `assets/main` is
|
|
46
|
+
* deliberately unresolvable from `gmail/main` — bridging the two means crossing
|
|
47
|
+
* the isolation every provider relies on, and that belongs in dispatch if it
|
|
48
|
+
* belongs anywhere. `lanes link attach <file> --connection <provider>.<account>`
|
|
49
|
+
* already prints a handle the mail tools accept.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const DEFAULT_LIMIT = 30;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The ceiling on one stored asset.
|
|
56
|
+
*
|
|
57
|
+
* Not a vendor limit — there is no vendor. It bounds what a `url` source can
|
|
58
|
+
* pull into the endpoint's memory in one call, which is the only place an
|
|
59
|
+
* unbounded read could come from, and it is sized so an ordinary document,
|
|
60
|
+
* spreadsheet, or photograph goes through and a video does not.
|
|
61
|
+
*/
|
|
62
|
+
const MAX_ASSET_BYTES = 25 * 1024 * 1024;
|
|
63
|
+
|
|
64
|
+
/** How much text a read will return before it describes the file instead. */
|
|
65
|
+
const MAX_TEXT_BYTES = 256 * 1024;
|
|
66
|
+
|
|
67
|
+
export const assetsProvider: ProviderDefinition = defineLocalProvider({
|
|
68
|
+
id: 'assets',
|
|
69
|
+
name: 'Assets',
|
|
70
|
+
version: '1.0.0',
|
|
71
|
+
description:
|
|
72
|
+
"Files the owner wants kept, addressed by filename. Storing one names a source — a path, a URL, a staged handle — and the endpoint reads the bytes; they are never encoded into a call. Writing is a separate capability from reading.",
|
|
73
|
+
|
|
74
|
+
configSchema: z.object({}),
|
|
75
|
+
connectionSchema: z.object({}),
|
|
76
|
+
|
|
77
|
+
bundles: [
|
|
78
|
+
{
|
|
79
|
+
name: 'read',
|
|
80
|
+
description: 'List and read stored files.',
|
|
81
|
+
oauth_scopes: [],
|
|
82
|
+
capabilities: ['file', 'list', 'get'],
|
|
83
|
+
default: true,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'write',
|
|
87
|
+
description: 'Store and delete files.',
|
|
88
|
+
oauth_scopes: [],
|
|
89
|
+
capabilities: ['store', 'remove'],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
|
|
93
|
+
capabilities: [
|
|
94
|
+
{
|
|
95
|
+
kind: 'resource',
|
|
96
|
+
name: 'file',
|
|
97
|
+
title: 'Stored file',
|
|
98
|
+
description:
|
|
99
|
+
'One stored file, addressed by its name. Text comes back as text; anything else is described rather than encoded.',
|
|
100
|
+
uriTemplate: 'assets://file/{name}',
|
|
101
|
+
redact: keepKeys('uri'),
|
|
102
|
+
|
|
103
|
+
async list(context) {
|
|
104
|
+
return (await allAssets(context.storage)).map((asset) => ({
|
|
105
|
+
uri: `assets://file/${encodeURIComponent(asset.name)}`,
|
|
106
|
+
name: asset.name,
|
|
107
|
+
}));
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async read(uri, params, context) {
|
|
111
|
+
const raw = params['name'];
|
|
112
|
+
if (!raw) throw new Error(`Malformed asset URI: ${uri}`);
|
|
113
|
+
|
|
114
|
+
const name = decodeURIComponent(raw);
|
|
115
|
+
const asset = await findAsset(context.storage, name);
|
|
116
|
+
if (asset === null) throw new Error(`No asset "${name}" on ${context.connection.key}`);
|
|
117
|
+
|
|
118
|
+
const bytes = await context.storage.get(name);
|
|
119
|
+
if (bytes === null) throw new Error(`No asset "${name}" on ${context.connection.key}`);
|
|
120
|
+
|
|
121
|
+
return textOrSummary(uri, name, asset.contentType, bytes);
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
kind: 'tool',
|
|
127
|
+
name: 'list',
|
|
128
|
+
title: 'List stored files',
|
|
129
|
+
description:
|
|
130
|
+
'Every file kept in this profile, newest first, with its type and size. This is the whole index — an asset carries no description, so what a file is for belongs in memory.',
|
|
131
|
+
inputSchema: z.object({
|
|
132
|
+
query: z.string().optional().describe('Restrict to names containing this text'),
|
|
133
|
+
limit: z
|
|
134
|
+
.number()
|
|
135
|
+
.int()
|
|
136
|
+
.min(1)
|
|
137
|
+
.max(200)
|
|
138
|
+
.optional()
|
|
139
|
+
.describe(`Maximum results (default ${DEFAULT_LIMIT})`),
|
|
140
|
+
}),
|
|
141
|
+
// Nothing kept: a filename is the owner's own material, the same call
|
|
142
|
+
// `memory.search` makes about a query.
|
|
143
|
+
async handler({ query, limit }, context) {
|
|
144
|
+
const needle = query?.toLowerCase();
|
|
145
|
+
const all = await allAssets(context.storage);
|
|
146
|
+
const found = needle ? all.filter((a) => a.name.toLowerCase().includes(needle)) : all;
|
|
147
|
+
const shown = found.slice(0, limit ?? DEFAULT_LIMIT);
|
|
148
|
+
|
|
149
|
+
context.audit.annotate({ scanned: all.length, matched: found.length });
|
|
150
|
+
|
|
151
|
+
if (shown.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
content: [
|
|
154
|
+
{
|
|
155
|
+
type: 'text',
|
|
156
|
+
text: `No ${needle ? 'matching ' : ''}assets on ${context.connection.key}.`,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
content: [
|
|
164
|
+
...shown.flatMap((asset) => [
|
|
165
|
+
{
|
|
166
|
+
type: 'resource_link' as const,
|
|
167
|
+
uri: `assets://file/${encodeURIComponent(asset.name)}`,
|
|
168
|
+
name: asset.name,
|
|
169
|
+
},
|
|
170
|
+
{ type: 'text' as const, text: describeAsset(asset) },
|
|
171
|
+
]),
|
|
172
|
+
...(found.length > shown.length
|
|
173
|
+
? [
|
|
174
|
+
{
|
|
175
|
+
type: 'text' as const,
|
|
176
|
+
text: `… ${found.length - shown.length} more. Raise limit, or narrow with query.`,
|
|
177
|
+
},
|
|
178
|
+
]
|
|
179
|
+
: []),
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
{
|
|
186
|
+
kind: 'tool',
|
|
187
|
+
name: 'get',
|
|
188
|
+
title: 'Read a stored file',
|
|
189
|
+
description:
|
|
190
|
+
'Return a text file\'s contents. A binary file is described instead — name, type, size, digest — because encoding it here is the cost this provider exists to avoid. The resource assets://file/{name} is the same content.',
|
|
191
|
+
inputSchema: z.object({ name: z.string().min(1).describe('The file name') }),
|
|
192
|
+
redact: keepKeys('name'),
|
|
193
|
+
async handler({ name }, context) {
|
|
194
|
+
const asset = await findAsset(context.storage, name);
|
|
195
|
+
const bytes = asset === null ? null : await context.storage.get(name);
|
|
196
|
+
|
|
197
|
+
if (asset === null || bytes === null) {
|
|
198
|
+
return {
|
|
199
|
+
content: [{ type: 'text', text: `No asset "${name}" on ${context.connection.key}.` }],
|
|
200
|
+
isError: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { text } = textOrSummary('', name, asset.contentType, bytes);
|
|
205
|
+
return { content: [{ type: 'text', text }] };
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
{
|
|
210
|
+
kind: 'tool',
|
|
211
|
+
name: 'store',
|
|
212
|
+
title: 'Store a file',
|
|
213
|
+
description:
|
|
214
|
+
'Keep a file in this profile. Name exactly one source — path, url, handle, message_id, or data — and the endpoint reads the bytes itself; never base64 a file into this call when any other source will do. Storing under a name that exists replaces it.',
|
|
215
|
+
inputSchema: z.object({
|
|
216
|
+
source: attachmentRefSchema.describe(
|
|
217
|
+
'Where the bytes come from. Exactly one of path, url, handle, message_id, or data.',
|
|
218
|
+
),
|
|
219
|
+
name: z
|
|
220
|
+
.string()
|
|
221
|
+
.optional()
|
|
222
|
+
.describe('What to call it. Taken from the source when omitted.'),
|
|
223
|
+
}),
|
|
224
|
+
// The name and the source shape are the record of what happened; the bytes
|
|
225
|
+
// are the content. `annotate` below adds the resolved facts, which is the
|
|
226
|
+
// half that makes a write log worth reading — see ADR-017.
|
|
227
|
+
redact: keepKeys('name'),
|
|
228
|
+
async handler({ source, name }, context) {
|
|
229
|
+
const [resolved] = await resolveAttachments([source], {
|
|
230
|
+
maxTotalBytes: MAX_ASSET_BYTES,
|
|
231
|
+
storage: context.storage,
|
|
232
|
+
signal: context.signal,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (!resolved) throw new Error('source named no file.');
|
|
236
|
+
|
|
237
|
+
const assetName = name ?? resolved.filename;
|
|
238
|
+
assertAssetName(assetName);
|
|
239
|
+
|
|
240
|
+
// Renaming can improve the type. `resolveAttachments` guesses from the
|
|
241
|
+
// *source's* filename, so storing a URL that ended `/download` as
|
|
242
|
+
// `report.csv` arrives as octet-stream; the name the owner chose is the
|
|
243
|
+
// better evidence, but only where the source had none to offer.
|
|
244
|
+
const contentType =
|
|
245
|
+
name && resolved.contentType === 'application/octet-stream'
|
|
246
|
+
? guessContentType(assetName)
|
|
247
|
+
: resolved.contentType;
|
|
248
|
+
const replaced = await context.storage.has(assetName);
|
|
249
|
+
|
|
250
|
+
await context.storage.put(assetName, resolved.bytes, { contentType });
|
|
251
|
+
|
|
252
|
+
// The resolved facts rather than the argument: `source` may literally be
|
|
253
|
+
// a file, so keeping it verbatim would put base64 in the log. This is
|
|
254
|
+
// the same annotation `gmail.send_message` records.
|
|
255
|
+
context.audit.annotate({ asset: assetName, replaced, ...receiptFor(resolved), origin: resolved.origin });
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
content: [
|
|
259
|
+
{
|
|
260
|
+
type: 'text',
|
|
261
|
+
text:
|
|
262
|
+
`${replaced ? 'Replaced' : 'Stored'} "${assetName}" on ${context.connection.key} — ` +
|
|
263
|
+
`${contentType}, ${humanBytes(resolved.bytes.byteLength)}, sha256 ${resolved.sha256.slice(0, 12)}…`,
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
type: 'resource_link',
|
|
267
|
+
uri: `assets://file/${encodeURIComponent(assetName)}`,
|
|
268
|
+
name: assetName,
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
{
|
|
276
|
+
kind: 'tool',
|
|
277
|
+
name: 'remove',
|
|
278
|
+
title: 'Delete a stored file',
|
|
279
|
+
description: 'Remove a file and its bytes. There is no trash.',
|
|
280
|
+
inputSchema: z.object({ name: z.string().min(1).describe('The file name') }),
|
|
281
|
+
redact: keepKeys('name'),
|
|
282
|
+
async handler({ name }, context) {
|
|
283
|
+
assertAssetName(name);
|
|
284
|
+
|
|
285
|
+
const existed = await context.storage.has(name);
|
|
286
|
+
await context.storage.delete(name);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
content: [
|
|
290
|
+
{
|
|
291
|
+
type: 'text',
|
|
292
|
+
text: existed
|
|
293
|
+
? `Deleted "${name}" from ${context.connection.key}.`
|
|
294
|
+
: `No asset "${name}" on ${context.connection.key}.`,
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
...(existed ? {} : { isError: true }),
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Text if it can be, a description if it cannot.
|
|
306
|
+
*
|
|
307
|
+
* One function for both the tool and the resource, so the two cannot come to
|
|
308
|
+
* different conclusions about the same file — which is the failure that would
|
|
309
|
+
* make "read it as a resource instead" a workaround for a refusal.
|
|
310
|
+
*/
|
|
311
|
+
function textOrSummary(
|
|
312
|
+
uri: string,
|
|
313
|
+
name: string,
|
|
314
|
+
contentType: string,
|
|
315
|
+
bytes: Uint8Array,
|
|
316
|
+
): { uri: string; mimeType: string; text: string } {
|
|
317
|
+
if (isTextual(contentType, bytes) && bytes.byteLength <= MAX_TEXT_BYTES) {
|
|
318
|
+
return { uri, mimeType: contentType, text: new TextDecoder().decode(bytes) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const why =
|
|
322
|
+
bytes.byteLength > MAX_TEXT_BYTES
|
|
323
|
+
? `larger than the ${humanBytes(MAX_TEXT_BYTES)} a read returns`
|
|
324
|
+
: 'not text';
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
uri,
|
|
328
|
+
mimeType: 'text/plain',
|
|
329
|
+
text:
|
|
330
|
+
`${name} — ${contentType}, ${humanBytes(bytes.byteLength)}, sha256 ${digestOf(bytes)}.\n` +
|
|
331
|
+
`Its contents are ${why}, so they are not returned. ` +
|
|
332
|
+
'To attach it to something, ask the owner for a handle: ' +
|
|
333
|
+
'lanes link attach <file> --connection <provider>.<account>',
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export default assetsProvider;
|