@gregiteen/ssss-cli 0.8.0 → 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 (45) hide show
  1. package/README.md +11 -12
  2. package/VERSION +1 -1
  3. package/conformance/README.md +1 -1
  4. package/conformance/fixtures.json +4 -48
  5. package/conformance/reference-bundle.ucw.json +4 -21
  6. package/docs/help/bundle.md +2 -2
  7. package/docs/help/overview.md +4 -4
  8. package/docs/help/semantic.md +5 -4
  9. package/docs/ssss-spec.md +32 -77
  10. package/package.json +22 -3
  11. package/registry/core.json +21 -28
  12. package/scripts/build-reference-bundle.mjs +0 -20
  13. package/scripts/cmd-adapter.mjs +29 -0
  14. package/scripts/cmd-import.mjs +1 -1
  15. package/scripts/cmd-migrate.mjs +26 -0
  16. package/scripts/cmd-new.mjs +5 -5
  17. package/scripts/cmd-primitive.mjs +48 -0
  18. package/scripts/cmd-registry.mjs +25 -0
  19. package/scripts/cmd-semantic.mjs +6 -8
  20. package/scripts/conformance-09.mjs +199 -0
  21. package/scripts/conformance.mjs +106 -186
  22. package/scripts/ssss.mjs +8 -2
  23. package/skills/ssss/SKILL.md +11 -15
  24. package/skills/ssss/evals/evals.json +3 -3
  25. package/skills/ssss/references/contract-reference.md +7 -8
  26. package/skills/ssss/scripts/validate-schema.mjs +1 -44
  27. package/src/authorization.mjs +61 -0
  28. package/src/bundle.mjs +4 -4
  29. package/src/engine.mjs +101 -503
  30. package/src/events.mjs +90 -0
  31. package/src/guard.mjs +32 -0
  32. package/src/http.mjs +26 -0
  33. package/src/idempotency.mjs +55 -0
  34. package/src/kernel.mjs +260 -0
  35. package/src/leases.mjs +125 -0
  36. package/src/primitive.mjs +227 -0
  37. package/src/projections.mjs +62 -0
  38. package/src/registry.mjs +190 -39
  39. package/src/semantic.mjs +124 -316
  40. package/src/ui.mjs +100 -0
  41. package/src/validator.mjs +87 -0
  42. package/src/vfs.mjs +203 -0
  43. package/types/kernel.d.ts +48 -0
  44. package/docs/help/localization.md +0 -43
  45. package/scripts/cmd-localize.mjs +0 -44
package/src/semantic.mjs CHANGED
@@ -1,33 +1,24 @@
1
1
  /**
2
- * Deterministic semantic projection and non-destructive localization helpers
3
- * (spec §11.9). No database, network, embedding provider, or YAML dependency.
2
+ * Semantic projection, multilingual embedding, retrieval, and runtime rendering.
3
+ * Canonical documents remain authored once; translations are never stored here.
4
4
  */
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import crypto from 'node:crypto';
8
- import { parseDocument, serializeDocument } from './frontmatter.mjs';
9
- import {
10
- documentHash,
11
- isSafeDocumentPath,
12
- loadRegistries,
13
- resolvePortability,
14
- validateDataConstraints,
15
- validateReferenceConstraints,
16
- } from './registry.mjs';
8
+ import { parseDocument } from './frontmatter.mjs';
9
+ import { createValidator } from './validator.mjs';
10
+ import { isSafeDocumentPath, resolvePortability, resolvePrimitiveDefinition, documentHash } from './registry.mjs';
17
11
 
18
- const DEFAULT_LOCALE_PATTERN = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u;
19
- const TRANSLATABLE_FIELDS = new Set(['title', 'description', 'body']);
20
- const APPROVED_STATUSES = new Set(['approved']);
12
+ const LANGUAGE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u;
13
+ const RENDER_FIELDS = new Set(['title', 'description', 'body']);
21
14
 
22
15
  function asStrings(value) {
23
16
  if (Array.isArray(value)) return value.filter((item) => typeof item === 'string' && item);
24
17
  return typeof value === 'string' && value ? [value] : [];
25
18
  }
26
19
 
