structured-context 0.13.0 → 0.14.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 (70) hide show
  1. package/README.md +2 -2
  2. package/dist/api.d.ts +17 -17
  3. package/dist/api.js +12 -12
  4. package/dist/commands/dump.js +1 -1
  5. package/dist/commands/validate.js +8 -8
  6. package/dist/config.d.ts +1 -1
  7. package/dist/config.js +4 -4
  8. package/dist/filter/augment-nodes.d.ts +5 -4
  9. package/dist/filter/augment-nodes.js +20 -19
  10. package/dist/filter/expand-include.d.ts +2 -2
  11. package/dist/filter/expand-include.js +15 -17
  12. package/dist/filter/filter-nodes.js +2 -2
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.js +17 -17
  15. package/dist/judge/context.d.ts +1 -1
  16. package/dist/judge/context.js +12 -12
  17. package/dist/judge/factory.d.ts +1 -1
  18. package/dist/judge/factory.js +1 -1
  19. package/dist/judge/index.d.ts +1 -1
  20. package/dist/judge/judge-node.d.ts +4 -4
  21. package/dist/judge/judge-node.js +7 -7
  22. package/dist/judge/prompt.d.ts +1 -1
  23. package/dist/judge/prompt.js +3 -3
  24. package/dist/judge/provider/fake.d.ts +1 -1
  25. package/dist/judge/provider/fake.js +3 -6
  26. package/dist/judge/provider/jev.js +1 -1
  27. package/dist/judge/provider/openai-decisions.js +1 -1
  28. package/dist/judge/trim.d.ts +1 -1
  29. package/dist/judge/types.d.ts +4 -1
  30. package/dist/judge/verdicts.d.ts +1 -1
  31. package/dist/load-space-graph.d.ts +3 -3
  32. package/dist/load-space-graph.js +4 -4
  33. package/dist/plugins/loader.js +1 -1
  34. package/dist/plugins/markdown/index.d.ts +6 -0
  35. package/dist/plugins/markdown/index.js +1 -0
  36. package/dist/plugins/markdown/parse-embedded.d.ts +7 -2
  37. package/dist/plugins/markdown/parse-embedded.js +23 -21
  38. package/dist/plugins/markdown/read-space.js +9 -8
  39. package/dist/plugins/markdown/render-bullets.js +3 -3
  40. package/dist/plugins/markdown/render-directory.js +1 -1
  41. package/dist/plugins/markdown/util.d.ts +12 -1
  42. package/dist/plugins/markdown/util.js +21 -0
  43. package/dist/plugins/mermaid/render-mermaid.js +5 -6
  44. package/dist/plugins/miro/cache.js +1 -1
  45. package/dist/plugins/miro/sync.js +3 -2
  46. package/dist/read/resolve-graph-edges.js +12 -18
  47. package/dist/render/render.d.ts +1 -1
  48. package/dist/render/render.js +1 -1
  49. package/dist/schema/evaluate-rule.d.ts +4 -4
  50. package/dist/schema/evaluate-rule.js +14 -10
  51. package/dist/schema/gating.d.ts +10 -10
  52. package/dist/schema/gating.js +45 -44
  53. package/dist/schema/validate-graph.js +8 -11
  54. package/dist/schema/validate-rules.d.ts +4 -4
  55. package/dist/schema/validate-rules.js +15 -17
  56. package/dist/schemas/_ost_strict.json +1 -1
  57. package/dist/space-context.d.ts +2 -2
  58. package/dist/space-context.js +3 -3
  59. package/dist/space-graph.d.ts +9 -9
  60. package/dist/space-graph.js +16 -16
  61. package/dist/testing.d.ts +2 -2
  62. package/dist/testing.js +3 -3
  63. package/dist/types.d.ts +18 -14
  64. package/dist/validate.d.ts +6 -6
  65. package/dist/validate.js +26 -27
  66. package/docs/concepts.md +25 -7
  67. package/docs/config.md +8 -0
  68. package/docs/rules.md +17 -13
  69. package/package.json +5 -5
  70. package/schemas/_ost_strict.json +1 -1
