@zeyos/client 0.3.0 → 0.4.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 (102) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +31 -1
  3. package/agents/README.md +2 -0
  4. package/agents/shared/zeyos-entity-map.md +5 -1
  5. package/agents/shared/zeyos-entity-reference.md +89 -33
  6. package/docs/03-cli/02-commands.md +28 -0
  7. package/docs/06-okf/01-overview.md +70 -0
  8. package/docs/06-okf/02-producing-and-consuming.md +46 -0
  9. package/docs/06-okf/03-keeping-fresh.md +53 -0
  10. package/docs/06-okf/04-loops.md +58 -0
  11. package/docs/06-okf/_category_.json +9 -0
  12. package/okf/concepts/counting-and-sums.md +10 -0
  13. package/okf/concepts/dates-unix-seconds.md +12 -0
  14. package/okf/concepts/enums.md +14 -0
  15. package/okf/concepts/filters-vs-filter.md +14 -0
  16. package/okf/concepts/index.md +8 -0
  17. package/okf/concepts/operationid-vocabulary.md +17 -0
  18. package/okf/concepts/visibility-column.md +13 -0
  19. package/okf/entities/accounts.md +82 -0
  20. package/okf/entities/actionsteps.md +84 -0
  21. package/okf/entities/addresses.md +50 -0
  22. package/okf/entities/applicationassets.md +43 -0
  23. package/okf/entities/applications.md +62 -0
  24. package/okf/entities/appointments.md +79 -0
  25. package/okf/entities/associations.md +41 -0
  26. package/okf/entities/binfiles.md +32 -0
  27. package/okf/entities/campaigns.md +66 -0
  28. package/okf/entities/categories.md +55 -0
  29. package/okf/entities/channels.md +54 -0
  30. package/okf/entities/comments.md +44 -0
  31. package/okf/entities/components.md +46 -0
  32. package/okf/entities/contacts.md +96 -0
  33. package/okf/entities/contacts2contacts.md +42 -0
  34. package/okf/entities/contracts.md +83 -0
  35. package/okf/entities/couponcodes.md +58 -0
  36. package/okf/entities/coupons.md +69 -0
  37. package/okf/entities/customfields.md +59 -0
  38. package/okf/entities/davservers.md +74 -0
  39. package/okf/entities/devices.md +65 -0
  40. package/okf/entities/documents.md +76 -0
  41. package/okf/entities/dunning.md +82 -0
  42. package/okf/entities/dunning2transactions.md +46 -0
  43. package/okf/entities/entities2channels.md +42 -0
  44. package/okf/entities/events.md +57 -0
  45. package/okf/entities/feedservers.md +67 -0
  46. package/okf/entities/files.md +50 -0
  47. package/okf/entities/follows.md +40 -0
  48. package/okf/entities/forks.md +54 -0
  49. package/okf/entities/groups.md +48 -0
  50. package/okf/entities/groups2users.md +44 -0
  51. package/okf/entities/index.md +93 -0
  52. package/okf/entities/invitations.md +53 -0
  53. package/okf/entities/items.md +95 -0
  54. package/okf/entities/ledgers.md +56 -0
  55. package/okf/entities/likes.md +40 -0
  56. package/okf/entities/links.md +70 -0
  57. package/okf/entities/mailinglists.md +67 -0
  58. package/okf/entities/mailingrecipients.md +45 -0
  59. package/okf/entities/mailservers.md +77 -0
  60. package/okf/entities/messagereads.md +40 -0
  61. package/okf/entities/messages.md +104 -0
  62. package/okf/entities/notes.md +73 -0
  63. package/okf/entities/objects.md +70 -0
  64. package/okf/entities/opportunities.md +87 -0
  65. package/okf/entities/participants.md +52 -0
  66. package/okf/entities/payments.md +76 -0
  67. package/okf/entities/permissions.md +46 -0
  68. package/okf/entities/pricelists.md +70 -0
  69. package/okf/entities/pricelists2accounts.md +46 -0
  70. package/okf/entities/prices.md +49 -0
  71. package/okf/entities/projects.md +72 -0
  72. package/okf/entities/records.md +75 -0
  73. package/okf/entities/relateditems.md +43 -0
  74. package/okf/entities/resources.md +55 -0
  75. package/okf/entities/services.md +64 -0
  76. package/okf/entities/stocktransactions.md +72 -0
  77. package/okf/entities/storages.md +56 -0
  78. package/okf/entities/suppliers.md +51 -0
  79. package/okf/entities/tasks.md +86 -0
  80. package/okf/entities/tickets.md +86 -0
  81. package/okf/entities/transactions.md +118 -0
  82. package/okf/entities/users.md +66 -0
  83. package/okf/entities/weblets.md +66 -0
  84. package/okf/index.md +11 -0
  85. package/okf/log.md +4 -0
  86. package/okf/metrics/cash-received.md +10 -0
  87. package/okf/metrics/index.md +6 -0
  88. package/okf/metrics/invoiced-net-revenue.md +16 -0
  89. package/okf/metrics/open-customers.md +14 -0
  90. package/okf/metrics/overdue-receivables.md +12 -0
  91. package/okf/playbooks/customer-360.md +12 -0
  92. package/okf/playbooks/index.md +5 -0
  93. package/okf/playbooks/revenue-this-year.md +19 -0
  94. package/okf/playbooks/ticket-work-packet.md +11 -0
  95. package/package.json +9 -2
  96. package/scripts/data/okf-curation.mjs +258 -0
  97. package/scripts/generate-client.mjs +4 -275
  98. package/scripts/generate-okf.mjs +241 -0
  99. package/scripts/lib/okf.mjs +272 -0
  100. package/scripts/lib/spec-model.mjs +325 -0
  101. package/src/index.js +4 -0
  102. package/src/runtime/okf.js +237 -0
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+ // Producer: emit an Open Knowledge Format (OKF v0.1) bundle for the ZeyOS data
3
+ // model under okf/. Structural content (entity Schema/FK/Enums/Indexes/Operations)
4
+ // is generated from openapi/{api,dbref}.json into managed blocks; curated content
5
+ // (metrics, playbooks, concepts, entity notes) is seeded from scripts/data and
6
+ // then owned by humans/the refiner. Deterministic: re-running with unchanged
7
+ // specs produces no diff (log.md only grows on real schema changes).
8
+
9
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
10
+ import { existsSync } from 'node:fs';
11
+ import { createHash } from 'node:crypto';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ import { buildEntityModel, collectOperations, loadSpec, loadOptionalSpec } from './lib/spec-model.mjs';
16
+ import {
17
+ OKF_VERSION, renderEntityGeneratedBody, spliceConcept, renderIndex, renderRootIndex,
18
+ diffEntityModels, prependLogEntry, replaceManagedBlock
19
+ } from './lib/okf.mjs';
20
+ import { ENTITY_META, METRICS, PLAYBOOKS, CONCEPTS } from './data/okf-curation.mjs';
21
+
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const OKF_DIR = path.join(ROOT, 'okf');
24
+ // Producer-internal state for the log.md schema diff. Lives outside okf/ (so the
25
+ // published bundle stays pure OKF content) and outside the npm `files` allowlist
26
+ // (so it is not shipped), but is committed so in-repo regenerations can diff.
27
+ const SNAPSHOT_FILE = path.join(ROOT, 'scripts/.okf-snapshot.json');
28
+
29
+ const VERB_RE = /^(list|get|create|update|delete|exists)/;
30
+ const CLUSTER_ORDER = ['crm', 'work', 'messaging', 'outreach', 'knowledge', 'collaboration', 'billing', 'collections', 'commerce', 'platform', 'reference'];
31
+ const CLUSTER_LABEL = {
32
+ crm: 'CRM & Customer', work: 'Work & Delivery', messaging: 'Messaging', outreach: 'Outreach',
33
+ knowledge: 'Knowledge', collaboration: 'Collaboration', billing: 'Billing & Payments',
34
+ collections: 'Collections', commerce: 'Commerce & Inventory', platform: 'Platform & Schema', reference: 'Reference'
35
+ };
36
+
37
+ function resourceFromPath(p) {
38
+ if (typeof p !== 'string') return null;
39
+ for (const segment of p.split('/')) {
40
+ if (segment && !segment.startsWith('{')) return segment;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ // Group an entity's REST operations into list/get/create/update/delete/exists by
46
+ // the operationId verb prefix. operationIds come straight from api.json, so this
47
+ // is the canonical vocabulary (no noun→opId guessing).
48
+ function operationsByResource(apiDoc) {
49
+ const byResource = new Map();
50
+ for (const op of collectOperations(apiDoc)) {
51
+ const resource = resourceFromPath(op.path);
52
+ if (!resource) continue;
53
+ if (!byResource.has(resource)) byResource.set(resource, {});
54
+ const bucket = byResource.get(resource);
55
+ const m = VERB_RE.exec(op.operationId);
56
+ const key = m ? m[1] : op.operationId;
57
+ if (!bucket[key]) bucket[key] = op.operationId;
58
+ }
59
+ return byResource;
60
+ }
61
+
62
+ // "listMailingLists" → "Mailing Lists"; "getDunningNotice" → "Dunning Notice".
63
+ function titleFromOps(ops, fallbackNoun) {
64
+ const op = ops.list || ops.get || null;
65
+ if (!op) return fallbackNoun.charAt(0).toUpperCase() + fallbackNoun.slice(1);
66
+ const stripped = op.replace(VERB_RE, '');
67
+ return stripped.replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim() || fallbackNoun;
68
+ }
69
+
70
+ // Authoritative entity → operationId table, derived from api.json. Injected into
71
+ // the shared skill reference so the (previously hand-maintained, drift-prone)
72
+ // operationId table is generated and always correct. Links resolve from
73
+ // agents/shared/ back up to okf/.
74
+ function renderOperationIdTable(docEntities, opsByResource) {
75
+ const cols = ['list', 'get', 'create', 'update', 'delete', 'exists'];
76
+ const header = `| Entity | Concept | ${cols.join(' | ')} |\n|${'---|'.repeat(cols.length + 2)}`;
77
+ const rows = docEntities.map((name) => {
78
+ const ops = opsByResource.get(name) || {};
79
+ const cells = cols.map((c) => (ops[c] ? `\`${ops[c]}\`` : '—'));
80
+ return `| \`${name}\` | [↗](../../okf/entities/${name}.md) | ${cells.join(' | ')} |`;
81
+ });
82
+ return `${header}\n${rows.join('\n')}`;
83
+ }
84
+
85
+ function renderCuratedDoc({ type, title, description, tags, body }) {
86
+ const fm = [`type: ${type}`, `title: ${title}`];
87
+ if (description) fm.push(`description: ${JSON.stringify(description)}`);
88
+ if (tags?.length) fm.push(`tags: [${tags.join(', ')}]`);
89
+ return `---\n${fm.join('\n')}\n---\n\n${body.replace(/\s+$/, '')}\n`;
90
+ }
91
+
92
+ async function readIfExists(file) {
93
+ return existsSync(file) ? readFile(file, 'utf8') : null;
94
+ }
95
+
96
+ async function writeFileEnsured(file, content) {
97
+ await mkdir(path.dirname(file), { recursive: true });
98
+ await writeFile(file, content, 'utf8');
99
+ }
100
+
101
+ async function seedIfAbsent(file, content) {
102
+ if (existsSync(file)) return false;
103
+ await writeFileEnsured(file, content);
104
+ return true;
105
+ }
106
+
107
+ function sourceSnapshot(docEntities, model, opsByResource) {
108
+ const payload = docEntities.map((name) => ({
109
+ name,
110
+ fields: model[name].fields.map((f) => `${f.name}:${f.type}:${f.notnull ? 1 : 0}:${f.fk ? f.fk.table : ''}:${f.enum ? Object.keys(f.enum).join('|') : ''}`),
111
+ ops: Object.values(opsByResource.get(name) || {}).sort()
112
+ }));
113
+ return createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 12);
114
+ }
115
+
116
+ async function main() {
117
+ const apiDoc = await loadSpec(path.join(ROOT, 'openapi/api.json'));
118
+ const dbref = await loadOptionalSpec(path.join(ROOT, 'openapi/dbref.json'));
119
+ const model = buildEntityModel(dbref);
120
+ const opsByResource = operationsByResource(apiDoc);
121
+
122
+ // API-backed entities that also have a dbref schema get an entity concept doc.
123
+ const docEntities = [...opsByResource.keys()].filter((name) => model[name]).sort();
124
+ const hasDoc = new Set(docEntities);
125
+ const linkForEntity = (name) => (hasDoc.has(name) ? `/entities/${name}.md` : null);
126
+
127
+ // ── Entity concept docs (managed-block splice) ──
128
+ for (const name of docEntities) {
129
+ const entity = model[name];
130
+ const ops = opsByResource.get(name) || {};
131
+ const meta = ENTITY_META[name] || {};
132
+ const generatedBody = renderEntityGeneratedBody({ entity, ops, linkForEntity });
133
+ const visibilityColumn = entity.fields.some((f) => f.name === 'visibility');
134
+
135
+ const frontmatter = {
136
+ type: 'ZeyOS Entity',
137
+ title: titleFromOps(ops, name),
138
+ description: meta.description || `ZeyOS \`${name}\` records.`,
139
+ resource: `zeyos://api/${name}`,
140
+ tags: [...(meta.tags || []), 'generated'],
141
+ api_backed: true,
142
+ list_operation: ops.list || undefined,
143
+ visibility_column: visibilityColumn
144
+ };
145
+
146
+ const seedBody = meta.note ? `# Notes\n\n${meta.note}` : '';
147
+ const file = path.join(OKF_DIR, 'entities', `${name}.md`);
148
+ const existing = await readIfExists(file);
149
+ await writeFileEnsured(file, spliceConcept({ existing, frontmatter, generatedBody, seedBody }));
150
+ }
151
+
152
+ // ── Curated narrative docs (seed-if-absent) ──
153
+ const curatedGroups = [
154
+ { dir: 'metrics', type: 'Metric', docs: METRICS },
155
+ { dir: 'playbooks', type: 'Playbook', docs: PLAYBOOKS },
156
+ { dir: 'concepts', type: 'Reference', docs: CONCEPTS }
157
+ ];
158
+ for (const group of curatedGroups) {
159
+ for (const doc of group.docs) {
160
+ const file = path.join(OKF_DIR, group.dir, `${doc.id}.md`);
161
+ await seedIfAbsent(file, renderCuratedDoc({ type: group.type, ...doc }));
162
+ }
163
+ }
164
+
165
+ // ── Index files (derived; always regenerated) ──
166
+ const byCluster = new Map();
167
+ for (const name of docEntities) {
168
+ const cluster = (ENTITY_META[name]?.tags || ['platform'])[0];
169
+ if (!byCluster.has(cluster)) byCluster.set(cluster, []);
170
+ byCluster.get(cluster).push(name);
171
+ }
172
+ const entitySections = CLUSTER_ORDER.filter((c) => byCluster.has(c)).map((cluster) => ({
173
+ heading: CLUSTER_LABEL[cluster] || cluster,
174
+ items: byCluster.get(cluster).sort().map((name) => ({
175
+ title: titleFromOps(opsByResource.get(name) || {}, name),
176
+ url: `${name}.md`,
177
+ description: ENTITY_META[name]?.description || ''
178
+ }))
179
+ }));
180
+ await writeFileEnsured(path.join(OKF_DIR, 'entities', 'index.md'), renderIndex(entitySections));
181
+
182
+ for (const group of curatedGroups) {
183
+ const items = [...group.docs]
184
+ .sort((a, b) => a.title.localeCompare(b.title))
185
+ .map((doc) => ({ title: doc.title, url: `${doc.id}.md`, description: doc.description }));
186
+ await writeFileEnsured(path.join(OKF_DIR, group.dir, 'index.md'), renderIndex([{ heading: group.dir.charAt(0).toUpperCase() + group.dir.slice(1), items }]));
187
+ }
188
+
189
+ // ── Root index (only index allowed frontmatter: okf_version + source_snapshot) ──
190
+ const snapshot = sourceSnapshot(docEntities, model, opsByResource);
191
+ const rootSections = [{
192
+ heading: 'ZeyOS Knowledge Bundle',
193
+ items: [
194
+ { title: 'Entities', url: 'entities/', description: `${docEntities.length} API-backed entity concepts (schema, foreign keys, enums, indexes, operations).` },
195
+ { title: 'Metrics', url: 'metrics/', description: 'Business metric definitions.' },
196
+ { title: 'Playbooks', url: 'playbooks/', description: 'Step-by-step query workflows.' },
197
+ { title: 'Concepts', url: 'concepts/', description: 'Cross-cutting query rules and footguns.' }
198
+ ]
199
+ }];
200
+ await writeFileEnsured(path.join(OKF_DIR, 'index.md'), renderRootIndex({ sourceSnapshot: snapshot, sections: rootSections }));
201
+
202
+ // ── Freshness: diff against the last snapshot → append log.md on change ──
203
+ const prevSnapshot = existsSync(SNAPSHOT_FILE) ? JSON.parse(await readFile(SNAPSHOT_FILE, 'utf8')) : null;
204
+ // Compact model: only the fields diffEntityModels reads (name/type/enum/fk), so the
205
+ // committed snapshot stays small (drops descriptions, indexes, nullability, defaults).
206
+ const currentModel = Object.fromEntries(docEntities.map((name) => [name, {
207
+ fields: model[name].fields.map((f) => ({ name: f.name, type: f.type, enum: f.enum, fk: f.fk ? { table: f.fk.table } : null }))
208
+ }]));
209
+ const logFile = path.join(OKF_DIR, 'log.md');
210
+ if (!prevSnapshot) {
211
+ const existingLog = await readIfExists(logFile);
212
+ if (!existingLog) {
213
+ const date = new Date().toISOString().slice(0, 10);
214
+ await writeFileEnsured(logFile, prependLogEntry({ existing: null, date, changes: [{ kind: 'Initialization', text: `OKF bundle initialized with ${docEntities.length} entity concepts.` }] }));
215
+ }
216
+ } else {
217
+ const changes = diffEntityModels(prevSnapshot.model, currentModel);
218
+ if (changes.length) {
219
+ const date = new Date().toISOString().slice(0, 10);
220
+ const existingLog = await readIfExists(logFile);
221
+ await writeFileEnsured(logFile, prependLogEntry({ existing: existingLog, date, changes }));
222
+ }
223
+ }
224
+ await writeFileEnsured(SNAPSHOT_FILE, `${JSON.stringify({ snapshot, model: currentModel }, null, 2)}\n`);
225
+
226
+ // ── Derive the shared skill reference's operationId table from OKF (canonical) ──
227
+ const refFile = path.join(ROOT, 'agents/shared/zeyos-entity-reference.md');
228
+ const refExisting = await readIfExists(refFile);
229
+ if (refExisting) {
230
+ const filled = replaceManagedBlock(refExisting, renderOperationIdTable(docEntities, opsByResource));
231
+ if (filled) await writeFile(refFile, filled, 'utf8');
232
+ else process.stderr.write(`[okf] note: ${path.relative(ROOT, refFile)} has no okf:generated markers; operationId table not injected.\n`);
233
+ }
234
+
235
+ process.stdout.write(`Generated OKF bundle (v${OKF_VERSION}) → okf/ (${docEntities.length} entities, snapshot ${snapshot})\n`);
236
+ }
237
+
238
+ main().catch((error) => {
239
+ console.error(error);
240
+ process.exit(1);
241
+ });
@@ -0,0 +1,272 @@
1
+ // Open Knowledge Format (OKF v0.1) rendering + managed-block splicing.
2
+ //
3
+ // Pure functions only (no fs): given specs/curation in, markdown out. The
4
+ // orchestrator (scripts/generate-okf.mjs) and the runtime consumer
5
+ // (src/runtime/okf.js) reuse the same conformance-relevant constants.
6
+
7
+ // The OKF conformance constants live in the shipped client so there is a single
8
+ // definition; the build-time renderer below imports them. (Generated content is
9
+ // fenced by the markers; the producer rewrites only between them, preserving
10
+ // curated `# Notes`/`# Metrics` prose added by humans or the refiner.)
11
+ import {
12
+ OKF_VERSION,
13
+ GENERATED_START,
14
+ GENERATED_END,
15
+ GENERATED_FRONTMATTER_KEYS as GENERATED_FRONTMATTER_KEY_LIST
16
+ } from '../../src/runtime/okf.js';
17
+
18
+ export { OKF_VERSION, GENERATED_START, GENERATED_END };
19
+
20
+ const GENERATED_FRONTMATTER_KEYS = new Set(GENERATED_FRONTMATTER_KEY_LIST);
21
+
22
+ // ── YAML (minimal; only the scalar/list shapes OKF frontmatter uses) ──────────
23
+
24
+ function needsQuote(s) {
25
+ return /^[\s>|*&!%@`#?,\[\]{}-]/.test(s) || /[:#]\s|: |\s$|^$/.test(s) || /["']/.test(s);
26
+ }
27
+
28
+ function toYamlScalar(value) {
29
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
30
+ if (typeof value === 'number') return String(value);
31
+ const s = String(value);
32
+ if (needsQuote(s)) return JSON.stringify(s); // JSON string is valid YAML double-quoted
33
+ return s;
34
+ }
35
+
36
+ function toYamlValue(value) {
37
+ if (Array.isArray(value)) return `[${value.map((v) => toYamlScalar(v)).join(', ')}]`;
38
+ return toYamlScalar(value);
39
+ }
40
+
41
+ /** Parse a `---`-delimited frontmatter block into ordered raw lines + a key set.
42
+ * Tolerant by design: we only need to know which keys exist so we can preserve
43
+ * human-added ones; we never re-serialize parsed values. */
44
+ export function parseFrontmatter(content) {
45
+ const match = /^---\n([\s\S]*?)\n---\n?/.exec(content || '');
46
+ if (!match) return { keys: new Set(), rawLines: [], body: content || '' };
47
+ const rawLines = match[1].split('\n');
48
+ const keys = new Set();
49
+ for (const line of rawLines) {
50
+ const kv = /^([A-Za-z0-9_]+):/.exec(line);
51
+ if (kv) keys.add(kv[1]);
52
+ }
53
+ return { keys, rawLines, body: (content || '').slice(match[0].length) };
54
+ }
55
+
56
+ function emitFrontmatter(generated, preservedRawLines = []) {
57
+ const lines = [];
58
+ for (const [key, value] of Object.entries(generated)) {
59
+ if (value === undefined || value === null) continue;
60
+ lines.push(`${key}: ${toYamlValue(value)}`);
61
+ }
62
+ for (const raw of preservedRawLines) lines.push(raw);
63
+ return lines.join('\n');
64
+ }
65
+
66
+ // ── Managed-block splice ──────────────────────────────────────────────────────
67
+
68
+ /** Extract the curated body that follows the generated block in an existing
69
+ * file, so it can be carried forward unchanged. */
70
+ function curatedTail(existingBody) {
71
+ const end = existingBody.indexOf(GENERATED_END);
72
+ if (end === -1) return null;
73
+ return existingBody.slice(end + GENERATED_END.length).replace(/^\n+/, '');
74
+ }
75
+
76
+ /**
77
+ * Produce a concept file, rewriting only generated frontmatter + the generated
78
+ * body region. Curated frontmatter keys and the curated body tail are preserved.
79
+ *
80
+ * @param {object} args
81
+ * @param {string?} args.existing Existing file content, or null/'' for new.
82
+ * @param {object} args.frontmatter Generated frontmatter (ordered object).
83
+ * @param {string} args.generatedBody Markdown to place between the markers.
84
+ * @param {string} args.seedBody Curated body to use only when the file is new.
85
+ */
86
+ export function spliceConcept({ existing, frontmatter, generatedBody, seedBody = '' }) {
87
+ const parsed = parseFrontmatter(existing || '');
88
+ const preserved = parsed.rawLines.filter((line) => {
89
+ const kv = /^([A-Za-z0-9_]+):/.exec(line);
90
+ return kv && !GENERATED_FRONTMATTER_KEYS.has(kv[1]);
91
+ });
92
+ const tail = existing ? curatedTail(parsed.body) : null;
93
+ const curated = (tail != null ? tail : seedBody).replace(/\s+$/, '');
94
+
95
+ const fm = emitFrontmatter(frontmatter, preserved);
96
+ const block = `${GENERATED_START}\n${generatedBody.replace(/\s+$/, '')}\n${GENERATED_END}`;
97
+ const parts = [`---\n${fm}\n---`, block];
98
+ if (curated) parts.push(curated);
99
+ return `${parts.join('\n\n')}\n`;
100
+ }
101
+
102
+ /**
103
+ * Replace only the content between the generated markers in an existing file,
104
+ * preserving everything before and after. Unlike spliceConcept this does not own
105
+ * frontmatter — it is for injecting a generated digest into an otherwise curated
106
+ * file (e.g. the shared skill references). Returns null if the markers are absent
107
+ * so the caller can warn rather than silently no-op.
108
+ */
109
+ export function replaceManagedBlock(existing, generatedBody) {
110
+ if (!existing || !existing.includes(GENERATED_START) || !existing.includes(GENERATED_END)) {
111
+ return null;
112
+ }
113
+ const start = existing.indexOf(GENERATED_START);
114
+ const end = existing.indexOf(GENERATED_END);
115
+ const before = existing.slice(0, start);
116
+ const after = existing.slice(end + GENERATED_END.length);
117
+ return `${before}${GENERATED_START}\n${generatedBody.replace(/\s+$/, '')}\n${GENERATED_END}${after}`;
118
+ }
119
+
120
+ // ── Entity rendering ───────────────────────────────────────────────────────────
121
+
122
+ const DASH = '—';
123
+
124
+ function fkCell(field, linkForEntity) {
125
+ if (!field.fk) return DASH;
126
+ const link = linkForEntity(field.fk.table);
127
+ const label = `${field.fk.table}.${field.fk.field}`;
128
+ return link ? `[${field.fk.table}](${link})` : label;
129
+ }
130
+
131
+ function renderSchemaTable(entity, linkForEntity) {
132
+ const header = '| Column | Type | Nullable | Default | Indexed | FK |\n|---|---|---|---|---|---|';
133
+ const rows = entity.fields.map((f) => {
134
+ const def = f.default == null ? DASH : `\`${String(f.default).replace(/\|/g, '\\|')}\``;
135
+ return `| \`${f.name}\` | ${f.type} | ${f.notnull ? 'no' : 'yes'} | ${def} | ${f.indexed ? 'yes' : DASH} | ${fkCell(f, linkForEntity)} |`;
136
+ });
137
+ return `# Schema\n\n${header}\n${rows.join('\n')}`;
138
+ }
139
+
140
+ function renderEnums(entity) {
141
+ const withEnum = entity.fields.filter((f) => f.enum);
142
+ if (!withEnum.length) return '';
143
+ const blocks = withEnum.map((f) => {
144
+ const pairs = Object.entries(f.enum).map(([k, v]) => `\`${k}\` = ${v}`).join(' · ');
145
+ return `### \`${f.name}\`\n\n${pairs}`;
146
+ });
147
+ return `# Enums\n\n${blocks.join('\n\n')}`;
148
+ }
149
+
150
+ function renderForeignKeys(entity, linkForEntity) {
151
+ const fks = entity.fields.filter((f) => f.fk);
152
+ if (!fks.length) return '';
153
+ const items = fks.map((f) => {
154
+ const link = linkForEntity(f.fk.table);
155
+ const target = link ? `[${f.fk.table}](${link})` : f.fk.table;
156
+ return `- \`${f.name}\` → ${target} (\`${f.fk.table}.${f.fk.field}\`)`;
157
+ });
158
+ return `# Foreign Keys\n\n${items.join('\n')}`;
159
+ }
160
+
161
+ function renderIndexes(entity) {
162
+ const idx = entity.indexes.filter((i) => !i.primary);
163
+ if (!idx.length) return '';
164
+ const items = idx.map((i) => {
165
+ const attrs = [i.method, i.unique ? 'unique' : null, i.partial ? 'partial' : null].filter(Boolean).join(', ');
166
+ const keys = i.keys.length ? ` on \`${i.keys.join(', ')}\`` : '';
167
+ return `- \`${i.name}\` — ${attrs}${keys}`;
168
+ });
169
+ const gin = idx.some((i) => i.method === 'gin') || idx.some((i) => i.partial);
170
+ const note = gin
171
+ ? '\n\n> Partial/GIN indexes back the `filters` (plural) query form for foreign-key fields. See [filters-vs-filter](/concepts/filters-vs-filter.md).'
172
+ : '';
173
+ return `# Indexes\n\n${items.join('\n')}${note}`;
174
+ }
175
+
176
+ function renderOperations(ops) {
177
+ const order = ['list', 'get', 'create', 'update', 'delete', 'exists'];
178
+ const present = order.filter((k) => ops[k]).map((k) => `- ${k}: \`${ops[k]}\``);
179
+ const extra = Object.entries(ops)
180
+ .filter(([k]) => !order.includes(k))
181
+ .map(([k, v]) => `- ${k}: \`${v}\``);
182
+ const lines = [...present, ...extra];
183
+ if (!lines.length) return '';
184
+ return `# Operations\n\n${lines.join('\n')}`;
185
+ }
186
+
187
+ /** Render the generated (managed) body for a ZeyOS entity concept. */
188
+ export function renderEntityGeneratedBody({ entity, ops, linkForEntity }) {
189
+ return [
190
+ renderSchemaTable(entity, linkForEntity),
191
+ renderForeignKeys(entity, linkForEntity),
192
+ renderEnums(entity),
193
+ renderIndexes(entity),
194
+ renderOperations(ops)
195
+ ].filter(Boolean).join('\n\n');
196
+ }
197
+
198
+ // ── Index + log rendering ──────────────────────────────────────────────────────
199
+
200
+ /** A sub-directory index.md: sections of `* [Title](url) - description` bullets.
201
+ * No frontmatter (spec §5). */
202
+ export function renderIndex(sections) {
203
+ const out = [];
204
+ for (const section of sections) {
205
+ out.push(`# ${section.heading}`, '');
206
+ for (const item of section.items) {
207
+ const desc = item.description ? ` - ${item.description}` : '';
208
+ out.push(`* [${item.title}](${item.url})${desc}`);
209
+ }
210
+ out.push('');
211
+ }
212
+ return `${out.join('\n').replace(/\s+$/, '')}\n`;
213
+ }
214
+
215
+ /** The bundle-root index.md — the only index that may carry frontmatter (§9). */
216
+ export function renderRootIndex({ sourceSnapshot, sections }) {
217
+ const fm = emitFrontmatter({ okf_version: OKF_VERSION, source_snapshot: sourceSnapshot });
218
+ return `---\n${fm}\n---\n\n${renderIndex(sections)}`;
219
+ }
220
+
221
+ // ── Schema diff → log.md (the freshness changelog) ──────────────────────────────
222
+
223
+ function enumString(enumObj) {
224
+ return enumObj ? Object.entries(enumObj).map(([k, v]) => `${k}=${v}`).join(',') : '';
225
+ }
226
+
227
+ /** Compare two buildEntityModel() outputs and describe what changed, for log.md. */
228
+ export function diffEntityModels(prev, next) {
229
+ const changes = [];
230
+ const prevNames = new Set(Object.keys(prev || {}));
231
+ const nextNames = new Set(Object.keys(next || {}));
232
+
233
+ for (const name of [...nextNames].sort()) {
234
+ if (!prevNames.has(name)) { changes.push({ kind: 'Creation', text: `Entity \`${name}\` added.` }); continue; }
235
+ const a = prev[name];
236
+ const b = next[name];
237
+ const aFields = new Map(a.fields.map((f) => [f.name, f]));
238
+ const bFields = new Map(b.fields.map((f) => [f.name, f]));
239
+ for (const fname of bFields.keys()) {
240
+ if (!aFields.has(fname)) changes.push({ kind: 'Update', text: `\`${name}\`: field \`${fname}\` added.` });
241
+ }
242
+ for (const fname of aFields.keys()) {
243
+ if (!bFields.has(fname)) changes.push({ kind: 'Update', text: `\`${name}\`: field \`${fname}\` removed.` });
244
+ }
245
+ for (const [fname, bf] of bFields) {
246
+ const af = aFields.get(fname);
247
+ if (!af) continue;
248
+ if (af.type !== bf.type) changes.push({ kind: 'Update', text: `\`${name}.${fname}\`: type ${af.type} → ${bf.type}.` });
249
+ if (enumString(af.enum) !== enumString(bf.enum)) changes.push({ kind: 'Update', text: `\`${name}.${fname}\`: enum values changed.` });
250
+ const afk = af.fk ? af.fk.table : '';
251
+ const bfk = bf.fk ? bf.fk.table : '';
252
+ if (afk !== bfk) changes.push({ kind: 'Update', text: `\`${name}.${fname}\`: foreign key ${afk || 'none'} → ${bfk || 'none'}.` });
253
+ }
254
+ }
255
+ for (const name of [...prevNames].sort()) {
256
+ if (!nextNames.has(name)) changes.push({ kind: 'Deprecation', text: `Entity \`${name}\` removed.` });
257
+ }
258
+ return changes;
259
+ }
260
+
261
+ /** Prepend a dated section to log.md (newest first). Idempotent across same-day
262
+ * re-runs only when `changes` is empty (caller skips). */
263
+ export function prependLogEntry({ existing, date, changes, title = 'OKF Update Log' }) {
264
+ const header = `# ${title}`;
265
+ const entryLines = [`## ${date}`, ...changes.map((c) => `* **${c.kind}**: ${c.text}`)];
266
+ const entry = entryLines.join('\n');
267
+ if (!existing || !existing.includes(header)) {
268
+ return `${header}\n\n${entry}\n`;
269
+ }
270
+ const body = existing.slice(existing.indexOf(header) + header.length).replace(/^\n+/, '');
271
+ return `${header}\n\n${entry}\n\n${body.replace(/\s+$/, '')}\n`;
272
+ }