27
- export function normalizeLocale(value, pattern = DEFAULT_LOCALE_PATTERN) {
28
- if (typeof value !== 'string' || !pattern.test(value)) {
29
- throw new Error(`Invalid locale '${value}'. Use a BCP47-style tag such as en, es-MX, or zh-Hant.`);
30
- }
20
+ export function normalizeLanguage(value) {
21
+ if (typeof value !== 'string' || !LANGUAGE_RE.test(value)) throw new Error(`Invalid language tag '${value}'.`);
31
22
  return value.split('-').map((part, index) => {
32
23
  if (index === 0) return part.toLowerCase();
33
24
  if (part.length === 2 || /^\d{3}$/.test(part)) return part.toUpperCase();
@@ -36,153 +27,40 @@ export function normalizeLocale(value, pattern = DEFAULT_LOCALE_PATTERN) {
36
27
  }).join('-');
37
28
  }
38
29
 
30
+ function tokenize(value) {
31
+ return [...new Set(String(value || '').normalize('NFKC').toLocaleLowerCase().match(/[\p{L}\p{N}]+/gu) || [])].sort();
32
+ }
33
+
39
34
  function walkDocuments(vaultRoot) {
40
- const root = path.resolve(vaultRoot);
41
- if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
42
- throw new Error(`Vault directory does not exist: ${root}`);
43
- }
35
+ const root = fs.realpathSync(path.resolve(vaultRoot));
44
36
  const documents = [];
45
37
  const stack = [root];
46
38
  while (stack.length) {
47
39
  const dir = stack.pop();
48
- const entries = fs.readdirSync(dir, { withFileTypes: true })
49
- .sort((a, b) => a.name.localeCompare(b.name));
50
- for (const entry of entries) {
40
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
51
41
  if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
52
42
  const absolute = path.join(dir, entry.name);
53
43
  const relative = path.relative(root, absolute).split(path.sep).join('/');
54
- if (entry.isSymbolicLink()) {
55
- throw new Error(`Refusing symlinked vault entry: ${relative}`);
56
- }
44
+ if (entry.isSymbolicLink()) throw new Error(`Refusing symlinked vault entry: ${relative}`);
57
45
  if (entry.isDirectory()) stack.push(absolute);
58
- else if (entry.isFile() && entry.name.endsWith('.md') &&
59
- entry.name !== 'index.md' && entry.name !== 'log.md') {
46
+ else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'index.md' && entry.name !== 'log.md') {
60
47
  if (!isSafeDocumentPath(relative)) throw new Error(`Unsafe vault path: ${relative}`);
61
48
  const content = fs.readFileSync(absolute, 'utf8');
62
49
  const parsed = parseDocument(content);
63
- if (!parsed.data.type) continue;
64
- documents.push({ path: relative, absolute, content, ...parsed });
50
+ if (parsed.data.type) documents.push({ path: relative, content, ...parsed });
65
51
  }
66
52
  }
67
53
  }
68
54
  return documents.sort((a, b) => a.path.localeCompare(b.path));
69
55
  }
70
56
 
71
- function documentMap(documents) {
72
- return new Map(documents.map((document) => [document.path, document]));
73
- }
74
-
75
- function validateReferences(document, documents, types) {
76
- const definition = types.get(document.data.type);
77
- return validateReferenceConstraints(
78
- definition,
79
- document.data,
80
- (referencePath) => documents.get(referencePath) || null,
81
- types
82
- );
83
- }
84
-
85
- function translationMap(documents, locale, types, { includeDrafts = false } = {}) {
86
- const wanted = normalizeLocale(locale);
87
- const bySource = new Map();
88
- for (const document of documents.values()) {
89
- if (document.data.type !== 'translation') continue;
90
- const normalized = normalizeLocale(document.data.locale);
91
- if (normalized !== wanted) continue;
92
- if (!includeDrafts && !APPROVED_STATUSES.has(document.data.status)) continue;
93
- const key = document.data.source_path;
94
- if (bySource.has(key)) {
95
- throw new Error(
96
- `Duplicate translation overlays for '${key}' and locale '${wanted}': ` +
97
- `${bySource.get(key).path}, ${document.path}`
98
- );
99
- }
100
- const issues = validateReferences(document, documents, types);
101
- if (issues.length) {
102
- throw new Error(`${document.path}: ${issues.map((issue) => `${issue.field}: ${issue.issue}`).join('; ')}`);
103
- }
104
- bySource.set(key, document);
105
- }
106
- return bySource;
107
- }
108
-
109
- export function applyTranslationOverlay(sourceContent, translationContent, options = {}) {
110
- const { types, core } = loadRegistries(options.registryDir);
111
- const source = parseDocument(sourceContent);
112
- const overlay = parseDocument(translationContent);
113
- if (overlay.data.type !== 'translation') throw new Error('Overlay must declare type: translation.');
114
- if (source.data.type === 'translation') throw new Error('Translations may not target another translation.');
115
- const sourcePath = options.sourcePath || overlay.data.source_path;
116
- if (!isSafeDocumentPath(sourcePath) || overlay.data.source_path !== sourcePath) {
117
- throw new Error(`Translation source_path '${overlay.data.source_path}' does not match '${sourcePath}'.`);
118
- }
119
- if (resolvePortability(types.get(source.data.type), source.data) !== 'structural') {
120
- throw new Error('Translation overlays may target structural documents only.');
121
- }
122
- const expectedHash = documentHash(sourceContent);
123
- if (overlay.data.source_hash !== expectedHash) {
124
- throw new Error(`Stale translation for '${sourcePath}'; expected source_hash '${expectedHash}'.`);
125
- }
126
- const localePattern = new RegExp(core.semantic?.locale_pattern || DEFAULT_LOCALE_PATTERN.source, 'u');
127
- const locale = normalizeLocale(overlay.data.locale, localePattern);
128
- const translatedFields = [...new Set(asStrings(overlay.data.translated_fields))];
129
- if (!translatedFields.length) throw new Error('translated_fields must not be empty.');
130
- for (const field of translatedFields) {
131
- if (!TRANSLATABLE_FIELDS.has(field)) {
132
- throw new Error(`Field '${field}' is a symbolic control field and may not be translated.`);
133
- }
134
- }
135
-
136
- const data = { ...source.data };
137
- let body = source.body;
138
- if (translatedFields.includes('title')) {
139
- if (!overlay.data.translated_title) throw new Error('translated_title is required when translating title.');
140
- data.title = overlay.data.translated_title;
141
- }
142
- if (translatedFields.includes('description')) {
143
- if (!overlay.data.translated_description) {
144
- throw new Error('translated_description is required when translating description.');
145
- }
146
- data.description = overlay.data.translated_description;
147
- }
148
- if (translatedFields.includes('body')) {
149
- if (!overlay.body.trim()) throw new Error('Translation body must not be empty when translating body.');
150
- body = overlay.body;
151
- }
152
- data.locale = locale;
153
- data.translation_of = sourcePath;
154
- data.translation_source_hash = expectedHash;
155
-
156
- return {
157
- content: serializeDocument(data, body),
158
- data,
159
- body,
160
- locale,
161
- status: overlay.data.status,
162
- translated_fields: translatedFields,
163
- };
164
- }
165
-
166
- function tokenize(value) {
167
- const normalized = String(value || '').normalize('NFKC').toLocaleLowerCase();
168
- return [...new Set(normalized.match(/[\p{L}\p{N}]+/gu) || [])].sort();
169
- }
170
-
171
57
  function extractEdges(documentPath, data, body) {
172
58
  const edges = [];
173
- for (const match of body.matchAll(/\[\[([^\]]+)\]\]/g)) {
174
- edges.push({ source: documentPath, relation: 'mentions', target: match[1].trim() });
175
- }
176
- for (const match of body.matchAll(/\[[^\]]*\]\((\/[^)]+\.md)\)/g)) {
177
- edges.push({ source: documentPath, relation: 'links_to', target: match[1].slice(1) });
178
- }
59
+ for (const match of body.matchAll(/\[\[([^\]]+)\]\]/g)) edges.push({ source: documentPath, relation: 'mentions', target: match[1].trim() });
60
+ for (const match of body.matchAll(/\[[^\]]*\]\((\/[^)]+\.md)\)/g)) edges.push({ source: documentPath, relation: 'links_to', target: match[1].slice(1) });
179
61
  for (const relation of Array.isArray(data.relations) ? data.relations : []) {
180
62
  if (relation && typeof relation === 'object' && relation.target) {
181
- edges.push({
182
- source: documentPath,
183
- relation: String(relation.relation || relation.type || 'related_to'),
184
- target: String(relation.target),
185
- });
63
+ edges.push({ source: documentPath, relation: String(relation.relation || relation.type || 'related_to'), target: String(relation.target) });
186
64
  }
187
65
  }