@@ -7,7 +7,7 @@ function resolveAll(rules) {
7
7
  function errorJudgements(rules, message) {
8
8
  return rules.map((rule) => ({
9
9
  rule,
10
- failed: true,
10
+ violated: true,
11
11
  evidence: `Error during evaluation: ${message}`,
12
12
  error: message,
13
13
  }));
@@ -17,20 +17,20 @@ function interpret(rules, resolved, results) {
17
17
  const result = results[rule.id];
18
18
  if (!result || isJudgeError(result)) {
19
19
  const message = result ? result.error : 'Model provider returned no result for this rule';
20
- return { rule, failed: true, evidence: `Error during evaluation: ${message}`, error: message };
20
+ return { rule, violated: true, evidence: `Error during evaluation: ${message}`, error: message };
21
21
  }
22
22
  const severity = resolved[i].severities[result.verdict];
23
23
  if (severity === undefined && !(result.verdict in resolved[i].spec.verdicts)) {
24
24
  const message = `Model provider returned unknown verdict "${result.verdict}" (allowed: ${Object.keys(resolved[i].spec.verdicts).join(', ')})`;
25
- return { rule, failed: true, evidence: `Error during evaluation: ${message}`, error: message };
25
+ return { rule, violated: true, evidence: `Error during evaluation: ${message}`, error: message };
26
26
  }
27
- const failed = severity !== undefined;
27
+ const violated = severity !== undefined;
28
28
  return {
29
29
  rule,
30
- failed,
30
+ violated,
31
31
  severity,
32
32
  verdict: result.verdict,
33
- guidance: failed ? resolved[i].guidance[result.verdict] : undefined,
33
+ guidance: violated ? resolved[i].guidance[result.verdict] : undefined,
34
34
  evidence: result.evidence,
35
35
  };
36
36
  });
@@ -62,7 +62,7 @@ function planUnits(graph, items, report) {
62
62
  continue;
63
63
  }
64
64
  if (fitted.trimmed && report) {
65
- report.nodes.add(node.label);
65
+ report.nodes.add(node.id);
66
66
  for (const rule of unitRules)
67
67
  report.ruleIds.add(rule.id);
68
68
  }
@@ -1,4 +1,4 @@
1
- import type { SemanticJudgeInput, SemanticJudgeResult, SemanticRuleSpec } from '../types.js';
1
+ import type { SemanticJudgeInput, SemanticJudgeResult, SemanticRuleSpec } from './types';
2
2
  /** System prompt: a strict judge that answers with one verdict from a closed set, as JSON. */
3
3
  export declare function buildJudgeSystemPrompt(): string;
4
4
  /** User prompt for one rule on one node. */
@@ -34,12 +34,12 @@ export function buildJudgeUserPrompt({ node, related = [] }, rule) {
34
34
  ...verdictList(rule),
35
35
  '',
36
36
  '# Node Under Evaluation',
37
- `- File / Label: ${node.label}`,
37
+ `- Id: ${node.id}`,
38
38
  `- Title: ${node.title}`,
39
39
  `- Type: ${node.type} (resolved: ${node.resolvedType})`,
40
40
  ];
41
- if (node.resolvedParents && node.resolvedParents.length > 0) {
42
- lines.push(`- Parents: ${node.resolvedParents.map((p) => p.title).join(', ')}`);
41
+ if (node.parentTitles.length > 0) {
42
+ lines.push(`- Parents: ${node.parentTitles.join(', ')}`);
43
43
  }
44
44
  lines.push('', '## Node Content & Frontmatter:', '```json', JSON.stringify(node.schemaData, null, 2), '```', '');
45
45
  if (related.length > 0) {
@@ -11,7 +11,7 @@ export type FakeJudgeHandler = (call: {
11
11
  type FakeConfig = FakeAnswer | FakeJudgeHandler | Map<string, FakeAnswer> | Record<string, FakeAnswer>;
12
12
  /**
13
13
  * In-memory fake judge for offline, deterministic tests. Configure with a single answer, a handler,
14
- * or answers keyed by `ruleId:title`, `ruleId:label` or `ruleId` (most specific wins). Unmatched calls get `pass`,
14
+ * or answers keyed by `ruleId:title`, `ruleId:id` or `ruleId` (most specific wins). Unmatched calls get `pass`,
15
15
  * or the rule's first declared verdict.
16
16
  */
17
17
  export declare class FakeJudgeProvider implements SemanticJudgeProvider {
@@ -12,7 +12,7 @@ function normalise(answer) {
12
12
  }
13
13
  /**
14
14
  * In-memory fake judge for offline, deterministic tests. Configure with a single answer, a handler,
15
- * or answers keyed by `ruleId:title`, `ruleId:label` or `ruleId` (most specific wins). Unmatched calls get `pass`,
15
+ * or answers keyed by `ruleId:title`, `ruleId:id` or `ruleId` (most specific wins). Unmatched calls get `pass`,
16
16
  * or the rule's first declared verdict.
17
17
  */
18
18
  export class FakeJudgeProvider {
@@ -40,15 +40,12 @@ export class FakeJudgeProvider {
40
40
  if (isHandler(config))
41
41
  return await config({ node, rule });
42
42
  if (config instanceof Map) {
43
- return (config.get(`${rule.id}:${node.title}`) ??
44
- config.get(`${rule.id}:${node.label}`) ??
45
- config.get(rule.id) ??
46
- fallback);
43
+ return (config.get(`${rule.id}:${node.title}`) ?? config.get(`${rule.id}:${node.id}`) ?? config.get(rule.id) ?? fallback);
47
44
  }
48
45
  if (isAnswer(config))
49
46
  return config;
50
47
  const rec = config;
51
- return rec[`${rule.id}:${node.title}`] ?? rec[`${rule.id}:${node.label}`] ?? rec[rule.id] ?? fallback;
48
+ return rec[`${rule.id}:${node.title}`] ?? rec[`${rule.id}:${node.id}`] ?? rec[rule.id] ?? fallback;
52
49
  }
53
50
  clearHistory() {
54
51
  this.callHistory = [];
@@ -109,7 +109,7 @@ function nodeState(node, related = []) {
109
109
  return {
110
110
  title: node.title,
111
111
  type: node.resolvedType,
112
- parents: node.resolvedParents?.map((p) => p.title) ?? [],
112
+ parents: node.parentTitles,
113
113
  ...node.schemaData,
114
114
  ...(related.length > 0 && {
115
115
  relatedNodes: related.map((r) => ({
@@ -22,7 +22,7 @@ export class OpenAIDecisionsJudgeProvider {
22
22
  const stateLines = [
23
23
  `Title: ${node.title}`,
24
24
  `Type: ${node.resolvedType}`,
25
- ...(node.resolvedParents?.length ? [`Parents: ${node.resolvedParents.map((p) => p.title).join(', ')}`] : []),
25
+ ...(node.parentTitles.length ? [`Parents: ${node.parentTitles.join(', ')}`] : []),
26
26
  ...fieldLines(node.schemaData ?? {}),
27
27
  ...(input.related ?? []).flatMap((r) => [
28
28
  '',
@@ -1,4 +1,4 @@
1
- import type { SemanticJudgeInput } from '../types.js';
1
+ import type { SemanticJudgeInput } from './types';
2
2
  /** The per-node input cap: estimated tokens of one node's input (the node and its related nodes), the same for every provider. */
3
3
  export declare const PER_NODE_INPUT_CAP_TOKENS = 32000;
4
4
  /** Estimated tokens of a value: its JSON length divided by 4. Provider-independent, and roughly right is enough. */
@@ -26,7 +26,10 @@ export interface RelatedNode {
26
26
  */
27
27
  export interface SemanticJudgeInput {
28
28
  /** The evaluated node. Its body is in `schemaData.content` only when the rules' `includeContent` asks for it. */
29
- node: Pick<SpaceNode, 'label' | 'title' | 'type' | 'resolvedType' | 'schemaData' | 'resolvedParents'>;
29
+ node: Pick<SpaceNode, 'id' | 'title' | 'type' | 'resolvedType' | 'schemaData'> & {
30
+ /** Display titles of the node's parents. */
31
+ parentTitles: string[];
32
+ };
30
33
  /** Nodes the rules' `scope` adds, as a flat list. Absent when the scope is empty. */
31
34
  related?: RelatedNode[];
32
35
  rules: SemanticRuleSpec[];
@@ -1,5 +1,5 @@
1
1
  import type { Rule, RuleSeverity } from '../types';
2
- import type { SemanticJudgeResult, SemanticRuleSpec } from '../types.js';
2
+ import type { SemanticJudgeResult, SemanticRuleSpec } from './types';
3
3
  /** Verdicts for a semantic rule that declares none. */
4
4
  export declare const DEFAULT_VERDICTS: Record<string, string>;
5
5
  /** A semantic rule resolved for evaluation: what the judge sees, and what each verdict means for the user. */
@@ -1,6 +1,6 @@
1
- import type { Config } from './config.js';
2
- import { type SpaceGraph } from './space-graph.js';
3
- import type { ReadSpaceResult, SpaceContext } from './types.js';
1
+ import type { Config } from './config';
2
+ import { type SpaceGraph } from './space-graph';
3
+ import type { ReadSpaceResult, SpaceContext } from './types';
4
4
  export interface AssembleSpaceGraphOptions {
5
5
  /** Named view (resolved via space.views) or a raw filter DSL expression. */
6
6
  filter?: string;
@@ -1,7 +1,7 @@
1
- import { filterNodes } from './filter/filter-nodes.js';
2
- import { readSpace } from './read/read-space.js';
3
- import { createSpaceContext } from './space-context.js';
4
- import { buildSpaceGraph } from './space-graph.js';
1
+ import { filterNodes } from './filter/filter-nodes';
2
+ import { readSpace } from './read/read-space';
3
+ import { createSpaceContext } from './space-context';
4
+ import { buildSpaceGraph } from './space-graph';
5
5
  /**
6
6
  * Assemble a SpaceGraph from a space context: read the space, drop nodes that
7
7
  * fail schema validation, build the graph for the selected hierarchy (the main
@@ -2,7 +2,7 @@ import { existsSync, readdirSync } from 'node:fs';
2
2
  import { dirname, isAbsolute, join, resolve } from 'node:path';
3
3
  import Ajv2020 from 'ajv/dist/2020';
4
4
  import { getConfigSourceFiles } from '../config';
5
- import { builtinPlugins } from '../index.js';
5
+ import { builtinPlugins } from '.';
6
6
  import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX, shortenPluginName, } from './util';
7
7
  /**
8
8
  * Walk a plugin's configSchema and resolve any string fields annotated with
@@ -32,6 +32,12 @@ export declare const MARKDOWN_CONFIG_SCHEMA: {
32
32
  readonly templatePrefix: {
33
33
  readonly type: "string";
34
34
  };
35
+ readonly folderIndex: {
36
+ readonly type: "array";
37
+ readonly items: {
38
+ readonly type: "string";
39
+ };
40
+ };
35
41
  readonly typeInference: {
36
42
  readonly type: "object";
37
43
  readonly properties: {
@@ -18,6 +18,7 @@ export const MARKDOWN_CONFIG_SCHEMA = {
18
18
  templateDir: { type: 'string', format: 'path' }, // format is hint to config loader to resolve relative directories
19
19
  fieldMap: { type: 'object', additionalProperties: { type: 'string' } },
20
20
  templatePrefix: { type: 'string' },
21
+ folderIndex: { type: 'array', items: { type: 'string' } },
21
22
  typeInference: TYPE_INFERENCE_CONFIG_SCHEMA,
22
23
  },
23
24
  additionalProperties: false,
@@ -57,10 +57,15 @@ export declare function normalizeHeadingSectionTarget(rawHeadingText: string): s
57
57
  export declare function defaultNodeType(stack: StackEntry[], hierarchy: readonly string[]): string;
58
58
  export interface ExtractEmbeddedOptions {
59
59
  /**
60
- * Title of the containing page. If provided (and pageType is not a space_on_a_page type),
60
+ * The containing page. If provided (and pageType is not a space_on_a_page type),
61
61
  * the page acts as a virtual depth-0 parent for first-level embedded headings.
62
+ * `id` is the page's node id (embedded node ids are `<id>#<title>`); `targets` are its
63
+ * link targets, most specific first (the first is used in generated parent links).
62
64
  */
63
- pageTitle?: string;
65
+ page?: {
66
+ id: string;
67
+ targets: string[];
68
+ };
64
69
  /**
65
70
  * Node type of the containing page.
66
71
  * - If set to a real node type: only headings with an explicit `[type:: x]` field or a
@@ -42,7 +42,7 @@ function appendParentField(parentNode, field, linkRef) {
42
42
  fieldValue.push(linkRef);
43
43
  }
44
44
  else {
45
- throw new Error(`Cannot append child link to field '${field}' on node '${parentNode.label}': ` +
45
+ throw new Error(`Cannot append child link to field '${field}' on node '${parentNode.id}': ` +
46
46
  `field exists but is not an array (found ${typeof fieldValue}). ` +
47
47
  `Child link: ${linkRef}`);
48
48
  }
@@ -219,7 +219,7 @@ function isFieldOnlyLine(line) {
219
219
  return hasBracketed;
220
220
  return /^[a-zA-Z][a-zA-Z0-9_-]*:: *.*$/.test(cleanText);
221
221
  }
222
- function processListItem(item, parentRef, contentTarget, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, parentFieldAppend, activeNodeFieldAppend, file, lineOffset, walk) {
222
+ function processListItem(item, parentRef, contentTarget, nodes, makeId, buildLinkTargets, typeAliases, fieldMap, pendingType, parentFieldAppend, activeNodeFieldAppend, file, lineOffset, walk) {
223
223
  const itemEnd = offsetsOf(item).end;
224
224
  const firstPara = item.children.find((c) => c.type === 'paragraph');
225
225
  if (!firstPara) {
@@ -270,7 +270,7 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
270
270
  : undefined;
271
271
  const linkTargets = buildLinkTargets(title);
272
272
  const newNode = {
273
- label: makeLabel(title),
273
+ id: makeId(title),
274
274
  title,
275
275
  schemaData,
276
276
  linkTargets,
@@ -294,7 +294,7 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
294
294
  continue;
295
295
  if (child.type === 'list') {
296
296
  for (const subItem of child.children) {
297
- processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, undefined, undefined, file, lineOffset, walk);
297
+ processListItem(subItem, nestedParentRef, newNode, nodes, makeId, buildLinkTargets, typeAliases, fieldMap, pendingType, undefined, undefined, file, lineOffset, walk);
298
298
  }
299
299
  }
300
300
  else {
@@ -316,7 +316,7 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
316
316
  * (directory) to find embedded sub-nodes within a page's content.
317
317
  */
318
318
  export function extractEmbeddedNodes(body, options) {
319
- const { pageTitle, pageType, metadata, fieldMap } = options;
319
+ const { page, pageType, metadata, fieldMap } = options;
320
320
  const levels = metadata.hierarchy?.levels ?? [];
321
321
  const hierarchy = levels.map((l) => l.type);
322
322
  const relationships = metadata.relationships ?? [];
@@ -325,7 +325,7 @@ export function extractEmbeddedNodes(body, options) {
325
325
  const nodes = [];
326
326
  // Preamble/root content sink - never added to nodes
327
327
  const rootNode = {
328
- label: '_root_',
328
+ id: '_root_',
329
329
  title: '_root_',
330
330
  schemaData: { type: 'space_on_a_page' },
331
331
  linkTargets: [],
@@ -335,8 +335,8 @@ export function extractEmbeddedNodes(body, options) {
335
335
  const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
336
336
  // In typed-page mode: stack starts with the page's own virtual entry (depth 0).
337
337
  // In space_on_a_page mode: stack starts empty (first heading has no parent).
338
- const stack = !isOnAPageMode && pageTitle !== undefined
339
- ? [{ depth: 0, title: pageTitle, nodeType: pageType, refTarget: pageTitle }]
338
+ const stack = !isOnAPageMode && page !== undefined
339
+ ? [{ depth: 0, title: page.targets[0] ?? page.id, nodeType: pageType, refTarget: page.targets[0] ?? page.id }]
340
340
  : [];
341
341
  let parseState = isOnAPageMode ? 'preamble' : 'active';
342
342
  let preambleNodeCount = 0;
@@ -442,8 +442,8 @@ export function extractEmbeddedNodes(body, options) {
442
442
  }
443
443
  return undefined;
444
444
  }
445
- function makeLabel(title) {
446
- return title;
445
+ function makeId(title) {
446
+ return (page ? `${page.id}#${title}` : title);
447
447
  }
448
448
  /**
449
449
  * Walk the stack backwards to find the deepest typed node entry (nodeType !== '').
@@ -486,24 +486,26 @@ export function extractEmbeddedNodes(body, options) {
486
486
  return g.headingNode;
487
487
  }
488
488
  function buildHeadingLinkTargets(rawHeadingText, title, anchor) {
489
- if (!pageTitle) {
489
+ if (!page) {
490
490
  return [title];
491
491
  }
492
492
  const targets = [];
493
493
  const sectionTarget = normalizeHeadingSectionTarget(rawHeadingText);
494
494
  if (sectionTarget) {
495
- targets.push(`${pageTitle}#${sectionTarget}`);
495
+ for (const pageTarget of page.targets)
496
+ targets.push(`${pageTarget}#${sectionTarget}`);
496
497
  }
497
498
  if (anchor) {
498
- targets.push(`${pageTitle}#^${anchor}`);
499
+ for (const pageTarget of page.targets)
500
+ targets.push(`${pageTarget}#^${anchor}`);
499
501
  }
500
502
  return targets.length > 0 ? targets : [title];
501
503
  }
502
504
  function buildListItemLinkTargets(title) {
503
- if (!pageTitle)
505
+ if (!page)
504
506
  return [title];
505
507
  const normalized = normalizeHeadingSectionTarget(title);
506
- return normalized ? [`${pageTitle}#${normalized}`] : [title];
508
+ return normalized ? page.targets.map((pageTarget) => `${pageTarget}#${normalized}`) : [title];
507
509
  }
508
510
  let grouping = null;
509
511
  let activeNode = rootNode;
@@ -604,7 +606,7 @@ export function extractEmbeddedNodes(body, options) {
604
606
  : undefined;
605
607
  const linkTargets = buildHeadingLinkTargets(rawText, title, anchor);
606
608
  const headingNode = {
607
- label: makeLabel(title),
609
+ id: makeId(title),
608
610
  title,
609
611
  schemaData,
610
612
  linkTargets,
@@ -655,14 +657,14 @@ export function extractEmbeddedNodes(body, options) {
655
657
  : undefined;
656
658
  const walk = { claims, pos: spanStart };
657
659
  for (const item of list.children) {
658
- processListItem(item, isParentSide ? undefined : semanticParent.ref, grouping.headingNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, definition.type, parentFieldAppendArg, activeNodeFieldAppendArg, options.file, options.lineOffset, walk);
660
+ processListItem(item, isParentSide ? undefined : semanticParent.ref, grouping.headingNode, nodes, makeId, buildListItemLinkTargets, typeAliases, fieldMap, definition.type, parentFieldAppendArg, activeNodeFieldAppendArg, options.file, options.lineOffset, walk);
659
661
  }
660
662
  grouping = null;
661
663
  }
662
664
  else {
663
665
  const walk = { claims, pos: spanStart };
664
666
  for (const item of list.children) {
665
- processListItem(item, parentRef, activeNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, undefined, undefined, undefined, options.file, options.lineOffset, walk);
667
+ processListItem(item, parentRef, activeNode, nodes, makeId, buildListItemLinkTargets, typeAliases, fieldMap, undefined, undefined, undefined, options.file, options.lineOffset, walk);
666
668
  }
667
669
  }
668
670
  }
@@ -755,7 +757,7 @@ export function extractEmbeddedNodes(body, options) {
755
757
  : undefined;
756
758
  const linkTargets = buildListItemLinkTargets(title);
757
759
  const rowNode = {
758
- label: makeLabel(title),
760
+ id: makeId(title),
759
761
  title,
760
762
  schemaData,
761
763
  linkTargets,
@@ -788,7 +790,7 @@ export function extractEmbeddedNodes(body, options) {
788
790
  const { fields: unbracketedFields } = extractUnbracketedFields(afterBracketed);
789
791
  const allFields = applyFieldMap({ ...unbracketedFields, ...bracketedFields }, fieldMap);
790
792
  if ('type' in allFields) {
791
- throw new Error(`Type override via paragraph field is not supported at "${activeNode.schemaData.title ?? activeNode.label}". ` +
793
+ throw new Error(`Type override via paragraph field is not supported at "${activeNode.schemaData.title ?? activeNode.id}". ` +
792
794
  `Put [type:: ${allFields.type}] directly in the heading text.`);
793
795
  }
794
796
  Object.assign(activeNode.schemaData, allFields);
@@ -817,7 +819,7 @@ export function extractEmbeddedNodes(body, options) {
817
819
  consumeBlock(activeNode);
818
820
  }
819
821
  else if (Array.isArray(parsed)) {
820
- throw new Error(`YAML block must be an object at "${activeNode.label}".`);
822
+ throw new Error(`YAML block must be an object at "${activeNode.id}".`);
821
823
  }
822
824
  else {
823
825
  claimBlock(activeNode);
@@ -5,7 +5,7 @@ import matter from 'gray-matter';
5
5
  import { extractSchemaTypeNames } from '../../schema/schema';
6
6
  import { extractLinksFromBody, extractLinksFromFrontmatter, getEdgeFieldNames } from './extract-content-links';
7
7
  import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded';
8
- import { applyFieldMap, inferTypeFromPath, MATTER_OPTIONS } from './util';
8
+ import { applyFieldMap, defaultPageTitle, inferTypeFromPath, MATTER_OPTIONS, pathLinkTargets } from './util';
9
9
  export function readSpaceOnAPage(context) {
10
10
  const { space, resolvedSchemaPath, schema: { metadata }, } = context;
11
11
  const filePath = resolve(space.path);
@@ -20,12 +20,11 @@ export function readSpaceOnAPage(context) {
20
20
  if (!hierarchyLevels || hierarchyLevels.length === 0) {
21
21
  throw new Error(`Schema at ${resolvedSchemaPath} must define "$metadata.hierarchy.levels" to read a space_on_a_page file.`);
22
22
  }
23
- const pageTitle = basename(filePath, '.md');
24
23
  const fmMatch = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
25
24
  const lineOffset = fmMatch ? (fmMatch[0].match(/\n/g) || []).length : 0;
26
25
  const fileName = basename(filePath);
27
26
  const { nodes, preambleNodeCount, terminatedHeadings } = extractEmbeddedNodes(body, {
28
- pageTitle,
27
+ page: { id: fileName, targets: pathLinkTargets(fileName) },
29
28
  pageType: 'space_on_a_page',
30
29
  metadata,
31
30
  file: fileName,
@@ -98,15 +97,17 @@ export async function readSpaceDirectory(context, options) {
98
97
  continue;
99
98
  }
100
99
  const pageType = data.type;
101
- const fileBase = basename(file, '.md');
102
- const title = data.title ?? fileBase;
100
+ const explicitTitle = data.title;
101
+ const title = explicitTitle ?? defaultPageTitle(file, mdCfg.folderIndex);
102
+ const pathTargets = pathLinkTargets(file);
103
103
  const fmMatch = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
104
104
  const lineOffset = fmMatch ? (fmMatch[0].match(/\n/g) || []).length : 0;
105
105
  const pageNode = {
106
- label: file,
106
+ id: file,
107
107
  title,
108
108
  schemaData: { title, ...data },
109
- linkTargets: [title, fileBase],
109
+ // A title derived from a folder index is display only, never a link target.
110
+ linkTargets: [...new Set([...(explicitTitle !== undefined ? [explicitTitle] : []), ...pathTargets])],
110
111
  type: pageType,
111
112
  source: { file, line: 1 },
112
113
  ...(data.content !== undefined ? { content: data.content } : {}),
@@ -114,7 +115,7 @@ export async function readSpaceDirectory(context, options) {
114
115
  };
115
116
  if (!ON_A_PAGE_TYPES.includes(pageType)) {
116
117
  const { nodes: embedded, preambleContent, preambleFields, terminatedHeadings, } = extractEmbeddedNodes(parsed.content, {
117
- pageTitle: fileBase,
118
+ page: { id: file, targets: pathTargets },
118
119
  pageType,
119
120
  metadata,
120
121
  fieldMap,
@@ -6,14 +6,14 @@ export function renderBullets(graph) {
6
6
  const indent = ' '.repeat(depth);
7
7
  const type = node.resolvedType;
8
8
  const title = node.title;
9
- const nodeChildren = children.get(title) ?? [];
10
- if (seen.has(title)) {
9
+ const nodeChildren = children.get(node.id) ?? [];
10
+ if (seen.has(node.id)) {
11
11
  if (nodeChildren.length > 0) {
12
12
  lines.push(`${indent}- ${type}: ${title} (*)`);
13
13
  }
14
14
  return;
15
15
  }
16
- seen.add(title);
16
+ seen.add(node.id);
17
17
  lines.push(`${indent}- ${type}: ${title}`);
18
18
  for (const child of nodeChildren) {
19
19
  renderNode(child, depth + 1);
@@ -1,6 +1,6 @@
1
1
  import { posix } from 'node:path';
2
2
  import matter from 'gray-matter';
3
- import { getMarkdownConfig } from '../../index.js';
3
+ import { getMarkdownConfig } from './index';
4
4
  import { invertFieldMap, MATTER_OPTIONS } from './util';
5
5
  /**
6
6
  * Sanitize a node title into a valid filesystem filename.
@@ -1,5 +1,5 @@
1
1
  import { type DumpOptions, type Schema } from 'js-yaml';
2
- import type { TypeInferenceConfig } from '../../index.js';
2
+ import type { TypeInferenceConfig } from '.';
3
3
  export declare function inferTypeFromPath(filePath: string, config: TypeInferenceConfig, knownTypes: Set<string>, typeAliases: Record<string, string> | undefined): string | undefined;
4
4
  /**
5
5
  * YAML schema for frontmatter and embedded YAML blocks: YAML 1.2 Core schema extended with
@@ -38,3 +38,14 @@ export declare function applyFieldMap(data: Record<string, unknown>, fieldMap: R
38
38
  * Used for write operations (e.g. template-sync) to translate back to file field names.
39
39
  */
40
40
  export declare function invertFieldMap(fieldMap: Record<string, string>): Record<string, string>;
41
+ /**
42
+ * Link targets for a page, most specific first: the space-relative path without `.md` and every shorter
43
+ * path suffix down to the basename. Obsidian writes these when a bare name is ambiguous.
44
+ */
45
+ export declare function pathLinkTargets(file: string): string[];
46
+ /**
47
+ * Display title for a page without an explicit `title`: the containing folder's name when the file is a
48
+ * folder index (its basename is listed in `folderIndex`, case-insensitively), otherwise the basename.
49
+ * An index at the space root has no folder to name it and keeps its basename.
50
+ */
51
+ export declare function defaultPageTitle(file: string, folderIndex: string[] | undefined): string;
@@ -119,3 +119,24 @@ export function applyFieldMap(data, fieldMap) {
119
119
  export function invertFieldMap(fieldMap) {
120
120
  return Object.fromEntries(Object.entries(fieldMap).map(([src, canonical]) => [canonical, src]));
121
121
  }
122
+ /**
123
+ * Link targets for a page, most specific first: the space-relative path without `.md` and every shorter
124
+ * path suffix down to the basename. Obsidian writes these when a bare name is ambiguous.
125
+ */
126
+ export function pathLinkTargets(file) {
127
+ const segments = file.replace(/\\/g, '/').replace(/\.md$/i, '').split('/');
128
+ return segments.map((_, i) => segments.slice(i).join('/'));
129
+ }
130
+ /**
131
+ * Display title for a page without an explicit `title`: the containing folder's name when the file is a
132
+ * folder index (its basename is listed in `folderIndex`, case-insensitively), otherwise the basename.
133
+ * An index at the space root has no folder to name it and keeps its basename.
134
+ */
135
+ export function defaultPageTitle(file, folderIndex) {
136
+ const segments = file.replace(/\\/g, '/').replace(/\.md$/i, '').split('/');
137
+ const base = segments[segments.length - 1];
138
+ const folder = segments[segments.length - 2];
139
+ if (folder !== undefined && folderIndex?.some((name) => name.toLowerCase() === base.toLowerCase()))
140
+ return folder;
141
+ return base;
142
+ }
@@ -15,7 +15,7 @@ function safeNodeId(id) {
15
15
  return id.replace(/[^a-zA-Z0-9_-]/g, '_');
16
16
  }
17
17
  export function renderMermaid(graph) {
18
- const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyTitles: hierarchyNodeSet } = graph;
18
+ const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyIds: hierarchyNodeSet } = graph;
19
19
  const levels = graph.hierarchy?.levels ?? [];
20
20
  let mmd = 'graph TD\n';
21
21
  for (let i = 0; i < levels.length; i++) {
@@ -25,21 +25,20 @@ export function renderMermaid(graph) {
25
25
  }
26
26
  const addedNodes = new Set();
27
27
  function addNodeAndChildren(node) {
28
- const nodeId = node.title;
28
+ const nodeId = node.id;
29
29
  if (addedNodes.has(nodeId))
30
30
  return;
31
31
  addedNodes.add(nodeId);
32
32
  const type = node.resolvedType;
33
33
  const priority = node.schemaData.priority;
34
- const label = priority ? `${nodeId} (${priority})` : nodeId;
34
+ const label = priority ? `${node.title} (${priority})` : node.title;
35
35
  const safeId = safeNodeId(nodeId);
36
36
  const escapedLabel = escapeMermaidString(label);
37
37
  mmd += ` ${safeId}["${escapedLabel}"]:::${type}\n`;
38
38
  const nodeChildren = children.get(nodeId) ?? [];
39
39
  for (const child of nodeChildren) {
40
- const childTitle = child.title;
41
- if (hierarchyNodeSet.has(childTitle)) {
42
- const safeChildId = safeNodeId(childTitle);
40
+ if (hierarchyNodeSet.has(child.id)) {
41
+ const safeChildId = safeNodeId(child.id);
43
42
  mmd += ` ${safeId} --> ${safeChildId}\n`;
44
43
  addNodeAndChildren(child);
45
44
  }
@@ -30,7 +30,7 @@ export function computeNodeHash(node) {
30
30
  status: node.schemaData.status,
31
31
  summary: node.schemaData.summary,
32
32
  priority: node.schemaData.priority,
33
- parents: node.resolvedParents.filter((r) => r.source === 'hierarchy').map((r) => r.title),
33
+ parents: node.resolvedParents.filter((r) => r.source === 'hierarchy').map((r) => r.id),
34
34
  };
35
35
  return createHash('sha256').update(JSON.stringify(relevant)).digest('hex').slice(0, 16);
36
36
  }
@@ -17,7 +17,7 @@ export async function miroSync(context, graph, options) {
17
17
  throw new Error('No frameId in miro plugin config. Pass --new-frame "Title" to create one.');
18
18
  // 3. Filter to hierarchy nodes only (graph already built by caller)
19
19
  const levels = graph.hierarchy?.levels ?? [];
20
- const nodes = [...graph.nodes.values()].filter((n) => graph.hierarchyTitles.has(n.title));
20
+ const nodes = [...graph.nodes.values()].filter((n) => graph.hierarchyIds.has(n.id));
21
21
  if (nodes.length === 0) {
22
22
  return 'No space nodes found.';
23
23
  }
@@ -257,9 +257,10 @@ export async function miroSync(context, graph, options) {
257
257
  const desiredEdges = new Map();
258
258
  for (const node of nodes) {
259
259
  const childTitle = node.title;
260
- for (const { title: parentTitle, source, hierarchy } of node.resolvedParents) {
260
+ for (const { id: parentId, source, hierarchy } of node.resolvedParents) {
261
261
  if (source !== 'hierarchy' || hierarchy !== graph.hierarchy?.name)
262
262
  continue;
263
+ const parentTitle = graph.nodes.get(parentId)?.title ?? parentId;
263
264
  // Both endpoints must have verified cards on the board
264
265
  if (verifiedCardIds.has(parentTitle) && verifiedCardIds.has(childTitle)) {
265
266
  const key = `${parentTitle}\u2192${childTitle}`;