structured-context 0.9.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 (112) hide show
  1. package/README.md +348 -0
  2. package/dist/commands/diagram.d.ts +5 -0
  3. package/dist/commands/diagram.js +12 -0
  4. package/dist/commands/docs.d.ts +1 -0
  5. package/dist/commands/docs.js +67 -0
  6. package/dist/commands/dump.d.ts +2 -0
  7. package/dist/commands/dump.js +6 -0
  8. package/dist/commands/plugins.d.ts +1 -0
  9. package/dist/commands/plugins.js +23 -0
  10. package/dist/commands/render.d.ts +6 -0
  11. package/dist/commands/render.js +35 -0
  12. package/dist/commands/schemas.d.ts +6 -0
  13. package/dist/commands/schemas.js +268 -0
  14. package/dist/commands/show.d.ts +4 -0
  15. package/dist/commands/show.js +7 -0
  16. package/dist/commands/spaces.d.ts +1 -0
  17. package/dist/commands/spaces.js +36 -0
  18. package/dist/commands/template-sync.d.ts +3 -0
  19. package/dist/commands/template-sync.js +13 -0
  20. package/dist/commands/validate-file.d.ts +28 -0
  21. package/dist/commands/validate-file.js +133 -0
  22. package/dist/commands/validate.d.ts +16 -0
  23. package/dist/commands/validate.js +349 -0
  24. package/dist/config.d.ts +38 -0
  25. package/dist/config.js +179 -0
  26. package/dist/constants.d.ts +6 -0
  27. package/dist/constants.js +6 -0
  28. package/dist/filter/augment-nodes.d.ts +23 -0
  29. package/dist/filter/augment-nodes.js +95 -0
  30. package/dist/filter/expand-include.d.ts +62 -0
  31. package/dist/filter/expand-include.js +181 -0
  32. package/dist/filter/filter-nodes.d.ts +21 -0
  33. package/dist/filter/filter-nodes.js +73 -0
  34. package/dist/filter/parse-expression.d.ts +20 -0
  35. package/dist/filter/parse-expression.js +60 -0
  36. package/dist/index.d.ts +3 -0
  37. package/dist/index.js +161 -0
  38. package/dist/integrations/miro/cache.d.ts +21 -0
  39. package/dist/integrations/miro/cache.js +55 -0
  40. package/dist/integrations/miro/client.d.ts +99 -0
  41. package/dist/integrations/miro/client.js +118 -0
  42. package/dist/integrations/miro/layout.d.ts +28 -0
  43. package/dist/integrations/miro/layout.js +72 -0
  44. package/dist/integrations/miro/styles.d.ts +11 -0
  45. package/dist/integrations/miro/styles.js +65 -0
  46. package/dist/integrations/miro/sync.d.ts +8 -0
  47. package/dist/integrations/miro/sync.js +347 -0
  48. package/dist/plugin-api.d.ts +12 -0
  49. package/dist/plugin-api.js +7 -0
  50. package/dist/plugins/index.d.ts +3 -0
  51. package/dist/plugins/index.js +3 -0
  52. package/dist/plugins/loader.d.ts +21 -0
  53. package/dist/plugins/loader.js +104 -0
  54. package/dist/plugins/markdown/index.d.ts +48 -0
  55. package/dist/plugins/markdown/index.js +51 -0
  56. package/dist/plugins/markdown/parse-embedded.d.ts +90 -0
  57. package/dist/plugins/markdown/parse-embedded.js +663 -0
  58. package/dist/plugins/markdown/read-space.d.ts +7 -0
  59. package/dist/plugins/markdown/read-space.js +89 -0
  60. package/dist/plugins/markdown/render-bullets.d.ts +2 -0
  61. package/dist/plugins/markdown/render-bullets.js +42 -0
  62. package/dist/plugins/markdown/render-mermaid.d.ts +2 -0
  63. package/dist/plugins/markdown/render-mermaid.js +57 -0
  64. package/dist/plugins/markdown/template-sync.d.ts +16 -0
  65. package/dist/plugins/markdown/template-sync.js +294 -0
  66. package/dist/plugins/markdown/util.d.ts +19 -0
  67. package/dist/plugins/markdown/util.js +80 -0
  68. package/dist/plugins/util.d.ts +60 -0
  69. package/dist/plugins/util.js +7 -0
  70. package/dist/read/read-space.d.ts +2 -0
  71. package/dist/read/read-space.js +22 -0
  72. package/dist/read/resolve-graph-edges.d.ts +11 -0
  73. package/dist/read/resolve-graph-edges.js +201 -0
  74. package/dist/read/wikilink-utils.d.ts +16 -0
  75. package/dist/read/wikilink-utils.js +38 -0
  76. package/dist/render/registry.d.ts +13 -0
  77. package/dist/render/registry.js +22 -0
  78. package/dist/render/render.d.ts +4 -0
  79. package/dist/render/render.js +28 -0
  80. package/dist/schema/evaluate-rule.d.ts +30 -0
  81. package/dist/schema/evaluate-rule.js +82 -0
  82. package/dist/schema/metadata-contract.d.ts +538 -0
  83. package/dist/schema/metadata-contract.js +115 -0
  84. package/dist/schema/schema-refs.d.ts +22 -0
  85. package/dist/schema/schema-refs.js +168 -0
  86. package/dist/schema/schema.d.ts +27 -0
  87. package/dist/schema/schema.js +378 -0
  88. package/dist/schema/validate-graph.d.ts +24 -0
  89. package/dist/schema/validate-graph.js +141 -0
  90. package/dist/schema/validate-rules.d.ts +10 -0
  91. package/dist/schema/validate-rules.js +51 -0
  92. package/dist/schemas/_ost_strict.json +81 -0
  93. package/dist/schemas/_sctx_base.json +72 -0
  94. package/dist/schemas/general.json +261 -0
  95. package/dist/schemas/generated/_structured_context_schema_meta.json +191 -0
  96. package/dist/schemas/knowledge_wiki.json +206 -0
  97. package/dist/schemas/strict_ost.json +97 -0
  98. package/dist/space-graph.d.ts +28 -0
  99. package/dist/space-graph.js +82 -0
  100. package/dist/types.d.ts +145 -0
  101. package/dist/types.js +0 -0
  102. package/docs/concepts.md +391 -0
  103. package/docs/config.md +140 -0
  104. package/docs/rules.md +120 -0
  105. package/docs/schemas.md +340 -0
  106. package/package.json +69 -0
  107. package/schemas/_ost_strict.json +81 -0
  108. package/schemas/_sctx_base.json +72 -0
  109. package/schemas/general.json +261 -0
  110. package/schemas/generated/_structured_context_schema_meta.json +191 -0
  111. package/schemas/knowledge_wiki.json +206 -0
  112. package/schemas/strict_ost.json +97 -0