188
66
  return edges;
@@ -192,211 +70,141 @@ function canonicalHash(value) {
192
70
  return `sha256:${crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')}`;
193
71
  }
194
72
 
195
- function localizedDocuments(vaultRoot, options = {}) {
196
- const { types, core } = loadRegistries(options.registryDir);
197
- const all = walkDocuments(vaultRoot);
198
- const documents = documentMap(all);
199
- for (const document of all) {
200
- const definition = types.get(document.data.type);
201
- if (!definition) throw new Error(`${document.path}: unknown type '${document.data.type}'.`);
202
- const issues = [
203
- ...validateDataConstraints(
204
- definition,
205
- document.data,
206
- core.universal_frontmatter?.required || ['type']
207
- ),
208
- ...(document.data.type === 'translation' ? [] : validateReferences(document, documents, types)),
209
- ];
210
- if (issues.length) {
211
- throw new Error(`${document.path}: ${issues.map((issue) => `${issue.field}: ${issue.issue}`).join('; ')}`);
212
- }
213
- }
214
- const overlays = options.locale
215
- ? translationMap(documents, options.locale, types, options)
216
- : new Map();
217
- const output = [];
218
- let excluded = 0;
219
-
220
- for (const source of all) {
221
- if (source.data.type === 'translation') continue;
222
- const portability = resolvePortability(types.get(source.data.type), source.data);
223
- if (!options.includePrivate && portability !== 'structural') {
224
- excluded++;
225
- continue;
226
- }
227
- const overlay = overlays.get(source.path);
228
- const localized = overlay
229
- ? applyTranslationOverlay(source.content, overlay.content, {
230
- sourcePath: source.path,
231
- registryDir: options.registryDir,
232
- })
233
- : { content: source.content, data: source.data, body: source.body, locale: source.data.locale || null };
234
- output.push({
235
- path: source.path,
236
- source,
237
- overlay: overlay || null,
238
- portability,
239
- ...localized,
240
- });
241
- }
242
- return { documents: output, excluded };
73
+ function semanticText(record) {
74
+ return [record.title, record.description, record.surface?.body, record.aliases?.join(' '), record.tags?.join(' ')].filter(Boolean).join('\n');
243
75
  }
244
76
 
245
77
  export function buildSemanticIndex(vaultRoot, options = {}) {
246
- const locale = options.locale ? normalizeLocale(options.locale) : null;
247
- const localized = localizedDocuments(vaultRoot, { ...options, locale });
78
+ const validator = options.validator || createValidator(options);
248
79
  const records = [];
249
80
  const edges = [];
250
-
251
- for (const document of localized.documents) {
252
- const data = document.data;
253
- const identity = data.semantic_id || data.resource || `${data.type}:${document.path}`;
254
- const labels = [data.title, data.description, data.name, data.slug, ...asStrings(data.aliases)];
255
- const tags = asStrings(data.tags);
81
+ let excluded = 0;
82
+ for (const document of walkDocuments(vaultRoot)) {
83
+ const validation = validator.validateDocument(document.content);
84
+ if (!validation.valid) throw new Error(`${document.path}: ${validation.errors.join('; ')}`);
85
+ const definition = resolvePrimitiveDefinition(validator.registry, validation.declared_type);
86
+ const portability = resolvePortability(definition, document.data);
87
+ if (!options.includePrivate && portability !== 'structural') { excluded++; continue; }
88
+ const identity = document.data.semantic_id || document.data.resource || `${definition.qualified_type}:${document.path}`;
89
+ const labels = [document.data.title, document.data.description, document.data.name, document.data.slug, ...asStrings(document.data.aliases)];
90
+ const tags = asStrings(document.data.tags);
256
91
  const record = {
257
92
  semantic_id: identity,
258
93
  path: document.path,
259
- type: data.type,
260
- portability: document.portability,
261
- locale: document.locale || null,
262
- title: data.title || null,
263
- description: data.description || null,
264
- resource: data.resource || null,
265
- aliases: asStrings(data.aliases),
94
+ type: definition.qualified_type,
95
+ primitive_version: definition.primitive_version || 1,
96
+ portability,
97
+ authored_language: document.data.language || document.data.locale || null,
98
+ title: document.data.title || null,
99
+ description: document.data.description || null,
100
+ resource: document.data.resource || null,
101
+ aliases: asStrings(document.data.aliases),
266
102
  tags,
103
+ relations: Array.isArray(document.data.relations) ? document.data.relations : [],
267
104
  content_hash: documentHash(document.content),
268
- translation: document.overlay ? {
269
- path: document.overlay.path,
270
- status: document.overlay.data.status,
271
- source_hash: document.overlay.data.source_hash,
272
- } : null,
105
+ surface: { title: document.data.title || null, description: document.data.description || null, body: document.body || '' },
273
106
  search: {
274
- identity: tokenize([identity, data.resource, data.type, document.path].filter(Boolean).join(' ')),
107
+ identity: tokenize([identity, document.data.resource, definition.qualified_type, document.path].filter(Boolean).join(' ')),
275
108
  labels: tokenize(labels.filter(Boolean).join(' ')),
276
109
  tags: tokenize(tags.join(' ')),
277
- text: tokenize(`${data.description || ''}\n${document.body || ''}`),
110
+ text: tokenize(`${document.data.description || ''}\n${document.body || ''}`),
278
111
  },
279
112
  };
280
113
  records.push(record);
281
- edges.push(...extractEdges(document.path, data, document.body));
114
+ edges.push(...extractEdges(document.path, document.data, document.body));
282
115
  }
283
- records.sort((a, b) => a.path.localeCompare(b.path));
284
- edges.sort((a, b) =>
285
- a.source.localeCompare(b.source) || a.relation.localeCompare(b.relation) || a.target.localeCompare(b.target)
286
- );
287
- const payload = {
288
- version: 1,
289
- locale,
290
- include_private: !!options.includePrivate,
291
- excluded_documents: localized.excluded,
292
- documents: records,
293
- edges,
294
- };
116
+ edges.sort((a, b) => a.source.localeCompare(b.source) || a.relation.localeCompare(b.relation) || a.target.localeCompare(b.target));
117
+ const payload = { version: 2, include_private: !!options.includePrivate, excluded_documents: excluded, embedding: null, documents: records, edges };
118
+ return { ...payload, index_hash: canonicalHash(payload) };
119
+ }
120
+
121
+ function validateVectors(vectors, expectedLength) {
122
+ if (!Array.isArray(vectors) || vectors.length !== expectedLength) throw new Error(`Embedding adapter returned ${vectors?.length || 0} vectors; expected ${expectedLength}.`);
123
+ const dimension = vectors[0]?.length;
124
+ if (!Number.isInteger(dimension) || dimension < 1) throw new Error('Embedding vectors must be non-empty arrays.');
125
+ for (const vector of vectors) {
126
+ if (!Array.isArray(vector) || vector.length !== dimension || vector.some((value) => typeof value !== 'number' || !Number.isFinite(value))) {
127
+ throw new Error('Embedding vectors must have one finite numeric dimension.');
128
+ }
129
+ }
130
+ return dimension;
131
+ }
132
+
133
+ export async function enrichSemanticIndex(index, { embed, model }) {
134
+ if (typeof embed !== 'function') throw new Error('A multilingual embed(texts, context) adapter is required.');
135
+ if (typeof model !== 'string' || !model) throw new Error('Embedding model identity is required.');
136
+ const vectors = await embed(index.documents.map(semanticText), { model, purpose: 'document' });
137
+ const dimension = validateVectors(vectors, index.documents.length);
138
+ const documents = index.documents.map((record, indexNumber) => ({ ...record, embedding: vectors[indexNumber] }));
139
+ const payload = { ...index, embedding: { model, dimension, multilingual: true }, documents };
140
+ delete payload.index_hash;
295
141
  return { ...payload, index_hash: canonicalHash(payload) };
296
142
  }
297
143
 
144
+ function cosine(a, b) {
145
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return 0;
146
+ let dot = 0; let aa = 0; let bb = 0;
147
+ for (let index = 0; index < a.length; index++) { dot += a[index] * b[index]; aa += a[index] ** 2; bb += b[index] ** 2; }
148
+ return aa && bb ? dot / (Math.sqrt(aa) * Math.sqrt(bb)) : 0;
149
+ }
150
+
298
151
  export function searchSemanticIndex(index, query, options = {}) {
299
- const queryTokens = tokenize(query);
300
- if (!queryTokens.length) return [];
301
- const allowedTypes = options.types ? new Set(options.types) : null;
152
+ const tokens = tokenize(query);
153
+ if (!tokens.length) return [];
302
154
  const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 10;
155
+ const allowedTypes = options.types ? new Set(options.types) : null;
303
156
  const results = [];
304
157
  for (const document of index.documents || []) {
305
158
  if (allowedTypes && !allowedTypes.has(document.type)) continue;
306
- const buckets = [
307
- [document.search?.identity || [], 12],
308
- [document.search?.labels || [], 7],
309
- [document.search?.tags || [], 5],
310
- [document.search?.text || [], 1],
311
- ];
312
- let score = 0;
159
+ const buckets = [[document.search?.identity || [], 12], [document.search?.labels || [], 7], [document.search?.tags || [], 5], [document.search?.text || [], 1]];
160
+ let lexical = 0;
313
161
  const matched = new Set();
314
- for (const token of queryTokens) {
315
- for (const [values, weight] of buckets) {
316
- if (values.includes(token)) {
317
- score += weight;
318
- matched.add(token);
319
- break;
320
- }
321
- }
162
+ for (const token of tokens) for (const [values, weight] of buckets) if (values.includes(token)) { lexical += weight; matched.add(token); break; }
163
+ const semantic = options.queryVector && document.embedding ? cosine(options.queryVector, document.embedding) : 0;
164
+ if (lexical > 0 || semantic > 0) {
165
+ results.push({ score: lexical + Math.max(0, semantic) * 20, evidence: { lexical, semantic, matched: [...matched].sort() }, document });
322
166
  }
323
- if (score > 0) results.push({ score, matched: [...matched].sort(), document });
324
167
  }
325
- return results
326
- .sort((a, b) => b.score - a.score || a.document.path.localeCompare(b.document.path))
327
- .slice(0, limit);
168
+ return results.sort((a, b) => b.score - a.score || a.document.path.localeCompare(b.document.path)).slice(0, limit);
328
169
  }
329
170
 
330
- function atomicWrite(target, content) {
331
- fs.mkdirSync(path.dirname(target), { recursive: true });
332
- const temp = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomUUID()}.tmp`);
333
- fs.writeFileSync(temp, content);
334
- fs.renameSync(temp, target);
171
+ export async function searchSemanticIndexWithAdapter(index, query, { embed, model = index.embedding?.model, ...options }) {
172
+ if (!index.embedding) throw new Error('Semantic index has no embeddings; enrich it first.');
173
+ if (model !== index.embedding.model) throw new Error(`Embedding model mismatch: index uses '${index.embedding.model}', query requested '${model}'.`);
174
+ const vectors = await embed([query], { model, purpose: 'query' });
175
+ validateVectors(vectors, 1);
176
+ if (vectors[0].length !== index.embedding.dimension) throw new Error('Query embedding dimension does not match the index.');
177
+ return searchSemanticIndex(index, query, { ...options, queryVector: vectors[0] });
335
178
  }
336
179
 
337
- function resolvePhysicalTarget(value) {
338
- const requested = path.resolve(value);
339
- let cursor = requested;
340
- const suffix = [];
341
- while (!fs.existsSync(cursor)) {
342
- suffix.unshift(path.basename(cursor));
343
- const parent = path.dirname(cursor);
344
- if (parent === cursor) break;
345
- cursor = parent;
346
- }
347
- const physical = fs.realpathSync(cursor);
348
- return path.join(physical, ...suffix);
180
+ export function prepareRenderRequest(record, options = {}) {
181
+ const language = normalizeLanguage(options.language);
182
+ return {
183
+ target_language: language,
184
+ instruction: 'Render only the natural-language presentation fields in target_language. Never alter invariant controls or invent authority.',
185
+ invariant: {
186
+ semantic_id: record.semantic_id,
187
+ path: record.path,
188
+ type: record.type,
189
+ primitive_version: record.primitive_version,
190
+ resource: record.resource,
191
+ portability: record.portability,
192
+ tags: record.tags,
193
+ relations: record.relations,
194
+ content_hash: record.content_hash,
195
+ available_actions: options.availableActions || [],
196
+ },
197
+ surface: { ...record.surface },
198
+ context: options.context || null,
199
+ };
349
200
  }
350
201
 
351
- export function materializeLocale(vaultRoot, locale, outputDir, options = {}) {
352
- const sourceRoot = fs.realpathSync(path.resolve(vaultRoot));
353
- const targetRoot = resolvePhysicalTarget(outputDir);
354
- const normalizedLocale = normalizeLocale(locale);
355
- if (targetRoot === sourceRoot || targetRoot.startsWith(sourceRoot + path.sep)) {
356
- throw new Error('Localization output must be outside the source vault.');
357
- }
358
- if (fs.existsSync(targetRoot)) {
359
- const targetStat = fs.lstatSync(targetRoot);
360
- if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
361
- throw new Error(`Localization output must be a real directory: ${targetRoot}`);
362
- }
363
- if (fs.readdirSync(targetRoot).length > 0) {
364
- throw new Error(`Localization output directory must be empty: ${targetRoot}`);
365
- }
366
- }
367
- const localized = localizedDocuments(sourceRoot, { ...options, locale: normalizedLocale });
368
- const parent = path.dirname(targetRoot);
369
- fs.mkdirSync(parent, { recursive: true });
370
- const stageRoot = path.join(parent, `.${path.basename(targetRoot)}.${crypto.randomUUID()}.stage`);
371
- const inventory = [];
372
- let targetWasEmpty = fs.existsSync(targetRoot);
373
- try {
374
- fs.mkdirSync(stageRoot);
375
- for (const document of localized.documents) {
376
- if (!isSafeDocumentPath(document.path)) throw new Error(`Unsafe projection path: ${document.path}`);
377
- const target = path.resolve(stageRoot, document.path);
378
- if (!target.startsWith(stageRoot + path.sep)) throw new Error(`Projection path escaped output: ${document.path}`);
379
- atomicWrite(target, document.content);
380
- inventory.push({ path: document.path, content_hash: documentHash(document.content) });
381
- }
382
- inventory.sort((a, b) => a.path.localeCompare(b.path));
383
- const manifest = {
384
- type: 'ssss_localization_projection',
385
- version: 1,
386
- locale: normalizedLocale,
387
- include_private: !!options.includePrivate,
388
- document_count: inventory.length,
389
- excluded_documents: localized.excluded,
390
- source_hash: canonicalHash(inventory),
391
- documents: inventory,
392
- };
393
- atomicWrite(path.join(stageRoot, '.ssss-projection.json'), JSON.stringify(manifest, null, 2) + '\n');
394
- if (targetWasEmpty) fs.rmdirSync(targetRoot);
395
- fs.renameSync(stageRoot, targetRoot);
396
- return manifest;
397
- } catch (error) {
398
- fs.rmSync(stageRoot, { recursive: true, force: true });
399
- if (targetWasEmpty && !fs.existsSync(targetRoot)) fs.mkdirSync(targetRoot);
400
- throw error;
401
- }
202
+ export async function renderSemanticRecord(record, { language, render, ...options }) {
203
+ if (typeof render !== 'function') throw new Error('An LLM render(request) adapter is required.');
204
+ const request = prepareRenderRequest(record, { ...options, language });
205
+ const rendered = await render(request);
206
+ if (!rendered || typeof rendered !== 'object' || Array.isArray(rendered)) throw new Error('Render adapter must return an object.');
207
+ for (const key of Object.keys(rendered)) if (!RENDER_FIELDS.has(key)) throw new Error(`Render adapter returned forbidden field '${key}'.`);
208
+ for (const [key, value] of Object.entries(rendered)) if (typeof value !== 'string') throw new Error(`Rendered field '${key}' must be a string.`);
209
+ return { record, presentation: { language: request.target_language, ...rendered }, invariant: request.invariant };
402
210
  }
package/src/ui.mjs ADDED
@@ -0,0 +1,100 @@
1
+ /** Typed, capability-bound generative UI projection contract. */
2
+ import crypto from 'node:crypto';
3
+
4
+ const SAFE_ID = /^[a-z][a-z0-9_.-]{0,127}$/;
5
+ const LAYOUTS = new Set(['form', 'table', 'detail', 'detail-with-actions', 'dashboard', 'wizard']);
6
+
7
+ function isRecord(value) { return !!value && typeof value === 'object' && !Array.isArray(value); }
8
+
9
+ export class UiRegistry {
10
+ constructor() { this.components = new Map(); this.actions = new Map(); }
11
+ registerComponent(definition) {
12
+ if (!SAFE_ID.test(definition?.id || '') || typeof definition.validateProps !== 'function') throw new Error('Component id and validateProps are required.');
13
+ if (this.components.has(definition.id)) throw new Error(`Duplicate UI component '${definition.id}'.`);
14
+ this.components.set(definition.id, { ...definition });
15
+ }
16
+ registerAction(definition) {
17
+ if (!SAFE_ID.test(definition?.id || '') || !definition.capability || !definition.command) throw new Error('Action id, capability, and command are required.');
18
+ if (this.actions.has(definition.id)) throw new Error(`Duplicate UI action '${definition.id}'.`);
19
+ this.actions.set(definition.id, { ...definition });
20
+ }
21
+ }
22
+
23
+ export function validateUiManifest(manifest, { registry, visibleFields = [], grantedCapabilities = [], maxComponents = 100 } = {}) {
24
+ const errors = [];
25
+ if (!isRecord(manifest)) return { valid: false, errors: [{ field: '(root)', issue: 'UI manifest must be an object.' }] };
26
+ if (manifest.type !== 'ssss:ui_projection') errors.push({ field: 'type', issue: 'Expected ssss:ui_projection.' });
27
+ if (!LAYOUTS.has(manifest.layout)) errors.push({ field: 'layout', issue: `Use one of: ${[...LAYOUTS].join(', ')}.` });
28
+ if (!Array.isArray(manifest.components)) errors.push({ field: 'components', issue: 'Components must be an array.' });
29
+ else if (manifest.components.length > maxComponents) errors.push({ field: 'components', issue: `At most ${maxComponents} components are allowed.` });
30
+ const allowedFields = new Set(visibleFields);
31
+ const capabilities = new Set(grantedCapabilities);
32
+ for (const [index, component] of (manifest.components || []).entries()) {
33
+ const prefix = `components[${index}]`;
34
+ if (!isRecord(component)) { errors.push({ field: prefix, issue: 'Component must be an object.' }); continue; }
35
+ const definition = registry?.components.get(component.component);
36
+ if (!definition) { errors.push({ field: `${prefix}.component`, issue: `Unregistered component '${component.component}'.` }); continue; }
37
+ if (component.bind && !allowedFields.has(component.bind)) errors.push({ field: `${prefix}.bind`, issue: `Field '${component.bind}' is hidden or unknown.` });
38
+ if (!isRecord(component.props) || typeof component.props.label !== 'string' || !component.props.label.trim()) {
39
+ errors.push({ field: `${prefix}.props.label`, issue: 'An accessible label is required.' });
40
+ }
41
+ if (component.action) {
42
+ const action = registry.actions.get(component.action);
43
+ if (!action) errors.push({ field: `${prefix}.action`, issue: `Unregistered action '${component.action}'.` });
44
+ else if (!capabilities.has(action.capability) && !capabilities.has('*:*')) errors.push({ field: `${prefix}.action`, issue: `Missing capability '${action.capability}'.` });
45
+ }
46
+ const propIssues = definition.validateProps(component.props || {});
47
+ for (const issue of propIssues || []) errors.push({ field: `${prefix}.props.${issue.field || '(root)'}`, issue: issue.issue || String(issue) });
48
+ }
49
+ return { valid: errors.length === 0, errors };
50
+ }
51
+
52
+ export function createDeterministicUi(definition, options = {}) {
53
+ const fields = (options.visibleFields || definition.fields || []).map((field) => typeof field === 'string' ? { id: field, name: field, kind: 'string' } : field);
54
+ return {
55
+ type: 'ssss:ui_projection',
56
+ projection_id: `ui_${crypto.createHash('sha256').update(`${definition.primitive_id || definition.qualified_type}\0${options.mode || 'form'}`).digest('hex').slice(0, 16)}`,
57
+ primitive: definition.primitive_id || definition.qualified_type,
58
+ layout: options.mode || 'form',
59
+ title: definition.name || definition.qualified_type,
60
+ components: fields.map((field) => ({
61
+ component: field.kind === 'enum' ? 'select-field' : field.kind === 'boolean' ? 'boolean-field' : 'data-field',
62
+ bind: field.id,
63
+ props: { label: field.name || field.id, required: !!field.required, values: field.values || undefined },
64
+ })),
65
+ };
66
+ }
67
+
68
+ export async function planUi({ definition, data, principal, language, planner, registry, visibleFields, grantedCapabilities }) {
69
+ const fallback = createDeterministicUi(definition, { visibleFields });
70
+ if (typeof planner !== 'function') return { manifest: fallback, generated: false, fallback_reason: 'planner unavailable' };
71
+ const safeData = Object.fromEntries(Object.entries(data || {}).filter(([key]) => visibleFields.includes(key)));
72
+ let candidate;
73
+ try {
74
+ candidate = await planner({
75
+ instruction: 'Compose a UI manifest using only registered components, visible fields, and actions. Do not emit executable code.',
76
+ primitive: {
77
+ primitive_id: definition.primitive_id,
78
+ qualified_type: definition.qualified_type,
79
+ name: definition.name,
80
+ fields: (definition.fields || []).filter((field) => visibleFields.includes(field.id)),
81
+ },
82
+ data: safeData,
83
+ principal: { id: principal.id, kind: principal.kind },
84
+ language,
85
+ components: [...registry.components.keys()],
86
+ actions: [...registry.actions.values()].filter((action) => grantedCapabilities.includes(action.capability)).map((action) => action.id),
87
+ });
88
+ } catch (error) {
89
+ return { manifest: fallback, generated: false, fallback_reason: error.message };
90
+ }
91
+ const validation = validateUiManifest(candidate, { registry, visibleFields, grantedCapabilities });
92
+ if (!validation.valid) return { manifest: fallback, generated: false, fallback_reason: 'generated manifest rejected', errors: validation.errors };
93
+ return { manifest: candidate, generated: true };
94
+ }
95
+
96
+ export function actionToEnvelope(manifestComponent, registry, input = {}) {
97
+ const action = registry.actions.get(manifestComponent.action);
98
+ if (!action) throw new Error(`Unknown UI action '${manifestComponent.action}'.`);
99
+ return typeof action.command === 'function' ? action.command(input) : structuredClone(action.command);
100
+ }