@nesso-how/mcp 0.1.0-alpha.37 → 0.1.0-alpha.38

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 CHANGED
@@ -21,7 +21,7 @@ Add to your MCP client (Cursor, Claude Desktop, etc.):
21
21
  }
22
22
  ```
23
23
 
24
- Tools: `get_relation_types`, `get_nesso_docs`.
24
+ Tools: `get_relation_types`, `get_nesso_docs`, `validate_graph`, `build_graph`.
25
25
 
26
26
  Full guide: [MCP integration](https://nesso.how/docs/guides/mcp-integration/).
27
27
 
package/dist/index.js CHANGED
@@ -2,8 +2,10 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server';
5
+ import { registerBuildGraph } from './tools/build-graph.js';
5
6
  import { registerGetRelationTypes } from './tools/get-relation-types.js';
6
7
  import { registerGetDocs } from './tools/get-docs.js';
8
+ import { registerValidateGraph } from './tools/validate-graph.js';
7
9
  const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
8
10
  const server = new McpServer({
9
11
  name: 'nesso',
@@ -11,6 +13,8 @@ const server = new McpServer({
11
13
  });
12
14
  registerGetRelationTypes(server);
13
15
  registerGetDocs(server);
16
+ registerValidateGraph(server);
17
+ registerBuildGraph(server);
14
18
  async function main() {
15
19
  const transport = new StdioServerTransport();
16
20
  await server.connect(transport);
@@ -0,0 +1,147 @@
1
+ import * as z from 'zod/v4';
2
+ import { type NessoGraphDocumentInput } from '@nesso-how/vocab-learning';
3
+ export interface GraphValidationIssue {
4
+ path: string;
5
+ message: string;
6
+ }
7
+ export interface GraphValidationResult {
8
+ valid: boolean;
9
+ errors: GraphValidationIssue[];
10
+ warnings: GraphValidationIssue[];
11
+ }
12
+ export declare const relationTypeEnum: z.ZodEnum<{
13
+ "subtype-of": "subtype-of";
14
+ "has-subtype": "has-subtype";
15
+ "instance-of": "instance-of";
16
+ "has-instance": "has-instance";
17
+ "part-of": "part-of";
18
+ contains: "contains";
19
+ "made-of": "made-of";
20
+ composes: "composes";
21
+ causes: "causes";
22
+ "caused-by": "caused-by";
23
+ produces: "produces";
24
+ "produced-by": "produced-by";
25
+ enables: "enables";
26
+ "enabled-by": "enabled-by";
27
+ prevents: "prevents";
28
+ "prevented-by": "prevented-by";
29
+ triggers: "triggers";
30
+ "triggered-by": "triggered-by";
31
+ inhibits: "inhibits";
32
+ "inhibited-by": "inhibited-by";
33
+ disables: "disables";
34
+ "disabled-by": "disabled-by";
35
+ consumes: "consumes";
36
+ "consumed-by": "consumed-by";
37
+ delays: "delays";
38
+ "delayed-by": "delayed-by";
39
+ requires: "requires";
40
+ "required-by": "required-by";
41
+ uses: "uses";
42
+ "used-by": "used-by";
43
+ "used-for": "used-for";
44
+ "purpose-of": "purpose-of";
45
+ precedes: "precedes";
46
+ follows: "follows";
47
+ "occurs-in": "occurs-in";
48
+ "has-occurrence": "has-occurrence";
49
+ during: "during";
50
+ spans: "spans";
51
+ "overlaps-with": "overlaps-with";
52
+ "derives-from": "derives-from";
53
+ "gives-rise-to": "gives-rise-to";
54
+ "contrasts-with": "contrasts-with";
55
+ "opposite-of": "opposite-of";
56
+ "similar-to": "similar-to";
57
+ "analogous-to": "analogous-to";
58
+ supports: "supports";
59
+ "supported-by": "supported-by";
60
+ contradicts: "contradicts";
61
+ explains: "explains";
62
+ "explained-by": "explained-by";
63
+ defines: "defines";
64
+ "defined-by": "defined-by";
65
+ }>;
66
+ export declare const buildGraphInputSchema: z.ZodObject<{
67
+ name: z.ZodString;
68
+ concepts: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
69
+ id: z.ZodOptional<z.ZodString>;
70
+ text: z.ZodString;
71
+ elaboration: z.ZodOptional<z.ZodObject<{
72
+ definition: z.ZodString;
73
+ examples: z.ZodString;
74
+ notes: z.ZodString;
75
+ imageUrl: z.ZodOptional<z.ZodString>;
76
+ imageTitle: z.ZodOptional<z.ZodString>;
77
+ imageDescriptionUrl: z.ZodOptional<z.ZodString>;
78
+ }, z.core.$strip>>;
79
+ }, z.core.$strip>]>>;
80
+ relations: z.ZodArray<z.ZodObject<{
81
+ from: z.ZodString;
82
+ to: z.ZodString;
83
+ relation: z.ZodEnum<{
84
+ "subtype-of": "subtype-of";
85
+ "has-subtype": "has-subtype";
86
+ "instance-of": "instance-of";
87
+ "has-instance": "has-instance";
88
+ "part-of": "part-of";
89
+ contains: "contains";
90
+ "made-of": "made-of";
91
+ composes: "composes";
92
+ causes: "causes";
93
+ "caused-by": "caused-by";
94
+ produces: "produces";
95
+ "produced-by": "produced-by";
96
+ enables: "enables";
97
+ "enabled-by": "enabled-by";
98
+ prevents: "prevents";
99
+ "prevented-by": "prevented-by";
100
+ triggers: "triggers";
101
+ "triggered-by": "triggered-by";
102
+ inhibits: "inhibits";
103
+ "inhibited-by": "inhibited-by";
104
+ disables: "disables";
105
+ "disabled-by": "disabled-by";
106
+ consumes: "consumes";
107
+ "consumed-by": "consumed-by";
108
+ delays: "delays";
109
+ "delayed-by": "delayed-by";
110
+ requires: "requires";
111
+ "required-by": "required-by";
112
+ uses: "uses";
113
+ "used-by": "used-by";
114
+ "used-for": "used-for";
115
+ "purpose-of": "purpose-of";
116
+ precedes: "precedes";
117
+ follows: "follows";
118
+ "occurs-in": "occurs-in";
119
+ "has-occurrence": "has-occurrence";
120
+ during: "during";
121
+ spans: "spans";
122
+ "overlaps-with": "overlaps-with";
123
+ "derives-from": "derives-from";
124
+ "gives-rise-to": "gives-rise-to";
125
+ "contrasts-with": "contrasts-with";
126
+ "opposite-of": "opposite-of";
127
+ "similar-to": "similar-to";
128
+ "analogous-to": "analogous-to";
129
+ supports: "supports";
130
+ "supported-by": "supported-by";
131
+ contradicts: "contradicts";
132
+ explains: "explains";
133
+ "explained-by": "explained-by";
134
+ defines: "defines";
135
+ "defined-by": "defined-by";
136
+ }>;
137
+ }, z.core.$strip>>;
138
+ }, z.core.$strip>;
139
+ export type BuildGraphInput = z.infer<typeof buildGraphInputSchema>;
140
+ /** Short element id (`n`/`e` + 5 base36 chars), retried until unique within `used`. */
141
+ export declare function newElementId(prefix: 'n' | 'e', used: ReadonlySet<string>): string;
142
+ /** Non-throwing validation for Nesso graph document JSON strings. */
143
+ export declare function validateGraphJson(graph: string): GraphValidationResult;
144
+ /** Build a valid Nesso graph document from structured agent input. */
145
+ export declare function buildGraphDocument(input: BuildGraphInput): NessoGraphDocumentInput;
146
+ /** Serialize a built graph document to importable JSON. */
147
+ export declare function buildGraphJson(input: BuildGraphInput): string;
@@ -0,0 +1,241 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import dagre from 'dagre';
3
+ import * as z from 'zod/v4';
4
+ import { deserialize, serialize, VOCABULARY, RELATION_TYPE_VALUES, } from '@nesso-how/vocab-learning';
5
+ const elaborationSchema = z.object({
6
+ definition: z.string(),
7
+ examples: z.string(),
8
+ notes: z.string(),
9
+ imageUrl: z.string().optional(),
10
+ imageTitle: z.string().optional(),
11
+ imageDescriptionUrl: z.string().optional(),
12
+ });
13
+ export const relationTypeEnum = z.enum(RELATION_TYPE_VALUES);
14
+ const conceptInputSchema = z.union([
15
+ z.string().describe('Concept label text'),
16
+ z.object({
17
+ id: z.string().optional().describe('Optional stable concept id (n-prefix recommended)'),
18
+ text: z.string().describe('Concept label'),
19
+ elaboration: elaborationSchema.optional(),
20
+ }),
21
+ ]);
22
+ export const buildGraphInputSchema = z.object({
23
+ name: z.string().describe('Graph display name'),
24
+ concepts: z
25
+ .array(conceptInputSchema)
26
+ .min(1)
27
+ .describe('Concept labels or objects with optional id and elaboration'),
28
+ relations: z
29
+ .array(z.object({
30
+ from: z.string().describe('Source concept id or label text'),
31
+ to: z.string().describe('Target concept id or label text'),
32
+ relation: relationTypeEnum.describe('Semantic relation type id'),
33
+ }))
34
+ .describe('Directed relations between concepts'),
35
+ });
36
+ const NODE_WIDTH = 180;
37
+ const NODE_HEIGHT = 60;
38
+ function asRecord(value) {
39
+ return typeof value === 'object' && value !== null ? value : null;
40
+ }
41
+ /** Short element id (`n`/`e` + 5 base36 chars), retried until unique within `used`. */
42
+ export function newElementId(prefix, used) {
43
+ for (;;) {
44
+ const id = prefix + Math.random().toString(36).slice(2, 7);
45
+ if (!used.has(id))
46
+ return id;
47
+ }
48
+ }
49
+ function issue(path, message) {
50
+ return { path, message };
51
+ }
52
+ function detectRuntimeFormat(root) {
53
+ const hasDocumentShape = Array.isArray(root.concepts) && Array.isArray(root.relations);
54
+ const hasRuntimeShape = Array.isArray(root.nodes) || Array.isArray(root.edges);
55
+ if (hasRuntimeShape && !hasDocumentShape) {
56
+ return issue('$', 'Expected graph document format with concepts[] and relations[], not runtime nodes/edges');
57
+ }
58
+ return null;
59
+ }
60
+ function collectStructuralIssues(doc) {
61
+ const errors = [];
62
+ const warnings = [];
63
+ if (doc.vocabulary === undefined) {
64
+ warnings.push(issue('vocabulary', 'Missing vocabulary reference; import works but metadata is incomplete'));
65
+ }
66
+ const conceptIds = new Set();
67
+ for (let i = 0; i < doc.concepts.length; i++) {
68
+ const id = doc.concepts[i].id;
69
+ if (conceptIds.has(id)) {
70
+ errors.push(issue(`concepts[${i}].id`, `Duplicate concept id "${id}"`));
71
+ }
72
+ conceptIds.add(id);
73
+ }
74
+ const relationIds = new Set();
75
+ for (let i = 0; i < doc.relations.length; i++) {
76
+ const rel = doc.relations[i];
77
+ if (relationIds.has(rel.id)) {
78
+ errors.push(issue(`relations[${i}].id`, `Duplicate relation id "${rel.id}"`));
79
+ }
80
+ relationIds.add(rel.id);
81
+ if (!conceptIds.has(rel.source)) {
82
+ errors.push(issue(`relations[${i}].source`, `Source "${rel.source}" does not reference an existing concept id`));
83
+ }
84
+ if (!conceptIds.has(rel.target)) {
85
+ errors.push(issue(`relations[${i}].target`, `Target "${rel.target}" does not reference an existing concept id`));
86
+ }
87
+ if (rel.type === undefined) {
88
+ warnings.push(issue(`relations[${i}].type`, 'Missing relation type; the app falls back to "causes" at render time'));
89
+ }
90
+ }
91
+ return { errors, warnings };
92
+ }
93
+ /** Non-throwing validation for Nesso graph document JSON strings. */
94
+ export function validateGraphJson(graph) {
95
+ const errors = [];
96
+ const warnings = [];
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(graph);
100
+ }
101
+ catch {
102
+ return {
103
+ valid: false,
104
+ errors: [issue('$', 'Invalid JSON')],
105
+ warnings,
106
+ };
107
+ }
108
+ const root = asRecord(parsed);
109
+ if (!root) {
110
+ return {
111
+ valid: false,
112
+ errors: [issue('$', 'Graph document root must be a JSON object')],
113
+ warnings,
114
+ };
115
+ }
116
+ const runtimeIssue = detectRuntimeFormat(root);
117
+ if (runtimeIssue) {
118
+ return { valid: false, errors: [runtimeIssue], warnings };
119
+ }
120
+ try {
121
+ const doc = deserialize(graph);
122
+ const structural = collectStructuralIssues(doc);
123
+ errors.push(...structural.errors);
124
+ warnings.push(...structural.warnings);
125
+ }
126
+ catch (err) {
127
+ errors.push(issue('$', err instanceof Error ? err.message : 'Graph document failed validation'));
128
+ }
129
+ return {
130
+ valid: errors.length === 0,
131
+ errors,
132
+ warnings,
133
+ };
134
+ }
135
+ function normalizeConceptInputs(input) {
136
+ const usedIds = new Set();
137
+ return input.map((item) => {
138
+ if (typeof item === 'string') {
139
+ const id = newElementId('n', usedIds);
140
+ usedIds.add(id);
141
+ return { id, label: item };
142
+ }
143
+ const id = item.id ?? newElementId('n', usedIds);
144
+ if (usedIds.has(id)) {
145
+ throw new Error(`Duplicate concept id "${id}" in build_graph input`);
146
+ }
147
+ usedIds.add(id);
148
+ return { id, label: item.text, elaboration: item.elaboration };
149
+ });
150
+ }
151
+ function resolveConceptRef(ref, concepts) {
152
+ const byId = concepts.find((c) => c.id === ref);
153
+ if (byId)
154
+ return byId.id;
155
+ const byLabel = concepts.filter((c) => c.label === ref);
156
+ if (byLabel.length === 1)
157
+ return byLabel[0].id;
158
+ if (byLabel.length > 1) {
159
+ throw new Error(`Ambiguous concept reference "${ref}" — multiple concepts share that label`);
160
+ }
161
+ throw new Error(`Unknown concept reference "${ref}" — use an id or unique label`);
162
+ }
163
+ function buildDagreGraph(concepts, relations) {
164
+ const g = new dagre.graphlib.Graph();
165
+ g.setDefaultEdgeLabel(() => ({}));
166
+ g.setGraph({ rankdir: 'LR', nodesep: 80, ranksep: 120 });
167
+ for (const concept of concepts) {
168
+ g.setNode(concept.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
169
+ }
170
+ for (const rel of relations) {
171
+ if (g.hasNode(rel.source) && g.hasNode(rel.target)) {
172
+ g.setEdge(rel.source, rel.target);
173
+ }
174
+ }
175
+ dagre.layout(g);
176
+ return g;
177
+ }
178
+ function dagreNodePosition(layout) {
179
+ if (layout?.x === undefined || layout?.y === undefined)
180
+ return null;
181
+ return { x: layout.x - NODE_WIDTH / 2, y: layout.y - NODE_HEIGHT / 2 };
182
+ }
183
+ function gridFallbackPosition(index) {
184
+ const col = index % 4;
185
+ const row = Math.floor(index / 4);
186
+ return { x: col * 250, y: row * 120 };
187
+ }
188
+ function layoutConceptPositions(concepts, relations) {
189
+ const positions = new Map();
190
+ const g = buildDagreGraph(concepts, relations);
191
+ for (const concept of concepts) {
192
+ const pos = dagreNodePosition(g.node(concept.id));
193
+ if (pos)
194
+ positions.set(concept.id, pos);
195
+ }
196
+ let fallbackIndex = 0;
197
+ for (const concept of concepts) {
198
+ if (positions.has(concept.id))
199
+ continue;
200
+ positions.set(concept.id, gridFallbackPosition(fallbackIndex));
201
+ fallbackIndex += 1;
202
+ }
203
+ return positions;
204
+ }
205
+ /** Build a valid Nesso graph document from structured agent input. */
206
+ export function buildGraphDocument(input) {
207
+ const resolvedConcepts = normalizeConceptInputs(input.concepts);
208
+ const relations = input.relations.map((rel) => ({
209
+ source: resolveConceptRef(rel.from, resolvedConcepts),
210
+ target: resolveConceptRef(rel.to, resolvedConcepts),
211
+ type: rel.relation,
212
+ }));
213
+ const usedEdgeIds = new Set();
214
+ const documentRelations = relations.map((rel) => ({
215
+ id: newElementId('e', usedEdgeIds),
216
+ source: rel.source,
217
+ target: rel.target,
218
+ type: rel.type,
219
+ }));
220
+ const positions = layoutConceptPositions(resolvedConcepts, relations);
221
+ const concepts = resolvedConcepts.map((concept) => {
222
+ const pos = positions.get(concept.id) ?? { x: 0, y: 0 };
223
+ return {
224
+ id: concept.id,
225
+ label: concept.label,
226
+ x: pos.x,
227
+ y: pos.y,
228
+ ...(concept.elaboration !== undefined ? { data: { elaboration: concept.elaboration } } : {}),
229
+ };
230
+ });
231
+ return {
232
+ vocabulary: { id: VOCABULARY.id, version: VOCABULARY.version },
233
+ name: input.name,
234
+ concepts,
235
+ relations: documentRelations,
236
+ };
237
+ }
238
+ /** Serialize a built graph document to importable JSON. */
239
+ export function buildGraphJson(input) {
240
+ return serialize(buildGraphDocument(input));
241
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,154 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { describe, expect, it } from 'vitest';
3
+ import { serialize } from '@nesso-how/vocab-learning';
4
+ import { buildGraphDocument, buildGraphJson, newElementId, validateGraphJson, } from './graph-tools.js';
5
+ const validDoc = {
6
+ vocabulary: { id: '@nesso-how/vocab-learning', version: '0.1.0' },
7
+ name: 'Demo',
8
+ concepts: [
9
+ { id: 'n1', label: 'Cause', x: 0, y: 0 },
10
+ { id: 'n2', label: 'Effect', x: 250, y: 0 },
11
+ ],
12
+ relations: [{ id: 'e1', source: 'n1', target: 'n2', type: 'causes' }],
13
+ };
14
+ describe('validateGraphJson', () => {
15
+ it('accepts a valid graph document', () => {
16
+ const result = validateGraphJson(serialize(validDoc));
17
+ expect(result.valid).toBe(true);
18
+ expect(result.errors).toEqual([]);
19
+ });
20
+ it('rejects invalid JSON', () => {
21
+ const result = validateGraphJson('{not json');
22
+ expect(result.valid).toBe(false);
23
+ expect(result.errors[0]?.message).toMatch(/Invalid JSON/);
24
+ });
25
+ it('rejects runtime nodes/edges shape', () => {
26
+ const result = validateGraphJson(JSON.stringify({
27
+ nodes: [{ id: 'n1' }],
28
+ edges: [{ id: 'e1', source: 'n1', target: 'n2' }],
29
+ }));
30
+ expect(result.valid).toBe(false);
31
+ expect(result.errors[0]?.message).toMatch(/concepts\[\] and relations\[\]/);
32
+ });
33
+ it('rejects unknown relation types', () => {
34
+ const result = validateGraphJson(JSON.stringify({
35
+ name: 'X',
36
+ concepts: [{ id: 'n1', label: 'A', x: 0, y: 0 }],
37
+ relations: [{ id: 'e1', source: 'n1', target: 'n1', type: 'not-real' }],
38
+ }));
39
+ expect(result.valid).toBe(false);
40
+ expect(result.errors[0]?.message).toMatch(/unknown relation type/);
41
+ });
42
+ it('rejects duplicate concept ids', () => {
43
+ const result = validateGraphJson(JSON.stringify({
44
+ name: 'X',
45
+ concepts: [
46
+ { id: 'n1', label: 'A', x: 0, y: 0 },
47
+ { id: 'n1', label: 'B', x: 100, y: 0 },
48
+ ],
49
+ relations: [],
50
+ }));
51
+ expect(result.valid).toBe(false);
52
+ expect(result.errors.some((e) => e.message.includes('Duplicate concept id'))).toBe(true);
53
+ });
54
+ it('rejects dangling relation endpoints', () => {
55
+ const result = validateGraphJson(JSON.stringify({
56
+ name: 'X',
57
+ concepts: [{ id: 'n1', label: 'A', x: 0, y: 0 }],
58
+ relations: [{ id: 'e1', source: 'n1', target: 'missing', type: 'causes' }],
59
+ }));
60
+ expect(result.valid).toBe(false);
61
+ expect(result.errors.some((e) => e.path.includes('target'))).toBe(true);
62
+ });
63
+ it('warns when relation type is omitted', () => {
64
+ const result = validateGraphJson(JSON.stringify({
65
+ name: 'X',
66
+ concepts: [
67
+ { id: 'n1', label: 'A', x: 0, y: 0 },
68
+ { id: 'n2', label: 'B', x: 100, y: 0 },
69
+ ],
70
+ relations: [{ id: 'e1', source: 'n1', target: 'n2' }],
71
+ }));
72
+ expect(result.valid).toBe(true);
73
+ expect(result.warnings.some((w) => w.path.includes('type'))).toBe(true);
74
+ });
75
+ it('warns when vocabulary is missing', () => {
76
+ const result = validateGraphJson(JSON.stringify({
77
+ name: 'X',
78
+ concepts: [{ id: 'n1', label: 'A', x: 0, y: 0 }],
79
+ relations: [],
80
+ }));
81
+ expect(result.valid).toBe(true);
82
+ expect(result.warnings.some((w) => w.path === 'vocabulary')).toBe(true);
83
+ });
84
+ });
85
+ describe('buildGraphDocument', () => {
86
+ it('builds a document that passes validation', () => {
87
+ const json = buildGraphJson({
88
+ name: 'Built',
89
+ concepts: ['Cause', 'Effect'],
90
+ relations: [{ from: 'Cause', to: 'Effect', relation: 'causes' }],
91
+ });
92
+ const result = validateGraphJson(json);
93
+ expect(result.valid).toBe(true);
94
+ });
95
+ it('resolves from/to by concept id', () => {
96
+ const doc = buildGraphDocument({
97
+ name: 'Refs',
98
+ concepts: [{ id: 'n_alpha', text: 'Alpha' }, 'Beta'],
99
+ relations: [{ from: 'n_alpha', to: 'Beta', relation: 'enables' }],
100
+ });
101
+ expect(doc.relations[0]?.source).toBe('n_alpha');
102
+ expect(doc.relations[0]?.type).toBe('enables');
103
+ });
104
+ it('assigns finite layout coordinates', () => {
105
+ const doc = buildGraphDocument({
106
+ name: 'Layout',
107
+ concepts: ['A', 'B', 'C', 'D'],
108
+ relations: [
109
+ { from: 'A', to: 'B', relation: 'causes' },
110
+ { from: 'B', to: 'C', relation: 'causes' },
111
+ { from: 'C', to: 'D', relation: 'causes' },
112
+ ],
113
+ });
114
+ for (const concept of doc.concepts) {
115
+ expect(Number.isFinite(concept.x)).toBe(true);
116
+ expect(Number.isFinite(concept.y)).toBe(true);
117
+ }
118
+ const xs = doc.concepts.map((c) => c.x);
119
+ expect(new Set(xs).size).toBeGreaterThan(1);
120
+ });
121
+ it('includes vocabulary metadata', () => {
122
+ const doc = buildGraphDocument({
123
+ name: 'Meta',
124
+ concepts: ['Only'],
125
+ relations: [],
126
+ });
127
+ expect(doc.vocabulary).toEqual({ id: '@nesso-how/vocab-learning', version: '0.1.0' });
128
+ });
129
+ it('rejects ambiguous label references', () => {
130
+ expect(() => buildGraphDocument({
131
+ name: 'Ambiguous',
132
+ concepts: ['Same', 'Same'],
133
+ relations: [{ from: 'Same', to: 'Same', relation: 'causes' }],
134
+ })).toThrow(/Ambiguous concept reference/);
135
+ });
136
+ it('rejects unknown references', () => {
137
+ expect(() => buildGraphDocument({
138
+ name: 'Missing',
139
+ concepts: ['A'],
140
+ relations: [{ from: 'A', to: 'Z', relation: 'causes' }],
141
+ })).toThrow(/Unknown concept reference/);
142
+ });
143
+ });
144
+ describe('newElementId', () => {
145
+ it('generates unique ids with the expected prefix', () => {
146
+ const used = new Set();
147
+ const id = newElementId('n', used);
148
+ expect(id.startsWith('n')).toBe(true);
149
+ expect(id.length).toBe(6);
150
+ used.add(id);
151
+ const next = newElementId('n', used);
152
+ expect(next).not.toBe(id);
153
+ });
154
+ });
@@ -4,13 +4,13 @@
4
4
  "slug": "guides/ai-mentor",
5
5
  "title": "AI mentor (Socrates)",
6
6
  "description": "How the Socratic mentor works, connecting a model, persona, and graph-aware context.",
7
- "markdown": ":::caution\nThis is the part of Nesso with the most potential and the most room to grow. Small models tend to drift out of the Socratic role and start explaining rather than questioning. A larger remote model works noticeably better. Improving the mentor is where most of the future work is headed.\n:::\n\nClick the **Socrates** entry in the **status bar** (bottom-left) to start a dialogue. The mentor reads your current graph and selection, and replies with **questions rather than explanations**. The goal is to surface what you understand and where the gaps are.\n\n## How it works\n\nEvery send rebuilds a system prompt from the live store: a snapshot of up to ~60 concept nodes, sorted weakest-first via **`nodeStrength()`** ([`context.ts`](https://github.com/nesso-how/nesso/blob/main/src/llm/context.ts)): **FSRS stability** dominates ordering, **Again/Hard** nudge weaker items up, overdue is only a slight tie-break. Each node line lists stability (`s=` days), days since last review, last FSRS rating, and `DUE` when the scheduler says so, plus typed edges (~2× the node allowance), current selection when any, and focal-neighbour context when a node is selected (`Focus:` / `Related:` lines). The conversation history stays in the mentor card and is reset when you switch graphs or click **New chat**.\n\nChat history is **not persisted**. It lives only for the current panel session.\n\n## Connecting a model\n\nThe mentor is **experimental** and runs against any OpenAI-compatible `chat/completions` endpoint. Configure it under **Settings -> AI**: base URL, model, and an optional API key.\n\nThe default targets a local [Ollama](https://ollama.com/) instance (`http://localhost:11434/v1`, model `gemma3:4b`). Install Ollama, pull a model, and the mentor works with nothing leaving your machine. Any hosted OpenAI-compatible endpoint works too; set the API key it expects.\n\nThere is **no built-in in-browser model**. Nesso previously bundled a small WebGPU model, but it was too small and slow to be useful and has been removed. Until a reachable endpoint is configured, the chat input stays disabled and the mentor shows a short setup hint.\n\n### Reaching local Ollama from the hosted app\n\nIf you use the hosted web app over HTTPS, requests to `http://localhost:11434` are allowed (localhost is exempt from mixed-content blocking), but Ollama still rejects the cross-origin request unless you allow the app's origin: start it with `OLLAMA_ORIGINS=https://app.nesso.how` (or run the desktop build, where this does not apply).\n\n## The Socratic persona\n\nThe system prompt (`getMentorBase` in [`MentorPanel.tsx`](https://github.com/nesso-how/nesso/blob/main/src/components/mentor/MentorPanel.tsx)) shapes Socrates:\n\n- One short question per turn by default; explain only enough to frame the question.\n- Replies are soft-capped at ~200 words (hard cap via output tokens).\n- No graph edits proposed in dialogue. Socrates probes; the user edits.\n- No emojis, flattery, JSON, or pseudo-graph markup. Sparse `*asterisks*` on key terms.\n- Replies in the active UI language (English or Italian). Snapshot tokens stay **English-shaped** (`s=…d`, `…d since review`, etc.), with the same spelling in the legend for every locale.\n\nIf you want a more permissive coach, fork the persona. It is plain text in the component and easy to swap.\n\n## Opening message\n\nWhen the panel opens, the mentor sends itself a short synthetic **user** turn so its first message reflects what's selected:\n\n- **A concept node selected:** opens on that concept and one of its relations.\n- **An edge selected (no node):** opens on the typed relation between its endpoints.\n- **Nothing selected:** opens on a weak spot in the graph (low stability plus weak **last reviews** (Again/Hard or a long gap); **DUE** is extra scheduler context).\n\nClick **New chat** in the header to reset history and request a fresh opener.\n\n## Context size\n\nLarge graphs are summarised, not truncated abruptly. The weakest-reviewed nodes appear first (`nodeStrength`), so the verbatim slice emphasises instability and risky last ratings; tail nodes are omitted with a short count only. Edges have a ~2x allowance over node count. These limits live in [`MentorPanel.tsx`](https://github.com/nesso-how/nesso/blob/main/src/components/mentor/MentorPanel.tsx) as `MAX_SNAPSHOT_NODES` and `MAX_SNAPSHOT_EDGES`.\n\n## Privacy\n\n- **Local Ollama:** prompts and responses go only to your own machine; nothing leaves the device.\n- **Hosted endpoint:** the system prompt (graph snapshot) and chat history are sent to whichever endpoint you configured, each turn. The usual provider-side logging applies.\n\nYour graph itself always stays on your device regardless of the endpoint."
7
+ "markdown": "The Socratic mentor is **experimental** and **off by default**. Enable it under **Settings AI** with the **Mentor** toggle (marked _Experimental_). While off, **Socrates** is hidden from the status bar.\n\nWhen enabled, click **Socrates** in the **status bar** (bottom-left) to start a dialogue. The mentor reads your current graph and selection, and replies with **questions rather than explanations**. The goal is to surface what you understand and where the gaps are.\n\n## How it works\n\nEvery send rebuilds a system prompt from the live store: a snapshot of up to ~60 concept nodes, sorted weakest-first via **`nodeStrength()`** ([`context.ts`](https://github.com/nesso-how/nesso/blob/main/src/llm/context.ts)): **FSRS stability** dominates ordering, **Again/Hard** nudge weaker items up, overdue is only a slight tie-break. Each node line lists stability (`s=` days), days since last review, last FSRS rating, and `DUE` when the scheduler says so, plus typed edges (~2× the node allowance), current selection when any, and focal-neighbour context when a node is selected (`Focus:` / `Related:` lines). The conversation history stays in the mentor card and is reset when you switch graphs or click **New chat**.\n\nChat history is **not persisted**. It lives only for the current panel session.\n\n## Connecting a model\n\nConfigure any OpenAI-compatible `chat/completions` endpoint under **Settings AI**: base URL, model, and an optional API key. Endpoint fields appear only while the mentor toggle is on.\n\nThe default targets a local [Ollama](https://ollama.com/) instance (`http://localhost:11434/v1`, model `gemma3:4b`). Install Ollama, pull a model, and the mentor works with nothing leaving your machine. Any hosted OpenAI-compatible endpoint works too; set the API key it expects.\n\nUntil a reachable endpoint is configured, the chat input stays disabled and the mentor shows a short setup hint.\n\n### Reaching local Ollama from the hosted app\n\nIf you use the hosted web app over HTTPS, requests to `http://localhost:11434` are allowed (localhost is exempt from mixed-content blocking), but Ollama still rejects the cross-origin request unless you allow the app's origin: start it with `OLLAMA_ORIGINS=https://app.nesso.how` (or run the desktop build, where this does not apply).\n\n## The Socratic persona\n\nThe system prompt (`getMentorBase` in [`MentorPanel.tsx`](https://github.com/nesso-how/nesso/blob/main/src/components/mentor/MentorPanel.tsx)) shapes Socrates:\n\n- One short question per turn by default; explain only enough to frame the question.\n- Replies are soft-capped at ~200 words (hard cap via output tokens).\n- No graph edits proposed in dialogue. Socrates probes; the user edits.\n- No emojis, flattery, JSON, or pseudo-graph markup. Sparse `*asterisks*` on key terms.\n- Replies in the active UI language (English or Italian). Snapshot tokens stay **English-shaped** (`s=…d`, `…d since review`, etc.), with the same spelling in the legend for every locale.\n\nIf you want a more permissive coach, fork the persona. It is plain text in the component and easy to swap.\n\n## Opening message\n\nWhen the panel opens, the mentor sends itself a short synthetic **user** turn so its first message reflects what's selected:\n\n- **A concept node selected:** opens on that concept and one of its relations.\n- **An edge selected (no node):** opens on the typed relation between its endpoints.\n- **Nothing selected:** opens on a weak spot in the graph (low stability plus weak **last reviews** (Again/Hard or a long gap); **DUE** is extra scheduler context).\n\nClick **New chat** in the header to reset history and request a fresh opener.\n\n## Context size\n\nLarge graphs are summarised, not truncated abruptly. The weakest-reviewed nodes appear first (`nodeStrength`), so the verbatim slice emphasises instability and risky last ratings; tail nodes are omitted with a short count only. Edges have a ~2x allowance over node count. These limits live in [`MentorPanel.tsx`](https://github.com/nesso-how/nesso/blob/main/src/components/mentor/MentorPanel.tsx) as `MAX_SNAPSHOT_NODES` and `MAX_SNAPSHOT_EDGES`.\n\n## Privacy\n\n- **Local Ollama:** prompts and responses go only to your own machine; nothing leaves the device.\n- **Hosted endpoint:** the system prompt (graph snapshot) and chat history are sent to whichever endpoint you configured, each turn. The usual provider-side logging applies.\n\nYour graph itself always stays on your device regardless of the endpoint."
8
8
  },
