@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.
Files changed (48) hide show
  1. package/README.md +20 -9
  2. package/instructions/agents/lanes-link-scout.md +14 -3
  3. package/instructions/skills/lanes-link/SKILL.md +80 -3
  4. package/package.json +2 -2
  5. package/src/cli/argv.ts +7 -0
  6. package/src/cli/commands/connect/index.ts +9 -6
  7. package/src/cli/commands/connection.ts +298 -0
  8. package/src/cli/commands/mcp/list.ts +123 -29
  9. package/src/cli/commands/operate/inspect.ts +37 -20
  10. package/src/cli/commands/operate/serve.ts +21 -0
  11. package/src/cli/commands/owner/assets.ts +132 -0
  12. package/src/cli/commands/owner/shared.ts +28 -4
  13. package/src/cli/commands/owner/tasks.ts +194 -0
  14. package/src/cli/commands/owner.ts +9 -4
  15. package/src/cli/config-edit.ts +33 -7
  16. package/src/cli/config-repair.ts +115 -11
  17. package/src/cli/dispatch-owner.ts +49 -8
  18. package/src/cli/lanes.ts +1 -1
  19. package/src/cli/main.ts +26 -3
  20. package/src/cli/provider-marks.ts +1 -1
  21. package/src/cli/runtime/registry.ts +10 -2
  22. package/src/cli/selection.ts +14 -0
  23. package/src/cli/usage.ts +18 -2
  24. package/src/connectivity/mail/attachments.ts +5 -1
  25. package/src/connectivity/mail/index.ts +6 -1
  26. package/src/connectivity/manifest/provider.ts +15 -2
  27. package/src/deployments/deploy.ts +3 -2
  28. package/src/deployments/prepare.ts +1 -1
  29. package/src/deployments/servable.ts +1 -1
  30. package/src/deployments/upload.ts +0 -53
  31. package/src/profile/load.ts +46 -0
  32. package/src/providers/assets/provider.ts +337 -0
  33. package/src/providers/assets/store.ts +167 -0
  34. package/src/providers/bunq/hints.ts +3 -1
  35. package/src/providers/bunq/redact.ts +13 -2
  36. package/src/providers/bunq/specs/bunq.v1.json +20 -1
  37. package/src/providers/bunq/specs/vendor.ts +59 -1
  38. package/src/providers/google/index.ts +1 -1
  39. package/src/providers/google/tasks/index.ts +3 -3
  40. package/src/providers/google/tasks/redact.ts +21 -11
  41. package/src/providers/index.ts +3 -3
  42. package/src/providers/owner.ts +39 -19
  43. package/src/providers/setup/plan.ts +17 -1
  44. package/src/providers/shared/vendor-operations.ts +81 -0
  45. package/src/providers/tasks/provider.ts +370 -0
  46. package/src/providers/tasks/store.ts +248 -0
  47. package/src/server/mcp/build.ts +1 -1
  48. package/src/server/mcp/instructions.ts +67 -8
@@ -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
+ };
@@ -29,7 +29,9 @@ export const BUNQ_HINTS: Record<string, string> = {
29
29
  UPDATE_DraftPayment_for_User_MonetaryAccount:
30
30
  'Changes a draft that is still pending — status ACCEPTED sends it, REJECTED cancels it. ' +
31
31
  'previous_updated_timestamp is required and comes from reading the draft first; it is what stops two ' +
32
- 'callers acting on the same draft.',
32
+ 'callers acting on the same draft. Those two fields are the entire call — bunq refuses entries and ' +
33
+ 'number_of_required_accepts here as superfluous — so changing what a draft pays means rejecting it and ' +
34
+ 'creating another.',
33
35
 
34
36
  CREATE_PaymentBatch_for_User_MonetaryAccount:
35
37
  'Up to 350 payments in one call. Executes immediately, like a direct payment, and is all-or-nothing: bunq rejects ' +
@@ -51,14 +51,25 @@ export const BUNQ_REDACT: Record<string, string[]> = {
51
51
  'status',
52
52
  'schedule',
53
53
  ],
54
+ // Five, not seven: `entries` and `number_of_required_accepts` are no longer
55
+ // arguments at all. This is the one write here whose event cannot name an
56
+ // amount or a counterparty, because the call does not carry them — it says
57
+ // which draft was accepted and against which version of it, and that is
58
+ // everything there is to keep.
59
+ //
60
+ // Not everything a reader wants, though, and worth being honest that the gap
61
+ // is real rather than closed. The entries are on the event that *created* the
62
+ // draft, and nothing joins the two: an `AuditEvent` records arguments only,
63
+ // and bunq returns the draft id in the create's response. Reconstructing what
64
+ // an ACCEPTED draft paid means matching by hand. `context.audit.annotate`,
65
+ // which `gmail.send_message` uses to record resolved facts, is the shape of a
66
+ // fix and is a change to dispatch rather than to this list.
54
67
  UPDATE_DraftPayment_for_User_MonetaryAccount: [
55
68
  'userID',
56
69
  'monetary-accountID',
57
70
  'itemId',
58
71
  'status',
59
- 'entries',
60
72
  'previous_updated_timestamp',
61
- 'number_of_required_accepts',
62
73
  ],
63
74
  CREATE_PaymentBatch_for_User_MonetaryAccount: ['userID', 'monetary-accountID', 'payments'],
64
75
  };
