contentbase 0.0.2 → 0.0.4

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 (85) hide show
  1. package/.mcp.json +9 -0
  2. package/CLI.md +593 -0
  3. package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
  4. package/MCP-SERVER-SPEC.md +453 -0
  5. package/PRIMER.md +540 -0
  6. package/README.md +289 -13
  7. package/bun.lock +43 -4
  8. package/dist/cnotes +0 -0
  9. package/docs/README.md +110 -0
  10. package/docs/TABLE-OF-CONTENTS.md +7 -0
  11. package/docs/models.ts +38 -0
  12. package/models.ts +38 -0
  13. package/package.json +12 -3
  14. package/public/web-demo/index.html +813 -0
  15. package/showcases/node_modules/.cache/.repl_history +3 -0
  16. package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
  17. package/src/__tests__/semantic-search.integration.test.ts +284 -0
  18. package/src/api/endpoints/actions.ts +34 -0
  19. package/src/api/endpoints/doc.ts +187 -0
  20. package/src/api/endpoints/docs-index.ts +26 -0
  21. package/src/api/endpoints/document.ts +171 -0
  22. package/src/api/endpoints/documents.ts +71 -0
  23. package/src/api/endpoints/inspect.ts +16 -0
  24. package/src/api/endpoints/models.ts +10 -0
  25. package/src/api/endpoints/query.ts +88 -0
  26. package/src/api/endpoints/root.ts +7 -0
  27. package/src/api/endpoints/search-reindex.ts +65 -0
  28. package/src/api/endpoints/search-status.ts +55 -0
  29. package/src/api/endpoints/search.ts +104 -0
  30. package/src/api/endpoints/semantic-search.ts +120 -0
  31. package/src/api/endpoints/text-search.ts +63 -0
  32. package/src/api/endpoints/validate.ts +34 -0
  33. package/src/api/helpers.ts +97 -0
  34. package/src/base-model.ts +12 -0
  35. package/src/cli/commands/action.ts +82 -44
  36. package/src/cli/commands/console.ts +124 -0
  37. package/src/cli/commands/create.ts +179 -53
  38. package/src/cli/commands/embed.ts +323 -0
  39. package/src/cli/commands/export.ts +58 -24
  40. package/src/cli/commands/extract.ts +174 -0
  41. package/src/cli/commands/help.ts +81 -0
  42. package/src/cli/commands/index.ts +17 -0
  43. package/src/cli/commands/init.ts +72 -48
  44. package/src/cli/commands/inspect.ts +56 -46
  45. package/src/cli/commands/mcp.ts +1219 -0
  46. package/src/cli/commands/search.ts +285 -0
  47. package/src/cli/commands/serve.ts +348 -0
  48. package/src/cli/commands/summary.ts +60 -0
  49. package/src/cli/commands/teach.ts +86 -0
  50. package/src/cli/commands/text-search.ts +134 -0
  51. package/src/cli/commands/validate.ts +126 -64
  52. package/src/cli/index.ts +88 -19
  53. package/src/cli/load-collection.ts +144 -17
  54. package/src/cli/registry.ts +28 -0
  55. package/src/collection.ts +361 -10
  56. package/src/define-model.ts +101 -4
  57. package/src/document.ts +47 -1
  58. package/src/extract-sections.ts +1 -1
  59. package/src/index.ts +14 -2
  60. package/src/model-instance.ts +35 -5
  61. package/src/node-shortcuts.ts +1 -1
  62. package/src/query/collection-query.ts +96 -9
  63. package/src/query/index.ts +7 -0
  64. package/src/query/query-dsl.ts +259 -0
  65. package/src/relationships/has-many.ts +39 -0
  66. package/src/relationships/index.ts +7 -2
  67. package/src/section.ts +2 -0
  68. package/src/types.ts +23 -1
  69. package/src/utils/index.ts +1 -0
  70. package/src/utils/match-pattern.ts +65 -0
  71. package/src/validator.ts +18 -1
  72. package/test/collection.test.ts +118 -2
  73. package/test/fixtures/sdlc/MODELS.md +106 -0
  74. package/test/fixtures/sdlc/SKILL.md +404 -0
  75. package/test/fixtures/sdlc/index.ts +9 -0
  76. package/test/fixtures/sdlc/models.ts +8 -6
  77. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
  78. package/test/fixtures/sdlc/templates/epic.md +23 -0
  79. package/test/fixtures/sdlc/templates/story.md +19 -0
  80. package/test/pattern.test.ts +191 -0
  81. package/test/query-dsl.test.ts +431 -0
  82. package/test/query.test.ts +29 -0
  83. package/test/relationships.test.ts +130 -0
  84. package/test/section.test.ts +61 -0
  85. package/test/table-of-contents.test.ts +49 -5
