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.
Files changed (94) hide show
  1. package/.env.example +44 -27
  2. package/CHANGELOG.md +266 -95
  3. package/README.md +73 -28
  4. package/dist/api-client.d.ts +0 -0
  5. package/dist/api-client.js +0 -0
  6. package/dist/canvas-format.d.ts +101 -0
  7. package/dist/canvas-format.js +259 -0
  8. package/dist/canvas-store.d.ts +62 -0
  9. package/dist/canvas-store.js +187 -0
  10. package/dist/config.d.ts +3 -0
  11. package/dist/config.js +44 -9
  12. package/dist/cube-manager.d.ts +0 -0
  13. package/dist/cube-manager.js +11 -0
  14. package/dist/formatters.d.ts +0 -0
  15. package/dist/formatters.js +0 -0
  16. package/dist/graph-provenance.d.ts +34 -0
  17. package/dist/graph-provenance.js +175 -0
  18. package/dist/graphify-import.d.ts +72 -0
  19. package/dist/graphify-import.js +221 -0
  20. package/dist/handlers/admin.d.ts +1 -0
  21. package/dist/handlers/admin.js +18 -0
  22. package/dist/handlers/arg-contract.d.ts +0 -0
  23. package/dist/handlers/arg-contract.js +0 -0
  24. package/dist/handlers/calendar.d.ts +0 -0
  25. package/dist/handlers/calendar.js +0 -0
  26. package/dist/handlers/canvas.d.ts +40 -0
  27. package/dist/handlers/canvas.js +334 -0
  28. package/dist/handlers/graph.d.ts +8 -0
  29. package/dist/handlers/graph.js +66 -14
  30. package/dist/handlers/index.d.ts +0 -0
  31. package/dist/handlers/index.js +30 -5
  32. package/dist/handlers/memory.d.ts +0 -0
  33. package/dist/handlers/memory.js +33 -2
  34. package/dist/handlers/search.d.ts +0 -0
  35. package/dist/handlers/search.js +56 -15
  36. package/dist/handlers/skill.d.ts +6 -0
  37. package/dist/handlers/skill.js +123 -0
  38. package/dist/handlers/think.d.ts +0 -0
  39. package/dist/handlers/think.js +0 -0
  40. package/dist/handlers/utils.d.ts +0 -0
  41. package/dist/handlers/utils.js +0 -0
  42. package/dist/handlers/wiki-export.d.ts +0 -0
  43. package/dist/handlers/wiki-export.js +7 -9
  44. package/dist/handlers/wiki-import-relations.d.ts +11 -0
  45. package/dist/handlers/wiki-import-relations.js +32 -0
  46. package/dist/handlers/wiki-import.d.ts +19 -0
  47. package/dist/handlers/wiki-import.js +382 -0
  48. package/dist/index.d.ts +0 -0
  49. package/dist/index.js +27 -6
  50. package/dist/keyword-enhancer.d.ts +0 -0
  51. package/dist/keyword-enhancer.js +0 -0
  52. package/dist/memory-analysis.d.ts +0 -0
  53. package/dist/memory-analysis.js +0 -0
  54. package/dist/memory-quality.d.ts +10 -0
  55. package/dist/memory-quality.js +57 -0
  56. package/dist/memory-write-response.d.ts +8 -0
  57. package/dist/memory-write-response.js +15 -0
  58. package/dist/models.d.ts +0 -0
  59. package/dist/models.js +0 -0
  60. package/dist/policy.d.ts +8 -0
  61. package/dist/policy.js +9 -0
  62. package/dist/providers/lite-embedding.d.ts +31 -0
  63. package/dist/providers/lite-embedding.js +60 -0
  64. package/dist/providers/local-jsonl-provider.d.ts +28 -0
  65. package/dist/providers/local-jsonl-provider.js +225 -0
  66. package/dist/providers/memory-provider.d.ts +21 -0
  67. package/dist/providers/memory-provider.js +2 -0
  68. package/dist/providers/provider-factory.d.ts +11 -0
  69. package/dist/providers/provider-factory.js +27 -0
  70. package/dist/query-processing.d.ts +0 -0
  71. package/dist/query-processing.js +0 -0
  72. package/dist/runtime-version.d.ts +2 -0
  73. package/dist/runtime-version.js +10 -0
  74. package/dist/server-lifecycle.d.ts +10 -0
  75. package/dist/server-lifecycle.js +30 -0
  76. package/dist/server.d.ts +5 -2
  77. package/dist/server.js +42 -48
  78. package/dist/skill-candidate.d.ts +11 -0
  79. package/dist/skill-candidate.js +56 -0
  80. package/dist/skill-lifecycle.d.ts +15 -0
  81. package/dist/skill-lifecycle.js +51 -0
  82. package/dist/stdio-compat-transport.d.ts +24 -0
  83. package/dist/stdio-compat-transport.js +75 -0
  84. package/dist/tools-registry.d.ts +140 -169
  85. package/dist/tools-registry.js +93 -31
  86. package/dist/types.d.ts +0 -0
  87. package/dist/types.js +0 -0
  88. package/dist/wiki-import-format.d.ts +47 -0
  89. package/dist/wiki-import-format.js +113 -0
  90. package/dist/wiki-import-safety.d.ts +25 -0
  91. package/dist/wiki-import-safety.js +31 -0
  92. package/dist/wiki-relations.d.ts +44 -0
  93. package/dist/wiki-relations.js +88 -0
  94. 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;