@@ -144,7 +144,26 @@
144
144
  "content": {
145
145
  "application/json": {
146
146
  "schema": {
147
- "$ref": "#/components/schemas/DraftPayment"
147
+ "type": "object",
148
+ "description": "The whole of what this call takes. bunq refuses any other field here as superfluous — the rest of the schema it shares belongs to the call that creates one.",
149
+ "properties": {
150
+ "status": {
151
+ "type": "string",
152
+ "description": "The status of the DraftPayment.",
153
+ "readOnly": false,
154
+ "writeOnly": false
155
+ },
156
+ "previous_updated_timestamp": {
157
+ "type": "string",
158
+ "description": "The last updated_timestamp that you received for this DraftPayment. This needs to be provided to prevent race conditions.",
159
+ "readOnly": false,
160
+ "writeOnly": true
161
+ }
162
+ },
163
+ "required": [
164
+ "status",
165
+ "previous_updated_timestamp"
166
+ ]
148
167
  }
149
168
  }
150
169
  }
@@ -22,6 +22,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
22
22
  import { join } from 'node:path';
23
23
  import { OpenAPIToolGenerator, type McpOpenAPITool } from 'mcp-from-openapi';
24
24
  import { cutCycles, referenced, type Spec } from '../../shared/openapi.ts';
25
+ import { projectRequestBody } from '../../shared/vendor-operations.ts';
25
26
 
26
27
  const SOURCE = 'https://raw.githubusercontent.com/bunq/doc/master/swagger.json';
27
28
  const OUT = 'bunq.v1.json';
@@ -187,6 +188,34 @@ function dropReadOnly(node: unknown): number {
187
188
  return dropped;
188
189
  }
189
190
 
191
+ /**
192
+ * The operations bunq describes with the schema of a *different* operation.
193
+ *
194
+ * `PUT .../draft-payment/{itemId}` points at `DraftPayment`, the same schema as
195
+ * the `POST` that creates one, which requires `entries` and
196
+ * `number_of_required_accepts`. For a create those two *are* the payment. For an
197
+ * update bunq refuses both as superfluous — its own generated SDK sends only
198
+ * `status`, `previous_updated_timestamp` and `schedule` here — so the tool asked
199
+ * for exactly what the bank rejects and every accept failed. Worse than the
200
+ * error: made to send `entries` for a draft that has them, a model reaches for
201
+ * the array echoed back or for `[]`, and both ask bunq to rewrite what the draft
202
+ * pays on the way to approving it. A hint cannot fix a required argument, which
203
+ * is the difference between this and the `payments`-is-really-an-array note
204
+ * above: nothing stops an agent sending an array, and nothing lets it omit a
205
+ * required field. `required` is asserted here rather than projected because
206
+ * bunq's own is the one written for the create.
207
+ *
208
+ * `schedule` is deliberately not projected: it carries `recurrence_unit` and
209
+ * `recurrence_size`, so approving a one-off draft would be a place to acquire a
210
+ * standing order — the risk `OPERATIONS` gives for leaving the scheduling
211
+ * *endpoints* out. Not the whole of that risk, though: `CREATE_DraftPayment`
212
+ * still offers the same field, because it comes with bunq's create schema.
213
+ * Closing that changes what the provider can do rather than whether a call
214
+ * works, and is not done here.
215
+ */
216
+ const UPDATE_BODIES: Record<string, readonly string[]> = {
217
+ UPDATE_DraftPayment_for_User_MonetaryAccount: ['status', 'previous_updated_timestamp'],
218
+ };
190
219
 
