@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
|
@@ -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
|
},
|
|
@@ -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.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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 =
|
|
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
|
|
300
|
+
...habitsFor(owner),
|
|
242
301
|
FILES,
|
|
243
302
|
REFUSAL,
|
|
244
303
|
...(remoteClients ? [AVAILABILITY] : []),
|