9
9
  {
10
10
  "slug": "guides/concepts-and-inspector",
11
11
  "title": "Concepts & Inspector",
12
12
  "description": "Adding concepts, drawing typed relations, and using the Inspector to enrich nodes with definitions, examples, notes, and images.",
13
- "markdown": "The canvas is the centre of Nesso. **Concepts** are nodes; **typed relations** are edges. The **Inspector** is the right-hand panel where you enrich whatever you've got selected.\n\n## Adding concepts\n\n- **Double-click** empty canvas to add a concept at the pointer.\n- **`N`** adds a concept at the viewport centre.\n- **Right-click** empty canvas and choose **Add concept here** to add one at the cursor.\n- New concepts open in edit mode. Type the label and press `Enter` to commit, `Esc` to cancel.\n- **Double-click** a concept to rename it inline.\n\nAn empty graph shows a centered **\"Your first concept\"** hint; the double-click still works through it.\n\nConcepts you add are stored locally in IndexedDB. Switch graphs from the sidebar; create new graphs from the **Graphs** list.\n\n## Drawing relations\n\nDrag from a node's right edge (`out` handle) to another node's left edge (`in` handle). On release, a **relation picker** opens, grouped by category. Pick the relation type and the edge is created.\n\n- Drag-to-self is ignored, so you can't accidentally create self-loops.\n- The connection line previews with the same quadratic geometry the final edge uses.\n- Edge type can be changed any time from the Inspector when an edge is selected.\n\nSee the [relation types reference](../../reference/relation-types/) for the full list, semantic meaning, and type properties. Per-type line style and glyph come from `@nesso-how/vocab-learning`; edge encoding density is under [Display options](#display-options-sidebar) below.\n\n## Selecting and editing\n\n- **Click** a node or edge to select it. The Inspector reflects the selection.\n- **Hold `⌘` / `Ctrl` and click** to toggle additional items into the selection.\n- **Drag on empty canvas** to marquee-select multiple items.\n- **`⌘A` / `Ctrl+A`** selects every concept and relation in the graph.\n- **Right-click** a concept, relation, or empty canvas for a context menu of the relevant actions (copy/cut/duplicate/delete a concept; flip / delete a relation; paste / add concept / center·fit on the canvas). To change a relation's type, select the edge and pick a new type in the Inspector.\n- **`Del`** or **`Backspace`** deletes the selection (one relation, one concept, or every concept in a marquee). Edges attached to a deleted concept go with it. Delete is also on the right-click menu and the Inspector's action toolbar.\n- **`⌘C` / `Ctrl+C`** copies the selection. Copying concepts also copies relations between them; copying a relation includes its two endpoints. **`⌘X` / `Ctrl+X`** cuts: it copies the selection and removes it in one step. **`⌘V` / `Ctrl+V`** pastes the clipboard with a small offset (right-click **Paste** drops it at the cursor instead). **`⌘D` / `Ctrl+D`** duplicates the selection in place without touching the clipboard. These also live on the right-click menu and the Inspector toolbar.\n- **Arrow keys** nudge a selected concept; **Shift + arrows** move it in larger steps.\n- **`⌘Z` / `Ctrl+Z`** undoes structural edits; **`⌘⇧Z` / `Ctrl+Shift+Z`** redoes. History has 50 steps and resets when you switch or import a graph.\n\n## The Inspector\n\nThe Inspector docks on the **right**, full height between the top bar and the status bar. Its header has a **collapse** control that shrinks it to a slim **rail** (keeping the selection plus a vertical action toolbar) and a **close** control; a docked bottom **action toolbar** offers copy / cut / duplicate / delete for a concept, or flip / delete for a relation.\n\nWhen a concept is selected it shows, top to bottom:\n\n- **Image + title:** the title edits inline (`Enter` commits, `Esc` reverts); the image button opens Commons search (see below).\n- **Memory** _(collapsible):_ the FSRS schedule, read-only — when due, stability (in days), last self-rating, review count (with lapses), and time since the last review.\n- **Definition**, **Examples**, **Notes** — see below.\n- **Relations** _(collapsible):_ outgoing and incoming edges, each connected concept shown with the relation glyph in a chip and the type on the right (incoming dimmed). Click a row to jump to that concept; change a relation's type by selecting the edge.\n\n### Notes fields\n\nThree free-text fields that travel with the concept and feed both the AI mentor and [Review](./review-mode/):\n\n- **Definition:** a one-sentence-ish explanation in your own words.\n- **Examples:** one per line. Press `Shift+Enter` or use the **Add** button to add a new line; press `Backspace` in an empty example to remove that line (unless it's the only one).\n- **Notes:** anything else: caveats, sources, mnemonics.\n\nIn Review, definition and examples appear when you **Reveal** a card so you can check your recall. The AI mentor uses the same fields in its graph snapshot and focal-neighbour context when a concept is selected.\n\n### Concept image\n\nPress the picture icon to open the **Wikimedia Commons search**. The query auto-fills from the concept title and runs immediately; pick any result to attach a 200-px thumbnail to the concept. The image shows in the Inspector, on reveal in Review mode, and is included as context for the AI mentor.\n\nThe image link and Commons description URL are persisted with the graph, so attribution is preserved on export.\n\n## Display options (sidebar)\n\n**Sidebar → Display** controls how the **active graph** is rendered: heatmap overlay, edge encoding density, curve style, and auto flip. Choices are saved **with the graph** in IndexedDB (and included in JSON export). New graphs start from the app defaults until you change them.\n\nWhen **Display → Curve** is set to **Arc**, **Auto flip** (on by default) bends relations toward the side that avoids overlapping nodes, flipping when the target is above the source on the right, or below on the left, and updates live while you drag concepts. **Flip curve** in the Inspector is **Off | Auto | On** while auto flip is on: **Auto** follows layout, **Off** / **On** pin a manual bend on that edge. With auto flip off for that graph, the control is **Off | On** only.\n\n## When an edge is selected\n\nThe Inspector shows the relation as a chip with its category colour and a dropdown of every relation type. Picking a new type updates the edge in place; the graph keeps its endpoints and identity.\n\n## Status bar and search\n\n- The **status bar** along the bottom shows the concept and relation counts. Its right side carries undo / redo, zoom out / in, and center·fit; the **Socrates** entry sits on the left.\n- **`⌘K` / `Ctrl+K`** opens a fuzzy search palette over concept titles. `Enter` selects and recenters the viewport; `Esc` closes.\n\n## Edge encoding density\n\nEdges carry three visual channels: colour (category), line style, and glyph. Crank this down for large or printed graphs from **Sidebar → Display → Edges**:\n\n- **Full:** colour + style + glyph (default).\n- **Category:** colour only.\n- **Minimal:** plain line, no encoding.\n\nSymmetric relations (similarity, opposition) never render an arrowhead regardless of encoding."
13
+ "markdown": "The canvas is the centre of Nesso. **Concepts** are nodes; **typed relations** are edges. The **Inspector** is the right-hand panel where you enrich whatever you've got selected.\n\n## Adding concepts\n\n- **Double-click** empty canvas to add a concept at the pointer.\n- **`N`** adds a concept at the viewport centre.\n- **Right-click** empty canvas and choose **Add concept here** to add one at the cursor.\n- New concepts open in edit mode. Type the label and press `Enter` to commit, `Esc` to cancel.\n- **Double-click** a concept to rename it inline.\n\nAn empty graph shows a centered **\"Your first concept\"** hint; the double-click still works through it.\n\nConcepts you add are stored locally in IndexedDB. Switch graphs from the sidebar; create new graphs from the **Graphs** list.\n\n## Drawing relations\n\nDrag from a node's right edge (`out` handle) to another node's left edge (`in` handle). On release, a **relation picker** opens, grouped by category. Pick the relation type and the edge is created.\n\n- Drag-to-self is ignored, so you can't accidentally create self-loops.\n- The connection line previews with the same quadratic geometry the final edge uses.\n- Edge type can be changed any time from the Inspector when an edge is selected.\n\nSee the [relation types reference](../../reference/relation-types/) for the full list, semantic meaning, and type properties. Per-type line style and glyph come from `@nesso-how/vocab-learning`; edge encoding density is under [Display options](#display-options-sidebar) below.\n\n## Selecting and editing\n\n- **Click** a node or edge to select it. The Inspector reflects the selection.\n- **Hold `⌘` / `Ctrl` and click** to toggle additional items into the selection.\n- **Drag on empty canvas** to marquee-select multiple items.\n- **`⌘A` / `Ctrl+A`** selects every concept and relation in the graph.\n- **Right-click** a concept, relation, or empty canvas for a context menu of the relevant actions (copy/cut/duplicate/delete a concept; flip / delete a relation; paste / add concept / center·fit on the canvas). To change a relation's type, select the edge and pick a new type in the Inspector.\n- **`Del`** or **`Backspace`** deletes the selection (one relation, one concept, or every concept in a marquee). Edges attached to a deleted concept go with it. Delete is also on the right-click menu and the Inspector's action toolbar.\n- **`⌘C` / `Ctrl+C`** copies the selection. Copying concepts also copies relations between them; copying a relation includes its two endpoints. **`⌘X` / `Ctrl+X`** cuts: it copies the selection and removes it in one step. **`⌘V` / `Ctrl+V`** pastes the clipboard with a small offset (right-click **Paste** drops it at the cursor instead). **`⌘D` / `Ctrl+D`** duplicates the selection in place without touching the clipboard. These also live on the right-click menu and the Inspector toolbar.\n- **Arrow keys** nudge a selected concept; **Shift + arrows** move it in larger steps.\n- **`⌘Z` / `Ctrl+Z`** undoes structural edits; **`⌘⇧Z` / `Ctrl+Shift+Z`** redoes. History has 50 steps and resets when you switch or import a graph.\n\n## The Inspector\n\nThe Inspector docks on the **right**, full height between the top bar and the status bar. Its header has a **collapse** control that shrinks it to a slim **rail** (keeping the selection plus a vertical action toolbar) and a **close** control; a docked bottom **action toolbar** offers copy / cut / duplicate / delete for a concept, or flip / delete for a relation.\n\nWhen a concept is selected it shows, top to bottom:\n\n- **Image + title:** the title edits inline (`Enter` commits, `Esc` reverts); the image button opens Commons search (see below).\n- **Memory** _(collapsible):_ the FSRS schedule, read-only — when due, stability (in days), last self-rating, review count (with lapses), and time since the last review.\n- **Definition**, **Examples**, **Notes** — see below.\n- **Relations** _(collapsible):_ outgoing and incoming edges, each connected concept shown with the relation glyph in a chip and the type on the right (incoming dimmed). Click a row to jump to that concept; change a relation's type by selecting the edge.\n\n### Notes fields\n\nThree free-text fields that travel with the concept and feed both the AI mentor and [Review](./review-mode/):\n\n- **Definition:** a one-sentence-ish explanation in your own words.\n- **Examples:** one per line. Press `Shift+Enter` or use the **Add** button to add a new line; press `Backspace` in an empty example to remove that line (unless it's the only one).\n- **Notes:** anything else: caveats, sources, mnemonics.\n\nIn Review, definition and examples appear when you **Reveal** a card so you can check your recall. The AI mentor uses the same fields in its graph snapshot and focal-neighbour context when a concept is selected.\n\n### Concept image\n\nPress the picture icon to open the **Wikimedia Commons search**. The query auto-fills from the concept title and runs immediately; pick any result to attach a 200-px thumbnail to the concept. The image shows in the Inspector, on reveal in Review mode, and is included as context for the AI mentor.\n\nThe image link and Commons description URL are persisted with the graph, so attribution is preserved on export.\n\n## Display options (sidebar)\n\n**Sidebar → Display** controls how the **active graph** is rendered: heatmap overlay, edge encoding density, curve style, and auto flip. Choices are saved **with the graph** in IndexedDB (and included in JSON export). New graphs start from the app defaults until you change them.\n\nWhen **Display → Curve** is set to **Arc**, **Auto flip** (on by default) bends relations toward the side that avoids overlapping nodes, flipping when the target is above the source on the right, or below on the left, and updates live while you drag concepts. **Flip curve** in the Inspector is **Off | Auto | On** while auto flip is on: **Auto** follows layout, **Off** / **On** pin a manual bend on that edge. With auto flip off for that graph, the control is **Off | On** only.\n\n## When an edge is selected\n\nThe Inspector shows the relation as a chip with its category colour and a dropdown of every relation type. Picking a new type updates the edge in place; the graph keeps its endpoints and identity.\n\n## Status bar and search\n\n- The **status bar** along the bottom shows the concept and relation counts. Its right side carries undo / redo, zoom out / in, and center·fit. When the mentor is enabled in **Settings → AI**, the **Socrates** entry appears on the left (see [AI mentor](./ai-mentor/)).\n- **`⌘K` / `Ctrl+K`** opens a fuzzy search palette over concept titles. `Enter` selects and recenters the viewport; `Esc` closes.\n\n## Edge encoding density\n\nEdges carry three visual channels: colour (category), line style, and glyph. Crank this down for large or printed graphs from **Sidebar → Display → Edges**:\n\n- **Full:** colour + style + glyph (default).\n- **Category:** colour only.\n- **Minimal:** plain line, no encoding.\n\nSymmetric relations (similarity, opposition) never render an arrowhead regardless of encoding."
14
14
  },