191
220
  async function vendor(): Promise<void> {
192
221
  const response = await fetch(SOURCE);
@@ -246,6 +275,34 @@ async function vendor(): Promise<void> {
246
275
  }
247
276
 
248
277
  const schemas = spec.components?.schemas ?? {};
278
+
279
+ const projected = new Set<string>();
280
+ for (const item of Object.values(paths)) {
281
+ for (const [method, operation] of Object.entries(item)) {
282
+ if (!METHODS.includes(method)) continue;
283
+ const fields = operation.operationId ? UPDATE_BODIES[operation.operationId] : undefined;
284
+ if (!fields || !operation.operationId) continue;
285
+
286
+ projectRequestBody(
287
+ operation as unknown as Record<string, unknown>,
288
+ operation.operationId,
289
+ schemas,
290
+ fields,
291
+ 'The whole of what this call takes. bunq refuses any other field here as superfluous — the rest ' +
292
+ 'of the schema it shares belongs to the call that creates one.',
293
+ );
294
+ projected.add(operation.operationId);
295
+ }
296
+ }
297
+
298
+ // The same refusal as `missing` above, and for a sharper reason: an entry that
299
+ // matches nothing narrows nothing, and what is left is the wide body this
300
+ // exists to remove — printed as a success.
301
+ const unprojected = Object.keys(UPDATE_BODIES).filter((id) => !projected.has(id));
302
+ if (unprojected.length > 0) {
303
+ throw new Error(`UPDATE_BODIES names operations the spec does not have — ${unprojected.join(', ')}`);
304
+ }
305
+
249
306
  const keep = referenced(paths, schemas);
250
307
  const trimmedSchemas = Object.fromEntries(
251
308
  Object.entries(schemas).filter(([name]) => keep.has(name)),
@@ -295,7 +352,8 @@ async function vendor(): Promise<void> {
295
352
  ` bunq ${String(Object.keys(paths).length).padStart(2)} paths, ` +
296
353
  `${seen.size} operations, ${Object.keys(trimmedSchemas).length} schemas, ` +
297
354
  `${cuts} cycle${cuts === 1 ? '' : 's'} cut, ${headers} protocol params dropped, ` +
298
- `${readOnly} read-only fields dropped, ${size}KB`,
355
+ `${readOnly} read-only fields dropped, ${projected.size} update ` +
356
+ `bod${projected.size === 1 ? 'y' : 'ies'} projected, ${size}KB`,
299
357
  );
300
358
 
301
359
  await report(trimmed);
@@ -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 tasks.*` narrows it further.
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 tasks = defineProvider({
36
- id: 'tasks',
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
  };
@@ -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 { tasks } from './google/tasks/index.ts';
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
- tasks,
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';
@@ -1,38 +1,58 @@
1
1
  /**
2
- * The owner layer — memory, skills, vault, setup, identity.
2
+ * The owner layer — memory, tasks, assets, skills, vault, setup, identity.
3
3
  *
4
- * Three providers that hold no third-party account: no OAuth, no vendor API, no
4
+ * Seven providers that hold no third-party account: no OAuth, no vendor API, no
5
5
  * rate limit anyone else imposes. They are ordinary `defineLocalProvider`
6
- * registrations, configured by the same `lanes link connect <provider>`, scoped by the
7
- * same profiles, and gated by the same policy evaluation as everything else —
8
- * which was the claim `docs/detailed/init.md` made and called the real test of the
9
- * architecture.
6
+ * registrations, scoped by the same profiles and gated by the same policy
7
+ * evaluation as everything else which was the claim `docs/detailed/init.md`
8
+ * made and called the real test of the architecture. Since ADR-050 they are also
9
+ * the ones a fresh profile arrives with already granted, because what they reach
10
+ * is the owner's own material and there is no account behind them to protect.
10
11
  *
11
- * They live in `./memory/`, `./skills/` and `./vault/`, beside `google/` and
12
- * `icloud/`, because that claim is only true if they are providers in the
13
- * layout as well as in the prose. This file is the one thing they share: a
14
- * barrel, because each is *constructed* with a store rather than declared as
15
- * data, so `#profile`'s registry builder needs all three together.
12
+ * Each lives in its own folder beside `google/` and `icloud/`, because that
13
+ * claim is only true if they are providers in the layout as well as in the
14
+ * prose. This file is the one thing they share: a barrel, because several are
15
+ * *constructed* with a store rather than declared as data, so the registry
16
+ * builder needs them together.
16
17
  *
17
- * `setup` joined them later and is the same shape: it holds no account either,
18
- * and it describes the other three rather than reaching anything. It is
19
- * read-only by construction see ADR-019 for why describing setup is not one
18
+ * `memory`, `tasks` and `assets` are the three that hold what the owner keeps,
19
+ * and they divide by what a thing *is* rather than by size: memory is what is
20
+ * true, tasks is what is to be done, assets is a file. That split is the whole
21
+ * of ADR-051, and the routing rule an agent needs is stated in
22
+ * `#server/mcp`'s instructions and in the bundled skill.
23
+ *
24
+ * `setup` is the same shape and holds no account either, but it describes the
25
+ * others rather than holding anything of the owner's. It is read-only by
26
+ * construction — see ADR-019 for why describing setup is not one
20
27
  * of ADR-007's control-plane exclusions.
21
28
  *
22
- * `identity` is the fifth and holds no account either. It says who the owner is
29
+ * `identity` holds no account either. It says who the owner is
23
30
  * — the names and addresses to write as them — and is read-only for the reason
24
31
  * `setup` is: what it reports is configuration, and configuration is changed in
25
32
  * the CLI. It is a provider of its own rather than a section of `setup` so that
26
33
  * naming the owner and describing what is connected are two policy decisions
27
34
  * instead of one.
28
35
  *
29
- * The ids `memory`, `skills`, `vault`, `setup`, and `identity` are reserved (`RESERVED_PROVIDER_IDS`)
30
- * and still refused by default — the registry has to be built with
31
- * `allowReserved` to hold them, so a third-party provider cannot claim a
32
- * namespace whose policy rules would then mean something else.
36
+ * All seven ids are reserved (`RESERVED_PROVIDER_IDS`) and still refused by
37
+ * default — the registry has to be built with `allowReserved` to hold them, so a
38
+ * third-party provider cannot claim a namespace whose policy rules would then
39
+ * mean something else. `tasks` cost something to reserve: Google Tasks held that
40
+ * id and was renamed `google_tasks`, because the plain noun belongs to the
41
+ * owner's own list and a manifest already registered under it would have thrown
42
+ * on the second registration rather than shadowing anything.
33
43
  */
34
44
 
35
45
  export { memoryProvider, memoryStorage, assertEntryId, type MemoryEntry } from './memory/provider.ts';
46
+ export { tasksProvider } from './tasks/provider.ts';
47
+ export {
48
+ ACTIVE_STATUSES,
49
+ TASK_STATUSES,
50
+ taskStorage,
51
+ type Task,
52
+ type TaskStatus,
53
+ } from './tasks/store.ts';
54
+ export { assetsProvider } from './assets/provider.ts';
55
+ export { assetStorage, type Asset } from './assets/store.ts';
36
56
  export { createSkillsProvider, type SkillsProviderOptions } from './skills/provider.ts';
37
57
  export { createVaultProvider, type VaultProviderOptions } from './vault/provider.ts';
38
58
  export { createSetupProvider, type SetupProviderOptions } from './setup/provider.ts';
@@ -1,5 +1,5 @@
1
1
  import type { ProviderManifest } from '#connectivity';
2
- import { hasOwnClientPath, setupRequirements, type SetupRequirement } from '#connectivity';
2
+ import { hasOwnClientPath, RESERVED_PROVIDER_IDS, setupRequirements, type SetupRequirement } from '#connectivity';
3
3
 
4
4
  /**
5
5
  * What connecting a provider involves, assembled from its manifest.
@@ -19,6 +19,21 @@ export interface ProviderPlan {
19
19
  readonly id: string;
20
20
  readonly name: string;
21
21
  readonly description: string;
22
+ /**
23
+ * This provider is part of what a profile *is*, not an account it holds.
24
+ *
25
+ * The owner layer — `memory`, `skills`, `vault`, `setup`, `identity` — keyed off
26
+ * `RESERVED_PROVIDER_IDS`, which is the same list the registry uses to stop a
27
+ * third-party manifest claiming one of those ids.
28
+ *
29
+ * Reported because it is the fact a surface needs and cannot derive.
30
+ * `multiAccount` is the nearest thing and it is not it: that is a credential
31
+ * test, and `icloud_drive` is `auth: none` without being owner-layer at all. A
32
+ * client wanting to group these, or to withhold a disconnect that would leave a
33
+ * dangling policy grant, was left hardcoding the list — which then goes stale
34
+ * the next time one is added here. This travels with the release instead.
35
+ */
36
+ readonly reserved: boolean;
22
37
  /** Connection keys of this provider already configured, e.g. `gmail.main`. */
23
38
  readonly connected: readonly string[];
24
39
  /**
@@ -122,6 +137,7 @@ export function planFor(
122
137
  id: manifest.id,
123
138
  name: manifest.name,
124
139
  description: manifest.description,
140
+ reserved: RESERVED_PROVIDER_IDS.includes(manifest.id),
125
141
  connected,
126
142
  multiAccount: manifest.auth.kind !== 'none',
127
143
  browser: manifest.auth.kind === 'oauth',
@@ -96,3 +96,84 @@ export function narrowRequestBody(
96
96
  body.content = Object.fromEntries(kept.map((type) => [type, body.content![type]]));
97
97
  return before.length - kept.length;
98
98
  }
99
+
100
+ /**
101
+ * Replace a request body with a projection of the schema it points at.
102
+ *
103
+ * The third surgery on this axis, and the first about the body being *wrong*
104
+ * rather than too wide. A document that describes two operations with one schema
105
+ * describes at least one of them wrongly, and `required` is where it bites: the
106
+ * generated tool asks for arguments the vendor refuses on the call they are
107
+ * attached to, so there is no correct call to make. It fails at the vendor, on
108
+ * every attempt, and nothing before the request can see it.
109
+ *
110
+ * Projecting rather than hand-writing is what keeps this tied to the document.
111
+ * The named fields are copied out of the vendor's own schema with their types
112
+ * and descriptions, so the tool still says what the vendor says. Everything this
113
+ * cannot verify it refuses instead: a field that is gone, a field the document
114
+ * marks read-only, a body offering a content type this does not rewrite, a
115
+ * `$ref` that does not point into `components.schemas`. A vendor refresh should
116
+ * fail loudly here rather than quietly restore the body it was called to fix.
117
+ *
118
+ * `required` is the one thing NOT projected — it is the caller's assertion, and
119
+ * necessarily so, since the whole disease is a `required` written for the other
120
+ * operation. Say why in the caller.
121
+ *
122
+ * `note` becomes the body schema's description. It lands in the committed
123
+ * document for whoever reads it there; it does **not** reach the agent, because
124
+ * the generator flattens body properties to the top level and drops the body
125
+ * schema's own description. The sentence an agent needs goes in `hints`.
126
+ *
127
+ * Apply before reachability, so a schema the projection no longer reaches leaves
128
+ * the document rather than lingering unused.
129
+ */
130
+ export function projectRequestBody(
131
+ operation: Record<string, unknown>,
132
+ operationId: string,
133
+ schemas: Record<string, unknown>,
134
+ fields: readonly string[],
135
+ note: string,
136
+ ): void {
137
+ const content = (operation['requestBody'] as { content?: Record<string, { schema?: { $ref?: string } }> })
138
+ ?.content;
139
+ const types = Object.keys(content ?? {});
140
+
141
+ const other = types.filter((type) => type !== 'application/json');
142
+ if (!content || types.length === 0 || other.length > 0) {
143
+ // A body left pointing at the wide schema on a second content type is the
144
+ // bug still present on whichever branch the generator happens to prefer.
145
+ throw new Error(
146
+ `${operationId}: expected a lone application/json request body to project, found ${types.join(', ') || 'none'}`,
147
+ );
148
+ }
149
+
150
+ const json = content['application/json'];
151
+ const reference = json?.schema?.$ref;
152
+ if (!json || typeof reference !== 'string' || !reference.startsWith('#/components/schemas/')) {
153
+ throw new Error(`${operationId}: request body is ${reference ?? 'not a $ref'}, not a schema reference`);
154
+ }
155
+
156
+ const name = reference.slice('#/components/schemas/'.length);
157
+ const source = (schemas[name] as { properties?: Record<string, unknown> } | undefined)?.properties ?? {};
158
+
159
+ const properties: Record<string, unknown> = {};
160
+ for (const field of fields) {
161
+ const property = source[field] as { readOnly?: boolean } | undefined;
162
+ if (!property) throw new Error(`${operationId}: ${name} no longer describes "${field}"`);
163
+ if (property.readOnly === true) {
164
+ // Projected fields are inlined into the path, where the read-only strip
165
+ // does not reach — so a field the vendor computes would survive here and
166
+ // be demanded as an argument it ignores.
167
+ throw new Error(`${operationId}: ${name}.${field} is read-only and cannot be a request field`);
168
+ }
169
+ properties[field] = property;
170
+ }
171
+
172
+ json.schema = {
173
+ type: 'object',
174
+ description: note,
175
+ properties,
176
+ required: [...fields],
177
+ } as never;
178
+ }
179
+