structured-context 0.9.3 → 0.11.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 (66) hide show
  1. package/README.md +22 -3
  2. package/dist/api.d.ts +33 -0
  3. package/dist/api.js +26 -0
  4. package/dist/commands/diagram.js +1 -1
  5. package/dist/commands/dump.js +1 -1
  6. package/dist/commands/miro-sync.d.ts +3 -0
  7. package/dist/commands/miro-sync.js +8 -0
  8. package/dist/commands/schemas.js +3 -2
  9. package/dist/commands/spaces.js +0 -2
  10. package/dist/commands/validate-file.d.ts +5 -24
  11. package/dist/commands/validate-file.js +26 -108
  12. package/dist/commands/validate.d.ts +1 -12
  13. package/dist/commands/validate.js +22 -157
  14. package/dist/config.d.ts +8 -12
  15. package/dist/config.js +25 -23
  16. package/dist/index.js +15 -23
  17. package/dist/load-space-graph.d.ts +40 -0
  18. package/dist/load-space-graph.js +42 -0
  19. package/dist/plugins/index.js +3 -1
  20. package/dist/plugins/loader.js +2 -2
  21. package/dist/plugins/markdown/extract-content-links.d.ts +23 -0
  22. package/dist/plugins/markdown/extract-content-links.js +144 -0
  23. package/dist/plugins/markdown/index.d.ts +42 -32
  24. package/dist/plugins/markdown/index.js +10 -15
  25. package/dist/plugins/markdown/parse-embedded.d.ts +1 -1
  26. package/dist/plugins/markdown/parse-embedded.js +9 -0
  27. package/dist/plugins/markdown/read-space.js +6 -3
  28. package/dist/plugins/markdown/template-sync.d.ts +1 -1
  29. package/dist/plugins/markdown/template-sync.js +2 -2
  30. package/dist/plugins/mermaid/index.d.ts +2 -0
  31. package/dist/plugins/mermaid/index.js +14 -0
  32. package/dist/plugins/{markdown → mermaid}/render-mermaid.js +17 -16
  33. package/dist/{integrations → plugins}/miro/cache.d.ts +1 -1
  34. package/dist/plugins/miro/index.d.ts +2 -0
  35. package/dist/plugins/miro/index.js +27 -0
  36. package/dist/{integrations → plugins}/miro/layout.d.ts +2 -2
  37. package/dist/{integrations → plugins}/miro/layout.js +3 -16
  38. package/dist/{integrations → plugins}/miro/styles.d.ts +2 -2
  39. package/dist/plugins/miro/sync.d.ts +7 -0
  40. package/dist/{integrations → plugins}/miro/sync.js +23 -41
  41. package/dist/plugins/util.d.ts +9 -0
  42. package/dist/plugins/util.js +4 -0
  43. package/dist/read/resolve-graph-edges.js +32 -0
  44. package/dist/render/render.d.ts +1 -0
  45. package/dist/render/render.js +20 -16
  46. package/dist/schema/evaluate-rule.js +1 -0
  47. package/dist/schema/schema.d.ts +3 -1
  48. package/dist/schema/schema.js +7 -3
  49. package/dist/schemas/_knowledge_wiki.json +1 -1
  50. package/dist/space-context.d.ts +24 -0
  51. package/dist/space-context.js +41 -0
  52. package/dist/types.d.ts +72 -2
  53. package/dist/validate.d.ts +59 -0
  54. package/dist/validate.js +316 -0
  55. package/docs/concepts.md +23 -0
  56. package/docs/config.md +2 -0
  57. package/package.json +11 -5
  58. package/schemas/_knowledge_wiki.json +1 -1
  59. package/dist/integrations/miro/sync.d.ts +0 -8
  60. package/dist/plugin-api.d.ts +0 -12
  61. package/dist/plugin-api.js +0 -7
  62. /package/dist/plugins/{markdown → mermaid}/render-mermaid.d.ts +0 -0
  63. /package/dist/{integrations → plugins}/miro/cache.js +0 -0
  64. /package/dist/{integrations → plugins}/miro/client.d.ts +0 -0
  65. /package/dist/{integrations → plugins}/miro/client.js +0 -0
  66. /package/dist/{integrations → plugins}/miro/styles.js +0 -0
@@ -3,6 +3,7 @@ import { toString as mdastToString } from 'mdast-util-to-string';
3
3
  import remarkGfm from 'remark-gfm';
4
4
  import remarkParse from 'remark-parse';
5
5
  import { unified } from 'unified';
6
+ import { extractLinksFromAstNode } from './extract-content-links';
6
7
  import { applyFieldMap, coerceDates } from './util';
7
8
  /** Type values that identify a space_on_a_page container (not themselves space nodes). */
8
9
  export const ON_A_PAGE_TYPES = ['ost_on_a_page', 'space_on_a_page'];
@@ -191,6 +192,7 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
191
192
  schemaData,
