@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,370 @@
1
+ import { z } from 'zod';
2
+ import { defineLocalProvider, keepKeys, type ProviderDefinition } from '#connectivity';
3
+ import {
4
+ ACTIVE_STATUSES,
5
+ TASK_STATUSES,
6
+ allTasks,
7
+ assertTaskId,
8
+ readTask,
9
+ slugify,
10
+ taskKey,
11
+ writeTask,
12
+ type Task,
13
+ type TaskStatus,
14
+ } from './store.ts';
15
+
16
+ /**
17
+ * `tasks` — what the owner has to do.
18
+ *
19
+ * **This exists because memory was being used for it.** "Remember to chase the
20
+ * invoice" was landing in `memory.write`, and memory has no way to express that
21
+ * something is finished: an entry is a fact, facts do not close, and nothing
22
+ * ever removed it. A task carries a status, which is the whole difference, and
23
+ * the routing rule is stated where an agent reads it — the paragraph in
24
+ * `#server/mcp`'s instructions and the bundled skill both say that a thing to
25
+ * *do* goes here and a thing that is merely *true* goes in memory. ADR-051.
26
+ *
27
+ * Everything else is memory's design, deliberately unchanged: one Markdown file
28
+ * per task in the `BlobStore` core scoped to `tasks/<connection>`, reading and
29
+ * writing as separate capabilities, no index. The format lives in `./store.ts`.
30
+ *
31
+ * **Reading and writing are separate**, for the reason ADR-012 §2 gives for
32
+ * memory: text an agent authors is stored once and re-served to every later
33
+ * session, including to a different agent. A task list is a smaller version of
34
+ * that risk rather than a different one — an injected "task" is an instruction
35
+ * with a due date — so the split is the same and so is the one-line narrowing,
36
+ * `deny: [tasks.add, tasks.update, tasks.remove]`.
37
+ */
38
+
39
+ const DEFAULT_LIMIT = 20;
40
+
41
+ const statusSchema = z.enum(TASK_STATUSES);
42
+
43
+ /** `in_progress · chase the invoice (due 2026-09-01) [billing]` */
44
+ function line(task: Task): string {
45
+ const tags = task.tags.length > 0 ? ` [${task.tags.join(', ')}]` : '';
46
+ const due = task.due ? ` (due ${task.due})` : '';
47
+ return `${task.status} · ${task.title}${due}${tags}`;
48
+ }
49
+
50
+ /** Whether a task matches a free-text query, over the parts a person would search. */
51
+ function matches(task: Task, needle: string): boolean {
52
+ return (
53
+ task.title.toLowerCase().includes(needle) ||
54
+ task.body.toLowerCase().includes(needle) ||
55
+ task.tags.some((tag) => tag.toLowerCase().includes(needle))
56
+ );
57
+ }
58
+
59
+ export const tasksProvider: ProviderDefinition = defineLocalProvider({
60
+ id: 'tasks',
61
+ name: 'Tasks',
62
+ version: '1.0.0',
63
+ description:
64
+ "What the owner has to do. A task carries a status, which is why this is not memory: use it for anything to be done, and memory for what is merely true. Writing is a separate capability from reading.",
65
+
66
+ configSchema: z.object({}),
67
+ connectionSchema: z.object({}),
68
+
69
+ bundles: [
70
+ {
71
+ name: 'read',
72
+ description: 'List and read tasks.',
73
+ oauth_scopes: [],
74
+ capabilities: ['task', 'list', 'get'],
75
+ default: true,
76
+ },
77
+ {
78
+ name: 'write',
79
+ description: 'Add tasks, change their status, and delete them.',
80
+ oauth_scopes: [],
81
+ capabilities: ['add', 'update', 'remove'],
82
+ },
83
+ ],
84
+
85
+ capabilities: [
86
+ /**
87
+ * Retrieval by address — a resource, not a tool, on ADR-006's rule: the
88
+ * answer is a function of the URI alone. The same case memory's `entry` is.
89
+ */
90
+ {
91
+ kind: 'resource',
92
+ name: 'task',
93
+ title: 'Task',
94
+ description: 'One task, addressed by its id.',
95
+ uriTemplate: 'tasks://task/{id}',
96
+ mimeType: 'text/markdown',
97
+ redact: keepKeys('uri'),
98
+
99
+ async list(context) {
100
+ return (await allTasks(context.storage)).map((task) => ({
101
+ uri: `tasks://task/${encodeURIComponent(task.id)}`,
102
+ name: task.title,
103
+ }));
104
+ },
105
+
106
+ async read(uri, params, context) {
107
+ const raw = params['id'];
108
+ if (!raw) throw new Error(`Malformed task URI: ${uri}`);
109
+
110
+ const id = decodeURIComponent(raw);
111
+ const task = await readTask(context.storage, id);
112
+ if (task === null) throw new Error(`No task "${id}" on ${context.connection.key}`);
113
+
114
+ return { uri, mimeType: 'text/markdown', text: describe(task) };
115
+ },
116
+ },
117
+
118
+ {
119
+ kind: 'tool',
120
+ name: 'list',
121
+ title: 'List tasks',
122
+ description:
123
+ 'Tasks the owner has open. Shows in_progress, open and blocked by default — finished, dropped and muted work is excluded unless you name a status, because the question is almost always what is outstanding. Ordered by status, then due date.',
124
+ inputSchema: z.object({
125
+ status: z
126
+ .array(statusSchema)
127
+ .optional()
128
+ .describe('Statuses to include. Defaults to in_progress, open and blocked.'),
129
+ tag: z.string().optional().describe('Restrict to tasks carrying this tag'),
130
+ query: z.string().optional().describe('Free text to look for in the title, notes, or tags'),
131
+ limit: z
132
+ .number()
133
+ .int()
134
+ .min(1)
135
+ .max(200)
136
+ .optional()
137
+ .describe(`Maximum results (default ${DEFAULT_LIMIT})`),
138
+ }),
139
+ // Nothing kept. A task query is as revealing as a memory search: it is
140
+ // the owner's own material being asked for by name.
141
+ async handler({ status, tag, query, limit }, context) {
142
+ const wanted = new Set<TaskStatus>(status ?? ACTIVE_STATUSES);
143
+ const needle = query?.toLowerCase();
144
+
145
+ const all = await allTasks(context.storage);
146
+ const found = all.filter(
147
+ (task) =>
148
+ wanted.has(task.status) &&
149
+ (!tag || task.tags.includes(tag)) &&
150
+ (!needle || matches(task, needle)),
151
+ );
152
+ const shown = found.slice(0, limit ?? DEFAULT_LIMIT);
153
+
154
+ context.audit.annotate({ scanned: all.length, matched: found.length });
155
+
156
+ if (shown.length === 0) {
157
+ const scope = status ? 'matching' : 'outstanding';
158
+ return {
159
+ content: [
160
+ { type: 'text', text: `No ${scope} tasks on ${context.connection.key}.` },
161
+ ],
162
+ };
163
+ }
164
+
165
+ // `resource_link` rather than a URI written into the text: core routes
166
+ // the link to the profile and connection this call was made on, and a
167
+ // provider must not learn either.
168
+ return {
169
+ content: [
170
+ ...shown.flatMap((task) => [
171
+ {
172
+ type: 'resource_link' as const,
173
+ uri: `tasks://task/${encodeURIComponent(task.id)}`,
174
+ name: task.title,
175
+ },
176
+ { type: 'text' as const, text: `${task.id} ${line(task)}` },
177
+ ]),
178
+ ...(found.length > shown.length
179
+ ? [
180
+ {
181
+ type: 'text' as const,
182
+ text: `… ${found.length - shown.length} more. Raise limit, or narrow with tag or query.`,
183
+ },
184
+ ]
185
+ : []),
186
+ ],
187
+ };
188
+ },
189
+ },
190
+
191
+ {
192
+ kind: 'tool',
193
+ name: 'get',
194
+ title: 'Read a task',
195
+ description:
196
+ 'Return one task by id, notes included. The resource tasks://task/{id} is the same content; this exists for clients that do not read resources.',
197
+ inputSchema: z.object({ id: z.string().min(1).describe('Task id') }),
198
+ redact: keepKeys('id'),
199
+ async handler({ id }, context) {
200
+ const task = await readTask(context.storage, id);
201
+
202
+ if (task === null) {
203
+ return {
204
+ content: [{ type: 'text', text: `No task "${id}" on ${context.connection.key}.` }],
205
+ isError: true,
206
+ };
207
+ }
208
+
209
+ return { content: [{ type: 'text', text: describe(task) }] };
210
+ },
211
+ },
212
+
213
+ {
214
+ kind: 'tool',
215
+ name: 'add',
216
+ title: 'Add a task',
217
+ description:
218
+ 'Record something to be done. This is where "remember to…", "add a todo", and "do not let me forget…" belong — not memory, which has no way to say a thing is finished.',
219
+ inputSchema: z.object({
220
+ title: z.string().min(1).describe('What is to be done, in one line'),
221
+ notes: z.string().optional().describe('Detail, as Markdown'),
222
+ status: statusSchema.optional().describe('Defaults to open'),
223
+ due: z
224
+ .string()
225
+ .optional()
226
+ .describe('When it is due, as the owner would write it — 2026-09-01, or an instant'),
227
+ tags: z.array(z.string()).optional().describe('Labels for filtering'),
228
+ id: z
229
+ .string()
230
+ .optional()
231
+ .describe('Task id. Derived from the title when omitted; naming an existing one replaces it.'),
232
+ }),
233
+ // The title and notes are the owner's own words; the rest is the shape of
234
+ // the change, which is what makes a write log worth having.
235
+ redact: keepKeys('id', 'status', 'due', 'tags'),
236
+ async handler({ title, notes, status, due, tags, id }, context) {
237
+ const taskId = id ?? slugify(title);
238
+ assertTaskId(taskId);
239
+
240
+ const now = new Date().toISOString();
241
+ const existing = await readTask(context.storage, taskId);
242
+
243
+ await writeTask(context.storage, {
244
+ id: taskId,
245
+ title,
246
+ status: status ?? 'open',
247
+ tags: tags ?? [],
248
+ ...(due ? { due } : {}),
249
+ // Preserved across a replace: when a task was first recorded is a fact
250
+ // about the task, not about the last time something touched it.
251
+ createdAt: existing?.createdAt ?? now,
252
+ updatedAt: now,
253
+ body: notes ?? '',
254
+ });
255
+
256
+ context.audit.annotate({ task: taskId, replaced: existing !== null });
257
+
258
+ return {
259
+ content: [
260
+ {
261
+ type: 'text',
262
+ text: `${existing ? 'Replaced' : 'Added'} task "${taskId}" on ${context.connection.key}.`,
263
+ },
264
+ { type: 'resource_link', uri: `tasks://task/${taskId}`, name: title },
265
+ ],
266
+ };
267
+ },
268
+ },
269
+
270
+ {
271
+ kind: 'tool',
272
+ name: 'update',
273
+ title: 'Change a task',
274
+ description:
275
+ 'Change a task in place — most often its status. Marking something done is an update, not a delete: the record of having done it is the useful part. Omitted fields are left as they are.',
276
+ inputSchema: z.object({
277
+ id: z.string().min(1).describe('Task id'),
278
+ status: statusSchema.optional().describe('The new status'),
279
+ title: z.string().optional().describe('Replaces the title'),
280
+ notes: z.string().optional().describe('Replaces the notes'),
281
+ due: z.string().optional().describe('Replaces the due date. Pass "" to clear it.'),
282
+ tags: z.array(z.string()).optional().describe('Replaces the tags'),
283
+ }),
284
+ redact: keepKeys('id', 'status', 'due', 'tags'),
285
+ async handler({ id, status, title, notes, due, tags }, context) {
286
+ const existing = await readTask(context.storage, id);
287
+
288
+ if (existing === null) {
289
+ return {
290
+ content: [{ type: 'text', text: `No task "${id}" on ${context.connection.key}.` }],
291
+ isError: true,
292
+ };
293
+ }
294
+
295
+ // An explicit empty string clears the date; `undefined` leaves it. The
296
+ // two are different intentions and a truthiness check would merge them.
297
+ const nextDue = due === undefined ? existing.due : due === '' ? undefined : due;
298
+
299
+ // `due` is pulled off `existing` rather than spread and overwritten,
300
+ // because spreading cannot *remove* a key: `{ ...existing, ...{} }`
301
+ // keeps the old date, which is precisely how clearing one silently
302
+ // failed to clear it.
303
+ const { due: _previous, ...rest } = existing;
304
+
305
+ await writeTask(context.storage, {
306
+ ...rest,
307
+ title: title ?? existing.title,
308
+ status: status ?? existing.status,
309
+ tags: tags ?? existing.tags,
310
+ ...(nextDue ? { due: nextDue } : {}),
311
+ updatedAt: new Date().toISOString(),
312
+ body: notes ?? existing.body,
313
+ });
314
+
315
+ context.audit.annotate({ task: id, from: existing.status, to: status ?? existing.status });
316
+
317
+ return {
318
+ content: [
319
+ {
320
+ type: 'text',
321
+ text: `Updated task "${id}" on ${context.connection.key} — now ${status ?? existing.status}.`,
322
+ },
323
+ ],
324
+ };
325
+ },
326
+ },
327
+
328
+ {
329
+ kind: 'tool',
330
+ name: 'remove',
331
+ title: 'Delete a task',
332
+ description:
333
+ 'Remove a task and its notes. For something that was finished or decided against, prefer update with status done or dropped — deleting loses the record that it happened.',
334
+ inputSchema: z.object({ id: z.string().min(1).describe('Task id') }),
335
+ redact: keepKeys('id'),
336
+ async handler({ id }, context) {
337
+ const existed = await context.storage.has(taskKey(id));
338
+ await context.storage.delete(taskKey(id));
339
+
340
+ return {
341
+ content: [
342
+ {
343
+ type: 'text',
344
+ text: existed
345
+ ? `Deleted task "${id}" from ${context.connection.key}.`
346
+ : `No task "${id}" on ${context.connection.key}.`,
347
+ },
348
+ ],
349
+ ...(existed ? {} : { isError: true }),
350
+ };
351
+ },
352
+ },
353
+ ],
354
+ });
355
+
356
+ /** One task as a document: the line a list would show, then the notes. */
357
+ function describe(task: Task): string {
358
+ const header = [
359
+ `# ${task.title}`,
360
+ '',
361
+ `status: ${task.status}`,
362
+ ...(task.due ? [`due: ${task.due}`] : []),
363
+ ...(task.tags.length > 0 ? [`tags: ${task.tags.join(', ')}`] : []),
364
+ `updated: ${task.updatedAt}`,
365
+ ];
366
+
367
+ return task.body.length > 0 ? `${header.join('\n')}\n\n${task.body}` : header.join('\n');
368
+ }
369
+
370
+ export default tasksProvider;
@@ -0,0 +1,248 @@
1
+ import type { BlobStore } from '#connectivity';
2
+ import {
3
+ splitOptionalFrontmatter,
4
+ stringList,
5
+ withFrontmatter,
6
+ } from '#providers/shared/frontmatter.ts';
7
+
8
+ /**
9
+ * How a task is stored, and the only place that knows.
10
+ *
11
+ * **One task is one Markdown file**, exactly as a memory entry is, and for the
12
+ * same reason: `lanes link tasks` and a text editor reach the same bytes, and
13
+ * there is no index row that can disagree with the file it describes. ADR-014
14
+ * reversed that split for memory and there is no argument for reintroducing it
15
+ * here. The document is frontmatter — title, status, tags, due, timestamps —
16
+ * above a body that is the notes.
17
+ *
18
+ * Its own file rather than living in `provider.ts` because the CLI needs it too
19
+ * (`lanes link tasks list` reads these bytes) and because the two together
20
+ * would pass the file-size budget. The seam is the one `skills/` already uses:
21
+ * this knows the format, the provider knows the capabilities.
22
+ *
23
+ * The store arrives scoped to `tasks/<connection>` by core, so nothing here
24
+ * prefixes a key or thinks about isolation — one connection's tasks are not
25
+ * addressable from another because of where the store was cut, not because of
26
+ * anything written below.
27
+ */
28
+
29
+ /**
30
+ * The statuses, in the order a list should show them.
31
+ *
32
+ * Six rather than three, and each earns its place by being a different answer
33
+ * to "why is this not done":
34
+ *
35
+ * in_progress started, and the thing to pick back up first
36
+ * open not started
37
+ * blocked waiting on something that is not the owner
38
+ * muted deliberately not being surfaced — a real state, and the one
39
+ * asked for by name. Distinct from `blocked`: blocked is waiting
40
+ * on the world, muted is a decision to stop being reminded.
41
+ * done finished
42
+ * dropped decided against, which is not the same fact as finished and
43
+ * should not be recorded as one
44
+ *
45
+ * The order is the sort order, which is why it is a tuple rather than a set.
46
+ */
47
+ export const TASK_STATUSES = [
48
+ 'in_progress',
49
+ 'open',
50
+ 'blocked',
51
+ 'muted',
52
+ 'done',
53
+ 'dropped',
54
+ ] as const;
55
+
56
+ export type TaskStatus = (typeof TASK_STATUSES)[number];
57
+
58
+ /** The statuses `tasks.list` shows unless asked otherwise. See `provider.ts`. */
59
+ export const ACTIVE_STATUSES: readonly TaskStatus[] = ['in_progress', 'open', 'blocked'];
60
+
61
+ export interface Task {
62
+ readonly id: string;
63
+ readonly title: string;
64
+ readonly status: TaskStatus;
65
+ readonly tags: readonly string[];
66
+ /**
67
+ * As the owner wrote it — `2026-08-27` or a full instant — never normalised.
68
+ *
69
+ * Normalising would have to invent a time zone: "Friday" recorded as an
70
+ * instant is a different day in two places, and what was typed was a day.
71
+ * `compareTasks` orders these as strings, which is correct for any ISO-8601
72
+ * prefix and is the only ordering claim made about them.
73
+ */
74
+ readonly due?: string;
75
+ readonly createdAt: string;
76
+ readonly updatedAt: string;
77
+ readonly body: string;
78
+ }
79
+
80
+ const TASK_ID = /^[a-z0-9][a-z0-9_-]*$/;
81
+
82
+ export function taskKey(id: string): string {
83
+ return `${id}.md`;
84
+ }
85
+
86
+ export function idFromKey(key: string): string | null {
87
+ if (!key.endsWith('.md')) return null;
88
+ const id = key.slice(0, -'.md'.length);
89
+ return TASK_ID.test(id) ? id : null;
90
+ }
91
+
92
+ export function assertTaskId(id: string): void {
93
+ if (!TASK_ID.test(id)) {
94
+ throw new Error(
95
+ `Task id ${JSON.stringify(id)} must be lowercase letters, digits, "_" or "-".`,
96
+ );
97
+ }
98
+ }
99
+
100
+ /** A stable id from a title, so adding a task does not demand one be invented. */
101
+ export function slugify(title: string): string {
102
+ const slug = title
103
+ .toLowerCase()
104
+ .replace(/[^a-z0-9]+/g, '-')
105
+ .replace(/^-+|-+$/g, '')
106
+ .slice(0, 60);
107
+
108
+ return slug.length > 0 ? slug : `task-${title.length}`;
109
+ }
110
+
111
+ /**
112
+ * Parse one stored task, tolerating anything.
113
+ *
114
+ * Every field falls back, and none of the fallbacks is an error, because this
115
+ * reads a directory the owner is invited to edit. A plain Markdown file dropped
116
+ * in there is an open task titled after its filename — which is a better answer
117
+ * than an exception that hides every other task behind it. An unrecognised
118
+ * `status` reads as `open` for the same reason: the useful failure is a task in
119
+ * the wrong column, not a listing that will not render.
120
+ */
121
+ export function parseTask(id: string, text: string, fallbackUpdatedAt: string): Task {
122
+ const { frontmatter, body } = splitOptionalFrontmatter(text);
123
+
124
+ const title = frontmatter['title'];
125
+ const status = frontmatter['status'];
126
+ const due = frontmatter['due'];
127
+ const createdAt = frontmatter['created_at'];
128
+ const updatedAt = frontmatter['updated_at'];
129
+
130
+ return {
131
+ id,
132
+ title: typeof title === 'string' && title.trim().length > 0 ? title : id,
133
+ status: readStatus(status),
134
+ tags: stringList(frontmatter['tags']),
135
+ ...(typeof due === 'string' && due.trim().length > 0 ? { due } : {}),
136
+ createdAt: typeof createdAt === 'string' ? createdAt : fallbackUpdatedAt,
137
+ updatedAt: typeof updatedAt === 'string' ? updatedAt : fallbackUpdatedAt,
138
+ body: body.trimEnd(),
139
+ };
140
+ }
141
+
142
+ /** An unrecognised status is `open`, not an error. See `parseTask`. */
143
+ function readStatus(raw: unknown): TaskStatus {
144
+ return typeof raw === 'string' && (TASK_STATUSES as readonly string[]).includes(raw)
145
+ ? (raw as TaskStatus)
146
+ : 'open';
147
+ }
148
+
149
+ export function serialiseTask(task: Omit<Task, 'id'>): string {
150
+ return withFrontmatter(
151
+ {
152
+ title: task.title,
153
+ status: task.status,
154
+ ...(task.tags.length > 0 ? { tags: [...task.tags] } : {}),
155
+ ...(task.due ? { due: task.due } : {}),
156
+ created_at: task.createdAt,
157
+ updated_at: task.updatedAt,
158
+ },
159
+ `${task.body.trimEnd()}\n`,
160
+ );
161
+ }
162
+
163
+ export async function readTask(storage: BlobStore, id: string): Promise<Task | null> {
164
+ const bytes = await storage.get(taskKey(id));
165
+ if (bytes === null) return null;
166
+
167
+ return parseTask(id, new TextDecoder().decode(bytes), new Date(0).toISOString());
168
+ }
169
+
170
+ export async function writeTask(storage: BlobStore, task: Task): Promise<void> {
171
+ const { id: _id, ...rest } = task;
172
+ await storage.put(taskKey(task.id), new TextEncoder().encode(serialiseTask(rest)), {
173
+ contentType: 'text/markdown',
174
+ });
175
+ }
176
+
177
+ /**
178
+ * How many tasks are read at once.
179
+ *
180
+ * The bound memory uses, for the reason memory gives: against a bucket each
181
+ * read is an HTTPS request, and firing four hundred at once trades a slow list
182
+ * for a rate-limited one.
183
+ */
184
+ const READ_CONCURRENCY = 16;
185
+
186
+ /**
187
+ * Every task, in the order a list wants them.
188
+ *
189
+ * One pass over all of them, and honest about it — the metadata a listing needs
190
+ * is inside each document, so there is nothing cheaper to consult. That is the
191
+ * cost of one file per task and it is the same trade memory makes.
192
+ */
193
+ export async function allTasks(storage: BlobStore): Promise<Task[]> {
194
+ const blobs = (await storage.list()).flatMap((blob) => {
195
+ const id = idFromKey(blob.key);
196
+ return id === null ? [] : [{ blob, id }];
197
+ });
198
+
199
+ const tasks: Task[] = [];
200
+
201
+ for (let start = 0; start < blobs.length; start += READ_CONCURRENCY) {
202
+ const batch = await Promise.all(
203
+ blobs.slice(start, start + READ_CONCURRENCY).map(async ({ blob, id }) => {
204
+ const bytes = await storage.get(blob.key);
205
+ return bytes === null
206
+ ? null
207
+ : parseTask(id, new TextDecoder().decode(bytes), blob.modifiedAt.toISOString());
208
+ }),
209
+ );
210
+ for (const task of batch) if (task) tasks.push(task);
211
+ }
212
+
213
+ return tasks.sort(compareTasks);
214
+ }
215
+
216
+ /**
217
+ * Status, then due date, then most recently touched.
218
+ *
219
+ * Not memory's plain `updatedAt` descending, because a task list is read to
220
+ * decide what to do next and the most recently *edited* task is rarely that.
221
+ * Undated sorts after dated within a status: a task with a date is making a
222
+ * claim about when, and one without is not, so the claim goes first.
223
+ */
224
+ export function compareTasks(a: Task, b: Task): number {
225
+ const rank = TASK_STATUSES.indexOf(a.status) - TASK_STATUSES.indexOf(b.status);
226
+ if (rank !== 0) return rank;
227
+
228
+ if (a.due !== b.due) {
229
+ if (!a.due) return 1;
230
+ if (!b.due) return -1;
231
+ return a.due.localeCompare(b.due);
232
+ }
233
+
234
+ return b.updatedAt.localeCompare(a.updatedAt);
235
+ }
236
+
237
+ /** The pieces `lanes link tasks` needs to reach the same bytes the provider does. */
238
+ export const taskStorage = {
239
+ key: taskKey,
240
+ idFromKey,
241
+ parse: parseTask,
242
+ serialise: serialiseTask,
243
+ read: readTask,
244
+ write: writeTask,
245
+ all: allTasks,
246
+ compare: compareTasks,
247
+ slugify,
248
+ };
@@ -38,7 +38,7 @@ export function buildMcpServer(options: BuildServerOptions): McpServer {
38
38
  // rather than trusting a copy to stay one. It is the whole endpoint being
39
39
  // described, not this connection, so it does not name the profiles the
40
40
  // way `title` does.
41
- description: 'A self-hostable MCP gateway for all your connections, memory, skills, and secrets',
41
+ description: 'A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets',
42
42
  websiteUrl: 'https://github.com/lanes-sh/link',
43
43
  icons: SERVER_ICONS,
44
44
  },