15
15
  {
16
16
  "slug": "guides/embedding-graphs",
@@ -22,13 +22,13 @@
22
22
  "slug": "guides/getting-started",
23
23
  "title": "Getting started",
24
24
  "description": "How to run Nesso locally or on the web.",
25
- "markdown": "Nesso is available as a hosted web app, a macOS desktop app, and as open-source code you can run locally. All data is stored in your browser or on your machine; nothing is sent to external servers unless you configure a remote AI endpoint.\n\n## Web app\n\nOpen [app.nesso.how](https://app.nesso.how) in your browser. The app works offline after the first load and runs in any modern browser. The AI mentor is optional and needs an OpenAI-compatible endpoint (see [Picking an AI backend](#picking-an-ai-backend)).\n\n## Desktop app (macOS)\n\nA pre-built alpha installer is published on [GitHub Releases](https://github.com/nesso-how/nesso/releases). Download the universal `.dmg` — it runs on both Apple silicon and Intel Macs — and open it.\n\n:::caution\nThe app is not signed with an Apple developer certificate. macOS will block it on first launch. After installing, run this command in the terminal to remove the quarantine flag:\n\n```sh\nxattr -cr /Applications/Nesso.app\n```\n\nThen open the app normally.\n:::\n\nThe desktop app **updates itself**: on launch it checks GitHub Releases and, when a newer build is available, offers to install it and relaunch. Auto-updates begin once you are on a build that ships the updater (`v0.1.0-alpha.25` or later), so that first version still needs a one-time manual download.\n\n## Run from source\n\nRequires [Node.js](https://nodejs.org/) and [pnpm](https://pnpm.io/).\n\n```sh\ngit clone https://github.com/nesso-how/nesso.git\ncd nesso\npnpm install\npnpm dev\n```\n\nFor a desktop build, [Rust](https://rustup.rs/) is required as well:\n\n```sh\npnpm build:desktop\n```\n\n## Picking an AI backend\n\nThe Socratic mentor is **experimental** and uses any OpenAI-compatible `chat/completions` endpoint: a local [Ollama](https://ollama.com/) instance, an OpenAI-compatible proxy, or a hosted provider. There is no built-in in-browser model, so configure an endpoint under **Settings -> AI** (`⌘,` / `Ctrl+,`) or the mentor stays disabled.\n\n:::caution\nAPI keys are stored client-side in `localStorage`. Do not self-host the web app publicly with secrets baked in.\n:::\n\n### Local setup with Ollama\n\nRun [Ollama](https://ollama.com/) locally, then pull a small instruction-tuned model:\n\n```sh\nollama pull gemma3:4b\n```\n\nIn **Settings -> AI**, set:\n\n- Base URL: `http://localhost:11434/v1`\n- Model: `gemma3:4b` (or `llama3.2:3b`, `qwen3:8b`; presets are shown in Settings)\n- API key: leave empty\n\nSettings auto-probes the endpoint and offers a **Pull** button if the model is missing. Prompts and responses stay on your machine.\n\nWhen using the hosted web app over HTTPS, allow its origin in Ollama so the browser request is not blocked by CORS: start Ollama with `OLLAMA_ORIGINS=https://app.nesso.how`. (Requests to `localhost` are exempt from mixed-content blocking, so only CORS needs configuring.)\n\n## Keyboard shortcuts\n\n| Shortcut | Action |\n| ---------------------- | ------------------------------ |\n| `?` | Show shortcuts dialog |\n| `⌘,` / `Ctrl+,` | Settings |\n| `⌘K` / `Ctrl+K` | Search concepts |\n| `N` | Add concept at viewport centre |\n| `R` | Open review mode |\n| `⌘Z` / `Ctrl+Z` | Undo |\n| `⌘⇧Z` / `Ctrl+Shift+Z` | Redo |\n| `Del` / `Backspace` | Delete selection |\n| `⌘A` / `Ctrl+A` | Select all |\n| `⌘X` / `Ctrl+X` | Cut selection |\n| `⌘C` / `Ctrl+C` | Copy selection |\n| `⌘V` / `Ctrl+V` | Paste |\n| `↑` `↓` `←` `→` | Nudge selected concept |\n| `Shift` + arrows | Nudge selected concept (large) |\n| `Esc` | Close dialog |\n\nHold `⌘` / `Ctrl` to add to a selection; drag on empty canvas to marquee-select."
25
+ "markdown": "Nesso is available as a hosted web app, a macOS desktop app, and as open-source code you can run locally. Graphs are stored in your browser or on your machine (local-first, no account). Optional telemetry and desktop update checks may contact external services; using the AI mentor with a remote endpoint sends prompts to that provider.\n\n## Web app\n\nOpen [app.nesso.how](https://app.nesso.how) in your browser. The app works offline after the first load and runs in any modern browser.\n\n## First run\n\nOn a fresh install, Nesso opens with an empty **Tutorial** graph and walks you through the essentials:\n\n1. **Welcome**: a short overview of typed knowledge graphs and spaced repetition.\n2. **Guided tour**: coachmarks on the real UI that walk you through adding and naming concepts, adding a definition in the inspector, connecting two ideas with a typed relation, then opening **Review**.\n3. **Telemetry** (optional): a one-time banner in the top-right asks whether to share anonymous usage events. Declining is remembered; you can change the choice anytime under **Settings → Privacy**.\n\nYou can skip the welcome screen or the tour at any step. Skipping removes the Tutorial graph and opens a demo seed map instead. Completing the tour keeps your Tutorial graph and also opens a demo seed map.\n\nDemo seed graphs are no longer loaded automatically on first launch; you build your first graph during the tour.\n\n## Desktop app (macOS)\n\nA pre-built alpha installer is published on [GitHub Releases](https://github.com/nesso-how/nesso/releases). Download the universal `.dmg` — it runs on both Apple silicon and Intel Macs — and open it.\n\n:::caution\nThe app is not signed with an Apple developer certificate. macOS will block it on first launch. After installing, run this command in the terminal to remove the quarantine flag:\n\n```sh\nxattr -cr /Applications/Nesso.app\n```\n\nThen open the app normally.\n:::\n\nThe desktop app **updates itself**: on launch it checks GitHub Releases and, when a newer build is available, offers to install it and relaunch. Auto-updates begin once you are on a build that ships the updater (`v0.1.0-alpha.25` or later), so that first version still needs a one-time manual download.\n\n## Run from source\n\nRequires [Node.js](https://nodejs.org/) and [pnpm](https://pnpm.io/).\n\n```sh\ngit clone https://github.com/nesso-how/nesso.git\ncd nesso\npnpm install\npnpm dev\n```\n\nFor a desktop build, [Rust](https://rustup.rs/) is required as well:\n\n```sh\npnpm build:desktop\n```\n\n## Picking an AI backend\n\nSee [AI mentor (Socrates)](./ai-mentor/) for setup, persona, context, and privacy. Quick path with local Ollama:\n\nRun [Ollama](https://ollama.com/) locally, then pull a small instruction-tuned model:\n\n```sh\nollama pull gemma3:4b\n```\n\nIn **Settings AI**, turn on **Mentor**, then set:\n\n- Base URL: `http://localhost:11434/v1`\n- Model: `gemma3:4b` (or `llama3.2:3b`, `qwen3:8b`; presets are shown in Settings)\n- API key: leave empty\n\nSettings auto-probes the endpoint and offers a **Pull** button if the model is missing. Prompts and responses stay on your machine.\n\nWhen using the hosted web app over HTTPS, allow its origin in Ollama so the browser request is not blocked by CORS: start Ollama with `OLLAMA_ORIGINS=https://app.nesso.how`. (Requests to `localhost` are exempt from mixed-content blocking, so only CORS needs configuring.)\n\n:::caution\nAPI keys are stored client-side in `localStorage`. Do not self-host the web app publicly with secrets baked in.\n:::\n\n## Keyboard shortcuts\n\n| Shortcut | Action |\n| ---------------------- | ------------------------------ |\n| `?` | Show shortcuts dialog |\n| `⌘,` / `Ctrl+,` | Settings |\n| `⌘K` / `Ctrl+K` | Search concepts |\n| `N` | Add concept at viewport centre |\n| `R` | Open review mode |\n| `⌘Z` / `Ctrl+Z` | Undo |\n| `⌘⇧Z` / `Ctrl+Shift+Z` | Redo |\n| `Del` / `Backspace` | Delete selection |\n| `⌘A` / `Ctrl+A` | Select all |\n| `⌘X` / `Ctrl+X` | Cut selection |\n| `⌘C` / `Ctrl+C` | Copy selection |\n| `⌘V` / `Ctrl+V` | Paste |\n| `↑` `↓` `←` `→` | Nudge selected concept |\n| `Shift` + arrows | Nudge selected concept (large) |\n| `Esc` | Close dialog |\n\nHold `⌘` / `Ctrl` to add to a selection; drag on empty canvas to marquee-select."
26
26
  },