@@ -0,0 +1,347 @@
1
+ import { updateSpaceField } from '../../config';
2
+ import { readSpace } from '../../read/read-space';
3
+ import { buildSpaceGraph } from '../../space-graph';
4
+ import { computeMiroCardHash, computeNodeHash, loadCache, saveCache } from './cache';
5
+ import { MiroClient, MiroNotFoundError } from './client';
6
+ import { CARD_WIDTH, layoutNewCards } from './layout';
7
+ import { buildCardDescription, buildCardTitle, getCardColor } from './styles';
8
+ export async function miroSync(context, options) {
9
+ 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;
15
+ // 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;
22
+ // 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));
30
+ if (nodes.length === 0) {
31
+ console.log('No space nodes found.');
32
+ return;
33
+ }
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`);
41
+ }
42
+ const client = new MiroClient(boardId, token);
43
+ let frameId;
44
+ let layoutOffset = null;
45
+ if (options.newFrame) {
46
+ // Calculate layout bounds to size the frame appropriately
47
+ const { bounds } = layoutNewCards(nodes, new Map(), levels);
48
+ const frameWidth = Math.max(1600, bounds.maxX - bounds.minX);
49
+ const frameHeight = Math.max(1200, bounds.maxY - bounds.minY);
50
+ // Miro positions child items relative to the parent frame's top-left corner.
51
+ // We shift card positions so the layout bounds start at (0, 0) in frame-local space.
52
+ layoutOffset = {
53
+ x: -bounds.minX,
54
+ y: -bounds.minY,
55
+ };
56
+ // Add padding around the calculated bounds for visual breathing room
57
+ const padding = 200;
58
+ const finalFrameWidth = frameWidth + padding * 2;
59
+ const finalFrameHeight = frameHeight + padding * 2;
60
+ if (options.dryRun) {
61
+ console.log(`[dry-run] Would create frame: "${options.newFrame}" (size: ${finalFrameWidth}x${finalFrameHeight})`);
62
+ frameId = 'dry-run-frame-id';
63
+ }
64
+ else {
65
+ const frame = await client.createFrame({
66
+ data: { title: options.newFrame, type: 'freeform' },
67
+ position: { x: 0, y: 0, origin: 'center' },
68
+ geometry: { width: finalFrameWidth, height: finalFrameHeight },
69
+ });
70
+ frameId = frame.id;
71
+ console.log(`Created frame "${options.newFrame}" (${frameId}) - size: ${finalFrameWidth}x${finalFrameHeight}`);
72
+ updateSpaceField(space.name, 'miroFrameId', frameId);
73
+ console.log(`Saved miroFrameId to config`);
74
+ }
75
+ }
76
+ else {
77
+ frameId = space.miroFrameId;
78
+ }
79
+ // 4. Load cache
80
+ const cache = loadCache(boardId, frameId);
81
+ cache.spaceName = space.name;
82
+ // 5. Verify cache against actual board state (skip for new frames)
83
+ // Fetch all cards from the frame to build a verified mapping
84
+ const existingPositions = new Map();
85
+ const verifiedCardIds = new Map(); // title → cardId (only cards that exist on board)
86
+ const miroCardData = new Map(); // cardId → actual Miro data
87
+ const staleCacheEntries = []; // titles with cached card IDs that no longer exist
88
+ // Skip fetching frame items for new frames (there are no existing cards)
89
+ if (!options.newFrame) {
90
+ // Build reverse lookup map (cardId → title) for O(1) cache lookup
91
+ const cacheByCardId = new Map();
92
+ for (const [title, cached] of Object.entries(cache.nodes)) {
93
+ cacheByCardId.set(cached.miroCardId, title);
94
+ }
95
+ const frameItems = await client.getItemsInFrame(frameId);
96
+ const boardCardIds = new Set();
97
+ for (const item of frameItems) {
98
+ if (item.type === 'card' && item.position && item.data) {
99
+ boardCardIds.add(item.id);
100
+ miroCardData.set(item.id, {
101
+ title: item.data.title,
102
+ description: item.data.description ?? '',
103
+ });
104
+ // Find which cached node this card belongs to using reverse lookup
105
+ const title = cacheByCardId.get(item.id);
106
+ if (title) {
107
+ existingPositions.set(title, {
108
+ x: item.position.x,
109
+ y: item.position.y,
110
+ });
111
+ verifiedCardIds.set(title, item.id);
112
+ }
113
+ }
114
+ }
115
+ // Detect stale cache entries (cached card IDs not on board)
116
+ for (const [title, cached] of Object.entries(cache.nodes)) {
117
+ if (!boardCardIds.has(cached.miroCardId)) {
118
+ staleCacheEntries.push(title);
119
+ }
120
+ }
121
+ if (staleCacheEntries.length > 0) {
122
+ if (options.verbose || options.dryRun) {
123
+ console.log(`Found ${staleCacheEntries.length} stale cache entries (cards deleted from board)`);
124
+ for (const title of staleCacheEntries) {
125
+ console.log(` - "${title}" (will recreate)`);
126
+ }
127
+ }
128
+ }
129
+ }
130
+ // 6. Determine which nodes are new vs updated vs unchanged
131
+ // Compare actual Miro card content against node (not cached hash)
132
+ const newNodes = [];
133
+ const updatedNodes = [];
134
+ let skippedCount = 0;
135
+ for (const node of nodes) {
136
+ const title = node.title;
137
+ // Compute what we expect to be in Miro (using the same build functions)
138
+ const expectedTitle = buildCardTitle(node);
139
+ const expectedDesc = buildCardDescription(node);
140
+ const expectedHash = computeMiroCardHash(expectedTitle, expectedDesc);
141
+ // Check if there's a verified card on the board for this node
142
+ const verifiedCardId = verifiedCardIds.get(title);
143
+ if (!verifiedCardId) {
144
+ // No card on board - needs to be created
145
+ newNodes.push(node);
146
+ }
147
+ else {
148
+ // Card exists - compare actual Miro content against expected node content
149
+ const miroData = miroCardData.get(verifiedCardId);
150
+ if (miroData) {
151
+ const miroHash = computeMiroCardHash(miroData.title, miroData.description);
152
+ if (miroHash !== expectedHash) {
153
+ // Miro content differs from node - needs update
154
+ if (options.verbose) {
155
+ console.log(`"${title}" differs from Miro:`);
156
+ console.log(` Expected: "${JSON.stringify(expectedTitle)}"`);
157
+ console.log(` Miro has: "${JSON.stringify(miroData.title)}"`);
158
+ console.log(` Expected desc: "${JSON.stringify(expectedDesc)}"`);
159
+ console.log(` Miro desc: "${JSON.stringify(miroData.description)}"`);
160
+ }
161
+ updatedNodes.push({ node, cardId: verifiedCardId });
162
+ }
163
+ else {
164
+ skippedCount++;
165
+ }
166
+ }
167
+ else {
168
+ // Card ID exists but we couldn't fetch data - recreate
169
+ newNodes.push(node);
170
+ }
171
+ }
172
+ }
173
+ // Compute positions for new cards
174
+ const { positions: newPositions } = layoutNewCards(newNodes, existingPositions, levels);
175
+ // 7. Create new cards
176
+ let createdCount = 0;
177
+ for (const node of newNodes) {
178
+ const title = node.title;
179
+ const type = node.schemaData.type;
180
+ let pos = newPositions.get(title) ?? { x: 0, y: 0 };
181
+ // Apply offset if we created a new frame (to center layout in frame)
182
+ if (layoutOffset) {
183
+ pos = { x: pos.x + layoutOffset.x, y: pos.y + layoutOffset.y };
184
+ }
185
+ else {
186
+ // For existing frames without layout offset, place at origin for simplicity
187
+ // User can manually rearrange or re-sync with --new-frame for better layout
188
+ pos = { x: 0, y: 0 };
189
+ }
190
+ if (options.dryRun) {
191
+ console.log(`[dry-run] Create card: "${title}" (${type}) at (${pos.x}, ${pos.y})`);
192
+ // For dry-run, use a fake ID so connectors can be calculated
193
+ verifiedCardIds.set(title, `dry-run-card-${title}`);
194
+ createdCount++;
195
+ continue;
196
+ }
197
+ if (options.verbose)
198
+ console.log(`Creating card: "${title}" (${type}) at (${pos.x}, ${pos.y})`);
199
+ const card = await client.createCard({
200
+ data: {
201
+ title: buildCardTitle(node),
202
+ description: buildCardDescription(node),
203
+ },
204
+ style: { cardTheme: getCardColor(type, levels) },
205
+ position: { x: pos.x, y: pos.y, origin: 'center' },
206
+ parent: { id: frameId },
207
+ geometry: { width: CARD_WIDTH },
208
+ });
209
+ cache.nodes[title] = {
210
+ miroCardId: card.id,
211
+ contentHash: computeNodeHash(node),
212
+ };
213
+ verifiedCardIds.set(title, card.id); // Add to verified set so connectors can use it
214
+ createdCount++;
215
+ }
216
+ // 8. Update changed cards
217
+ let updatedCount = 0;
218
+ for (const { node, cardId } of updatedNodes) {
219
+ const title = node.title;
220
+ if (options.dryRun) {
221
+ console.log(`[dry-run] Update card: "${title}"`);
222
+ updatedCount++;
223
+ continue;
224
+ }
225
+ if (options.verbose)
226
+ console.log(`Updating card: "${title}"`);
227
+ try {
228
+ await client.updateCard(cardId, {
229
+ data: {
230
+ title: buildCardTitle(node),
231
+ description: buildCardDescription(node),
232
+ },
233
+ });
234
+ cache.nodes[title] = {
235
+ miroCardId: cardId,
236
+ contentHash: computeNodeHash(node),
237
+ };
238
+ updatedCount++;
239
+ }
240
+ catch (e) {
241
+ if (e instanceof MiroNotFoundError) {
242
+ // Card was deleted from Miro — recreate it
243
+ console.log(`Card "${title}" missing from Miro, recreating...`);
244
+ const type = node.schemaData.type;
245
+ const card = await client.createCard({
246
+ data: {
247
+ title: buildCardTitle(node),
248
+ description: buildCardDescription(node),
249
+ },
250
+ style: { cardTheme: getCardColor(type, levels) },
251
+ position: { x: 0, y: 0, origin: 'center' },
252
+ parent: { id: frameId },
253
+ geometry: { width: CARD_WIDTH },
254
+ });
255
+ cache.nodes[title] = {
256
+ miroCardId: card.id,
257
+ contentHash: computeNodeHash(node),
258
+ };
259
+ createdCount++;
260
+ }
261
+ else {
262
+ throw e;
263
+ }
264
+ }
265
+ }
266
+ // 9. Sync connectors
267
+ const prefix = options.dryRun ? '[dry-run] ' : '';
268
+ let connectorsCreated = 0;
269
+ let connectorsDeleted = 0;
270
+ // Build desired parent→child pairs from OST data
271
+ // Only include edges where both endpoints have verified cards on the board
272
+ const desiredEdges = new Map();
273
+ for (const node of nodes) {
274
+ const childTitle = node.title;
275
+ for (const { title: parentTitle, source } of node.resolvedParents) {
276
+ if (source !== 'hierarchy')
277
+ continue;
278
+ // Both endpoints must have verified cards on the board
279
+ if (verifiedCardIds.has(parentTitle) && verifiedCardIds.has(childTitle)) {
280
+ const key = `${parentTitle}\u2192${childTitle}`;
281
+ desiredEdges.set(key, { parentTitle, childTitle });
282
+ }
283
+ }
284
+ }
285
+ // Build cardId → title mapping from VERIFIED cards only
286
+ const cardIdToTitle = new Map();
287
+ for (const [title, cardId] of verifiedCardIds.entries()) {
288
+ cardIdToTitle.set(cardId, title);
289
+ }
290
+ // Find existing connectors that we created (from cache)
291
+ const existingConnectorIds = new Set(Object.values(cache.connectors).map((c) => c.miroConnectorId));
292
+ // Verify our cached connectors still exist and connect our cards
293
+ const allConnectors = await client.getConnectors();
294
+ const validCachedEdges = new Map(); // edge key → connector ID
295
+ for (const conn of allConnectors) {
296
+ // Skip connectors not in our cache
297
+ if (!existingConnectorIds.has(conn.id))
298
+ continue;
299
+ // Skip connectors that don't connect two items
300
+ if (!conn.startItem || !conn.endItem)
301
+ continue;
302
+ const startTitle = cardIdToTitle.get(conn.startItem.id);
303
+ const endTitle = cardIdToTitle.get(conn.endItem.id);
304
+ if (startTitle && endTitle) {
305
+ validCachedEdges.set(`${startTitle}\u2192${endTitle}`, conn.id);
306
+ }
307
+ }
308
+ // Create missing connectors
309
+ for (const [key, { parentTitle, childTitle }] of desiredEdges) {
310
+ if (!validCachedEdges.has(key)) {
311
+ if (options.verbose || options.dryRun)
312
+ console.log(`${prefix}Creating connector: ${parentTitle} -> ${childTitle}`);
313
+ if (!options.dryRun) {
314
+ const conn = await client.createConnector(verifiedCardIds.get(parentTitle), verifiedCardIds.get(childTitle));
315
+ cache.connectors[key] = { miroConnectorId: conn.id };
316
+ }
317
+ connectorsCreated++;
318
+ }
319
+ }
320
+ // Delete ONLY connectors we created that are no longer valid
321
+ for (const [key, cached] of Object.entries(cache.connectors)) {
322
+ // Skip if this edge is still desired
323
+ if (desiredEdges.has(key))
324
+ continue;
325
+ // Check if the connector still exists in Miro and connects our cards
326
+ if (validCachedEdges.has(key)) {
327
+ if (options.verbose || options.dryRun)
328
+ console.log(`${prefix}Deleting stale connector: ${key}`);
329
+ if (!options.dryRun) {
330
+ await client.deleteConnector(cached.miroConnectorId);
331
+ connectorsDeleted++;
332
+ }
333
+ }
334
+ // Remove from cache regardless (it's either deleted or invalid)
335
+ if (!options.dryRun) {
336
+ delete cache.connectors[key];
337
+ }
338
+ }
339
+ // 10. Save cache
340
+ cache.lastSync = new Date().toISOString();
341
+ if (!options.dryRun)
342
+ saveCache(cache);
343
+ // 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`);
347
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Public API for external structured-context plugins.
3
+ *
4
+ * Import from this module to get the types needed to implement a StructuredContextPlugin:
5
+ *
6
+ * import type { StructuredContextPlugin, PluginContext, ParseResult } from 'structured-context/plugin-api';
7
+ */
8
+ export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
9
+ export type { ParseHook, ParseResult, PluginContext, RenderFormat, RenderHook, RenderOptions, StructuredContextPlugin, TemplateSyncHook, TemplateSyncOptions, } from './plugins/util';
10
+ export type { SharedEmbeddingFields } from './schema/metadata-contract';
11
+ export type { SpaceGraph } from './space-graph';
12
+ export type { BaseNode, EdgeDefinition, HierarchyLevel, Relationship, SchemaMetadata, SchemaWithMetadata, SpaceContext, SpaceNode, UnresolvedRef, } from './types';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Public API for external structured-context plugins.
3
+ *
4
+ * Import from this module to get the types needed to implement a StructuredContextPlugin:
5
+ *
6
+ * import type { StructuredContextPlugin, PluginContext, ParseResult } from 'structured-context/plugin-api';
7
+ */
@@ -0,0 +1,3 @@
1
+ import type { StructuredContextPlugin } from './util';
2
+ /** All built-in plugins, in default load order. */
3
+ export declare const builtinPlugins: StructuredContextPlugin[];
@@ -0,0 +1,3 @@
1
+ import { markdownPlugin } from './markdown';
2
+ /** All built-in plugins, in default load order. */
3
+ export const builtinPlugins = [markdownPlugin];
@@ -0,0 +1,21 @@
1
+ import { type StructuredContextPlugin } from './util';
2
+ export type LoadedPlugin = {
3
+ plugin: StructuredContextPlugin;
4
+ pluginConfig: Record<string, unknown>;
5
+ };
6
+ /**
7
+ * Discover all available plugins: built-ins first, then any config-adjacent plugins found
8
+ * in {configDir}/plugins/ across all loaded config files. Does not load npm plugins
9
+ * (those are only declared in space configs).
10
+ */
11
+ export declare function discoverPlugins(): Promise<StructuredContextPlugin[]>;
12
+ /**
13
+ * Load plugins for a space.
14
+ *
15
+ * Built-in plugins are always included (with config from the map if declared, else {}).
16
+ * External plugins are loaded from the map and prepended in declaration order.
17
+ * Resolution order for external plugins: config-adjacent → npm.
18
+ * Fields annotated with format:'path' in a plugin's configSchema are resolved
19
+ * relative to configDir.
20
+ */
21
+ export declare function loadPlugins(pluginMap: Record<string, Record<string, unknown>>, configDir: string): Promise<LoadedPlugin[]>;
@@ -0,0 +1,104 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import Ajv, {} from 'ajv';
4
+ import { getConfigSourceFiles } from '../config';
5
+ import { builtinPlugins } from '.';
6
+ import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX } from './util';
7
+ /**
8
+ * Walk a plugin's configSchema and resolve any string fields annotated with
9
+ * format:'path' relative to configDir.
10
+ */
11
+ function resolveConfigPaths(schema, config, configDir) {
12
+ const props = schema.properties;
13
+ if (!props)
14
+ return config;
15
+ const result = { ...config };
16
+ for (const [key, propSchema] of Object.entries(props)) {
17
+ if (propSchema.format === 'path' && typeof result[key] === 'string') {
18
+ const value = result[key];
19
+ if (!isAbsolute(value)) {
20
+ result[key] = resolve(configDir, value);
21
+ }
22
+ }
23
+ }
24
+ return result;
25
+ }
26
+ /**
27
+ * Resolve an external plugin by canonical name.
28
+ * Resolution order: config-adjacent ({configDir}/plugins/{name}) → npm (import(name)).
29
+ */
30
+ async function resolveExternalPlugin(name, configDir) {
31
+ const localPath = resolve(join(configDir, CONFIG_PLUGINS_DIR, name));
32
+ const module = existsSync(localPath) || existsSync(`${localPath}.ts`) ? await import(localPath) : await import(name);
33
+ const plugin = module.default ?? module;
34
+ if (!plugin || typeof plugin.name !== 'string') {
35
+ throw new Error(`Plugin "${name}" must export a StructuredContextPlugin as its default export`);
36
+ }
37
+ return plugin;
38
+ }
39
+ /**
40
+ * Discover all available plugins: built-ins first, then any config-adjacent plugins found
41
+ * in {configDir}/plugins/ across all loaded config files. Does not load npm plugins
42
+ * (those are only declared in space configs).
43
+ */
44
+ export async function discoverPlugins() {
45
+ // Convert the Set of files into a Set of dirs
46
+ const configDirs = [...new Set(Array.from(getConfigSourceFiles()).map((f) => dirname(f)))];
47
+ const plugins = [...builtinPlugins];
48
+ const seenNames = new Set(builtinPlugins.map((p) => p.name));
49
+ for (const configDir of configDirs) {
50
+ const pluginsDir = join(configDir, CONFIG_PLUGINS_DIR);
51
+ if (existsSync(pluginsDir)) {
52
+ const entries = readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX));
53
+ for (const entry of entries) {
54
+ if (!seenNames.has(entry)) {
55
+ plugins.push(await resolveExternalPlugin(entry, configDir));
56
+ seenNames.add(entry);
57
+ }
58
+ }
59
+ }
60
+ }
61
+ return plugins;
62
+ }
63
+ /**
64
+ * Load plugins for a space.
65
+ *
66
+ * Built-in plugins are always included (with config from the map if declared, else {}).
67
+ * External plugins are loaded from the map and prepended in declaration order.
68
+ * Resolution order for external plugins: config-adjacent → npm.
69
+ * Fields annotated with format:'path' in a plugin's configSchema are resolved
70
+ * relative to configDir.
71
+ */
72
+ export async function loadPlugins(pluginMap, configDir) {
73
+ const builtinsByName = new Map(builtinPlugins.map((p) => [p.name, p]));
74
+ const ajv = new Ajv();
75
+ ajv.addFormat('path', (value) => value.length > 0 && !value.includes('\0'));
76
+ const loaded = [];
77
+ // External plugins: entries in the map that are not built-in names
78
+ for (const [rawName, rawConfig] of Object.entries(pluginMap)) {
79
+ const name = normalizePluginName(rawName);
80
+ if (builtinsByName.has(name))
81
+ continue;
82
+ if (!name.startsWith(PLUGIN_PREFIX)) {
83
+ throw new Error(`Plugin name must start with "${PLUGIN_PREFIX}" (got "${rawName}")`);
84
+ }
85
+ const plugin = await resolveExternalPlugin(name, configDir);
86
+ const pluginConfig = resolveConfigPaths(plugin.configSchema, rawConfig, configDir);
87
+ const validate = ajv.compile(plugin.configSchema);
88
+ if (!validate(pluginConfig)) {
89
+ throw new Error(`Invalid config for plugin "${name}": ${JSON.stringify(validate.errors)}`);
90
+ }
91
+ loaded.push({ plugin, pluginConfig });
92
+ }
93
+ // Built-in plugins: always loaded, config taken from map if declared (with or without prefix)
94
+ for (const builtin of builtinPlugins) {
95
+ const rawConfig = pluginMap[builtin.name] ?? pluginMap[builtin.name.slice(PLUGIN_PREFIX.length)] ?? {};
96
+ const pluginConfig = resolveConfigPaths(builtin.configSchema, rawConfig, configDir);
97
+ const validate = ajv.compile(builtin.configSchema);
98
+ if (!validate(pluginConfig)) {
99
+ throw new Error(`Invalid config for plugin "${builtin.name}": ${JSON.stringify(validate.errors)}`);
100
+ }
101
+ loaded.push({ plugin: builtin, pluginConfig });
102
+ }
103
+ return loaded;
104
+ }
@@ -0,0 +1,48 @@
1
+ import type { StructuredContextPlugin } from '../util';
2
+ export type TypeInferenceConfig = {
3
+ mode?: 'folder-name' | 'off';
4
+ folderMap?: Record<string, string>;
5
+ };
6
+ export type MarkdownPluginConfig = {
7
+ templateDir?: string;
8
+ fieldMap?: Record<string, string>;
9
+ templatePrefix?: string;
10
+ typeInference?: TypeInferenceConfig;
11
+ };
12
+ export declare const MARKDOWN_CONFIG_SCHEMA: {
13
+ type: string;
14
+ properties: {
15
+ templateDir: {
16
+ type: string;
17
+ format: string;
18
+ };
19
+ fieldMap: {
20
+ type: string;
21
+ additionalProperties: {
22
+ type: string;
23
+ };
24
+ };
25
+ templatePrefix: {
26
+ type: string;
27
+ };
28
+ typeInference: {
29
+ type: string;
30
+ properties: {
31
+ mode: {
32
+ type: string;
33
+ enum: string[];
34
+ };
35
+ folderMap: {
36
+ type: string;
37
+ additionalProperties: {
38
+ type: string;
39
+ };
40
+ };
41
+ };
42
+ additionalProperties: boolean;
43
+ };
44
+ };
45
+ additionalProperties: boolean;
46
+ };
47
+ export declare function getMarkdownConfig(plugins?: Record<string, Record<string, unknown>>): MarkdownPluginConfig;
48
+ export declare const markdownPlugin: StructuredContextPlugin;
@@ -0,0 +1,51 @@
1
+ import { statSync } from 'node:fs';
2
+ import { PLUGIN_PREFIX } from '../util';
3
+ import { readSpaceDirectory, readSpaceOnAPage } from './read-space';
4
+ import { renderBullets } from './render-bullets';
5
+ import { renderMermaid } from './render-mermaid';
6
+ import { templateSync } from './template-sync';
7
+ export const MARKDOWN_CONFIG_SCHEMA = {
8
+ type: 'object',
9
+ properties: {
10
+ templateDir: { type: 'string', format: 'path' }, // format is hint to config loader to resolve relative directories
11
+ fieldMap: { type: 'object', additionalProperties: { type: 'string' } },
12
+ templatePrefix: { type: 'string' },
13
+ typeInference: {
14
+ type: 'object',
15
+ properties: {
16
+ mode: { type: 'string', enum: ['folder-name', 'off'] },
17
+ folderMap: { type: 'object', additionalProperties: { type: 'string' } },
18
+ },
19
+ additionalProperties: false,
20
+ },
21
+ },
22
+ additionalProperties: false,
23
+ };
24
+ export function getMarkdownConfig(plugins) {
25
+ return (plugins?.[`${PLUGIN_PREFIX}markdown`] ?? {});
26
+ }
27
+ async function parse(context) {
28
+ if (statSync(context.space.path).isFile()) {
29
+ return readSpaceOnAPage(context);
30
+ }
31
+ return await readSpaceDirectory(context);
32
+ }
33
+ export const markdownPlugin = {
34
+ name: `${PLUGIN_PREFIX}markdown`,
35
+ configSchema: MARKDOWN_CONFIG_SCHEMA,
36
+ parse,
37
+ templateSync,
38
+ render: {
39
+ formats: [
40
+ { name: 'bullets', description: 'Indented bullet list' },
41
+ { name: 'mermaid', description: 'Mermaid graph TD diagram' },
42
+ ],
43
+ render(_context, graph, { format }) {
44
+ if (format === 'bullets')
45
+ return renderBullets(graph);
46
+ if (format === 'mermaid')
47
+ return renderMermaid(graph);
48
+ throw new Error(`Unknown markdown render format: "${format}"`);
49
+ },
50
+ },
51
+ };