192
193
  linkTargets,
193
194
  type,
195
+ contentLinks: [],
194
196
  };
195
197
  nodes.push(newNode);
196
198
  if (parentFieldAppend) {
@@ -230,6 +232,7 @@ export function extractEmbeddedNodes(body, options) {
230
232
  schemaData: { type: 'space_on_a_page' },
231
233
  linkTargets: [],
232
234
  type: 'space_on_a_page',
235
+ contentLinks: [],
233
236
  };
234
237
  const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
235
238
  // In typed-page mode: stack starts with the page's own virtual entry (depth 0).
@@ -471,6 +474,7 @@ export function extractEmbeddedNodes(body, options) {
471
474
  schemaData,
472
475
  linkTargets,
473
476
  type,
477
+ contentLinks: [],
474
478
  };
475
479
  // Push to stack BEFORE resolving semantic parent — stack[-2] is the correct parent.
476
480
  const refTarget = linkTargets[0] ?? title;
@@ -608,6 +612,7 @@ export function extractEmbeddedNodes(body, options) {
608
612
  schemaData,
609
613
  linkTargets,
610
614
  type: rowTypeStr,
615
+ contentLinks: [],
611
616
  };
612
617
  nodes.push(rowNode);
613
618
  if (tableParentFieldAppend) {
@@ -640,6 +645,8 @@ export function extractEmbeddedNodes(body, options) {
640
645
  Object.assign(activeNode.schemaData, allFields);
641
646
  if (remainingText)
642
647
  appendContent(activeNode, remainingText);
648
+ activeNode.contentLinks ??= [];
649
+ activeNode.contentLinks.push(...extractLinksFromAstNode(child));
643
650
  }
644
651
  else if (child.type === 'code' && child.lang?.trim() === 'yaml') {
645
652
  const code = child;
@@ -656,6 +663,8 @@ export function extractEmbeddedNodes(body, options) {
656
663
  }
657
664
  else {
658
665
  appendContent(activeNode, mdastToString(child));
666
+ activeNode.contentLinks ??= [];
667
+ activeNode.contentLinks.push(...extractLinksFromAstNode(child));
659
668
  }
660
669
  }
661
670
  }
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { basename, join, resolve } from 'node:path';
3
- import { Glob } from 'bun';
3
+ import fg from 'fast-glob';
4
4
  import matter from 'gray-matter';
5
5
  import { extractSchemaTypeNames } from '../../schema/schema';
6
+ import { extractLinksFromBody, extractLinksFromFrontmatter, getEdgeFieldNames } from './extract-content-links';
6
7
  import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded';
7
8
  import { applyFieldMap, coerceDates, inferTypeFromPath } from './util';
8
9
  export function readSpaceOnAPage(context) {
@@ -42,7 +43,8 @@ export async function readSpaceDirectory(context, options) {
42
43
  const absoluteTemplateDir = templateDir ? resolve(templateDir) : undefined;
43
44
  const typeInferenceCfg = mdCfg.typeInference;
44
45
  const knownTypes = typeInferenceCfg?.mode !== 'off' ? extractSchemaTypeNames(context.schema, context.schemaRefRegistry) : undefined;
45
- const files = await Array.fromAsync(new Glob('**/*.md').scan({ cwd: directory, followSymlinks: true }));
46
+ const edgeFields = getEdgeFieldNames(metadata);
47
+ const files = await fg('**/*.md', { cwd: directory, followSymbolicLinks: true });
46
48
  const nodes = [];
47
49
  const parseIssues = [];
48
50
  for (const file of files) {
@@ -70,7 +72,7 @@ export async function readSpaceDirectory(context, options) {
70
72
  continue;
71
73
  }
72
74
  if (!parsed.data || Object.keys(parsed.data).length === 0) {
73
- parseIssues.push({ file, severity: 'warning', type: 'empty' });
75
+ parseIssues.push({ file, severity: 'warning', type: 'no-type', message: 'No front-matter or type specified' });
74
76
  continue;
75
77
  }
76
78
  const data = coerceDates(applyFieldMap(parsed.data, fieldMap));
@@ -93,6 +95,7 @@ export async function readSpaceDirectory(context, options) {
93
95
  schemaData: { title, ...data },
94
96
  linkTargets: [title, fileBase],
95
97
  type: pageType,
98
+ contentLinks: [...extractLinksFromFrontmatter(data, edgeFields), ...extractLinksFromBody(parsed.content)],
96
99
  });
97
100
  if (!ON_A_PAGE_TYPES.includes(pageType)) {
98
101
  const { nodes: embedded, terminatedHeadings } = extractEmbeddedNodes(parsed.content, {
@@ -1,5 +1,5 @@
1
1
  import type { AnySchemaObject } from 'ajv';
2
- import type { HierarchyLevel, Relationship, SchemaWithMetadata } from '../../plugin-api';
2
+ import type { HierarchyLevel, Relationship, SchemaWithMetadata } from '../../api';
3
3
  import type { PluginContext, TemplateSyncOptions } from '../util';
4
4
  export interface TypeVariant {
5
5
  required: string[];
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { Glob } from 'bun';
3
+ import fg from 'fast-glob';
4
4
  import matter from 'gray-matter';
5
5
  import yaml from 'js-yaml';
6
6
  import { mergeVariantProperties, resolveRef } from '../../schema/schema-refs';
@@ -190,7 +190,7 @@ export async function templateSync(context, options) {
190
190
  const { schema, schemaRefRegistry } = context;
191
191
  const typeVariants = getTypeVariants(schema, schemaRefRegistry);
192
192
  const matchedTypes = new Set();
193
- const files = await Array.fromAsync(new Glob('*.md').scan({ cwd: templateDir, absolute: true }));
193
+ const files = await fg('*.md', { cwd: templateDir, absolute: true });
194
194
  const dryRun = options.dryRun ?? false;
195
195
  let filesModified = 0;
196
196
  let filesCreated = 0;
@@ -0,0 +1,2 @@
1
+ import type { StructuredContextPlugin } from '../util';
2
+ export declare const mermaidPlugin: StructuredContextPlugin;
@@ -0,0 +1,14 @@
1
+ import { PLUGIN_PREFIX } from '../util';
2
+ import { renderMermaid } from './render-mermaid';
3
+ export const mermaidPlugin = {
4
+ name: `${PLUGIN_PREFIX}mermaid`,
5
+ configSchema: { type: 'object', additionalProperties: false },
6
+ render: {
7
+ formats: [{ name: 'graph', description: 'Mermaid graph TD diagram' }],
8
+ render(_context, graph, { format }) {
9
+ if (format === 'graph')
10
+ return renderMermaid(graph);
11
+ throw new Error(`Unknown mermaid render format: "${format}"`);
12
+ },
13
+ },
14
+ };
@@ -1,3 +1,13 @@
1
+ const LEVEL_PALETTE = [
2
+ { fill: '#ff9999', stroke: '#ff0000' },
3
+ { fill: '#99ccff', stroke: '#0066cc' },
4
+ { fill: '#99ff99', stroke: '#00cc00' },
5
+ { fill: '#ffcc99', stroke: '#cc9900' },
6
+ { fill: '#cc99ff', stroke: '#6600cc' },
7
+ { fill: '#ff99cc', stroke: '#cc0066' },
8
+ { fill: '#99ffff', stroke: '#009999' },
9
+ { fill: '#ffff99', stroke: '#cccc00' },
10
+ ];
1
11
  function escapeMermaidString(str) {
2
12
  return str.replace(/"/g, '"');
3
13
  }
@@ -5,20 +15,13 @@ function safeNodeId(id) {
5
15
  return id.replace(/[^a-zA-Z0-9_-]/g, '_');
6
16
  }
7
17
  export function renderMermaid(graph) {
8
- const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyTitles: hierarchyNodeSet } = graph;
18
+ const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyTitles: hierarchyNodeSet, levels } = graph;
9
19
  let mmd = 'graph TD\n';
10
- mmd += ' classDef vision fill:#ff9999,stroke:#ff0000,stroke-width:2px\n';
11
- mmd += ' classDef mission fill:#99ccff,stroke:#0066cc,stroke-width:2px\n';
12
- mmd += ' classDef goal fill:#99ff99,stroke:#00cc00,stroke-width:2px\n';
13
- mmd += ' classDef opportunity fill:#ffcc99,stroke:#cc9900,stroke-width:2px\n';
14
- mmd += ' classDef solution fill:#cc99ff,stroke:#6600cc,stroke-width:2px\n';
15
- mmd += ' classDef identified fill:#f0f0f0,stroke:#999999,stroke-dasharray: 5 5\n';
16
- mmd += ' classDef wondering fill:#fff0cc,stroke:#cccc00,stroke-dasharray: 5 5\n';
17
- mmd += ' classDef exploring fill:#ffcc99,stroke:#cc9900,stroke-dasharray: 5 5\n';
18
- mmd += ' classDef active fill:#99ff99,stroke:#00cc00,stroke-width:2px\n';
19
- mmd += ' classDef paused fill:#ffcc99,stroke:#cc9900,stroke-width:2px\n';
20
- mmd += ' classDef completed fill:#ccccff,stroke:#6666cc,stroke-width:2px\n';
21
- mmd += ' classDef archived fill:#e0e0e0,stroke:#999999,stroke-width:2px\n';
20
+ for (let i = 0; i < levels.length; i++) {
21
+ const level = levels[i];
22
+ const color = LEVEL_PALETTE[i % LEVEL_PALETTE.length];
23
+ mmd += ` classDef ${level.type} fill:${color.fill},stroke:${color.stroke},stroke-width:2px\n`;
24
+ }
22
25
  const addedNodes = new Set();
23
26
  function addNodeAndChildren(node) {
24
27
  const nodeId = node.title;
@@ -26,13 +29,11 @@ export function renderMermaid(graph) {
26
29
  return;
27
30
  addedNodes.add(nodeId);
28
31
  const type = node.resolvedType;
29
- const status = node.schemaData.status;
30
32
  const priority = node.schemaData.priority;
31
33
  const label = priority ? `${nodeId} (${priority})` : nodeId;
32
- const className = `${type}_${status}`;
33
34
  const safeId = safeNodeId(nodeId);
34
35
  const escapedLabel = escapeMermaidString(label);
35
- mmd += ` ${safeId}["${escapedLabel}"]:::${className}\n`;
36
+ mmd += ` ${safeId}["${escapedLabel}"]:::${type}\n`;
36
37
  const nodeChildren = children.get(nodeId) ?? [];
37
38
  for (const child of nodeChildren) {
38
39
  const childTitle = child.title;
@@ -1,4 +1,4 @@
1
- import type { SpaceNode } from '../../types';
1
+ import type { SpaceNode } from '../../api';
2
2
  export interface CachedNode {
3
3
  miroCardId: string;
4
4
  contentHash: string;
@@ -0,0 +1,2 @@
1
+ import { type StructuredContextPlugin } from '../../api';
2
+ export declare const miroPlugin: StructuredContextPlugin;
@@ -0,0 +1,27 @@
1
+ import { PLUGIN_PREFIX } from '../../api';
2
+ import { miroSync } from './sync';
3
+ export const miroPlugin = {
4
+ name: `${PLUGIN_PREFIX}miro`,
5
+ configSchema: {
6
+ type: 'object',
7
+ properties: {
8
+ boardId: { type: 'string' },
9
+ frameId: { type: 'string' },
10
+ },
11
+ additionalProperties: false,
12
+ },
13
+ render: {
14
+ formats: [{ name: 'board', description: 'Sync space to Miro board' }],
15
+ async render(context, graph, { format, data }) {
16
+ if (format === 'board') {
17
+ const options = {
18
+ newFrame: data?.newFrame,
19
+ dryRun: data?.dryRun,
20
+ verbose: data?.verbose,
21
+ };
22
+ return await miroSync(context, graph, options);
23
+ }
24
+ throw new Error(`Unknown miro render format: "${format}"`);
25
+ },
26
+ },
27
+ };
@@ -1,4 +1,4 @@
1
- import type { HierarchyLevel, SpaceNode } from '../../types';
1
+ import type { HierarchyLevel, SpaceNode } from '../../api';
2
2
  export declare const CARD_WIDTH = 320;
3
3
  export interface LayoutResult {
4
4
  positions: Map<string, {
@@ -25,4 +25,4 @@ export interface LayoutResult {
25
25
  export declare function layoutNewCards(newNodes: SpaceNode[], existingPositions: Map<string, {
26
26
  x: number;
27
27
  y: number;
28
- }>, hierarchyLevels: HierarchyLevel[]): LayoutResult;
28
+ }>, hierarchyLevels: readonly HierarchyLevel[]): LayoutResult;
@@ -3,18 +3,6 @@ const CARD_HEIGHT = 160;
3
3
  const H_GAP = 40;
4
4
  const V_GAP = 60;
5
5
  const FRAME_PADDING = 60;
6
- /**
7
- * DEPRECATED - likely not needed after migration to render plugin and SpaceGraph
8
- * Build a depth map from hierarchy levels.
9
- * The position in the hierarchy array determines the depth.
10
- */
11
- function buildDepthMap(hierarchyLevels) {
12
- const depthMap = new Map();
13
- for (const [i, level] of hierarchyLevels.entries()) {
14
- depthMap.set(level.type, i);
15
- }
16
- return depthMap;
17
- }
18
6
  /**
19
7
  * Compute positions for new cards only. Existing cards keep their Miro positions.
20
8
  * New cards are laid out in rows grouped by OST type depth, starting below
@@ -25,8 +13,6 @@ function buildDepthMap(hierarchyLevels) {
25
13
  * Returns positions and a bounding box covering all cards (for frame sizing).
26
14
  */
27
15
  export function layoutNewCards(newNodes, existingPositions, hierarchyLevels) {
28
- // Build depth map from hierarchy levels (position in hierarchy = depth)
29
- const depthMap = buildDepthMap(hierarchyLevels);
30
16
  // Find the lowest y among existing cards
31
17
  let lowestY = 0;
32
18
  for (const pos of existingPositions.values()) {
@@ -35,10 +21,11 @@ export function layoutNewCards(newNodes, existingPositions, hierarchyLevels) {
35
21
  }
36
22
  }
37
23
  const startY = existingPositions.size > 0 ? lowestY + V_GAP * 2 : 0;
38
- // Group new nodes by depth
24
+ // Group new nodes by depth (position in hierarchy levels array)
39
25
  const byDepth = new Map();
40
26
  for (const node of newNodes) {
41
- const depth = depthMap.get(node.schemaData.type) ?? depthMap.size;
27
+ const idx = hierarchyLevels.findIndex((l) => l.type === node.schemaData.type);
28
+ const depth = idx === -1 ? hierarchyLevels.length : idx;
42
29
  if (!byDepth.has(depth))
43
30
  byDepth.set(depth, []);
44
31
  byDepth.get(depth)?.push(node);
@@ -1,4 +1,4 @@
1
- import type { HierarchyLevel, SpaceNode } from '../../types';
1
+ import type { HierarchyLevel, SpaceNode } from '../../api';
2
2
  /**
3
3
  * Get card color based on type and hierarchy levels.
4
4
  * Colors are assigned from palette based on level position.
@@ -6,6 +6,6 @@ import type { HierarchyLevel, SpaceNode } from '../../types';
6
6
  * @param type - The node's type string
7
7
  * @param hierarchyLevels - Hierarchy levels from metadata
8
8
  */
9
- export declare function getCardColor(type: string, hierarchyLevels: HierarchyLevel[]): string;
9
+ export declare function getCardColor(type: string, hierarchyLevels: readonly HierarchyLevel[]): string;
10
10
  export declare function buildCardTitle(node: SpaceNode): string;
11
11
  export declare function buildCardDescription(node: SpaceNode): string;
@@ -0,0 +1,7 @@
1
+ import type { PluginContext, SpaceGraph } from '../../api';
2
+ export interface SyncOptions {
3
+ newFrame?: string;
4
+ dryRun?: boolean;
5
+ verbose?: boolean;
6
+ }
7
+ export declare function miroSync(context: PluginContext, graph: SpaceGraph, options: SyncOptions): Promise<string>;
@@ -1,50 +1,34 @@
1
- import { updateSpaceField } from '../../config';
2
- import { readSpace } from '../../read/read-space';
3
- import { buildSpaceGraph } from '../../space-graph';
4
1
  import { computeMiroCardHash, computeNodeHash, loadCache, saveCache } from './cache';
5
2
  import { MiroClient, MiroNotFoundError } from './client';
6
3
  import { CARD_WIDTH, layoutNewCards } from './layout';
7
4
  import { buildCardDescription, buildCardTitle, getCardColor } from './styles';
8
- export async function miroSync(context, options) {
5
+ export async function miroSync(context, graph, options) {
9
6
  const token = process.env.MIRO_TOKEN;
10
- if (!token) {
11
- console.error('MIRO_TOKEN environment variable is required');
12
- process.exit(1);
13
- }
14
- const { space, schema: { metadata }, } = context;
7
+ if (!token)
8
+ throw new Error('MIRO_TOKEN environment variable is required');
9
+ const { space } = context;
10
+ const boardId = context.pluginConfig.boardId;
11
+ const configuredFrameId = context.pluginConfig.frameId;
15
12
  // 1. Resolve board
16
- if (!space.miroBoardId) {
17
- console.error(`No miroBoardId configured for space "${space.name}".`);
18
- console.error('Add miroBoardId to the space entry in config.');
19
- process.exit(1);
20
- }
21
- const boardId = space.miroBoardId;
13
+ if (!boardId)
14
+ throw new Error(`No boardId configured for space "${space.name}". Add boardId to the miro plugin config in the space entry.`);
22
15
  // 2. Resolve frame
23
- if (!space.miroFrameId && !options.newFrame) {
24
- console.error('No miroFrameId in space config. Pass --new-frame "Title" to create one.');
25
- process.exit(1);
26
- }
27
- // 3. Load space nodes (load before creating frame so we can calculate size)
28
- let nodes;
29
- ({ nodes } = await readSpace(context));
16
+ if (!configuredFrameId && !options.newFrame)
17
+ throw new Error('No frameId in miro plugin config. Pass --new-frame "Title" to create one.');
18
+ // 3. Filter to hierarchy nodes only (graph already built by caller)
19
+ const nodes = [...graph.nodes.values()].filter((n) => graph.hierarchyTitles.has(n.title));
30
20
  if (nodes.length === 0) {
31
- console.log('No space nodes found.');
32
- return;
21
+ return 'No space nodes found.';
33
22
  }
34
- // Filter to hierarchy nodes only
35
- const levels = metadata.hierarchy?.levels ?? [];
36
- const { nonHierarchy, hierarchyTitles: hierarchyNodeTitles } = buildSpaceGraph(nodes, levels);
37
- // Filter nodes to only hierarchy nodes
38
- nodes = nodes.filter((n) => hierarchyNodeTitles.has(n.title));
39
- if (options.verbose && nonHierarchy.length > 0) {
40
- console.log(`Excluded ${nonHierarchy.length} non-hierarchy nodes from sync`);
23
+ if (options.verbose && graph.nonHierarchy.length > 0) {
24
+ console.log(`Excluded ${graph.nonHierarchy.length} non-hierarchy nodes from sync`);
41
25
  }
42
26
  const client = new MiroClient(boardId, token);
43
27
  let frameId;
44
28
  let layoutOffset = null;
45
29
  if (options.newFrame) {
46
30
  // Calculate layout bounds to size the frame appropriately
47
- const { bounds } = layoutNewCards(nodes, new Map(), levels);
31
+ const { bounds } = layoutNewCards(nodes, new Map(), graph.levels);
48
32
  const frameWidth = Math.max(1600, bounds.maxX - bounds.minX);
49
33
  const frameHeight = Math.max(1200, bounds.maxY - bounds.minY);
50
34
  // Miro positions child items relative to the parent frame's top-left corner.
@@ -69,12 +53,12 @@ export async function miroSync(context, options) {
69
53
  });
70
54
  frameId = frame.id;
71
55
  console.log(`Created frame "${options.newFrame}" (${frameId}) - size: ${finalFrameWidth}x${finalFrameHeight}`);
72
- updateSpaceField(space.name, 'miroFrameId', frameId);
73
- console.log(`Saved miroFrameId to config`);
56
+ context.callbacks?.persistConfig?.({ frameId });
57
+ console.log(`Saved frameId to miro plugin config`);
74
58
  }
75
59
  }
76
60
  else {
77
- frameId = space.miroFrameId;
61
+ frameId = configuredFrameId;
78
62
  }
79
63
  // 4. Load cache
80
64
  const cache = loadCache(boardId, frameId);
@@ -171,7 +155,7 @@ export async function miroSync(context, options) {
171
155
  }
172
156
  }
173
157
  // Compute positions for new cards
174
- const { positions: newPositions } = layoutNewCards(newNodes, existingPositions, levels);
158
+ const { positions: newPositions } = layoutNewCards(newNodes, existingPositions, graph.levels);
175
159
  // 7. Create new cards
176
160
  let createdCount = 0;
177
161
  for (const node of newNodes) {
@@ -201,7 +185,7 @@ export async function miroSync(context, options) {
201
185
  title: buildCardTitle(node),
202
186
  description: buildCardDescription(node),
203
187
  },
204
- style: { cardTheme: getCardColor(type, levels) },
188
+ style: { cardTheme: getCardColor(type, graph.levels) },
205
189
  position: { x: pos.x, y: pos.y, origin: 'center' },
206
190
  parent: { id: frameId },
207
191
  geometry: { width: CARD_WIDTH },
@@ -247,7 +231,7 @@ export async function miroSync(context, options) {
247
231
  title: buildCardTitle(node),
248
232
  description: buildCardDescription(node),
249
233
  },
250
- style: { cardTheme: getCardColor(type, levels) },
234
+ style: { cardTheme: getCardColor(type, graph.levels) },
251
235
  position: { x: 0, y: 0, origin: 'center' },
252
236
  parent: { id: frameId },
253
237
  geometry: { width: CARD_WIDTH },
@@ -341,7 +325,5 @@ export async function miroSync(context, options) {
341
325
  if (!options.dryRun)
342
326
  saveCache(cache);
343
327
  // Summary
344
- console.log(`\n${prefix}Sync complete:`);
345
- console.log(` Cards: ${createdCount} created, ${updatedCount} updated, ${skippedCount} unchanged`);
346
- console.log(` Connectors: ${connectorsCreated} created, ${connectorsDeleted} deleted`);
328
+ return `\n${prefix}Sync complete:\n Cards: ${createdCount} created, ${updatedCount} updated, ${skippedCount} unchanged\n Connectors: ${connectorsCreated} created, ${connectorsDeleted} deleted`;
347
329
  }
@@ -5,9 +5,16 @@ export declare const PLUGIN_PREFIX = "sctx-";
5
5
  export declare const CONFIG_PLUGINS_DIR = "plugins";
6
6
  /** Normalize a plugin name to its canonical prefixed form. */
7
7
  export declare function normalizePluginName(name: string): string;
8
+ /** Shorten a plugin name to its non-prefixed form. */
9
+ export declare function shortenPluginName(name: string): string;
8
10
  export type PluginContext = SpaceContext & {
9
11
  /** Validated config for this plugin invocation. */
10
12
  pluginConfig: Record<string, unknown>;
13
+ /** Optional callback functions for plugins to interact with the system. */
14
+ callbacks?: {
15
+ /** Persist plugin config updates to the config file. */
16
+ persistConfig?: (updates: Record<string, string>) => void;
17
+ };
11
18
  };
12
19
  export type ParseResult = {
13
20
  nodes: BaseNode[];
@@ -33,6 +40,8 @@ export type RenderFormat = {
33
40
  export type RenderOptions = {
34
41
  /** The format name being rendered (e.g. 'bullets', 'mermaid'). */
35
42
  format: string;
43
+ /** Plugin-specific extra data (e.g. CLI flags forwarded by a convenience command). */
44
+ data?: Record<string, unknown>;
36
45
  };
37
46
  /**
38
47
  * The render hook on a plugin: declares available formats and handles rendering.
@@ -5,3 +5,7 @@ export const CONFIG_PLUGINS_DIR = 'plugins';
5
5
  export function normalizePluginName(name) {
6
6
  return name.startsWith(PLUGIN_PREFIX) ? name : `${PLUGIN_PREFIX}${name}`;
7
7
  }
8
+ /** Shorten a plugin name to its non-prefixed form. */
9
+ export function shortenPluginName(name) {
10
+ return name.startsWith(PLUGIN_PREFIX) ? name.slice(PLUGIN_PREFIX.length) : name;
11
+ }
@@ -126,6 +126,33 @@ function resolveEdge(nodesByType, targetIndex, edge, source, selfRef, unresolved
126
126
  }
127
127
  }
128
128
  }
129
+ /**
130
+ * Classify a ContentLink into a ResolvedContentLink by determining its location.
131
+ *
132
+ * Wikilinks are resolved against the target index: if found, location is 'node',
133
+ * otherwise 'internal' (in the source system but not a space node).
134
+ * Markdown links are classified by URL scheme: external (http/https), system (file://),
135
+ * protocol (other scheme), or internal (relative path / no scheme).
136
+ */
137
+ function classifyContentLink(link, targetIndex) {
138
+ const { linkSyntax, target, text, action, anchor } = link;
139
+ const base = { text, target, action, ...(anchor !== undefined ? { anchor } : {}) };
140
+ if (linkSyntax === 'wikilink') {
141
+ // Try the full target#anchor key first, then the bare target — either means it's a space node.
142
+ const lookupKey = anchor !== undefined ? `${target}#${anchor}` : target;
143
+ const inIndex = targetIndex.has(lookupKey) || (anchor !== undefined && targetIndex.has(target));
144
+ return { ...base, location: inIndex ? 'node' : 'internal' };
145
+ }
146
+ // Markdown link: classify by URL scheme.
147
+ const colonIdx = target.indexOf('://');
148
+ if (colonIdx > 0) {
149
+ const scheme = target.slice(0, colonIdx).toLowerCase();
150
+ const location = scheme === 'https' || scheme === 'http' ? 'external' : scheme === 'file' ? 'system' : 'protocol';
151
+ return { ...base, location };
152
+ }
153
+ // Relative path or fragment — in the source system but not a resolvable space node.
154
+ return { ...base, location: 'internal' };
155
+ }
129
156
  /**
130
157
  * Enrich parsed nodes into SpaceNodes by applying type alias resolution and resolving
131
158
  * parent links using the hierarchy levels and relationships from schema metadata.
@@ -141,6 +168,7 @@ export function resolveGraphEdges(nodes, metadata) {
141
168
  ...n,
142
169
  resolvedType: resolveNodeType(n.type, typeAliases),
143
170
  resolvedParents: [],
171
+ resolvedLinks: [],
144
172
  }));
145
173
  const targetIndex = buildTargetIndex(spaceNodes);
146
174
  // Build nodesByType map
@@ -186,6 +214,10 @@ export function resolveGraphEdges(nodes, metadata) {
186
214
  };
187
215
  resolveEdge(nodesByType, targetIndex, edge, 'relationship', rel.type === rel.parent, unresolvedRefs, typeAliases);
188
216
  }
217
+ // 3. Resolve content links — classify each raw ContentLink into a ResolvedContentLink.
218
+ for (const node of spaceNodes) {
219
+ node.resolvedLinks = (node.contentLinks ?? []).map((link) => classifyContentLink(link, targetIndex));
220
+ }
189
221
  // Deduplicate by (label, field, ref) — the same broken link may be encountered across
190
222
  // multiple resolveEdge calls (e.g. selfRef + regular hierarchy share the same field).
191
223
  const seen = new Set();
@@ -1,4 +1,5 @@
1
1
  import type { SpaceContext } from '../types';
2
2
  export declare function executeRender(formatName: string, context: SpaceContext, options: {
3
3
  filter?: string;
4
+ data?: Record<string, unknown>;
4
5
  }): Promise<string>;
@@ -1,7 +1,7 @@
1
- import { filterNodes } from '../filter/filter-nodes';
1
+ import { shortenPluginName } from '@/plugins/util';
2
+ import { updateSpaceField } from '../config';
3
+ import { assembleSpaceGraph } from '../load-space-graph';
2
4
  import { loadPlugins } from '../plugins/loader';
3
- import { readSpace } from '../read/read-space';
4
- import { buildSpaceGraph } from '../space-graph';
5
5
  import { buildFormatRegistry } from './registry';
6
6
  export async function executeRender(formatName, context, options) {
7
7
  const pluginMap = context.space?.plugins ?? {};
@@ -12,17 +12,21 @@ export async function executeRender(formatName, context, options) {
12
12
  const available = registry.map((r) => r.qualifiedName).join(', ');
13
13
  throw new Error(`Unknown render format: "${formatName}".${available ? ` Available: ${available}` : ' No formats registered.'}`);
14
14
  }
15
- const { nodes: allNodes } = await readSpace(context);
16
- // Validate: drop nodes that fail schema validation
17
- const { schemaValidator } = context;
18
- const validNodes = allNodes.filter((node) => schemaValidator(node.schemaData));
19
- const levels = context.schema.metadata.hierarchy?.levels ?? [];
20
- let graph = buildSpaceGraph(validNodes, levels);
21
- // Filter: apply filter expression if provided
22
- if (options.filter) {
23
- const expression = context.space.views?.[options.filter]?.expression ?? options.filter;
24
- graph = await filterNodes(expression, graph);
25
- }
26
- const pluginContext = { ...context, pluginConfig: entry.plugin.pluginConfig };
27
- return entry.plugin.plugin.render.render(pluginContext, graph, { format: entry.format.name });
15
+ const graph = await assembleSpaceGraph(context, { filter: options.filter });
16
+ const shortName = shortenPluginName(entry.plugin.plugin.name);
17
+ const pluginContext = {
18
+ ...context,
19
+ pluginConfig: entry.plugin.pluginConfig,
20
+ callbacks: {
21
+ persistConfig: (updates) => {
22
+ for (const [field, value] of Object.entries(updates)) {
23
+ updateSpaceField(context.space.name, field, value, shortName);
24
+ }
25
+ },
26
+ },
27
+ };
28
+ return entry.plugin.plugin.render.render(pluginContext, graph, {
29
+ format: entry.format.name,
30
+ data: options.data,
31
+ });
28
32
  }
@@ -52,6 +52,7 @@ function flattenNode(node) {
52
52
  resolvedType: node.resolvedType,
53
53
  resolvedParentTitle: node.resolvedParents[0]?.title, // first parent or undefined, provided for convenience
54
54
  resolvedParentTitles: node.resolvedParents.map((r) => r.title), // full array of parent titles
55
+ resolvedLinks: node.resolvedLinks,
55
56
  };
56
57
  }
57
58
  /**
@@ -1,6 +1,8 @@
1
1
  import { type AnySchemaObject, type ValidateFunction } from 'ajv';
2
2
  import type { SchemaMetadata, SchemaWithMetadata } from '../types';
3
- export declare const bundledSchemasDir: string;
3
+ export declare let bundledSchemasDir: string;
4
+ /** Override the bundled schemas directory (e.g. when running in a bundled context). */
5
+ export declare function setBundledSchemasDir(dir: string): void;
4
6
  export declare function readRawSchema(schemaPath: string): AnySchemaObject;
5
7
  /**
6
8
  * Build the full two-layer registry for a schema path:
@@ -3,11 +3,15 @@ import { basename, dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { isDeepStrictEqual } from 'node:util';
5
5
  import Ajv, {} from 'ajv';
6
- import { JSON5 } from 'bun';
6
+ import JSON5 from 'json5';
7
7
  import { DIALECT_META_SCHEMA, METADATA_SCHEMA, SCHEMA_META_ID, } from './metadata-contract';
8
8
  import { isObject, mergeVariantProperties, resolveJsonPointer } from './schema-refs';
9
- const packageDir = dirname(fileURLToPath(import.meta.url));
10
- export const bundledSchemasDir = join(packageDir, '..', '..', 'schemas');
9
+ const packageDir = import.meta.url ? dirname(fileURLToPath(import.meta.url)) : '';
10
+ export let bundledSchemasDir = packageDir ? join(packageDir, '..', '..', 'schemas') : '';
11
+ /** Override the bundled schemas directory (e.g. when running in a bundled context). */
12
+ export function setBundledSchemasDir(dir) {
13
+ bundledSchemasDir = dir;
14
+ }
11
15
  const validateMetadataContract = new Ajv().compile(METADATA_SCHEMA);
12
16
  export function readRawSchema(schemaPath) {
13
17
  return JSON5.parse(readFileSync(resolve(schemaPath), 'utf-8'));
@@ -207,7 +207,7 @@
207
207
  },
208
208
  "note": {
209
209
  "type": "object",
210
- "description": "Personal notes, journal entries, appointments, experiential records. Your own data about your journey — not sourced from external material.",
210
+ "description": "Personal notes, journal entries, talk notes, or rough thoughts not drawn from an external URL.",
211
211
  "allOf": [{ "$ref": "sctx://_sctx_base#/$defs/baseNodeProps" }],
212
212
  "properties": {
213
213
  "type": { "const": "note" },