27
27
  {
28
28
  "slug": "guides/mcp-integration",
29
29
  "title": "MCP",
30
30
  "description": "Connect Nesso to Claude, Cursor, or any MCP-compatible AI client.",
31
- "markdown": "The `@nesso-how/mcp` package is a [Model Context Protocol](https://modelcontextprotocol.io/) server that gives AI clients access to Nesso documentation and the full relation-type vocabulary. Once connected, models can answer questions about how Nesso works and use the correct relation names when you build graphs yourself in the app or in JSON.\n\n## Setup\n\n### Claude Desktop\n\nOpen `claude_desktop_config.json`. On macOS it lives at `~/Library/Application Support/Claude/claude_desktop_config.json`; on Windows at `%APPDATA%\\Claude\\claude_desktop_config.json`.\n\nAdd a `nesso` entry under `mcpServers`:\n\n```json\n{\n \"mcpServers\": {\n \"nesso\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@nesso-how/mcp\"]\n }\n }\n}\n```\n\nSave the file and restart Claude Desktop. The Nesso tools will be available to the model automatically.\n\n### Cursor\n\nOpen **Settings -> MCP** and add a new server with command `npx` and args `[\"-y\", \"@nesso-how/mcp\"]`.\n\n### Other MCP clients\n\nAny client that speaks the stdio MCP transport works. Run `npx -y @nesso-how/mcp` as the server command. No other configuration is required.\n\n## What it can do\n\nOnce connected, you can ask your AI client things like:\n\n- \"What relation types does Nesso support?\" (uses `get_relation_types`)\n- \"Show me the Nesso getting started guide\" (uses `get_nesso_docs`)\n\nYou can build graphs in [app.nesso.how](https://app.nesso.how) yourself, or ask the model to generate a graph as a JSON file and import it via **Graph menu Import JSON**. Use `get_relation_types` to make sure the model picks valid relation names.\n\n## Tools reference\n\n### `get_nesso_docs`\n\nFetches documentation pages from this site. Call it without a `slug` to get a table of contents, or with a slug (e.g. `\"guides/getting-started\"`) to get the full page content.\n\n### `get_relation_types`\n\nReturns the complete list of relation types grouped by category id (`taxonomic`, `structural`, … from `RELATION_CATEGORIES` in `@nesso-how/vocab-learning`), with line style, symmetry, and type properties (transitive, inverse, strength, polarity, cardinality). Use this whenever you need valid type names for graph JSON or explanations for the learner."
31
+ "markdown": "The `@nesso-how/mcp` package is a [Model Context Protocol](https://modelcontextprotocol.io/) server that gives AI clients access to Nesso documentation, the full relation-type vocabulary, and tools to **build** and **validate** graph documents. Once connected, models can answer questions about how Nesso works, produce importable graph JSON, and check files before writing them back to disk.\n\n## Setup\n\n### Claude Desktop\n\nOpen `claude_desktop_config.json`. On macOS it lives at `~/Library/Application Support/Claude/claude_desktop_config.json`; on Windows at `%APPDATA%\\Claude\\claude_desktop_config.json`.\n\nAdd a `nesso` entry under `mcpServers`:\n\n```json\n{\n \"mcpServers\": {\n \"nesso\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@nesso-how/mcp\"]\n }\n }\n}\n```\n\nSave the file and restart Claude Desktop. The Nesso tools will be available to the model automatically.\n\n### Cursor\n\nOpen **Settings -> MCP** and add a new server with command `npx` and args `[\"-y\", \"@nesso-how/mcp\"]`.\n\n### Other MCP clients\n\nAny client that speaks the stdio MCP transport works. Run `npx -y @nesso-how/mcp` as the server command. No other configuration is required.\n\n## What it can do\n\nOnce connected, you can ask your AI client things like:\n\n- \"What relation types does Nesso support?\" (uses `get_relation_types`)\n- \"Show me the Nesso getting started guide\" (uses `get_nesso_docs`)\n- \"Build a graph about photosynthesis with causes and enables relations\" (uses `build_graph`)\n- \"Validate this graph JSON before I save it\" (uses `validate_graph`)\n\nThe MCP server is **stateless** and does not read or write files. Your client uses its own filesystem tools to read `.json` graph files, passes the contents inline to `validate_graph` or `build_graph`, and writes the result back. Nesso picks up external edits through its normal workspace sync.\n\n### Agent workflow for graph files\n\nA typical end-to-end flow when the client has filesystem access:\n\n1. **Read** the target graph `.json` from the project folder (client filesystem tool).\n2. **Validate** with `validate_graph` — fix any `errors` before saving; review `warnings` (e.g. missing `vocabulary` or relation `type`).\n3. **Build or extend** with `build_graph` when creating a new graph from structured concepts and relations — the tool assigns ids, vocabulary metadata, valid relation types, and layout positions.\n4. **Re-validate** the output with `validate_graph` if the client edited the JSON by hand.\n5. **Write** the JSON back to disk (client filesystem tool). Open or sync the graph in Nesso.\n\nExample prompt:\n\n> Read `graphs/biology.json` in this workspace, validate it with Nesso MCP, then use `build_graph` to add concepts \"Chloroplast\" and \"Glucose\" linked by `produces`, merge the result, re-validate, and write the file back.\n\nYou can also build graphs directly in [app.nesso.how](https://app.nesso.how) or import JSON via **Graph menu → Import JSON**.\n\n## Tools reference\n\n### `get_nesso_docs`\n\nFetches documentation pages from this site. Call it without a `slug` to get a table of contents, or with a slug (e.g. `\"guides/getting-started\"`) to get the full page content.\n\n### `get_relation_types`\n\nReturns the complete list of relation types grouped by category id (`taxonomic`, `structural`, … from `RELATION_CATEGORIES` in `@nesso-how/vocab-learning`), with line style, symmetry, and type properties (transitive, inverse, strength, polarity, cardinality). Use this whenever you need valid type names for graph JSON or explanations for the learner.\n\n### `validate_graph`\n\n**Input:** `{ \"graph\": \"<json string>\" }` — a Nesso graph **document** (`concepts[]`, `relations[]`), not runtime React Flow `nodes`/`edges`.\n\n**Output:** `{ \"valid\": boolean, \"errors\": [{ \"path\", \"message\" }], \"warnings\": [{ \"path\", \"message\" }] }`\n\nRuns envelope and vocabulary validation plus structural checks the deserializer does not cover today: duplicate ids, dangling relation endpoints, unknown relation types. Warnings flag missing `vocabulary` or relation `type` (the app falls back to `causes` at render time).\n\n`valid` is `true` only when `errors` is empty.\n\n### `build_graph`\n\n**Input:**\n\n```json\n{\n \"name\": \"Photosynthesis\",\n \"concepts\": [\n \"Sunlight\",\n {\n \"text\": \"Chloroplast\",\n \"elaboration\": { \"definition\": \"...\", \"examples\": \"...\", \"notes\": \"\" }\n }\n ],\n \"relations\": [{ \"from\": \"Sunlight\", \"to\": \"Chloroplast\", \"relation\": \"enables\" }]\n}\n```\n\n- `concepts` — label strings or objects with optional `id`, `text`, and `elaboration`.\n- `relations` — `from` / `to` match concept `id` or label (must be unambiguous); `relation` must be a valid type id from `get_relation_types`.\n\n**Output:** Pretty-printed graph document JSON ready to write to disk (includes `vocabulary`, generated ids, and dagre layout positions). FSRS review fields and React Flow edge shape (`type: \"nesso\"`, handles) are applied by the app on import, not stored in the file."
32
32
  },
