@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
|
@@ -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
|
+
};
|
package/src/server/mcp/build.ts
CHANGED
|
@@ -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,
|
|
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
|
},
|