@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.
Files changed (42) 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/operate/inspect.ts +37 -20
  9. package/src/cli/commands/operate/serve.ts +21 -0
  10. package/src/cli/commands/owner/assets.ts +132 -0
  11. package/src/cli/commands/owner/shared.ts +28 -4
  12. package/src/cli/commands/owner/tasks.ts +194 -0
  13. package/src/cli/commands/owner.ts +9 -4
  14. package/src/cli/config-edit.ts +33 -7
  15. package/src/cli/config-repair.ts +115 -11
  16. package/src/cli/dispatch-owner.ts +49 -8
  17. package/src/cli/lanes.ts +1 -1
  18. package/src/cli/main.ts +21 -2
  19. package/src/cli/provider-marks.ts +1 -1
  20. package/src/cli/runtime/registry.ts +10 -2
  21. package/src/cli/selection.ts +14 -0
  22. package/src/cli/usage.ts +18 -2
  23. package/src/connectivity/mail/attachments.ts +5 -1
  24. package/src/connectivity/mail/index.ts +6 -1
  25. package/src/connectivity/manifest/provider.ts +15 -2
  26. package/src/deployments/deploy.ts +3 -2
  27. package/src/deployments/prepare.ts +1 -1
  28. package/src/deployments/servable.ts +1 -1
  29. package/src/deployments/upload.ts +0 -53
  30. package/src/profile/load.ts +46 -0
  31. package/src/providers/assets/provider.ts +337 -0
  32. package/src/providers/assets/store.ts +167 -0
  33. package/src/providers/google/index.ts +1 -1
  34. package/src/providers/google/tasks/index.ts +3 -3
  35. package/src/providers/google/tasks/redact.ts +21 -11
  36. package/src/providers/index.ts +3 -3
  37. package/src/providers/owner.ts +39 -19
  38. package/src/providers/setup/plan.ts +17 -1
  39. package/src/providers/tasks/provider.ts +370 -0
  40. package/src/providers/tasks/store.ts +248 -0
  41. package/src/server/mcp/build.ts +1 -1
  42. package/src/server/mcp/instructions.ts +67 -8
@@ -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
  },
@@ -64,6 +64,29 @@ about this person or their work, search it. Writing to memory is a separate
64
64
  grant, and what you write is served back to every later session — including to
65
65
  a different agent — so write when asked to remember something, not by habit.`;
66
66
 
67
+ const TASKS = `**Tasks are what the owner has to do**, each with a status. "Remember to…" and
68
+ "add a todo" belong here. Closing one is an update, not a delete, and a listing
69
+ shows outstanding work unless you ask for more.`;
70
+
71
+ /**
72
+ * The pair, when both are reachable — which after ADR-050 is the ordinary case.
73
+ *
74
+ * Not the two paragraphs above concatenated. The mistake this is here to prevent
75
+ * is a routing one — a thing to *do* written into memory, where nothing can ever
76
+ * close it — and a routing rule is shorter and clearer said once, in one
77
+ * sentence naming both stores, than implied by two paragraphs that each describe
78
+ * only themselves. It also very nearly pays for itself: this replaces `MEMORY`
79
+ * rather than joining it, so the pair costs about what the single one did.
80
+ */
81
+ const MEMORY_AND_TASKS = `**Memory and tasks are different stores.** Search memory before concluding you do
82
+ not know something about this person or their work. A thing to *do* goes in
83
+ tasks, not memory — "remember to…" is a task, and it has a status. Both are
84
+ served back to every later session, so write when asked, not by habit.`;
85
+
86
+ const ASSETS = `**Assets are the owner's own files**, kept by name in this profile. Storing one
87
+ names a source, exactly as an attachment does; a text asset reads back as text
88
+ and anything else is described rather than encoded.`;
89
+
67
90
  const SKILLS = `**Skills are the owner's procedures**, surfaced as prompts rather than tools.
68
91
  That is deliberate: a procedure is selected by the person, not chosen by the
69
92
  model, and you cannot read one's body. They belong to one profile, so a skill
@@ -133,15 +156,39 @@ runs, and a client can report it unreachable while it is up. That is ordinary
133
156
  not a fault to diagnose, and not authorization you have lost. Say the call did
