@lanes-sh/link 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -9
- package/instructions/agents/lanes-link-scout.md +14 -3
- package/instructions/skills/lanes-link/SKILL.md +80 -3
- package/package.json +2 -2
- package/src/cli/argv.ts +7 -0
- package/src/cli/commands/connect/index.ts +9 -6
- package/src/cli/commands/connection.ts +298 -0
- package/src/cli/commands/operate/inspect.ts +37 -20
- package/src/cli/commands/operate/serve.ts +21 -0
- package/src/cli/commands/owner/assets.ts +132 -0
- package/src/cli/commands/owner/shared.ts +28 -4
- package/src/cli/commands/owner/tasks.ts +194 -0
- package/src/cli/commands/owner.ts +9 -4
- package/src/cli/config-edit.ts +33 -7
- package/src/cli/config-repair.ts +115 -11
- package/src/cli/dispatch-owner.ts +49 -8
- package/src/cli/lanes.ts +1 -1
- package/src/cli/main.ts +21 -2
- package/src/cli/provider-marks.ts +1 -1
- package/src/cli/runtime/registry.ts +10 -2
- package/src/cli/selection.ts +14 -0
- package/src/cli/usage.ts +18 -2
- package/src/connectivity/mail/attachments.ts +5 -1
- package/src/connectivity/mail/index.ts +6 -1
- package/src/connectivity/manifest/provider.ts +15 -2
- package/src/deployments/deploy.ts +3 -2
- package/src/deployments/prepare.ts +1 -1
- package/src/deployments/servable.ts +1 -1
- package/src/deployments/upload.ts +0 -53
- package/src/profile/load.ts +46 -0
- package/src/providers/assets/provider.ts +337 -0
- package/src/providers/assets/store.ts +167 -0
- package/src/providers/google/index.ts +1 -1
- package/src/providers/google/tasks/index.ts +3 -3
- package/src/providers/google/tasks/redact.ts +21 -11
- package/src/providers/index.ts +3 -3
- package/src/providers/owner.ts +39 -19
- package/src/providers/setup/plan.ts +17 -1
- package/src/providers/tasks/provider.ts +370 -0
- package/src/providers/tasks/store.ts +248 -0
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/instructions.ts +67 -8
package/src/providers/owner.ts
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
* `
|
|
18
|
-
* and
|
|
19
|
-
*
|
|
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`
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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',
|
|
@@ -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;
|