package/src/collection.ts CHANGED
@@ -1,17 +1,110 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
3
  import matter from "gray-matter";
4
+ import picomatch from "picomatch";
4
5
  import { Document } from "./document";
5
6
  import { CollectionQuery } from "./query/collection-query";
6
7
  import { createModelInstance } from "./model-instance";
7
8
  import { readDirectory } from "./utils/read-directory";
9
+ import { pluralize } from "./utils/inflect";
10
+ import { Base } from "./base-model";
8
11
  import type {
9
12
  ModelDefinition,
10
13
  CollectionItem,
11
14
  CollectionOptions,
12
15
  InferModelInstance,
16
+ HasManyDefinition,
17
+ RelationshipDefinition,
13
18
  } from "./types";
14
19
 
20
+ // ─── Zod schema introspection ───
21
+
22
+ export interface FieldInfo {
23
+ name: string;
24
+ type: string;
25
+ required: boolean;
26
+ defaultValue?: unknown;
27
+ description?: string;
28
+ }
29
+
30
+ function describeZodType(schema: any): { type: string; defaultValue?: unknown; optional: boolean; description?: string } {
31
+ const def = schema?._zod?.def;
32
+ if (!def) return { type: "unknown", optional: false };
33
+
34
+ const description = schema.description ?? def.description;
35
+
36
+ let optional = schema._zod.optout === "optional";
37
+ let defaultValue: unknown = undefined;
38
+
39
+ if (def.type === "default") {
40
+ defaultValue = def.defaultValue;
41
+ const inner = describeZodType(def.innerType);
42
+ return { type: inner.type, defaultValue, optional: true, description: description ?? inner.description };
43
+ }
44
+
45
+ if (def.type === "optional") {
46
+ const inner = describeZodType(def.innerType);
47
+ return { ...inner, optional: true, description: description ?? inner.description };
48
+ }
49
+
50
+ if (def.type === "nullable") {
51
+ const inner = describeZodType(def.innerType);
52
+ return { type: `${inner.type} | null`, optional: inner.optional, defaultValue: inner.defaultValue, description: description ?? inner.description };
53
+ }
54
+
55
+ if (def.type === "enum") {
56
+ const values = Object.keys(def.entries);
57
+ return { type: `enum(\`${values.join("`, `")}\`)`, optional, description };
58
+ }
59
+
60
+ if (def.type === "array") {
61
+ const element = describeZodType(def.element);
62
+ return { type: `${element.type}[]`, optional, description };
63
+ }
64
+
65
+ if (def.type === "record") {
66
+ const valType = describeZodType(def.valueType);
67
+ return { type: `record<string, ${valType.type}>`, optional, description };
68
+ }
69
+
70
+ if (def.type === "literal") {
71
+ return { type: `literal(${JSON.stringify(def.value)})`, optional, description };
72
+ }
73
+
74
+ if (def.type === "union") {
75
+ const options = (def.options as any[]).map((o: any) => describeZodType(o).type);
76
+ return { type: options.join(" | "), optional, description };
77
+ }
78
+
79
+ return { type: def.type ?? "unknown", optional, description };
80
+ }
81
+
82
+ export function introspectMetaSchema(schema: any): FieldInfo[] {
83
+ const shape = schema?._zod?.def?.shape;
84
+ if (!shape) return [];
85
+
86
+ return Object.entries(shape).map(([name, fieldSchema]) => {
87
+ const info = describeZodType(fieldSchema);
88
+ return {
89
+ name,
90
+ type: info.type,
91
+ required: !info.optional,
92
+ ...(info.defaultValue !== undefined && { defaultValue: info.defaultValue }),
93
+ ...(info.description && { description: info.description }),
94
+ };
95
+ });
96
+ }
97
+
98
+ function isModelDefinition(value: unknown): boolean {
99
+ return (
100
+ typeof value === "object" &&
101
+ value !== null &&
102
+ typeof (value as any).name === "string" &&
103
+ typeof (value as any).prefix === "string" &&
104
+ "meta" in (value as any)
105
+ );
106
+ }
107
+
15
108
  export class Collection {
16
109
  readonly rootPath: string;
17
110
  readonly name: string;
@@ -23,11 +116,13 @@ export class Collection {
23
116
  #actions: Map<string, (collection: Collection, ...args: any[]) => any> =
24
117
  new Map();
25
118
  #loaded = false;
119
+ #autoDiscover: boolean;
26
120
 
27
121
  constructor(options: CollectionOptions) {
28
122
  this.rootPath = path.resolve(options.rootPath);
29
123
  this.name = options.name ?? options.rootPath;
30
124
  this.extensions = options.extensions ?? ["mdx", "md"];
125
+ this.#autoDiscover = options.autoDiscover ?? true;
31
126
  }
32
127
 
33
128
  // ─── Model registration ───
@@ -54,6 +149,29 @@ export class Collection {
54
149
  return Array.from(this.#models.values());
55
150
  }
56
151
 
152
+ // ─── Model auto-discovery ───
153
+
154
+ /**
155
+ * Discover and register model definitions from a
156
+ * models.{ts,js,mjs} file in the collection's root path.
157
+ */
158
+ async #discoverModels(): Promise<void> {
159
+ for (const ext of ["ts", "js", "mjs"]) {
160
+ const candidate = path.resolve(this.rootPath, `models.${ext}`);
161
+ try {
162
+ const mod = await import(candidate);
163
+ for (const value of Object.values(mod)) {
164
+ if (isModelDefinition(value) && !this.#models.has((value as any).name)) {
165
+ this.register(value as any);
166
+ }
167
+ }
168
+ return;
169
+ } catch {
170
+ continue;
171
+ }
172
+ }
173
+ }
174
+
57
175
  // ─── Loading ───
58
176
 
59
177
  get loaded(): boolean {
@@ -69,7 +187,28 @@ export class Collection {
69
187
  }
70
188
 
71
189
  get available(): string[] {
72
- return Array.from(this.#items.keys());
190
+ return Array.from(this.#items.keys()).filter(
191
+ (id) => !this.#isExcludedByModel(id)
192
+ );
193
+ }
194
+
195
+ /**
196
+ * Check if a pathId is excluded by any model's exclude patterns.
197
+ * Patterns are matched against the full pathId using picomatch (globs) or RegExp.
198
+ */
199
+ #isExcludedByModel(pathId: string): boolean {
200
+ for (const def of this.modelDefinitions) {
201
+ if (!def.exclude || def.exclude.length === 0) continue;
202
+ if (!pathId.startsWith(def.prefix)) continue;
203
+ for (const pattern of def.exclude) {
204
+ if (pattern instanceof RegExp) {
205
+ if (pattern.test(pathId)) return true;
206
+ } else {
207
+ if (picomatch.isMatch(pathId, pattern)) return true;
208
+ }
209
+ }
210
+ }
211
+ return false;
73
212
  }
74
213
 
75
214
  /**
@@ -83,6 +222,16 @@ export class Collection {
83
222
  return this;
84
223
  }
85
224
 
225
+ // Auto-discover models if none have been manually registered
226
+ if (this.#models.size === 0 && this.#autoDiscover) {
227
+ await this.#discoverModels();
228
+ }
229
+
230
+ // Auto-register Base model as catch-all if not already registered
231
+ if (!this.#models.has("Base")) {
232
+ this.register(Base);
233
+ }
234
+
86
235
  if (this.#loaded && refresh) {
87
236
  this.#items.clear();
88
237
  }
@@ -96,6 +245,11 @@ export class Collection {
96
245
  await Promise.all(
97
246
  paths.map(async (filePath) => {
98
247
  const pathId = this.getPathId(filePath);
248
+
249
+ // Globally exclude templates directory — templates are only used
250
+ // for scaffolding and should never appear in queries or listings
251
+ if (pathId.startsWith("templates/") || pathId === "templates") return;
252
+
99
253
  const raw = await fs.readFile(filePath, "utf8");
100
254
  const stat = await fs.stat(filePath);
101
255
  const { data, content } = matter(raw);
@@ -107,12 +261,19 @@ export class Collection {
107
261
  path: filePath,
108
262
  createdAt: stat.ctime,
109
263
  updatedAt: stat.mtime,
264
+ size: stat.size,
110
265
  });
111
266
  })
112
267
  );
113
268
 
114
269
  // Refresh any already-created documents
115
270
  if (this.#loaded && refresh) {
271
+ // Evict documents that no longer exist on disk
272
+ for (const [pathId] of this.#documents) {
273
+ if (!this.#items.has(pathId)) {
274
+ this.#documents.delete(pathId);
275
+ }
276
+ }
116
277
  await Promise.all(
117
278
  Array.from(this.#documents.values()).map((doc) => doc.reload())
118
279
  );
@@ -135,6 +296,13 @@ export class Collection {
135
296
  );
136
297
  }
137
298
 
299
+ // Strip known extensions if provided (e.g. "some-doc.md" -> "some-doc")
300
+ const extensionPattern = new RegExp(
301
+ `\\.(${this.extensions.join("|")})$`,
302
+ "i"
303
+ );
304
+ pathId = pathId.replace(extensionPattern, "");
305
+
138
306
  let doc = this.#documents.get(pathId);
139
307
  if (doc) return doc;
140
308
 
@@ -198,13 +366,31 @@ export class Collection {
198
366
  const item = this.#items.get(pathId);
199
367
  if (!item) return undefined;
200
368
 
369
+ // 1. Explicit _model meta key takes priority
370
+ if (item.meta._model && typeof item.meta._model === "string") {
371
+ const explicit = this.#models.get(item.meta._model);
372
+ if (explicit) return explicit;
373
+ }
374
+
375
+ // 2. Check non-Base models by match/prefix
376
+ let baseModel: ModelDefinition<any, any, any, any, any> | undefined;
201
377
  for (const def of this.#models.values()) {
378
+ if (def.name === "Base") {
379
+ baseModel = def;
380
+ continue;
381
+ }
202
382
  if (def.match) {
203
383
  if (def.match({ id: pathId, meta: item.meta })) return def;
204
384
  } else {
205
385
  if (pathId.startsWith(def.prefix)) return def;
206
386
  }
207
387
  }
388
+
389
+ // 3. Fall back to Base model only for root-level documents (no subfolder)
390
+ if (baseModel && !pathId.includes("/")) {
391
+ return baseModel;
392
+ }
393
+
208
394
  return undefined;
209
395
  }
210
396
 
@@ -225,7 +411,13 @@ export class Collection {
225
411
  pathId: string,
226
412
  options: { content: string; extension?: string }
227
413
  ): Promise<CollectionItem> {
228
- const extension = options.extension ?? ".mdx";
414
+ const extension = options.extension ?? ".md";
415
+ if (options.content == null) {
416
+ throw new Error(
417
+ `saveItem("${pathId}"): content must be a string, got ${typeof options.content}. ` +
418
+ `Use doc.save() or pass the full raw content including frontmatter.`
419
+ );
420
+ }
229
421
  const { data, content } = matter(options.content);
230
422
 
231
423
  if (!this.#items.has(pathId)) {
@@ -237,6 +429,7 @@ export class Collection {
237
429
  path: filePath,
238
430
  createdAt: new Date(),
239
431
  updatedAt: new Date(),
432
+ size: Buffer.byteLength(options.content, "utf8"),
240
433
  });
241
434
  }
242
435
 
@@ -251,6 +444,10 @@ export class Collection {
251
444
  item.content = content;
252
445
  item.meta = data;
253
446
  item.updatedAt = new Date();
447
+ item.size = Buffer.byteLength(options.content, "utf8");
448
+
449
+ // Invalidate cached Document so the next query rebuilds from fresh data
450
+ this.#documents.delete(pathId);
254
451
 
255
452
  return item;
256
453
  }
@@ -271,7 +468,7 @@ export class Collection {
271
468
 
272
469
  async readItem(
273
470
  pathId: string,
274
- extension: string = "mdx"
471
+ extension: string = "md"
275
472
  ): Promise<CollectionItem> {
276
473
  let filePath: string;
277
474
 
@@ -292,6 +489,7 @@ export class Collection {
292
489
  path: filePath,
293
490
  createdAt: stat.ctime,
294
491
  updatedAt: stat.mtime,
492
+ size: stat.size,
295
493
  };
296
494
 
297
495
  this.#items.set(pathId, item);
@@ -412,8 +610,7 @@ export class Collection {
412
610
  const ids = grouped.get(def.name);
413
611
  if (!ids || ids.length === 0) continue;
414
612
 
415
- const depth = options.title ? "##" : "#";
416
- lines.push(`${depth} ${def.name}`, "");
613
+ lines.push(`## ${pluralize(def.name)}`, "");
417
614
  for (const id of ids) {
418
615
  lines.push(this.#tocEntry(id, basePath));
419
616
  }
@@ -421,8 +618,7 @@ export class Collection {
421
618
  }
422
619
 
423
620
  if (ungrouped.length > 0) {
424
- const depth = options.title ? "##" : "#";
425
- lines.push(`${depth} Other`, "");
621
+ lines.push(`## Other`, "");
426
622
  for (const id of ungrouped) {
427
623
  lines.push(this.#tocEntry(id, basePath));
428
624
  }
@@ -438,6 +634,69 @@ export class Collection {
438
634
  return lines.join("\n").trimEnd() + "\n";
439
635
  }
440
636
 
637
+ /**
638
+ * Renders an ASCII file tree of all documents in the collection.
639
+ *
640
+ * @example
641
+ * ```
642
+ * const tree = collection.renderFileTree();
643
+ * // epics/
644
+ * // authentication.mdx
645
+ * // searching-and-browsing.mdx
646
+ * // stories/
647
+ * // authentication/
648
+ * // a-user-should-be-able-to-register.mdx
649
+ * ```
650
+ */
651
+ renderFileTree(): string {
652
+ if (!this.#loaded) {
653
+ throw new Error(
654
+ "Collection has not been loaded. Call load() first."
655
+ );
656
+ }
657
+
658
+ const sorted = [...this.available].sort();
659
+
660
+ // Build a nested tree structure from pathIds
661
+ interface TreeNode {
662
+ children: Map<string, TreeNode>;
663
+ }
664
+ const root: TreeNode = { children: new Map() };
665
+
666
+ for (const pathId of sorted) {
667
+ const item = this.#items.get(pathId)!;
668
+ const ext = path.extname(item.path);
669
+ const fullPath = `${pathId}${ext}`;
670
+ const parts = fullPath.split("/");
671
+
672
+ let node = root;
673
+ for (const part of parts) {
674
+ if (!node.children.has(part)) {
675
+ node.children.set(part, { children: new Map() });
676
+ }
677
+ node = node.children.get(part)!;
678
+ }
679
+ }
680
+
681
+ // Render the tree with indentation and connector lines
682
+ const lines: string[] = [];
683
+
684
+ const render = (node: TreeNode, prefix: string) => {
685
+ const entries = [...node.children.entries()];
686
+ entries.forEach(([name, child], i) => {
687
+ const isLast = i === entries.length - 1;
688
+ const connector = isLast ? "└── " : "├── ";
689
+ const isDir = child.children.size > 0;
690
+ lines.push(`${prefix}${connector}${name}${isDir ? "/" : ""}`);
691
+ const nextPrefix = prefix + (isLast ? " " : "│ ");
692
+ render(child, nextPrefix);
693
+ });
694
+ };
695
+
696
+ render(root, "");
697
+ return lines.join("\n") + "\n";
698
+ }
699
+
441
700
  #tocEntry(pathId: string, basePath: string): string {
442
701
  const item = this.#items.get(pathId)!;
443
702
  const ext = path.extname(item.path);
@@ -446,20 +705,112 @@ export class Collection {
446
705
  return `- [${doc.title}](${relativePath})`;
447
706
  }
448
707
 
708
+ // ─── Model Summary ───
709
+
710
+ /**
711
+ * Generate a plain-text summary of the collection and its models.
712
+ * Returns the same output as `cnotes inspect`.
713
+ */
714
+ generateModelSummary(): string {
715
+ if (!this.#loaded) {
716
+ throw new Error("Collection has not been loaded. Call load() first.");
717
+ }
718
+
719
+ const lines: string[] = [];
720
+
721
+ lines.push(`Collection: ${this.name}`);
722
+ lines.push(`Root: ${this.rootPath}`);
723
+ lines.push(`Items: ${this.available.length}`);
724
+ lines.push("");
725
+
726
+ for (const def of this.modelDefinitions) {
727
+ const matchingItems = this.available.filter(
728
+ (id) => this.findModelDefinition(id)?.name === def.name
729
+ );
730
+ lines.push(` Model: ${def.name}`);
731
+ lines.push(` Prefix: ${def.prefix}`);
732
+ const fields = introspectMetaSchema(def.meta);
733
+ lines.push(
734
+ ` Meta: ${fields.length > 0 ? fields.map((f) => `${f.name}(${f.type})`).join(", ") : "(none)"}`
735
+ );
736
+ lines.push(
737
+ ` Sections: ${Object.keys(def.sections).join(", ") || "(none)"}`
738
+ );
739
+ lines.push(
740
+ ` Relationships: ${Object.keys(def.relationships).join(", ") || "(none)"}`
741
+ );
742
+ lines.push(` Documents: ${matchingItems.length}`);
743
+ if (matchingItems.length > 0) {
744
+ lines.push(` IDs: ${matchingItems.join(", ")}`);
745
+ }
746
+ lines.push("");
747
+ }
748
+
749
+ if (this.availableActions.length > 0) {
750
+ lines.push(`Actions: ${this.availableActions.join(", ")}`);
751
+ }
752
+
753
+ return lines.join("\n").trimEnd();
754
+ }
755
+
756
+ /**
757
+ * Write README.md to the collection root.
758
+ * Preserves the `## Overview` section if it already exists.
759
+ * The generated summary is placed in the `## Summary` section.
760
+ */
761
+ async saveModelSummary(): Promise<string> {
762
+ const summary = this.generateModelSummary();
763
+ const modelsPath = path.join(this.rootPath, "README.md");
764
+
765
+ // Preserve existing Overview section content
766
+ let overview = "";
767
+ try {
768
+ const existing = await fs.readFile(modelsPath, "utf8");
769
+ const overviewStart = existing.indexOf("## Overview");
770
+ if (overviewStart !== -1) {
771
+ const contentStart = existing.indexOf("\n", overviewStart) + 1;
772
+ const nextHeading = existing.indexOf("\n## ", contentStart);
773
+ const contentEnd = nextHeading !== -1 ? nextHeading : existing.length;
774
+ overview = existing.slice(contentStart, contentEnd).trim();
775
+ }
776
+ } catch {
777
+ // No existing file
778
+ }
779
+
780
+ const lines: string[] = [
781
+ "# Models",
782
+ "",
783
+ "## Overview",
784
+ "",
785
+ overview || "",
786
+ "",
787
+ "## Summary",
788
+ "",
789
+ "```",
790
+ summary,
791
+ "```",
792
+ "",
793
+ ];
794
+
795
+ const markdown = lines.join("\n");
796
+ await fs.writeFile(modelsPath, markdown, "utf8");
797
+ return summary;
798
+ }
799
+
449
800
  // ─── Serialization ───
450
801
 
451
802
  toJSON(options: { content?: boolean } = {}): Record<string, unknown> {
452
803
  const models = this.modelDefinitions.map((def) => ({
453
804
  name: def.name,
454
805
  prefix: def.prefix,
455
- matchingPaths: this.available.filter((id) =>
456
- id.startsWith(def.prefix)
806
+ matchingPaths: this.available.filter(
807
+ (id) => this.findModelDefinition(id)?.name === def.name
457
808
  ),
458
809
  }));
459
810
 
460
811
  const result: Record<string, unknown> = {
461
812
  models,
462
- itemIds: Array.from(this.#items.keys()),
813
+ itemIds: this.available,
463
814
  };
464
815
 
465
816
  if (options.content) {
@@ -17,12 +17,21 @@ export interface DefineModelConfig<
17
17
  TComputed extends Record<string, (self: any) => any>,
18
18
  > {
19
19
  prefix?: string;
20
+ /** Human-readable description of this model. Auto-generated from schema if not provided. */
21
+ description?: string;
20
22
  meta?: TMeta;
21
23
  sections?: TSections;
22
24
  relationships?: TRelationships;
23
25
  computed?: TComputed;
26
+ /** Named scopes — reusable query presets */
27
+ scopes?: Record<string, (query: any) => any>;
24
28
  match?: (doc: DocumentRef) => boolean;
25
29
  defaults?: Partial<z.input<TMeta>>;
30
+ pattern?: string | string[];
31
+ /** Glob patterns or RegExp to exclude matching pathIds from queries, listings, and TOC */
32
+ exclude?: (string | RegExp)[];
33
+ /** When true, documents are not required to have an H1 title */
34
+ titleOptional?: boolean;
26
35
  }
27
36
 
28
37
  /**
@@ -65,16 +74,104 @@ export function defineModel<
65
74
  > = {} as any
66
75
  ): ModelDefinition<TName, TMeta, TSections, TRelationships, TComputed> {
67
76
  const meta = (config.meta ?? z.looseObject({})) as TMeta;
77
+ const sections = (config.sections ?? ({} as any)) as TSections;
78
+ const relationships = (config.relationships ?? ({} as any)) as TRelationships;
79
+ const computed = (config.computed ?? ({} as any)) as TComputed;
68
80
 
69
- return {
81
+ const def: any = {
70
82
  name,
71
83
  prefix: config.prefix ?? pluralize(name.toLowerCase()),
72
84
  meta,
73
85
  schema: meta,
74
- sections: (config.sections ?? ({} as any)) as TSections,
75
- relationships: (config.relationships ?? ({} as any)) as TRelationships,
76
- computed: (config.computed ?? ({} as any)) as TComputed,
86
+ sections,
87
+ relationships,
88
+ computed,
89
+ scopes: config.scopes ?? {},
77
90
  match: config.match,
78
91
  defaults: config.defaults,
92
+ pattern: config.pattern,
93
+ exclude: config.exclude,
94
+ titleOptional: config.titleOptional,
79
95
  };
96
+
97
+ // description is lazy — computed on first access if not provided by the user.
98
+ // This avoids calling relationship target thunks during defineModel() which
99
+ // would break circular references (e.g. Epic ↔ Story).
100
+ if (config.description) {
101
+ def.description = config.description;
102
+ } else {
103
+ let cached: string | undefined;
104
+ Object.defineProperty(def, "description", {
105
+ get() {
106
+ if (cached === undefined) {
107
+ cached = generateDescription(name, meta, sections, relationships, computed);
108
+ }
109
+ return cached;
110
+ },
111
+ enumerable: true,
112
+ configurable: true,
113
+ });
114
+ }
115
+
116
+ return def;
117
+ }
118
+
119
+ /**
120
+ * Auto-generates a human-readable description from the model's schema.
121
+ * Safe to call after all models are defined (relationship thunks are resolved lazily).
122
+ */
123
+ export function generateDescription(
124
+ name: string,
125
+ meta: z.ZodType,
126
+ sections: Record<string, SectionDefinition<any>>,
127
+ relationships: Record<string, RelationshipDefinition<any>>,
128
+ computed: Record<string, (self: any) => any>
129
+ ): string {
130
+ const parts: string[] = [];
131
+
132
+ // Extract meta field names from Zod schema shape
133
+ const shape = (meta as any)?._zod?.def?.shape;
134
+ const metaKeys = shape ? Object.keys(shape) : [];
135
+ if (metaKeys.length > 0) {
136
+ parts.push(`metadata (${metaKeys.join(", ")})`);
137
+ }
138
+
139
+ // Section headings
140
+ const sectionEntries = Object.values(sections ?? {});
141
+ if (sectionEntries.length > 0) {
142
+ const headings = sectionEntries.map((s: any) => s.heading);
143
+ parts.push(`section${headings.length === 1 ? "" : "s"} (${headings.join(", ")})`);
144
+ }
145
+
146
+ // Relationships — resolve target thunks now (safe at access time, not at define time)
147
+ const relEntries = Object.entries(relationships ?? {});
148
+ if (relEntries.length > 0) {
149
+ const relDescs = relEntries.map(([key, rel]: [string, any]) => {
150
+ const targetName = typeof rel.target === "function" ? rel.target()?.name : undefined;
151
+ return targetName ? `${key} → ${targetName}` : key;
152
+ });
153
+ parts.push(`relationship${relDescs.length === 1 ? "" : "s"} (${relDescs.join(", ")})`);
154
+ }
155
+
156
+ // Computed
157
+ const computedKeys = Object.keys(computed ?? {});
158
+ if (computedKeys.length > 0) {
159
+ parts.push(`computed ${computedKeys.length === 1 ? "property" : "properties"} (${computedKeys.join(", ")})`);
160
+ }
161
+
162
+ // Scopes — accessed from the definition at runtime, not passed as parameter
163
+ // to keep backward compat with existing callers
164
+
165
+ if (parts.length === 0) {
166
+ return `A ${name} document.`;
167
+ }
168
+
169
+ const article = /^[aeiou]/i.test(name) ? "An" : "A";
170
+ return `${article} ${name} has ${joinNatural(parts)}.`;
171
+ }
172
+
173
+ function joinNatural(items: string[]): string {
174
+ if (items.length <= 1) return items[0] ?? "";
175
+ if (items.length === 2) return `${items[0]} and ${items[1]}`;
176
+ return items.slice(0, -1).join(", ") + ", and " + items[items.length - 1];
80
177
  }