oh-memos-mcp 2.0.1 → 3.0.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/.env.example +44 -27
- package/CHANGELOG.md +266 -95
- package/README.md +73 -28
- package/dist/api-client.d.ts +0 -0
- package/dist/api-client.js +0 -0
- package/dist/canvas-format.d.ts +101 -0
- package/dist/canvas-format.js +259 -0
- package/dist/canvas-store.d.ts +62 -0
- package/dist/canvas-store.js +187 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +44 -9
- package/dist/cube-manager.d.ts +0 -0
- package/dist/cube-manager.js +11 -0
- package/dist/formatters.d.ts +0 -0
- package/dist/formatters.js +0 -0
- package/dist/graph-provenance.d.ts +34 -0
- package/dist/graph-provenance.js +175 -0
- package/dist/graphify-import.d.ts +72 -0
- package/dist/graphify-import.js +221 -0
- package/dist/handlers/admin.d.ts +1 -0
- package/dist/handlers/admin.js +18 -0
- package/dist/handlers/arg-contract.d.ts +0 -0
- package/dist/handlers/arg-contract.js +0 -0
- package/dist/handlers/calendar.d.ts +0 -0
- package/dist/handlers/calendar.js +0 -0
- package/dist/handlers/canvas.d.ts +40 -0
- package/dist/handlers/canvas.js +334 -0
- package/dist/handlers/graph.d.ts +8 -0
- package/dist/handlers/graph.js +66 -14
- package/dist/handlers/index.d.ts +0 -0
- package/dist/handlers/index.js +30 -5
- package/dist/handlers/memory.d.ts +0 -0
- package/dist/handlers/memory.js +33 -2
- package/dist/handlers/search.d.ts +0 -0
- package/dist/handlers/search.js +56 -15
- package/dist/handlers/skill.d.ts +6 -0
- package/dist/handlers/skill.js +123 -0
- package/dist/handlers/think.d.ts +0 -0
- package/dist/handlers/think.js +0 -0
- package/dist/handlers/utils.d.ts +0 -0
- package/dist/handlers/utils.js +0 -0
- package/dist/handlers/wiki-export.d.ts +0 -0
- package/dist/handlers/wiki-export.js +7 -9
- package/dist/handlers/wiki-import-relations.d.ts +11 -0
- package/dist/handlers/wiki-import-relations.js +32 -0
- package/dist/handlers/wiki-import.d.ts +19 -0
- package/dist/handlers/wiki-import.js +382 -0
- package/dist/index.d.ts +0 -0
- package/dist/index.js +27 -6
- package/dist/keyword-enhancer.d.ts +0 -0
- package/dist/keyword-enhancer.js +0 -0
- package/dist/memory-analysis.d.ts +0 -0
- package/dist/memory-analysis.js +0 -0
- package/dist/memory-quality.d.ts +10 -0
- package/dist/memory-quality.js +57 -0
- package/dist/memory-write-response.d.ts +8 -0
- package/dist/memory-write-response.js +15 -0
- package/dist/models.d.ts +0 -0
- package/dist/models.js +0 -0
- package/dist/policy.d.ts +8 -0
- package/dist/policy.js +9 -0
- package/dist/providers/lite-embedding.d.ts +31 -0
- package/dist/providers/lite-embedding.js +60 -0
- package/dist/providers/local-jsonl-provider.d.ts +28 -0
- package/dist/providers/local-jsonl-provider.js +225 -0
- package/dist/providers/memory-provider.d.ts +21 -0
- package/dist/providers/memory-provider.js +2 -0
- package/dist/providers/provider-factory.d.ts +11 -0
- package/dist/providers/provider-factory.js +27 -0
- package/dist/query-processing.d.ts +0 -0
- package/dist/query-processing.js +0 -0
- package/dist/runtime-version.d.ts +2 -0
- package/dist/runtime-version.js +10 -0
- package/dist/server-lifecycle.d.ts +10 -0
- package/dist/server-lifecycle.js +30 -0
- package/dist/server.d.ts +5 -2
- package/dist/server.js +42 -48
- package/dist/skill-candidate.d.ts +11 -0
- package/dist/skill-candidate.js +56 -0
- package/dist/skill-lifecycle.d.ts +15 -0
- package/dist/skill-lifecycle.js +51 -0
- package/dist/stdio-compat-transport.d.ts +24 -0
- package/dist/stdio-compat-transport.js +75 -0
- package/dist/tools-registry.d.ts +140 -169
- package/dist/tools-registry.js +93 -31
- package/dist/types.d.ts +0 -0
- package/dist/types.js +0 -0
- package/dist/wiki-import-format.d.ts +47 -0
- package/dist/wiki-import-format.js +113 -0
- package/dist/wiki-import-safety.d.ts +25 -0
- package/dist/wiki-import-safety.js +31 -0
- package/dist/wiki-relations.d.ts +44 -0
- package/dist/wiki-relations.js +88 -0
- package/package.json +18 -6
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbolic task canvas — parse and render.
|
|
3
|
+
*
|
|
4
|
+
* A canvas is one Mermaid file. It is the whole truth: there is no parallel
|
|
5
|
+
* JSON sidecar, because two files describing one task drift apart the first
|
|
6
|
+
* time a write fails halfway. The cost of that choice is that the format has to
|
|
7
|
+
* survive a round trip exactly, which is what the tests pin down.
|
|
8
|
+
*
|
|
9
|
+
* The shape borrows the `NNN-Nn` node id from TencentDB Agent Memory, for the
|
|
10
|
+
* reason that made it work there: it is greppable. An id in a canvas, a commit
|
|
11
|
+
* message or a memory body all lead back to the same node.
|
|
12
|
+
*
|
|
13
|
+
* What is ours is the `ref`: an anchor with a scheme, so a node can point at a
|
|
14
|
+
* memory in the graph (`mem:`), a file the harness already offloaded (`file:`),
|
|
15
|
+
* or an inline remark (`note:`). Their design only ever pointed at local files.
|
|
16
|
+
*/
|
|
17
|
+
export declare const NODE_STATUSES: readonly ["todo", "doing", "done", "blocked"];
|
|
18
|
+
export type NodeStatus = (typeof NODE_STATUSES)[number];
|
|
19
|
+
export interface CanvasNode {
|
|
20
|
+
/** `NNN-Nn`, allocated monotonically and never reused. */
|
|
21
|
+
id: string;
|
|
22
|
+
status: NodeStatus;
|
|
23
|
+
summary: string;
|
|
24
|
+
/** `mem:<uuid>` | `file:<path>` | `note:<text>`, or null. */
|
|
25
|
+
ref: string | null;
|
|
26
|
+
}
|
|
27
|
+
export interface Canvas {
|
|
28
|
+
/** Three-digit canvas prefix; every node id starts with it. */
|
|
29
|
+
prefix: string;
|
|
30
|
+
taskGoal: string;
|
|
31
|
+
createdTime: string | null;
|
|
32
|
+
updatedTime: string | null;
|
|
33
|
+
nodes: CanvasNode[];
|
|
34
|
+
}
|
|
35
|
+
/** Cap on a rendered node summary. Long enough to be useful, short enough that
|
|
36
|
+
* a 20-node canvas still costs a few hundred tokens rather than a few thousand. */
|
|
37
|
+
export declare const SUMMARY_MAX = 120;
|
|
38
|
+
/**
|
|
39
|
+
* Reduce a title to a filename-safe slug.
|
|
40
|
+
*
|
|
41
|
+
* The whitelist is `[a-z0-9-]` and nothing else — not a blacklist of dangerous
|
|
42
|
+
* characters, because that is the kind of list that is always missing one entry.
|
|
43
|
+
* `.` is outside the whitelist, so `..` cannot survive and traversal is
|
|
44
|
+
* structurally impossible rather than merely checked for.
|
|
45
|
+
*
|
|
46
|
+
* Returns "" when nothing usable remains (a purely CJK title, for instance);
|
|
47
|
+
* callers must have a fallback and must not write a file named "".
|
|
48
|
+
*/
|
|
49
|
+
export declare function slugify(input: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Make text safe to sit inside a Mermaid `["..."]` label.
|
|
52
|
+
*
|
|
53
|
+
* Mermaid has no escape syntax inside a quoted label, so the only reliable move
|
|
54
|
+
* is to replace the characters that would terminate it with lookalikes. A
|
|
55
|
+
* summary is prose for a human to skim; swapping `"` for `'` costs nothing a
|
|
56
|
+
* reader will miss, and it makes `x"] --> EVIL["pwned` inert.
|
|
57
|
+
*
|
|
58
|
+
* Arrows are neutralised too: a literal `-->` inside a label does not break
|
|
59
|
+
* Mermaid, but it reads as an edge to anyone scanning the file, and to the
|
|
60
|
+
* regex in `parseCanvas` if it ever loosens.
|
|
61
|
+
*/
|
|
62
|
+
export declare function escapeLabel(text: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Trim to `max` characters, counting by code point.
|
|
65
|
+
*
|
|
66
|
+
* `String.prototype.slice` counts UTF-16 units, so slicing a string of astral
|
|
67
|
+
* characters mid-pair yields a lone surrogate — which is not a character, and
|
|
68
|
+
* which some JSON consumers reject outright. Spreading into an array iterates
|
|
69
|
+
* code points, so a cut always lands on a boundary.
|
|
70
|
+
*/
|
|
71
|
+
export declare function truncateSummary(text: string, max?: number): string;
|
|
72
|
+
/**
|
|
73
|
+
* Next unused node id for this canvas.
|
|
74
|
+
*
|
|
75
|
+
* Deliberately max+1 rather than filling gaps. A deleted `000-N2` may still be
|
|
76
|
+
* cited by a commit message, a memory body or another node's ref; handing that
|
|
77
|
+
* id to a new node would silently repoint every one of those citations. Ids are
|
|
78
|
+
* cheap, so they are spent rather than recycled.
|
|
79
|
+
*/
|
|
80
|
+
export declare function allocateNodeId(canvas: Canvas): string;
|
|
81
|
+
export declare function renderCanvas(canvas: Canvas): string;
|
|
82
|
+
/**
|
|
83
|
+
* Parse a canvas file.
|
|
84
|
+
*
|
|
85
|
+
* Never throws. A canvas is recovered state — it is read at the moment the
|
|
86
|
+
* model has just lost its context, which is the worst possible moment to hand
|
|
87
|
+
* back an exception. A damaged header costs the goal; a damaged line costs that
|
|
88
|
+
* line; everything still parseable is returned.
|
|
89
|
+
*/
|
|
90
|
+
export declare function parseCanvas(content: string): Canvas;
|
|
91
|
+
export interface CanvasCounts {
|
|
92
|
+
done: number;
|
|
93
|
+
doing: number;
|
|
94
|
+
todo: number;
|
|
95
|
+
blocked: number;
|
|
96
|
+
}
|
|
97
|
+
export declare function countByStatus(canvas: Canvas): CanvasCounts;
|
|
98
|
+
/** One-line digest for the progressive-disclosure index: enough to decide
|
|
99
|
+
* whether to open the canvas, not enough to be worth injecting wholesale. */
|
|
100
|
+
export declare function formatCanvasHeadline(canvas: Canvas): string;
|
|
101
|
+
//# sourceMappingURL=canvas-format.d.ts.map
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbolic task canvas — parse and render.
|
|
3
|
+
*
|
|
4
|
+
* A canvas is one Mermaid file. It is the whole truth: there is no parallel
|
|
5
|
+
* JSON sidecar, because two files describing one task drift apart the first
|
|
6
|
+
* time a write fails halfway. The cost of that choice is that the format has to
|
|
7
|
+
* survive a round trip exactly, which is what the tests pin down.
|
|
8
|
+
*
|
|
9
|
+
* The shape borrows the `NNN-Nn` node id from TencentDB Agent Memory, for the
|
|
10
|
+
* reason that made it work there: it is greppable. An id in a canvas, a commit
|
|
11
|
+
* message or a memory body all lead back to the same node.
|
|
12
|
+
*
|
|
13
|
+
* What is ours is the `ref`: an anchor with a scheme, so a node can point at a
|
|
14
|
+
* memory in the graph (`mem:`), a file the harness already offloaded (`file:`),
|
|
15
|
+
* or an inline remark (`note:`). Their design only ever pointed at local files.
|
|
16
|
+
*/
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Types
|
|
19
|
+
// ============================================================================
|
|
20
|
+
export const NODE_STATUSES = ["todo", "doing", "done", "blocked"];
|
|
21
|
+
/** Cap on a rendered node summary. Long enough to be useful, short enough that
|
|
22
|
+
* a 20-node canvas still costs a few hundred tokens rather than a few thousand. */
|
|
23
|
+
export const SUMMARY_MAX = 120;
|
|
24
|
+
const STATUS_SET = new Set(NODE_STATUSES);
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// slugify
|
|
27
|
+
// ============================================================================
|
|
28
|
+
const SLUG_MAX = 60;
|
|
29
|
+
/**
|
|
30
|
+
* Reduce a title to a filename-safe slug.
|
|
31
|
+
*
|
|
32
|
+
* The whitelist is `[a-z0-9-]` and nothing else — not a blacklist of dangerous
|
|
33
|
+
* characters, because that is the kind of list that is always missing one entry.
|
|
34
|
+
* `.` is outside the whitelist, so `..` cannot survive and traversal is
|
|
35
|
+
* structurally impossible rather than merely checked for.
|
|
36
|
+
*
|
|
37
|
+
* Returns "" when nothing usable remains (a purely CJK title, for instance);
|
|
38
|
+
* callers must have a fallback and must not write a file named "".
|
|
39
|
+
*/
|
|
40
|
+
export function slugify(input) {
|
|
41
|
+
const lowered = String(input ?? "").toLowerCase();
|
|
42
|
+
const kept = lowered.replace(/[^a-z0-9]+/g, "-");
|
|
43
|
+
const collapsed = kept.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
|
|
44
|
+
return collapsed.slice(0, SLUG_MAX).replace(/-+$/g, "");
|
|
45
|
+
}
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// escapeLabel
|
|
48
|
+
// ============================================================================
|
|
49
|
+
/**
|
|
50
|
+
* Make text safe to sit inside a Mermaid `["..."]` label.
|
|
51
|
+
*
|
|
52
|
+
* Mermaid has no escape syntax inside a quoted label, so the only reliable move
|
|
53
|
+
* is to replace the characters that would terminate it with lookalikes. A
|
|
54
|
+
* summary is prose for a human to skim; swapping `"` for `'` costs nothing a
|
|
55
|
+
* reader will miss, and it makes `x"] --> EVIL["pwned` inert.
|
|
56
|
+
*
|
|
57
|
+
* Arrows are neutralised too: a literal `-->` inside a label does not break
|
|
58
|
+
* Mermaid, but it reads as an edge to anyone scanning the file, and to the
|
|
59
|
+
* regex in `parseCanvas` if it ever loosens.
|
|
60
|
+
*/
|
|
61
|
+
export function escapeLabel(text) {
|
|
62
|
+
return String(text ?? "")
|
|
63
|
+
.replace(/[\r\n]+/g, " ")
|
|
64
|
+
.replace(/"/g, "'")
|
|
65
|
+
.replace(/\[/g, "(")
|
|
66
|
+
.replace(/\]/g, ")")
|
|
67
|
+
.replace(/\{/g, "(")
|
|
68
|
+
.replace(/\}/g, ")")
|
|
69
|
+
.replace(/-->/g, "→")
|
|
70
|
+
.replace(/\s{2,}/g, " ")
|
|
71
|
+
.trim();
|
|
72
|
+
}
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// truncateSummary
|
|
75
|
+
// ============================================================================
|
|
76
|
+
/**
|
|
77
|
+
* Trim to `max` characters, counting by code point.
|
|
78
|
+
*
|
|
79
|
+
* `String.prototype.slice` counts UTF-16 units, so slicing a string of astral
|
|
80
|
+
* characters mid-pair yields a lone surrogate — which is not a character, and
|
|
81
|
+
* which some JSON consumers reject outright. Spreading into an array iterates
|
|
82
|
+
* code points, so a cut always lands on a boundary.
|
|
83
|
+
*/
|
|
84
|
+
export function truncateSummary(text, max = SUMMARY_MAX) {
|
|
85
|
+
const chars = [...String(text ?? "")];
|
|
86
|
+
if (chars.length <= max)
|
|
87
|
+
return chars.join("");
|
|
88
|
+
if (max <= 1)
|
|
89
|
+
return chars.slice(0, Math.max(0, max)).join("");
|
|
90
|
+
return chars.slice(0, max - 1).join("") + "…";
|
|
91
|
+
}
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// allocateNodeId
|
|
94
|
+
// ============================================================================
|
|
95
|
+
const NODE_ID_RE = /^(\d{3})-N(\d+)$/;
|
|
96
|
+
/**
|
|
97
|
+
* Next unused node id for this canvas.
|
|
98
|
+
*
|
|
99
|
+
* Deliberately max+1 rather than filling gaps. A deleted `000-N2` may still be
|
|
100
|
+
* cited by a commit message, a memory body or another node's ref; handing that
|
|
101
|
+
* id to a new node would silently repoint every one of those citations. Ids are
|
|
102
|
+
* cheap, so they are spent rather than recycled.
|
|
103
|
+
*/
|
|
104
|
+
export function allocateNodeId(canvas) {
|
|
105
|
+
const prefix = /^\d{3}$/.test(canvas.prefix) ? canvas.prefix : "000";
|
|
106
|
+
let max = 0;
|
|
107
|
+
for (const node of canvas.nodes) {
|
|
108
|
+
const m = NODE_ID_RE.exec(node.id);
|
|
109
|
+
if (!m || m[1] !== prefix)
|
|
110
|
+
continue;
|
|
111
|
+
const n = Number(m[2]);
|
|
112
|
+
if (Number.isFinite(n) && n > max)
|
|
113
|
+
max = n;
|
|
114
|
+
}
|
|
115
|
+
return `${prefix}-N${max + 1}`;
|
|
116
|
+
}
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// renderCanvas
|
|
119
|
+
// ============================================================================
|
|
120
|
+
/**
|
|
121
|
+
* A ref is carried in its own `<br/>` field and JSON-escaped.
|
|
122
|
+
*
|
|
123
|
+
* Windows paths are the reason: `C:\Users\x` contains backslashes, which JSON
|
|
124
|
+
* escapes and which must come back byte-identical or the anchor stops resolving.
|
|
125
|
+
* Label escaping is for prose; refs get JSON, which is lossless.
|
|
126
|
+
*/
|
|
127
|
+
function renderRef(ref) {
|
|
128
|
+
return JSON.stringify(ref).slice(1, -1).replace(/"/g, "\\u0022");
|
|
129
|
+
}
|
|
130
|
+
function parseRef(raw) {
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(`"${raw}"`);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return raw;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function renderCanvas(canvas) {
|
|
139
|
+
const meta = {
|
|
140
|
+
taskGoal: canvas.taskGoal,
|
|
141
|
+
createdTime: canvas.createdTime,
|
|
142
|
+
updatedTime: canvas.updatedTime,
|
|
143
|
+
prefix: canvas.prefix,
|
|
144
|
+
};
|
|
145
|
+
const lines = [
|
|
146
|
+
`%%${JSON.stringify(meta)}%%`,
|
|
147
|
+
"graph LR",
|
|
148
|
+
];
|
|
149
|
+
for (const node of canvas.nodes) {
|
|
150
|
+
const parts = [
|
|
151
|
+
`status: ${node.status}`,
|
|
152
|
+
`summary: ${escapeLabel(truncateSummary(node.summary))}`,
|
|
153
|
+
];
|
|
154
|
+
if (node.ref !== null)
|
|
155
|
+
parts.push(`ref: ${renderRef(node.ref)}`);
|
|
156
|
+
lines.push(` ${node.id}["${parts.join("<br/>")}"]`);
|
|
157
|
+
}
|
|
158
|
+
// Edges are emitted after all node declarations so that a truncated file
|
|
159
|
+
// still yields every node it contains, rather than losing the tail to a
|
|
160
|
+
// dangling arrow.
|
|
161
|
+
for (let i = 1; i < canvas.nodes.length; i++) {
|
|
162
|
+
lines.push(` ${canvas.nodes[i - 1].id} --> ${canvas.nodes[i].id}`);
|
|
163
|
+
}
|
|
164
|
+
return lines.join("\n") + "\n";
|
|
165
|
+
}
|
|
166
|
+
// ============================================================================
|
|
167
|
+
// parseCanvas
|
|
168
|
+
// ============================================================================
|
|
169
|
+
/** Only a line that *starts* with a node declaration opens a node, so an id
|
|
170
|
+
* mentioned inside a summary is read as prose, which is what it is. */
|
|
171
|
+
const NODE_LINE_RE = /^\s*(\d{3}-N\d+)\["([\s\S]*?)"\]\s*$/;
|
|
172
|
+
/**
|
|
173
|
+
* Parse a canvas file.
|
|
174
|
+
*
|
|
175
|
+
* Never throws. A canvas is recovered state — it is read at the moment the
|
|
176
|
+
* model has just lost its context, which is the worst possible moment to hand
|
|
177
|
+
* back an exception. A damaged header costs the goal; a damaged line costs that
|
|
178
|
+
* line; everything still parseable is returned.
|
|
179
|
+
*/
|
|
180
|
+
export function parseCanvas(content) {
|
|
181
|
+
const text = String(content ?? "");
|
|
182
|
+
const canvas = {
|
|
183
|
+
prefix: "000",
|
|
184
|
+
taskGoal: "",
|
|
185
|
+
createdTime: null,
|
|
186
|
+
updatedTime: null,
|
|
187
|
+
nodes: [],
|
|
188
|
+
};
|
|
189
|
+
const metaMatch = text.match(/^%%\{[\s\S]*?\}%%/);
|
|
190
|
+
let prefixFromHeader = false;
|
|
191
|
+
if (metaMatch) {
|
|
192
|
+
try {
|
|
193
|
+
const raw = metaMatch[0].slice(2, -2);
|
|
194
|
+
const parsed = JSON.parse(raw);
|
|
195
|
+
canvas.taskGoal = typeof parsed.taskGoal === "string" ? parsed.taskGoal : "";
|
|
196
|
+
canvas.createdTime = typeof parsed.createdTime === "string" ? parsed.createdTime : null;
|
|
197
|
+
canvas.updatedTime = typeof parsed.updatedTime === "string" ? parsed.updatedTime : null;
|
|
198
|
+
if (typeof parsed.prefix === "string" && /^\d{3}$/.test(parsed.prefix)) {
|
|
199
|
+
canvas.prefix = parsed.prefix;
|
|
200
|
+
prefixFromHeader = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Corrupt header: the nodes below are still worth having.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const seen = new Set();
|
|
208
|
+
for (const line of text.split(/\r?\n/)) {
|
|
209
|
+
const m = NODE_LINE_RE.exec(line);
|
|
210
|
+
if (!m)
|
|
211
|
+
continue;
|
|
212
|
+
const id = m[1];
|
|
213
|
+
if (seen.has(id))
|
|
214
|
+
continue; // first declaration wins
|
|
215
|
+
seen.add(id);
|
|
216
|
+
const body = m[2];
|
|
217
|
+
const fields = body.split("<br/>");
|
|
218
|
+
let status = "todo";
|
|
219
|
+
let summary = "";
|
|
220
|
+
let ref = null;
|
|
221
|
+
for (const field of fields) {
|
|
222
|
+
const sep = field.indexOf(":");
|
|
223
|
+
if (sep === -1)
|
|
224
|
+
continue;
|
|
225
|
+
const key = field.slice(0, sep).trim().toLowerCase();
|
|
226
|
+
const value = field.slice(sep + 1).trim();
|
|
227
|
+
if (key === "status") {
|
|
228
|
+
status = STATUS_SET.has(value) ? value : "todo";
|
|
229
|
+
}
|
|
230
|
+
else if (key === "summary") {
|
|
231
|
+
summary = value;
|
|
232
|
+
}
|
|
233
|
+
else if (key === "ref") {
|
|
234
|
+
ref = parseRef(value);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
canvas.nodes.push({ id, status, summary, ref });
|
|
238
|
+
}
|
|
239
|
+
if (!prefixFromHeader && canvas.nodes.length > 0) {
|
|
240
|
+
const m = NODE_ID_RE.exec(canvas.nodes[0].id);
|
|
241
|
+
if (m)
|
|
242
|
+
canvas.prefix = m[1];
|
|
243
|
+
}
|
|
244
|
+
return canvas;
|
|
245
|
+
}
|
|
246
|
+
export function countByStatus(canvas) {
|
|
247
|
+
const counts = { done: 0, doing: 0, todo: 0, blocked: 0 };
|
|
248
|
+
for (const node of canvas.nodes)
|
|
249
|
+
counts[node.status] += 1;
|
|
250
|
+
return counts;
|
|
251
|
+
}
|
|
252
|
+
/** One-line digest for the progressive-disclosure index: enough to decide
|
|
253
|
+
* whether to open the canvas, not enough to be worth injecting wholesale. */
|
|
254
|
+
export function formatCanvasHeadline(canvas) {
|
|
255
|
+
const c = countByStatus(canvas);
|
|
256
|
+
const goal = canvas.taskGoal || "(no goal recorded)";
|
|
257
|
+
return `${goal} — ${c.done} done / ${c.doing} doing / ${c.todo} todo${c.blocked > 0 ? ` / ${c.blocked} blocked` : ""}`;
|
|
258
|
+
}
|
|
259
|
+
//# sourceMappingURL=canvas-format.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbolic task canvas — filesystem storage.
|
|
3
|
+
*
|
|
4
|
+
* Canvases live at `{cubesDir}/{cube_id}/canvas/{name}.mmd`, beside the cube
|
|
5
|
+
* whose task they describe. They are deliberately *not* written into Neo4j or
|
|
6
|
+
* Qdrant: a canvas is short-term session state that changes many times an hour,
|
|
7
|
+
* and paying an embedding round trip per status flip would be absurd. Long-term
|
|
8
|
+
* facts go through `memos_save`; the canvas points at them with a `mem:` ref.
|
|
9
|
+
*
|
|
10
|
+
* Two properties matter here, and both are about failing safely rather than
|
|
11
|
+
* failing loudly:
|
|
12
|
+
*
|
|
13
|
+
* 1. Every path is built by `canvasPath`, which rejects anything that would
|
|
14
|
+
* escape the canvas directory. `cube_id` is derived from a caller-supplied
|
|
15
|
+
* project path, so it is untrusted input too — not just the canvas name.
|
|
16
|
+
*
|
|
17
|
+
* 2. Writes are atomic (temp file + rename). A canvas is read at precisely the
|
|
18
|
+
* moment context was lost, so a half-written file is worse than a stale one.
|
|
19
|
+
*/
|
|
20
|
+
import { type Canvas } from "./canvas-format.js";
|
|
21
|
+
export declare class CanvasPathError extends Error {
|
|
22
|
+
constructor(message: string);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a canvas file path, or throw.
|
|
26
|
+
*
|
|
27
|
+
* The name is passed through `slugify`, whose whitelist excludes `.`, `/` and
|
|
28
|
+
* `\` — so `..`, absolute paths and nested segments cannot survive it. The
|
|
29
|
+
* post-resolve containment check is a second, independent line: if a future
|
|
30
|
+
* change to slugify ever lets something through, this still catches it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function canvasPath(cubesDir: string, cubeId: string, name: string): string;
|
|
33
|
+
/** Returns null when the canvas does not exist. A damaged file yields an empty
|
|
34
|
+
* canvas rather than an exception — see `parseCanvas`. */
|
|
35
|
+
export declare function loadCanvas(cubesDir: string, cubeId: string, name: string): Canvas | null;
|
|
36
|
+
/** Writes atomically and returns the path written. */
|
|
37
|
+
export declare function saveCanvas(cubesDir: string, cubeId: string, name: string, canvas: Canvas): string;
|
|
38
|
+
export interface CanvasEntry {
|
|
39
|
+
/** Filename without the extension — the handle callers pass back in. */
|
|
40
|
+
name: string;
|
|
41
|
+
path: string;
|
|
42
|
+
canvas: Canvas;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Every canvas in a cube, sorted by name.
|
|
46
|
+
*
|
|
47
|
+
* Returns [] rather than throwing for a missing directory or a bad cube id:
|
|
48
|
+
* this feeds the context-resume index, which must never be the thing that
|
|
49
|
+
* breaks a session recovery.
|
|
50
|
+
*/
|
|
51
|
+
export declare function listCanvases(cubesDir: string, cubeId: string): CanvasEntry[];
|
|
52
|
+
/**
|
|
53
|
+
* Next unused three-digit canvas prefix, or null once 999 is taken.
|
|
54
|
+
*
|
|
55
|
+
* Max+1, never gap-filling, for the same reason node ids are not recycled: a
|
|
56
|
+
* prefix appears inside every node id it minted, and reissuing it would make
|
|
57
|
+
* two canvases' ids ambiguous. Returning null instead of wrapping keeps the
|
|
58
|
+
* failure visible — a caller that has 1000 canvases in one cube has a different
|
|
59
|
+
* problem than a naming collision.
|
|
60
|
+
*/
|
|
61
|
+
export declare function nextPrefix(cubesDir: string, cubeId: string): string | null;
|
|
62
|
+
//# sourceMappingURL=canvas-store.d.ts.map
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbolic task canvas — filesystem storage.
|
|
3
|
+
*
|
|
4
|
+
* Canvases live at `{cubesDir}/{cube_id}/canvas/{name}.mmd`, beside the cube
|
|
5
|
+
* whose task they describe. They are deliberately *not* written into Neo4j or
|
|
6
|
+
* Qdrant: a canvas is short-term session state that changes many times an hour,
|
|
7
|
+
* and paying an embedding round trip per status flip would be absurd. Long-term
|
|
8
|
+
* facts go through `memos_save`; the canvas points at them with a `mem:` ref.
|
|
9
|
+
*
|
|
10
|
+
* Two properties matter here, and both are about failing safely rather than
|
|
11
|
+
* failing loudly:
|
|
12
|
+
*
|
|
13
|
+
* 1. Every path is built by `canvasPath`, which rejects anything that would
|
|
14
|
+
* escape the canvas directory. `cube_id` is derived from a caller-supplied
|
|
15
|
+
* project path, so it is untrusted input too — not just the canvas name.
|
|
16
|
+
*
|
|
17
|
+
* 2. Writes are atomic (temp file + rename). A canvas is read at precisely the
|
|
18
|
+
* moment context was lost, so a half-written file is worse than a stale one.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
21
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
22
|
+
import { parseCanvas, renderCanvas, slugify } from "./canvas-format.js";
|
|
23
|
+
export class CanvasPathError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "CanvasPathError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const CANVAS_DIR = "canvas";
|
|
30
|
+
const CANVAS_EXT = ".mmd";
|
|
31
|
+
/** `cube_id` is machine-derived (`detectCubeFromPath`), so this is a shape check
|
|
32
|
+
* rather than a sanitiser — a value that does not look like a cube id is a bug
|
|
33
|
+
* upstream, and guessing at what it meant would hide it. */
|
|
34
|
+
const CUBE_ID_RE = /^[a-z0-9_]+$/;
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Path construction
|
|
37
|
+
// ============================================================================
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a canvas file path, or throw.
|
|
40
|
+
*
|
|
41
|
+
* The name is passed through `slugify`, whose whitelist excludes `.`, `/` and
|
|
42
|
+
* `\` — so `..`, absolute paths and nested segments cannot survive it. The
|
|
43
|
+
* post-resolve containment check is a second, independent line: if a future
|
|
44
|
+
* change to slugify ever lets something through, this still catches it.
|
|
45
|
+
*/
|
|
46
|
+
export function canvasPath(cubesDir, cubeId, name) {
|
|
47
|
+
const rawName = String(name ?? "");
|
|
48
|
+
const rawCube = String(cubeId ?? "");
|
|
49
|
+
// A NUL truncates a path in some syscalls, so `a\u0000.mmd` could end up
|
|
50
|
+
// addressing `a`. slugify drops it anyway; reject explicitly rather than
|
|
51
|
+
// quietly accepting a name that was plainly an attack.
|
|
52
|
+
if (rawName.includes("\u0000") || rawCube.includes("\u0000")) {
|
|
53
|
+
throw new CanvasPathError("canvas name must not contain a NUL byte");
|
|
54
|
+
}
|
|
55
|
+
if (!CUBE_ID_RE.test(rawCube)) {
|
|
56
|
+
throw new CanvasPathError(`invalid cube_id: ${JSON.stringify(rawCube)}`);
|
|
57
|
+
}
|
|
58
|
+
const slug = slugify(rawName);
|
|
59
|
+
if (!slug) {
|
|
60
|
+
throw new CanvasPathError(`canvas name ${JSON.stringify(rawName)} contains no usable characters ` +
|
|
61
|
+
`(allowed: letters, digits, hyphen)`);
|
|
62
|
+
}
|
|
63
|
+
// slugify is lossy, so a name that only differs by stripped characters would
|
|
64
|
+
// silently collide with, or redirect to, another canvas. Refuse instead.
|
|
65
|
+
if (slug !== rawName.toLowerCase()) {
|
|
66
|
+
const looksLikePath = /[\\/]|\.\./.test(rawName);
|
|
67
|
+
if (looksLikePath) {
|
|
68
|
+
throw new CanvasPathError(`canvas name must be a bare name, not a path: ${JSON.stringify(rawName)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const dir = resolve(cubesDir, rawCube, CANVAS_DIR);
|
|
72
|
+
const full = resolve(dir, `${slug}${CANVAS_EXT}`);
|
|
73
|
+
const dirWithSep = dir.endsWith(sep) ? dir : dir + sep;
|
|
74
|
+
if (!full.startsWith(dirWithSep) || basename(full) !== `${slug}${CANVAS_EXT}`) {
|
|
75
|
+
throw new CanvasPathError(`resolved path escapes the canvas directory: ${full}`);
|
|
76
|
+
}
|
|
77
|
+
return full;
|
|
78
|
+
}
|
|
79
|
+
function canvasDirFor(cubesDir, cubeId) {
|
|
80
|
+
if (!CUBE_ID_RE.test(String(cubeId ?? "")))
|
|
81
|
+
return null;
|
|
82
|
+
return resolve(cubesDir, cubeId, CANVAS_DIR);
|
|
83
|
+
}
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Load / save
|
|
86
|
+
// ============================================================================
|
|
87
|
+
/** Returns null when the canvas does not exist. A damaged file yields an empty
|
|
88
|
+
* canvas rather than an exception — see `parseCanvas`. */
|
|
89
|
+
export function loadCanvas(cubesDir, cubeId, name) {
|
|
90
|
+
const path = canvasPath(cubesDir, cubeId, name);
|
|
91
|
+
if (!existsSync(path))
|
|
92
|
+
return null;
|
|
93
|
+
try {
|
|
94
|
+
return parseCanvas(readFileSync(path, "utf8"));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null; // unreadable (permissions, race) — treat as absent
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Writes atomically and returns the path written. */
|
|
101
|
+
export function saveCanvas(cubesDir, cubeId, name, canvas) {
|
|
102
|
+
const path = canvasPath(cubesDir, cubeId, name);
|
|
103
|
+
const dir = resolve(cubesDir, cubeId, CANVAS_DIR);
|
|
104
|
+
mkdirSync(dir, { recursive: true });
|
|
105
|
+
// Same directory as the target, so the rename cannot cross a filesystem
|
|
106
|
+
// boundary and degrade into a non-atomic copy.
|
|
107
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
108
|
+
try {
|
|
109
|
+
writeFileSync(tmp, renderCanvas(canvas), "utf8");
|
|
110
|
+
renameSync(tmp, path);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
try {
|
|
114
|
+
if (existsSync(tmp))
|
|
115
|
+
unlinkSync(tmp);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// best effort: the write already failed, and masking that error with a
|
|
119
|
+
// cleanup error would lose the useful one
|
|
120
|
+
}
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
return path;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Every canvas in a cube, sorted by name.
|
|
127
|
+
*
|
|
128
|
+
* Returns [] rather than throwing for a missing directory or a bad cube id:
|
|
129
|
+
* this feeds the context-resume index, which must never be the thing that
|
|
130
|
+
* breaks a session recovery.
|
|
131
|
+
*/
|
|
132
|
+
export function listCanvases(cubesDir, cubeId) {
|
|
133
|
+
const dir = canvasDirFor(cubesDir, cubeId);
|
|
134
|
+
if (!dir || !existsSync(dir))
|
|
135
|
+
return [];
|
|
136
|
+
let files;
|
|
137
|
+
try {
|
|
138
|
+
files = readdirSync(dir);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const entries = [];
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
if (!file.endsWith(CANVAS_EXT))
|
|
146
|
+
continue;
|
|
147
|
+
const name = file.slice(0, -CANVAS_EXT.length);
|
|
148
|
+
const path = join(dir, file);
|
|
149
|
+
try {
|
|
150
|
+
entries.push({ name, path, canvas: parseCanvas(readFileSync(path, "utf8")) });
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
continue; // unreadable file should not hide the readable ones
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
157
|
+
return entries;
|
|
158
|
+
}
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// Prefix allocation
|
|
161
|
+
// ============================================================================
|
|
162
|
+
const PREFIX_RE = /^(\d{3})/;
|
|
163
|
+
/**
|
|
164
|
+
* Next unused three-digit canvas prefix, or null once 999 is taken.
|
|
165
|
+
*
|
|
166
|
+
* Max+1, never gap-filling, for the same reason node ids are not recycled: a
|
|
167
|
+
* prefix appears inside every node id it minted, and reissuing it would make
|
|
168
|
+
* two canvases' ids ambiguous. Returning null instead of wrapping keeps the
|
|
169
|
+
* failure visible — a caller that has 1000 canvases in one cube has a different
|
|
170
|
+
* problem than a naming collision.
|
|
171
|
+
*/
|
|
172
|
+
export function nextPrefix(cubesDir, cubeId) {
|
|
173
|
+
let max = -1;
|
|
174
|
+
for (const entry of listCanvases(cubesDir, cubeId)) {
|
|
175
|
+
const m = PREFIX_RE.exec(entry.name);
|
|
176
|
+
if (!m)
|
|
177
|
+
continue;
|
|
178
|
+
const n = Number(m[1]);
|
|
179
|
+
if (Number.isFinite(n) && n > max)
|
|
180
|
+
max = n;
|
|
181
|
+
}
|
|
182
|
+
const next = max + 1;
|
|
183
|
+
if (next > 999)
|
|
184
|
+
return null;
|
|
185
|
+
return String(next).padStart(3, "0");
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=canvas-store.js.map
|
package/dist/config.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ export declare const logger: {
|
|
|
10
10
|
error: (msg: string) => void;
|
|
11
11
|
exception: (msg: string, err?: unknown) => void;
|
|
12
12
|
};
|
|
13
|
+
export type MemosMode = "full" | "lite";
|
|
14
|
+
export declare const MEMOS_MODE: MemosMode;
|
|
15
|
+
export declare const MEMOS_PROVIDER: string;
|
|
13
16
|
export declare const MEMOS_URL: string;
|
|
14
17
|
export declare const MEMOS_USER: string;
|
|
15
18
|
export declare const MEMOS_DEFAULT_CUBE: string;
|