@lanes-sh/link 0.4.1 → 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/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 +21 -2
- 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/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/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
|
@@ -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;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { guessContentType } from '#connectivity/mail';
|
|
3
|
+
import type { BlobStore } from '#connectivity';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How an asset is stored, and the only place that knows.
|
|
7
|
+
*
|
|
8
|
+
* **The key is the filename.** `invoice-2026-03.pdf` is stored at
|
|
9
|
+
* `assets/<connection>/invoice-2026-03.pdf`, and that is the whole of the
|
|
10
|
+
* layout — no id, no prefix, no sidecar, no index. `BlobStore.list()` already
|
|
11
|
+
* reports size and mtime, and the content type follows from the extension, so
|
|
12
|
+
* every fact a listing needs is either the key itself or something the store
|
|
13
|
+
* already had. Nothing is written twice and nothing can disagree.
|
|
14
|
+
*
|
|
15
|
+
* That is memory's design carried over rather than a new one: ADR-014 reversed
|
|
16
|
+
* an index-plus-body split for exactly this reason, and it applies harder here,
|
|
17
|
+
* because a sidecar holding an asset's name would be a second name for a file
|
|
18
|
+
* that already has one. What the owner sees under
|
|
19
|
+
* `~/.lanes-link/data/<profile>/assets/main/` is a directory of their files,
|
|
20
|
+
* with their names, openable by anything.
|
|
21
|
+
*
|
|
22
|
+
* The cost is that an asset carries no description, and that is deliberate:
|
|
23
|
+
* "the March invoice is in assets as invoice-2026-03.pdf" is a memory entry.
|
|
24
|
+
* Prose in a store with no way to search it would be worse than either.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What may be a name, and why it is narrower than a filename.
|
|
29
|
+
*
|
|
30
|
+
* No path separator, so the namespace stays flat: `scopeBlobStore` would happily
|
|
31
|
+
* accept `a/b` and create a directory, and a listing that nests is one where
|
|
32
|
+
* `list()`'s single prefix scan stops describing what is there. No leading dot,
|
|
33
|
+
* because a dotfile is invisible in exactly the directory the owner is meant to
|
|
34
|
+
* be able to read. No control characters, which are not names.
|
|
35
|
+
*
|
|
36
|
+
* Spaces and unicode are allowed. Refusing them would reject the names files
|
|
37
|
+
* actually have, and the name is percent-encoded at the one boundary that needs
|
|
38
|
+
* it — a resource URI — rather than being restricted to survive it.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_NAME = 200;
|
|
41
|
+
const CONTROL = /[\x00-\x1F\x7F]/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Suffixes a blob adapter treats as its own bookkeeping.
|
|
45
|
+
*
|
|
46
|
+
* The filesystem adapter writes `<key>.meta` beside a blob whose content type its
|
|
47
|
+
* extension cannot express, `<key>.tmp` while a write is in flight, and skips
|
|
48
|
+
* both in `list()`. So an asset called `report.meta` would be stored, would
|
|
49
|
+
* never appear in a listing, and would read back only if you already knew the
|
|
50
|
+
* name — silent, and shaped like data loss. Memory never met this because every
|
|
51
|
+
* key it writes ends `.md`; an asset's key is whatever the file was called.
|
|
52
|
+
*/
|
|
53
|
+
const ADAPTER_SUFFIXES = ['.meta', '.tmp'];
|
|
54
|
+
|
|
55
|
+
export function assertAssetName(name: string): void {
|
|
56
|
+
const why = nameProblem(name);
|
|
57
|
+
if (why) throw new Error(`Asset name ${JSON.stringify(name)} ${why}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nameProblem(name: string): string | null {
|
|
61
|
+
if (name.length === 0) return 'must not be empty.';
|
|
62
|
+
if (name.length > MAX_NAME) return `must be at most ${MAX_NAME} characters.`;
|
|
63
|
+
if (name.includes('/') || name.includes('\\')) {
|
|
64
|
+
return 'must not contain a path separator — assets are a flat set of files, not a tree.';
|
|
65
|
+
}
|
|
66
|
+
if (name.startsWith('.')) return 'must not start with a dot.';
|
|
67
|
+
if (CONTROL.test(name)) return 'must not contain control characters.';
|
|
68
|
+
|
|
69
|
+
const reserved = ADAPTER_SUFFIXES.find((suffix) => name.endsWith(suffix));
|
|
70
|
+
if (reserved) {
|
|
71
|
+
return `must not end in "${reserved}" — a blob store keeps its own bookkeeping under that suffix and would hide the file.`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether a listed key is an asset. Anything unnameable is skipped, not repaired. */
|
|
78
|
+
export function nameFromKey(key: string): string | null {
|
|
79
|
+
return nameProblem(key) === null ? key : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface Asset {
|
|
83
|
+
readonly name: string;
|
|
84
|
+
readonly bytes: number;
|
|
85
|
+
readonly contentType: string;
|
|
86
|
+
readonly modifiedAt: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every asset, newest first.
|
|
91
|
+
*
|
|
92
|
+
* One `list()` and no reads at all, which is the payoff for the key being the
|
|
93
|
+
* filename: memory has to open every entry to list it, and this does not.
|
|
94
|
+
*/
|
|
95
|
+
export async function allAssets(storage: BlobStore): Promise<Asset[]> {
|
|
96
|
+
return (await storage.list())
|
|
97
|
+
.flatMap((blob) => {
|
|
98
|
+
const name = nameFromKey(blob.key);
|
|
99
|
+
return name === null
|
|
100
|
+
? []
|
|
101
|
+
: [
|
|
102
|
+
{
|
|
103
|
+
name,
|
|
104
|
+
bytes: blob.size,
|
|
105
|
+
contentType: blob.contentType ?? guessContentType(name),
|
|
106
|
+
modifiedAt: blob.modifiedAt.toISOString(),
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
})
|
|
110
|
+
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt) || a.name.localeCompare(b.name));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function findAsset(storage: BlobStore, name: string): Promise<Asset | null> {
|
|
114
|
+
return (await allAssets(storage)).find((asset) => asset.name === name) ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function digestOf(bytes: Uint8Array): string {
|
|
118
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether these bytes can be handed to a model as text.
|
|
123
|
+
*
|
|
124
|
+
* Two questions, and both have to pass. The declared type has to be a text one
|
|
125
|
+
* — `text/*`, or a structured type that is text by construction — and the bytes
|
|
126
|
+
* have to contain no NUL, because a mislabelled binary is common and a content
|
|
127
|
+
* type is a claim rather than an observation. Failing either means the asset is
|
|
128
|
+
* described instead of returned, which is the honest answer: `ResourceContents`
|
|
129
|
+
* carries text and nothing else, and base64 into a conversation is the cost this
|
|
130
|
+
* whole mechanism exists to avoid.
|
|
131
|
+
*/
|
|
132
|
+
export function isTextual(contentType: string, bytes: Uint8Array): boolean {
|
|
133
|
+
const type = contentType.split(';')[0]!.trim().toLowerCase();
|
|
134
|
+
|
|
135
|
+
const declared =
|
|
136
|
+
type.startsWith('text/') ||
|
|
137
|
+
type.endsWith('+json') ||
|
|
138
|
+
type.endsWith('+xml') ||
|
|
139
|
+
['application/json', 'application/xml', 'application/yaml', 'application/x-yaml'].includes(
|
|
140
|
+
type,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return declared && !bytes.includes(0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `invoice.pdf application/pdf 184 KB 2026-08-27` */
|
|
147
|
+
export function describeAsset(asset: Asset): string {
|
|
148
|
+
return `${asset.name} ${asset.contentType} ${humanBytes(asset.bytes)} ${asset.modifiedAt.slice(0, 10)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function humanBytes(bytes: number): string {
|
|
152
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
153
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
154
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The pieces `lanes link assets` needs to reach the same bytes the provider does. */
|
|
158
|
+
export const assetStorage = {
|
|
159
|
+
all: allAssets,
|
|
160
|
+
find: findAsset,
|
|
161
|
+
describe: describeAsset,
|
|
162
|
+
humanBytes,
|
|
163
|
+
digest: digestOf,
|
|
164
|
+
assertName: assertAssetName,
|
|
165
|
+
nameFromKey,
|
|
166
|
+
isTextual,
|
|
167
|
+
};
|
|
@@ -7,5 +7,5 @@ export { driveMcp } from './drive-mcp/index.ts';
|
|
|
7
7
|
export { gmail, GMAIL_SCOPES } from './gmail/index.ts';
|
|
8
8
|
export { gmailImap } from './gmail-imap/index.ts';
|
|
9
9
|
export { gmailMcp } from './gmail-mcp/index.ts';
|
|
10
|
+
export { googleTasks } from './tasks/index.ts';
|
|
10
11
|
export { sheets } from './sheets/index.ts';
|
|
11
|
-
export { tasks } from './tasks/index.ts';
|
|
@@ -17,7 +17,7 @@ import { TASKS_REDACT } from './redact.ts';
|
|
|
17
17
|
* What bounds it is what bounds the others. The token can delete a whole list;
|
|
18
18
|
* the tool surface cannot, because `tasklists.delete` is not vendored — Tasks
|
|
19
19
|
* has no trash, so destroying a list destroys every task in it. `lanes link
|
|
20
|
-
* policy deny
|
|
20
|
+
* policy deny google_tasks.*` narrows it further.
|
|
21
21
|
*
|
|
22
22
|
* No `identity` block, and that is a decision rather than an omission. Nothing
|
|
23
23
|
* reachable under `auth/tasks` returns an address: `tasklists.list` answers
|
|
@@ -32,8 +32,8 @@ import { TASKS_REDACT } from './redact.ts';
|
|
|
32
32
|
*/
|
|
33
33
|
const TASKS_SCOPES = ['https://www.googleapis.com/auth/tasks'];
|
|
34
34
|
|
|
35
|
-
export const
|
|
36
|
-
id: '
|
|
35
|
+
export const googleTasks = defineProvider({
|
|
36
|
+
id: 'google_tasks',
|
|
37
37
|
name: 'Google Tasks',
|
|
38
38
|
description:
|
|
39
39
|
'Read and write task lists and tasks — create, edit, complete, reorder, and delete — via the Tasks REST API.',
|
|
@@ -6,18 +6,28 @@
|
|
|
6
6
|
* them. `due` and `status` are kept, because "marked the thing due Friday done"
|
|
7
7
|
* is the shape of the change and neither argument says what the thing was.
|
|
8
8
|
*
|
|
9
|
+
* **The keys carry Google's whole operationId**, `tasks.tasks.patch` rather than
|
|
10
|
+
* `tasks.patch`. `shortenName` strips the provider id from a discovered tool
|
|
11
|
+
* name, and this provider's id is `google_tasks` while the API namespaces its
|
|
12
|
+
* operations under `tasks` — so nothing is stripped and the capability is
|
|
13
|
+
* `google_tasks.tasks.patch`. `contacts` has read this way since it shipped: its
|
|
14
|
+
* id is `contacts` and the People API says `people.people.searchContacts`. The
|
|
15
|
+
* keys were the short form while the id was `tasks` and the prefix happened to
|
|
16
|
+
* match; renaming the provider for the built-in task list (ADR-051) ended the
|
|
17
|
+
* coincidence.
|
|
18
|
+
*
|
|
9
19
|
* Two argument names are not what they look like, and a wrong key here fails
|
|
10
20
|
* silently — the lookup misses, every value is withheld, and it reads exactly
|
|
11
21
|
* like working redaction. The generator disambiguates a collision between a
|
|
12
22
|
* path or query parameter and a body field by prefixing the location, so
|
|
13
|
-
* `tasks.insert` takes `queryParent` (the query one) beside the body's
|
|
14
|
-
* `parent`. `tasks.move` has no such collision and takes a plain `parent`.
|
|
23
|
+
* `tasks.tasks.insert` takes `queryParent` (the query one) beside the body's
|
|
24
|
+
* `parent`. `tasks.tasks.move` has no such collision and takes a plain `parent`.
|
|
15
25
|
*/
|
|
16
26
|
export const TASKS_REDACT: Record<string, string[]> = {
|
|
17
|
-
'tasklists.list': ['maxResults'],
|
|
18
|
-
'tasklists.insert': [],
|
|
19
|
-
'tasklists.patch': ['tasklist'],
|
|
20
|
-
'tasks.list': [
|
|
27
|
+
'tasks.tasklists.list': ['maxResults'],
|
|
28
|
+
'tasks.tasklists.insert': [],
|
|
29
|
+
'tasks.tasklists.patch': ['tasklist'],
|
|
30
|
+
'tasks.tasks.list': [
|
|
21
31
|
'tasklist',
|
|
22
32
|
'showCompleted',
|
|
23
33
|
'showDeleted',
|
|
@@ -26,9 +36,9 @@ export const TASKS_REDACT: Record<string, string[]> = {
|
|
|
26
36
|
'dueMax',
|
|
27
37
|
'maxResults',
|
|
28
38
|
],
|
|
29
|
-
'tasks.get': ['tasklist', 'task'],
|
|
30
|
-
'tasks.insert': ['tasklist', 'queryParent', 'previous', 'due', 'status'],
|
|
31
|
-
'tasks.patch': ['tasklist', 'task', 'due', 'status'],
|
|
32
|
-
'tasks.delete': ['tasklist', 'task'],
|
|
33
|
-
'tasks.move': ['tasklist', 'task', 'parent', 'previous'],
|
|
39
|
+
'tasks.tasks.get': ['tasklist', 'task'],
|
|
40
|
+
'tasks.tasks.insert': ['tasklist', 'queryParent', 'previous', 'due', 'status'],
|
|
41
|
+
'tasks.tasks.patch': ['tasklist', 'task', 'due', 'status'],
|
|
42
|
+
'tasks.tasks.delete': ['tasklist', 'task'],
|
|
43
|
+
'tasks.tasks.move': ['tasklist', 'task', 'parent', 'previous'],
|
|
34
44
|
};
|
package/src/providers/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { gmail } from './google/gmail/index.ts';
|
|
|
11
11
|
import { gmailImap } from './google/gmail-imap/index.ts';
|
|
12
12
|
import { gmailMcp } from './google/gmail-mcp/index.ts';
|
|
13
13
|
import { sheets } from './google/sheets/index.ts';
|
|
14
|
-
import {
|
|
14
|
+
import { googleTasks } from './google/tasks/index.ts';
|
|
15
15
|
import { icloudCalendar } from './icloud/calendar/index.ts';
|
|
16
16
|
import { icloudContacts } from './icloud/contacts/index.ts';
|
|
17
17
|
import { icloudDrive } from './icloud/drive/index.ts';
|
|
@@ -63,7 +63,7 @@ export const PROVIDERS: readonly (ProviderManifest | ProviderDefinition)[] = [
|
|
|
63
63
|
sheets,
|
|
64
64
|
docs,
|
|
65
65
|
calendar,
|
|
66
|
-
|
|
66
|
+
googleTasks,
|
|
67
67
|
contacts,
|
|
68
68
|
gmailImap,
|
|
69
69
|
gmailMcp,
|
|
@@ -97,8 +97,8 @@ export {
|
|
|
97
97
|
gmail,
|
|
98
98
|
gmailImap,
|
|
99
99
|
gmailMcp,
|
|
100
|
+
googleTasks,
|
|
100
101
|
sheets,
|
|
101
|
-
tasks,
|
|
102
102
|
} from './google/index.ts';
|
|
103
103
|
export { icloudCalendar, icloudContacts, icloudDrive, icloudMail } from './icloud/index.ts';
|
|
104
104
|
export { bunq } from './bunq/index.ts';
|