134
157
  not land, do not redo what already succeeded, and offer to retry.`;
135
158
 
136
- /** Which paragraph each owner-layer provider brings, when it is reachable. */
159
+ /** Which paragraph each owner-layer provider brings, when it is reachable alone. */
137
160
  const OWNER_HABITS: Record<string, string> = {
138
161
  memory: MEMORY,
162
+ tasks: TASKS,
163
+ assets: ASSETS,
139
164
  skills: SKILLS,
140
165
  vault: VAULT,
141
166
  setup: SETUP,
142
167
  identity: IDENTITY,
143
168
  };
144
169
 
170
+ /**
171
+ * The paragraphs this principal should be told, in `RESERVED_PROVIDER_IDS` order.
172
+ *
173
+ * A lookup per provider would be enough if every paragraph described exactly one
174
+ * provider, and one does not: memory and tasks are only worth distinguishing
175
+ * from each other, so when both are reachable they collapse into one. The
176
+ * substitution is conditional rather than unconditional for the reason the
177
+ * docstring at the top of this file gives — prose describing a tool that is not
178
+ * there is worse than absent prose, and a profile carrying `deny: [tasks.*]` is
179
+ * exactly the case that would produce it.
180
+ */
181
+ function habitsFor(reachable: readonly string[]): string[] {
182
+ const present = new Set(reachable);
183
+ const paired = present.has('memory') && present.has('tasks');
184
+
185
+ return reachable.flatMap((id) => {
186
+ if (paired && id === 'memory') return [MEMORY_AND_TASKS];
187
+ if (paired && id === 'tasks') return [];
188
+ return OWNER_HABITS[id] ? [OWNER_HABITS[id]!] : [];
189
+ });
190
+ }
191
+
145
192
  /**
146
193
  * The whole string's ceiling, and the only budget there is.
147
194
  *
@@ -158,11 +205,23 @@ const OWNER_HABITS: Record<string, string> = {
158
205
  * Raised a second time, to 2500, for `IDENTITY`, and the same answer for the
159
206
  * same reason: an agent signing as the wrong person has already sent the
160
207
  * message, and a skill loaded only when relevant is not loaded at the moment
161
- * that happens. The measured worst case — twenty profiles, twenty connections
162
- * each, every owner provider reachable, remote clients — is 2474, so this is
163
- * the measurement plus a little, not a round number picked first. Two things
164
- * hold it there: the paragraph names no identity, and it is spent only by a
165
- * profile that declared one.
208
+ * that happens.
209
+ *
210
+ * Raised a third time, to 2700, for tasks and assets (ADR-051), and the answer
211
+ * is the same shape a third time. The memory/tasks distinction is a routing rule
212
+ * applied at the instant of a write: an agent that files "remember to chase the
213
+ * invoice" as a memory entry has put it somewhere nothing can ever close, and it
214
+ * has already done so by the time a skill would have been loaded. The client
215
+ * that most needs the rule is the one holding no skills directory.
216
+ *
217
+ * The arithmetic, because the number is a measurement and not a round figure:
218
+ * twenty profiles, twenty connections each, every owner provider reachable,
219
+ * remote clients, is **2686**. That is the *unpaired* case — memory reachable
220
+ * and tasks denied — which runs three characters longer than the ordinary one
221
+ * (2683), because `MEMORY_AND_TASKS` is slightly shorter than `MEMORY` and
222
+ * `TASKS` apart. Worth stating, because the last two raises were both certified
223
+ * against a case an endpoint does not actually serve, and the widest case here
224
+ * is the one that looks like the narrower configuration.
166
225
  *
167
226
  * Exported because the test asserted `2000` as a literal while the code
168
227
  * reserved room against a second, differently-derived number — so the two could
@@ -171,7 +230,7 @@ const OWNER_HABITS: Record<string, string> = {
171
230
  * exactly the final length, because `join` adds the same two characters the
172
231
  * reduce already counted.
173
232
  */
174
- export const MAX_INSTRUCTIONS = 2500;
233
+ export const MAX_INSTRUCTIONS = 2700;
175
234
 
176
235
  /** Which of the owner-layer providers this principal can actually reach. */
177
236
  function ownerProviders(merged: ReadonlyMap<string, MergedCapability>): string[] {
@@ -238,7 +297,7 @@ export function serverInstructions(
238
297
  const sections = [
239
298
  OPENING,
240
299
  ROUTING,
241
- ...owner.map((id) => OWNER_HABITS[id]).filter((habit): habit is string => habit !== undefined),
300
+ ...habitsFor(owner),
242
301
  FILES,
243
302
  REFUSAL,
244
303
  ...(remoteClients ? [AVAILABILITY] : []),