33
33
  {
34
34
  "slug": "guides/review-mode",
@@ -40,7 +40,7 @@
40
40
  "slug": "introduction",
41
41
  "title": "Introduction",
42
42
  "description": "What Nesso is, why it exists, and the principles it is built on.",
43
- "markdown": ":::caution\nThis is an early-stage project. Some features are rough, some are not yet built, and this documentation is just getting started too.\n:::\n\nNesso is an open-source app for building typed, AI-assisted knowledge graphs for active learning. It is built on a specific claim about how understanding works and a specific critique of how most current tools approach it.\n\n## The problem with passive learning tools\n\nPassive learning is not a new problem. AI has made it the default and amplified it at scale: You hand over a source and receive a summary, ask a question and receive an answer, or describe what you want to learn and receive a pre-built map. This is convenient, and pedagogically counterproductive. Decades of cognitive science converge on the same conclusion: deep understanding is not received; it is constructed. When the work of deciding how concepts relate is offloaded to a system, the process that produces comprehension is bypassed.\n\nAlongside this, most learning platforms treat user data as a resource. In the context of learning, this data is uniquely sensitive: it reveals not just what someone has read, but how they reason, where they struggle, and how their understanding evolves over time. Capturing it passively, and often opaquely, is at odds with the interests of the people the tools claim to serve.\n\nFinally, most platforms are proprietary silos: closed standards, locked formats, no way to inspect or extend them. Educators, developers, and learners themselves have no meaningful recourse when a platform makes choices they disagree with or stops serving their needs.\n\n## What Nesso does instead\n\nNesso inverts the flow. The learner constructs their own knowledge structure: a typed concept graph that reflects how _they_ understand, not just what they have consumed. The act of deciding which relation holds between two concepts (does X _cause_ Y, or merely _enable_ it? is A an _instance of_ B, or a _subtype of_ it?) is where elaborative processing happens. The decision is the learning.\n\nAlgorithms work on what the learner has built, not on a generic curriculum. Spaced repetition is driven by graph structure: concepts with low stability or untested connections surface before well-reinforced ones. The review queue is always a function of the learner's own map.\n\nAI is present, but with a constrained role. The AI mentor, Socrates, does not deliver pre-packaged answers. It asks questions calibrated to the learner's current graph, probing understanding, surfacing gaps, and leaving the work of constructing answers to the learner. It is designed to accelerate active thinking, not to replace it.\n\n## Principles\n\n**Constructivist by design.** Every feature is oriented around the learner doing cognitive work: drawing edges, labelling relations, writing definitions in their own words, self-rating recall. The system does not do this work for them.\n\n**Private by architecture.** In the web app, graphs are stored locally in your browser. In the desktop app, they are also saved as plain JSON files on your machine. Your graph content, definitions, and Socrates conversations never leave your device unless you connect a remote AI endpoint yourself. Optional telemetry (anonymous crash reports and aggregated usage events) is off by default and opt-in from Settings, Privacy; it never includes graph content, chat, or keys. Privacy is an implementation detail, not a policy promise.\n\n**Open by default.** The code is MIT-licensed. Data formats are documented and importable/exportable as plain JSON. The MCP server makes the graph vocabulary available to any compatible client. Technical work done here is intended to be useful beyond this application.\n\n**Provider-agnostic AI.** Nesso talks to any OpenAI-compatible `chat/completions` endpoint. Users choose whether to run a model locally or connect a remote provider; no vendor is privileged by the architecture.\n\n## What Nesso is not\n\nNesso is not a note-taking app. It does not replace a text editor, a spaced-repetition deck manager, or a general-purpose LLM interface. It is specifically a tool for the phase of learning where understanding a domain means deciding how its concepts relate to each other, and testing whether you can hold that structure under questioning.\n\nIt is also not a finished product. The codebase is publicly available for inspection and contribution.\n\n---\n\nThe remainder of this documentation covers how to use the app and how to integrate with it programmatically. If you want to start immediately, [Getting started](./guides/getting-started/) has everything you need."
43
+ "markdown": ":::caution\nThis is an early-stage project. Some features are rough, some are not yet built, and this documentation is just getting started too.\n:::\n\nNesso is an open-source app for building typed, AI-assisted knowledge graphs for active learning. It is built on a specific claim about how understanding works and a specific critique of how most current tools approach it.\n\n## The problem with passive learning tools\n\nPassive learning is not a new problem. AI has made it the default and amplified it at scale: You hand over a source and receive a summary, ask a question and receive an answer, or describe what you want to learn and receive a pre-built map. This is convenient, and pedagogically counterproductive. Decades of cognitive science converge on the same conclusion: deep understanding is not received; it is constructed. When the work of deciding how concepts relate is offloaded to a system, the process that produces comprehension is bypassed.\n\nAlongside this, most learning platforms treat user data as a resource. In the context of learning, this data is uniquely sensitive: it reveals not just what someone has read, but how they reason, where they struggle, and how their understanding evolves over time. Capturing it passively, and often opaquely, is at odds with the interests of the people the tools claim to serve.\n\nFinally, most platforms are proprietary silos: closed standards, locked formats, no way to inspect or extend them. Educators, developers, and learners themselves have no meaningful recourse when a platform makes choices they disagree with or stops serving their needs.\n\n## What Nesso does instead\n\nNesso inverts the flow. The learner constructs their own knowledge structure: a typed concept graph that reflects how _they_ understand, not just what they have consumed. The act of deciding which relation holds between two concepts (does X _cause_ Y, or merely _enable_ it? is A an _instance of_ B, or a _subtype of_ it?) is where elaborative processing happens. The decision is the learning.\n\nAlgorithms work on what the learner has built, not on a generic curriculum. Spaced repetition is driven by graph structure: concepts with low stability or untested connections surface before well-reinforced ones. The review queue is always a function of the learner's own map.\n\nAn optional AI mentor, Socrates, can probe what you have built. It asks questions calibrated to your current graph rather than delivering answers. See [AI mentor (Socrates)](./guides/ai-mentor/) for setup and behaviour.\n\n## Principles\n\n**Constructivist by design.** Every feature is oriented around the learner doing cognitive work: drawing edges, labelling relations, writing definitions in their own words, self-rating recall. The system does not do this work for them.\n\n**Private by architecture.** In the web app, graphs are stored locally in your browser. In the desktop app, they are also saved as plain JSON files on your machine. Your graph content and definitions stay on your device. Mentor chat is session-only in the app; if you enable the mentor and use a remote AI endpoint, prompts are sent to that provider each turn. Optional telemetry (anonymous crash reports and aggregated usage events) is off by default and opt-in from **Settings Privacy**; it never includes graph content, chat, or keys. Privacy is an implementation detail, not a policy promise.\n\n**Open by default.** The code is MIT-licensed. Data formats are documented and importable/exportable as plain JSON. The MCP server makes the graph vocabulary available to any compatible client. Technical work done here is intended to be useful beyond this application.\n\n**Provider-agnostic AI.** The mentor talks to any OpenAI-compatible `chat/completions` endpoint. You choose whether to run a model locally or connect a remote provider; no vendor is privileged by the architecture.\n\n## What Nesso is not\n\nNesso is not a note-taking app. It does not replace a text editor, a spaced-repetition deck manager, or a general-purpose LLM interface. It is specifically a tool for the phase of learning where understanding a domain means deciding how its concepts relate to each other, and testing whether you can hold that structure under questioning.\n\nIt is also not a finished product. The codebase is publicly available for inspection and contribution.\n\n---\n\nThe remainder of this documentation covers how to use the app and how to integrate with it programmatically. If you want to start immediately, [Getting started](./guides/getting-started/) has everything you need."
44
44
  },
