klypix-mcp 1.18.0 → 1.19.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/bin/klypix-mcp.mjs +12 -1
- package/package.json +2 -2
- package/src/agent-rules.mjs +1 -1
- package/src/klypix-core.mjs +35 -0
- package/src/klypix-format.mjs +126 -1
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
24
24
|
import {
|
|
25
25
|
resolveVault, getEmbedder, buildKlypixMap, cardSchema, connSchema,
|
|
26
26
|
opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
|
|
27
|
-
opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage,
|
|
27
|
+
opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage, opBrainAsk,
|
|
28
28
|
} from '../src/klypix-core.mjs';
|
|
29
29
|
|
|
30
30
|
// Real package version for the MCP handshake (was hardcoded '1.0.0', which
|
|
@@ -116,6 +116,17 @@ server.registerTool('search_all_brains', {
|
|
|
116
116
|
},
|
|
117
117
|
}, async ({ query, as_of }) => toContent(await opSearchAllBrains({ vault: VAULT, query, as_of, log })));
|
|
118
118
|
|
|
119
|
+
server.registerTool('brain_ask', {
|
|
120
|
+
title: 'Ask the project brain a question (whole-brain, correction-aware answer)',
|
|
121
|
+
description: 'Answer a natural-language question from the WHOLE project brain — "what did we decide about X?", "where did the auth work land?", "why did we drop Y?". Ranks every card (semantic on-device + lexical hybrid), INCLUDES superseded/archived history (flagged, so you can see how a decision changed), and attaches each stale card\'s live CORRECTION so the answer reflects the current truth, not an outdated card. Returns a synthesis-ready context (full cards + provenance + lifecycle) for you to turn into a direct, cited answer — it does not itself write prose. Prefer this over search_canvases when the user asks a QUESTION (not a keyword lookup). Optional as_of (YYYY-MM-DD) answers "what was true then". Defaults to the project brain ("brain").',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
question: z.string().describe('The natural-language question to answer from the brain.'),
|
|
124
|
+
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
|
125
|
+
as_of: z.string().optional().describe('Optional YYYY-MM-DD: answer as of that date (superseded cards count as live if they were current then).'),
|
|
126
|
+
k: z.number().optional().describe('Max cards to surface for synthesis (default 10, capped 20).'),
|
|
127
|
+
},
|
|
128
|
+
}, async ({ question, canvas, as_of, k }) => toContent(await opBrainAsk({ vault: VAULT, canvas, question, as_of, k, log })));
|
|
129
|
+
|
|
119
130
|
server.registerTool('brain_insights', {
|
|
120
131
|
title: 'What matters in a brain — hubs, orphans, stale questions',
|
|
121
132
|
description: 'Structural read of a brain.klypix: the most-connected "hub" cards (load-bearing decisions), orphaned decisions (no connections — maybe forgotten), stale open questions (aging & unresolved), and area sizes. Use to answer "what matters here / what am I forgetting / what should I review?" — read it at the start of a planning session, or before tidying.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"description": "An open, local-first, agent-neutral canvas file your AI reads and writes over MCP — works with Claude, Cursor, Cline, any model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"node": ">=18"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs"
|
|
56
|
+
"test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/src/agent-rules.mjs
CHANGED
|
@@ -55,7 +55,7 @@ survives across sessions, agents, and context resets.
|
|
|
55
55
|
|
|
56
56
|
**At the start of a task — read it** so you know the project's state and past decisions:
|
|
57
57
|
- if \`.claude/brain-brief.md\` exists, read it — it is the full session brief the brain hook regenerates at every session start (Focus, open questions, skills, recent decisions).
|
|
58
|
-
- with the \`klypix-canvas\` MCP server: call \`search_canvases\`
|
|
58
|
+
- with the \`klypix-canvas\` MCP server: to **answer a question** from the brain ("what did we decide about X?", "where did Y land?"), call \`brain_ask\` — it ranks the whole brain, includes superseded history, and surfaces the current truth for any corrected card. Use \`search_canvases\` for a raw keyword lookup, \`read_canvas\` (canvas: \`"brain"\`) for the whole thing, or \`brain_insights\` for the load-bearing cards.
|
|
59
59
|
- or via CLI: \`npx klypix-read brain.klypix\`
|
|
60
60
|
|
|
61
61
|
**When you make a real decision, finding, or milestone — capture it HERE** so it persists for the next session/agent:
|
package/src/klypix-core.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
brainInsights, insightsToMarkdown, addBrainConnections, proposeStructuralConnections, atomicWrite,
|
|
27
27
|
findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
|
|
28
28
|
selectGardenCandidates, applyGarden, detectContradictions,
|
|
29
|
+
rankForQuestion, questionContextToMarkdown,
|
|
29
30
|
} from './klypix-format.mjs';
|
|
30
31
|
|
|
31
32
|
// ── Card / connection input shape (single source for every face) ─────────────
|
|
@@ -369,6 +370,40 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
|
|
|
369
370
|
return { blocks: [text(`# Cross-project matches for "${query}" (${scored.length} hits in ${brains.length} brains, top ${top.length} · ${mode}${asOfNote})\n\n${lines.join('\n')}`)] };
|
|
370
371
|
}
|
|
371
372
|
|
|
373
|
+
// ── brain_ask — answer a natural-language question over the WHOLE brain ───────
|
|
374
|
+
// "What did we decide about X?" / "Where did the auth work land?" The daily-use
|
|
375
|
+
// surface: hybrid retrieval (semantic on-device + lexical) over every card
|
|
376
|
+
// (including archived history, flagged), correction-aware (a stale hit carries its
|
|
377
|
+
// live correction), assembled into a SYNTHESIS-READY context the calling agent
|
|
378
|
+
// turns into a direct, cited answer. The engine never calls an LLM (pure retrieval
|
|
379
|
+
// + assembly) — same "engine selects, model writes" contract as brain_connect.
|
|
380
|
+
export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log = () => {} }) {
|
|
381
|
+
const q = String(question || '').trim();
|
|
382
|
+
if (!q) return err('brain_ask needs a question.');
|
|
383
|
+
const t = brainTarget(vault, canvas);
|
|
384
|
+
if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
|
|
385
|
+
if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
|
|
386
|
+
const asOfTs = as_of ? Date.parse(as_of) : null;
|
|
387
|
+
if (as_of && Number.isNaN(asOfTs)) return err(`Bad as_of date: "${as_of}" — use YYYY-MM-DD.`);
|
|
388
|
+
let struct;
|
|
389
|
+
try { ({ struct } = await parseKlypix(fs.readFileSync(t.file))); } catch (e) { return err(`Read failed: ${e.message}`); }
|
|
390
|
+
const stamp = brainStamp(t.file, struct, t.how);
|
|
391
|
+
// Semantic blend (best-effort, time-bounded): embed the question + the brain's
|
|
392
|
+
// cards on-device, hand rankForQuestion a Map<cardId, cosine>. A missing/warming
|
|
393
|
+
// model degrades cleanly to pure lexical.
|
|
394
|
+
let semantic = null, mode = 'lexical';
|
|
395
|
+
try {
|
|
396
|
+
const pipe = await Promise.race([getEmbedder(log), new Promise(r => setTimeout(() => r(null), 20_000))]);
|
|
397
|
+
if (pipe) {
|
|
398
|
+
const [qv] = await embedTexts(pipe, [q]);
|
|
399
|
+
const vecs = await vectorsForBrain(pipe, t.file, struct.cards);
|
|
400
|
+
if (qv && vecs && vecs.size) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); mode = 'semantic+lexical (on-device)'; }
|
|
401
|
+
}
|
|
402
|
+
} catch { semantic = null; mode = 'lexical (semantic warming — retry for semantic ranking)'; }
|
|
403
|
+
const result = rankForQuestion(struct, q, { semantic, k: Math.max(1, Math.min(20, k || 10)), as_of: asOfTs != null ? as_of : null });
|
|
404
|
+
return { blocks: [text(stamp + questionContextToMarkdown(q, result, { mode, as_of: asOfTs != null ? as_of : null }))] };
|
|
405
|
+
}
|
|
406
|
+
|
|
372
407
|
export async function opBrainInsights({ vault, canvas, staleDays }) {
|
|
373
408
|
const t = brainTarget(vault, canvas);
|
|
374
409
|
if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
|
package/src/klypix-format.mjs
CHANGED
|
@@ -498,13 +498,20 @@ export async function tidyBrain(buffer) {
|
|
|
498
498
|
|
|
499
499
|
// Normalize every text card to the compact brain font (so the render matches
|
|
500
500
|
// our height measure → no overlap) + cache each card's measured height.
|
|
501
|
+
// ALSO drop each card's `authoredInParent` anchor: the KLYPIX app freezes a
|
|
502
|
+
// child's in-container position in that field and RE-DERIVES the card's x/y
|
|
503
|
+
// from it on every render (ContainerItem group-scale) — so it would ignore
|
|
504
|
+
// the masonry x/y we write and snap cards back to their old single-column
|
|
505
|
+
// spots (the container then auto-grows to wrap them → skyscraper). Clearing
|
|
506
|
+
// the anchor makes THIS layout the card's authored baseline; the app re-seeds
|
|
507
|
+
// from our x/y. Verified against the app's render math in test/layout-cluster.
|
|
501
508
|
const meta = new Map(); // id -> { h }
|
|
502
509
|
for (const c of struct.cards) {
|
|
503
510
|
if (c.type === 'container') continue;
|
|
504
511
|
const wrapped = wrapText(String(c.text ?? ''));
|
|
505
512
|
let createdAt = 0;
|
|
506
513
|
const ip = `items/${shard(c.id)}/${c.id}.json`;
|
|
507
|
-
try { const f = zip.file(ip); if (f) { const j = JSON.parse(await f.async('string')); createdAt = Number(j.createdAt) || 0; j.fontSize = G.FONT; j.content = wrapped; zip.file(ip, JSON.stringify(j)); } } catch { /* leave as-is */ }
|
|
514
|
+
try { const f = zip.file(ip); if (f) { const j = JSON.parse(await f.async('string')); createdAt = Number(j.createdAt) || 0; j.fontSize = G.FONT; j.content = wrapped; delete j.authoredInParent; zip.file(ip, JSON.stringify(j)); } } catch { /* leave as-is */ }
|
|
508
515
|
meta.set(c.id, { h: measureCardH(wrapped), createdAt });
|
|
509
516
|
}
|
|
510
517
|
|
|
@@ -738,6 +745,12 @@ export async function tidyBrain(buffer) {
|
|
|
738
745
|
for (const kid of plans.get(cid).kids) {
|
|
739
746
|
canvas.positions[kid.id] = { ...canvas.positions[kid.id], x: x + kid.dx, y: y + kid.dy, w: G.CARD_W, h: kid.h };
|
|
740
747
|
}
|
|
748
|
+
// Drop the container's frozen group-scale baseline so the app's
|
|
749
|
+
// child-scaling pass early-returns (ContainerItem: `if
|
|
750
|
+
// (!item.authoredW) return`) and honors our masonry child x/y
|
|
751
|
+
// instead of scaling children off a stale authored size.
|
|
752
|
+
const cp = `items/${shard(cid)}/${cid}.json`;
|
|
753
|
+
try { const f = zip.file(cp); if (f) { const j = JSON.parse(await f.async('string')); if (j.authoredW != null || j.authoredH != null) { delete j.authoredW; delete j.authoredH; zip.file(cp, JSON.stringify(j)); } } } catch { /* leave as-is */ }
|
|
741
754
|
}
|
|
742
755
|
canvas.settings = { ...(canvas.settings || {}), brainLayout: 'cluster-v1' };
|
|
743
756
|
}
|
|
@@ -993,6 +1006,118 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
|
|
|
993
1006
|
return scored.filter(s => s.score >= minScore).slice(0, topK);
|
|
994
1007
|
}
|
|
995
1008
|
|
|
1009
|
+
// ── Ask-the-brain — whole-brain, correction-aware retrieval for a question ───
|
|
1010
|
+
// The surface a human actually uses ("what did we decide about X?", "where did
|
|
1011
|
+
// the auth work land?"). Distinct from the per-prompt recall hook (which injects
|
|
1012
|
+
// a few RELATED cards into every prompt) and search_canvases (raw substring): this
|
|
1013
|
+
// RANKS the whole brain against a natural-language question and assembles a
|
|
1014
|
+
// SYNTHESIS-READY context for the calling agent to answer from — the engine stays
|
|
1015
|
+
// model-free (mirrors brain_connect/garden: engine selects, model writes prose).
|
|
1016
|
+
// Three things make it an ANSWER path, not a card dump:
|
|
1017
|
+
// 1. Hybrid-ready — lexical always (the shared scorer's weights + length-norm),
|
|
1018
|
+
// semantic blended when the caller (core, with the on-device embedder) passes
|
|
1019
|
+
// a sim map; on a lexical miss the semantic floor still surfaces paraphrases.
|
|
1020
|
+
// 2. History-aware — INCLUDES archived/superseded cards (penalized + flagged), so
|
|
1021
|
+
// "what did we decide" can show the arc (decided A → changed to B), and an
|
|
1022
|
+
// optional as_of answers "what was true then".
|
|
1023
|
+
// 3. Truth-aware — every stale hit carries its live CORRECTION (the P1 machinery),
|
|
1024
|
+
// so the agent answers from the correction, never the outdated card alone.
|
|
1025
|
+
// Pure + node-runnable. `semantic` is Map<cardId, 0..1> or null.
|
|
1026
|
+
const deathDateOfCard = (text) => { const m = /(?:↩︎ superseded|↩ superseded|✅) (\d{4}-\d{2}-\d{2})/.exec(String(text)); return m ? Date.parse(m[1]) : null; };
|
|
1027
|
+
export function rankForQuestion(struct, question, { semantic = null, k = 10, as_of = null, now = Date.now(), semFloor = 0.30, recentDays = 30 } = {}) {
|
|
1028
|
+
const tokens = queryTokens(question);
|
|
1029
|
+
if (!struct || !Array.isArray(struct.cards) || (!tokens.length && !semantic)) return { hits: [], total: 0, tokens };
|
|
1030
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1031
|
+
const asOfTs = as_of ? Date.parse(as_of) : null;
|
|
1032
|
+
const timeTravel = asOfTs != null && Number.isFinite(asOfTs);
|
|
1033
|
+
const cutoff = now - recentDays * 86_400_000;
|
|
1034
|
+
const scored = [];
|
|
1035
|
+
for (const c of struct.cards) {
|
|
1036
|
+
if (c.type === 'container' || !(c.text || '').trim()) continue;
|
|
1037
|
+
const arch = isArchived(c);
|
|
1038
|
+
if (timeTravel) {
|
|
1039
|
+
if ((c.createdAt || 0) > asOfTs) continue; // didn't exist yet
|
|
1040
|
+
if (arch) {
|
|
1041
|
+
// A card archived NOW: keep it ONLY if it demonstrably outlived
|
|
1042
|
+
// as_of (its retirement is stamped LATER) — then it was the live
|
|
1043
|
+
// truth then. If it died by as_of, or carries NO dated stamp (we
|
|
1044
|
+
// can't prove it was still live), exclude it — precision-first, so
|
|
1045
|
+
// a "what was true then" answer never asserts a since-dead fact.
|
|
1046
|
+
const died = deathDateOfCard(c.text);
|
|
1047
|
+
if (died == null || died <= asOfTs) continue;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
const titleW = wordsOf(c.title);
|
|
1051
|
+
const bodyW = wordsOf(c.text);
|
|
1052
|
+
const tagStems = new Set((c.tags || []).map(t => String(t).toLowerCase().replace(/^#/, '').replace(/^(file|dir)-/, '')).filter(Boolean));
|
|
1053
|
+
const lenNorm = Math.min(1, 6 / Math.max(6, Math.log2(bodyW.size || 1)));
|
|
1054
|
+
let lex = 0;
|
|
1055
|
+
for (const tok of tokens) {
|
|
1056
|
+
if (titleW.has(tok)) lex += 3;
|
|
1057
|
+
else if (tagStems.has(tok)) lex += 3;
|
|
1058
|
+
else if (bodyW.has(tok)) lex += lenNorm;
|
|
1059
|
+
}
|
|
1060
|
+
const sem = semantic ? (semantic.get(c.id) ?? null) : null;
|
|
1061
|
+
if (lex <= 0 && (sem == null || sem < semFloor)) continue; // no lexical AND no strong semantic → skip
|
|
1062
|
+
// In time-travel a surviving card WAS live at as_of, so it is NOT stale
|
|
1063
|
+
// history — don't demote or flag it as archived (that status is a present
|
|
1064
|
+
// fact). Outside time-travel, archived cards are demoted but never excluded
|
|
1065
|
+
// (history matters for "what did we…").
|
|
1066
|
+
const effArch = timeTravel ? false : arch;
|
|
1067
|
+
let score = sem != null ? sem * 10 + Math.min(lex, 6) * 0.5 : lex;
|
|
1068
|
+
if (!timeTravel && (c.createdAt || 0) >= cutoff) score += 0.5;
|
|
1069
|
+
if (/🛠/.test(c.text)) score += 1; // standing skills
|
|
1070
|
+
if (effArch) score -= 1.5;
|
|
1071
|
+
scored.push({ card: c, score, sem, archived: effArch });
|
|
1072
|
+
}
|
|
1073
|
+
scored.sort((a, b) => b.score - a.score || (b.card.createdAt || 0) - (a.card.createdAt || 0));
|
|
1074
|
+
const top = scored.slice(0, k);
|
|
1075
|
+
// Correction overlays on the surfaced hits — a stale card gets its live
|
|
1076
|
+
// corrector so the agent answers from the truth (edge or cue; P1 machinery).
|
|
1077
|
+
// NOT in time-travel: a correction is a PRESENT fact; importing a future
|
|
1078
|
+
// corrector into a "what was true then" answer would contaminate it (a
|
|
1079
|
+
// 2026-05 correction leaking into a 2026-02 query). Then-live cards stand
|
|
1080
|
+
// as they were.
|
|
1081
|
+
let overlays = new Map();
|
|
1082
|
+
if (!timeTravel) { try { overlays = correctionOverlaysFor(struct, top.map(h => h.card)); } catch { /* best-effort */ } }
|
|
1083
|
+
const hits = top.map(h => ({ ...h, correction: overlays.get(h.card.id) || null }));
|
|
1084
|
+
return { hits, total: scored.length, tokens };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Assemble the ranked hits into a SYNTHESIS-READY markdown context: a header that
|
|
1088
|
+
// instructs the agent to answer the question directly (cite cards, honor
|
|
1089
|
+
// corrections, admit gaps), then each hit full-text with provenance + lifecycle +
|
|
1090
|
+
// its correction. Char-budgeted so a huge brain can't blow the tool result.
|
|
1091
|
+
export function questionContextToMarkdown(question, result, { mode = 'lexical', as_of = null, budgetChars = 9000 } = {}) {
|
|
1092
|
+
const { hits, total } = result;
|
|
1093
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
1094
|
+
const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
|
|
1095
|
+
if (!hits.length) {
|
|
1096
|
+
return `# No brain cards answer: “${flat(question)}”\n`
|
|
1097
|
+
+ `Searched the whole brain (${mode}) and found nothing relevant. Tell the user the brain doesn't cover this yet — don't guess. If you learn the answer this session, capture it: \`🧠 BRAIN [Area]: <decision>\`.\n`;
|
|
1098
|
+
}
|
|
1099
|
+
const out = [];
|
|
1100
|
+
out.push(`# Answer “${flat(question)}” from these ${hits.length} brain card(s)${as_of ? ` (as of ${as_of})` : ''} — ${total} matched, ${mode} ranking`);
|
|
1101
|
+
out.push('_Synthesize a DIRECT answer from the cards below, then cite the ones you used by [Area]+date. Where a card is marked ⚠️ CORRECTED, answer from the correction, NOT the stale card. Include superseded/archived cards only to show how a decision CHANGED. If the cards don\'t actually answer the question, say so — don\'t pad._');
|
|
1102
|
+
out.push('');
|
|
1103
|
+
let used = out.join('\n').length;
|
|
1104
|
+
let shown = 0;
|
|
1105
|
+
for (const h of hits) {
|
|
1106
|
+
const c = h.card;
|
|
1107
|
+
const status = h.archived ? ' · ⛔ archived/superseded' : '';
|
|
1108
|
+
const rel = h.sem != null ? ` · sim ${h.sem.toFixed(2)}` : '';
|
|
1109
|
+
let block = `## [${flat(c.area) || 'Notes'}] ${day(c.createdAt)}${status}${rel}\n${flat(c.text)}`;
|
|
1110
|
+
if (h.correction) {
|
|
1111
|
+
block += `\n\n ⚠️ CORRECTED — this card is STALE; the current truth is:\n ${flat(h.correction.by.text).slice(0, 600)}`;
|
|
1112
|
+
}
|
|
1113
|
+
if (used + block.length + 2 > budgetChars && shown > 0) { out.push(`\n_…and ${hits.length - shown} more matched card(s) omitted for length — narrow the question or use search for the rest._`); break; }
|
|
1114
|
+
out.push(block, '');
|
|
1115
|
+
used += block.length + 2;
|
|
1116
|
+
shown++;
|
|
1117
|
+
}
|
|
1118
|
+
return out.join('\n') + '\n';
|
|
1119
|
+
}
|
|
1120
|
+
|
|
996
1121
|
// ── External-state reconcile — migration omission tripwire ───────────────────
|
|
997
1122
|
// The brain is a NARRATION-capture system: a fact exists only if someone wrote a
|
|
998
1123
|
// 🧠 marker or a rationale-bearing commit body. Applying a DB migration to prod
|