45
45
  {
46
46
  "slug": "reference/relation-types",
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from '@modelcontextprotocol/server';
2
+ export declare function registerBuildGraph(server: McpServer): void;
@@ -0,0 +1,27 @@
1
+ import { buildGraphInputSchema, buildGraphJson } from '../lib/graph-tools.js';
2
+ export function registerBuildGraph(server) {
3
+ server.registerTool('build_graph', {
4
+ description: 'Build a valid Nesso graph document JSON from structured concepts and relations. ' +
5
+ 'Assigns ids, vocabulary metadata, relation types, and dagre layout positions. ' +
6
+ 'Returns importable JSON for the client to write to disk.',
7
+ inputSchema: buildGraphInputSchema,
8
+ }, async (input) => {
9
+ try {
10
+ const json = buildGraphJson(input);
11
+ return {
12
+ content: [{ type: 'text', text: json }],
13
+ };
14
+ }
15
+ catch (err) {
16
+ return {
17
+ content: [
18
+ {
19
+ type: 'text',
20
+ text: err instanceof Error ? err.message : String(err),
21
+ },
22
+ ],
23
+ isError: true,
24
+ };
25
+ }
26
+ });
27
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from '@modelcontextprotocol/server';
2
+ export declare function registerValidateGraph(server: McpServer): void;
@@ -0,0 +1,17 @@
1
+ import * as z from 'zod/v4';
2
+ import { validateGraphJson } from '../lib/graph-tools.js';
3
+ export function registerValidateGraph(server) {
4
+ server.registerTool('validate_graph', {
5
+ description: 'Validate a Nesso graph document JSON string. Returns valid, errors, and warnings. ' +
6
+ 'Checks envelope shape, vocabulary rules, duplicate ids, dangling relation endpoints, ' +
7
+ 'and known relation types. Use after reading a graph file or before writing one back.',
8
+ inputSchema: z.object({
9
+ graph: z.string().describe('Nesso graph document as a JSON string'),
10
+ }),
11
+ }, async ({ graph }) => {
12
+ const result = validateGraphJson(graph);
13
+ return {
14
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
15
+ };
16
+ });
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nesso-how/mcp",
3
- "version": "0.1.0-alpha.37",
3
+ "version": "0.1.0-alpha.38",
4
4
  "description": "MCP server exposing Nesso knowledge graph tools to LLM clients",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,11 +20,13 @@
20
20
  "dist"
21
21
  ],
22
22
  "dependencies": {
23
+ "dagre": "^0.8.5",
23
24
  "zod": "^4.4.3",
24
25
  "@modelcontextprotocol/server": "^2.0.0-alpha.2",
25
- "@nesso-how/vocab-learning": "0.1.0-alpha.37"
26
+ "@nesso-how/vocab-learning": "0.1.0-alpha.38"
26
27
  },
27
28
  "devDependencies": {
29
+ "@types/dagre": "^0.7.53",
28
30
  "@types/node": "^22.0.0",
29
31
  "typescript": "~5.8.3"
30
32
  },