@aikdna/kdna-core 0.2.2 → 0.3.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.
package/src/compose.js CHANGED
@@ -120,4 +120,258 @@ function loadAndCompose(dataMaps, options = {}) {
120
120
  return { domains, context, activeIndices };
121
121
  }
122
122
 
123
- module.exports = { composeContext, classifySignals, composeChecks, loadAndCompose };
123
+ /**
124
+ * Compose context with source attribution — every axiom, misunderstanding,
125
+ * banned term, and self-check is prefixed with its origin domain.
126
+ *
127
+ * @param {Array<{id:string, core:object, patterns:object}>} domains
128
+ * @param {object} [options]
129
+ * @param {string} [options.separator] — section separator
130
+ * @returns {{context: string, attributionMap: object}}
131
+ */
132
+ function composeContextWithAttribution(domains, options = {}) {
133
+ if (!domains || !domains.length) return { context: '', attributionMap: {} };
134
+
135
+ const separator = options.separator || '\n\n---\n\n';
136
+ const lines = [];
137
+ const attributionMap = {};
138
+ let axiomIndex = 0;
139
+ let misreadingIndex = 0;
140
+ let termIndex = 0;
141
+ let checkIndex = 0;
142
+
143
+ for (const domain of domains) {
144
+ if (!domain || !domain.core) continue;
145
+ const name = domain.id || domain.core.meta?.domain || 'unknown';
146
+ const core = domain.core;
147
+ const patterns = domain.patterns || {};
148
+
149
+ lines.push(`## [${name}] Domain cognition`);
150
+
151
+ if (core.axioms?.length) {
152
+ lines.push(`### Axioms`);
153
+ for (const a of core.axioms) {
154
+ const tag = `[${name}:axiom.${a.id}]`;
155
+ lines.push(`- ${tag} ${a.one_sentence}`);
156
+ if (a.applies_when?.length) {
157
+ lines.push(` APPLIES WHEN: ${a.applies_when.join('; ')}`);
158
+ }
159
+ if (a.does_not_apply_when?.length) {
160
+ lines.push(` DOES NOT APPLY WHEN: ${a.does_not_apply_when.join('; ')}`);
161
+ }
162
+ if (a.failure_risk) lines.push(` RISK IF MISAPPLIED: ${a.failure_risk}`);
163
+ attributionMap[tag] = { domain: name, type: 'axiom', id: a.id, index: axiomIndex++ };
164
+ }
165
+ }
166
+
167
+ if (patterns.misunderstandings?.length) {
168
+ lines.push(`### Misunderstandings`);
169
+ for (const m of patterns.misunderstandings) {
170
+ const tag = `[${name}:misunderstanding.${m.id}]`;
171
+ lines.push(`- ${tag} WRONG: ${m.wrong}`);
172
+ lines.push(` CORRECT: ${m.correct}`);
173
+ if (m.failure_risk) lines.push(` RISK: ${m.failure_risk}`);
174
+ attributionMap[tag] = { domain: name, type: 'misunderstanding', id: m.id, index: misreadingIndex++ };
175
+ }
176
+ }
177
+
178
+ if (patterns.terminology?.banned_terms?.length) {
179
+ lines.push(`### Banned terms`);
180
+ for (const t of patterns.terminology.banned_terms) {
181
+ const term = typeof t === 'string' ? t : t.term;
182
+ const tag = `[${name}:banned_term.${term}]`;
183
+ const replace = typeof t === 'object' ? t.replace_with : null;
184
+ lines.push(`- ${tag} "${term}"${replace ? ` → use: ${replace}` : ''}`);
185
+ attributionMap[tag] = { domain: name, type: 'banned_term', term, index: termIndex++ };
186
+ }
187
+ }
188
+
189
+ if (patterns.self_check?.length) {
190
+ lines.push(`### Self-checks`);
191
+ for (const q of patterns.self_check) {
192
+ const text = typeof q === 'string' ? q : q.question;
193
+ const tag = `[${name}:self_check.${checkIndex}]`;
194
+ if (text) lines.push(`- ${tag} ${text}`);
195
+ attributionMap[tag] = { domain: name, type: 'self_check', index: checkIndex++ };
196
+ }
197
+ }
198
+
199
+ lines.push(separator.trimEnd());
200
+ }
201
+
202
+ return { context: lines.join('\n'), attributionMap };
203
+ }
204
+
205
+ /**
206
+ * Classify signals across a cluster of domains. Returns which domains
207
+ * matched and which were excluded. Unlike classifySignals (which just
208
+ * returns indices), this provides full diagnostic info.
209
+ *
210
+ * @param {string} input
211
+ * @param {Array<{id:string, name:string, role:string, core:object}>} domainEntries
212
+ * @returns {{selected: Array, excluded: Array}}
213
+ */
214
+ function classifySignalsAcrossDomains(input, domainEntries) {
215
+ if (!input || !domainEntries?.length) return { selected: [], excluded: [] };
216
+
217
+ const lower = input.toLowerCase();
218
+ const selected = [];
219
+ const excluded = [];
220
+
221
+ for (const entry of domainEntries) {
222
+ const signals = entry.core?.trigger_signals || [];
223
+ const negativeSignals = entry.core?.negative_signals || [];
224
+ const doesNotApply = [];
225
+ for (const a of entry.core?.axioms || []) {
226
+ if (Array.isArray(a.does_not_apply_when)) doesNotApply.push(...a.does_not_apply_when);
227
+ }
228
+
229
+ // Check negative signals
230
+ const blocked = negativeSignals.some((s) => lower.includes(s.toLowerCase())) ||
231
+ doesNotApply.some((s) => lower.includes(s.toLowerCase()));
232
+
233
+ if (blocked) {
234
+ excluded.push({ id: entry.id, name: entry.name, reason: 'blocked by does_not_apply_when', role: entry.role });
235
+ continue;
236
+ }
237
+
238
+ // No signals defined → required domain (always active)
239
+ if (!signals.length) {
240
+ selected.push({ id: entry.id, name: entry.name, role: entry.role, reason: 'required' });
241
+ continue;
242
+ }
243
+
244
+ // Check positive signals
245
+ const matched = signals.some((s) => lower.includes(s.toLowerCase()));
246
+ if (matched) {
247
+ selected.push({ id: entry.id, name: entry.name, role: entry.role, reason: 'signal_match' });
248
+ } else {
249
+ excluded.push({ id: entry.id, name: entry.name, reason: 'no signal match', role: entry.role });
250
+ }
251
+ }
252
+
253
+ return { selected, excluded };
254
+ }
255
+
256
+ /**
257
+ * Load a cluster from its manifest.
258
+ *
259
+ * @param {string} clusterManifestPath — path to kdna.cluster.json
260
+ * @param {function} domainLoader — fn(domainId) → {core, patterns} or null
261
+ * @returns {{manifest:object, domains:Array, errors:Array}}
262
+ */
263
+ function loadCluster(clusterManifestPath, domainLoader) {
264
+ const fs = require('fs');
265
+ const manifest = JSON.parse(fs.readFileSync(clusterManifestPath, 'utf8'));
266
+ const domains = [];
267
+ const errors = [];
268
+
269
+ if (!manifest.domains || !Array.isArray(manifest.domains)) {
270
+ return { manifest, domains, errors: ['No domains defined in cluster manifest'] };
271
+ }
272
+
273
+ for (const entry of manifest.domains) {
274
+ try {
275
+ const loaded = domainLoader(entry.id);
276
+ if (loaded) {
277
+ domains.push({ id: entry.id, name: loaded.core?.meta?.domain || entry.id, role: entry.role || 'advisor', required: entry.required !== false, core: loaded.core, patterns: loaded.patterns });
278
+ } else {
279
+ errors.push(`Domain ${entry.id}: not found or failed to load`);
280
+ }
281
+ } catch (e) {
282
+ errors.push(`Domain ${entry.id}: ${e.message}`);
283
+ }
284
+ }
285
+
286
+ return { manifest, domains, errors };
287
+ }
288
+
289
+ /**
290
+ * Detect conflicts between loaded domains in a cluster.
291
+ *
292
+ * @param {Array<{id:string, core:object, patterns:object}>} domains
293
+ * @returns {Array<{type:string, domains:string[], description:string}>}
294
+ */
295
+ function detectDomainConflicts(domains) {
296
+ if (!domains || domains.length < 2) return [];
297
+
298
+ const conflicts = [];
299
+ const bannedTermMap = new Map();
300
+
301
+ // Check banned term conflicts — same term banned by one, preferred by another
302
+ for (const d of domains) {
303
+ const terms = d.patterns?.terminology?.banned_terms || [];
304
+ for (const t of terms) {
305
+ const term = typeof t === 'string' ? t : t.term;
306
+ if (bannedTermMap.has(term)) {
307
+ conflicts.push({
308
+ type: 'term_conflict',
309
+ domains: [bannedTermMap.get(term), d.id],
310
+ description: `Banned term "${term}" appears in multiple domains`,
311
+ });
312
+ }
313
+ bannedTermMap.set(term, d.id);
314
+ }
315
+ }
316
+
317
+ // Check stance conflicts — contradictory stances
318
+ for (let i = 0; i < domains.length; i++) {
319
+ for (let j = i + 1; j < domains.length; j++) {
320
+ const sA = (domains[i].core?.stances || []).map((s) => (typeof s === 'string' ? s : s.stance));
321
+ const sB = (domains[j].core?.stances || []).map((s) => (typeof s === 'string' ? s : s.stance));
322
+ for (const sa of sA) {
323
+ for (const sb of sB) {
324
+ // Simple negation check — can be extended
325
+ if (sa && sb && (sa.includes('not') && sb.toLowerCase().includes(sa.toLowerCase().replace('not ', ''))) ||
326
+ (sb.includes('not') && sa.toLowerCase().includes(sb.toLowerCase().replace('not ', '')))) {
327
+ conflicts.push({
328
+ type: 'stance_conflict',
329
+ domains: [domains[i].id, domains[j].id],
330
+ description: `Potential stance conflict: "${sa.slice(0, 60)}" vs "${sb.slice(0, 60)}"`,
331
+ });
332
+ }
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ return conflicts;
339
+ }
340
+
341
+ /**
342
+ * Generate a judgment trace for a cluster operation.
343
+ *
344
+ * @param {object} params
345
+ * @param {string} params.input — the original user input
346
+ * @param {Array} params.loadedDomains — all domains in the cluster
347
+ * @param {Array} params.activeDomains — domains that were activated
348
+ * @param {Array} params.conflicts — detected conflicts
349
+ * @returns {object}
350
+ */
351
+ function generateClusterTrace({ input, loadedDomains, activeDomains, conflicts }) {
352
+ return {
353
+ input: (input || '').slice(0, 200),
354
+ timestamp: new Date().toISOString(),
355
+ loaded_domains: (loadedDomains || []).map((d) => d.id || d.name || '?'),
356
+ active_domains: (activeDomains || []).map((d) => d.id || d.name || '?'),
357
+ active_count: (activeDomains || []).length,
358
+ domains_excluded: (loadedDomains || []).length - (activeDomains || []).length,
359
+ conflicts: (conflicts || []).map((c) => ({
360
+ type: c.type,
361
+ domains: c.domains,
362
+ description: c.description,
363
+ })),
364
+ };
365
+ }
366
+
367
+ module.exports = {
368
+ composeContext,
369
+ composeContextWithAttribution,
370
+ classifySignals,
371
+ classifySignalsAcrossDomains,
372
+ composeChecks,
373
+ loadAndCompose,
374
+ loadCluster,
375
+ detectDomainConflicts,
376
+ generateClusterTrace,
377
+ };
package/src/index.mjs CHANGED
@@ -16,4 +16,4 @@ export { validateDomainSchema, validateCrossFile } from './validate-pure.js';
16
16
 
17
17
  export { renderPreviewHTML, escHtml, renderCard } from './render.js';
18
18
 
19
- export { composeContext, classifySignals, composeChecks, loadAndCompose } from './compose.js';
19
+ export { composeContext, composeContextWithAttribution, classifySignals, classifySignalsAcrossDomains, composeChecks, loadAndCompose, loadCluster, detectDomainConflicts, generateClusterTrace } from './compose.js';
package/src/lint-pure.js CHANGED
@@ -6,6 +6,29 @@
6
6
  * Output: { errors: string[], warnings: string[] }
7
7
  */
8
8
 
9
+ /**
10
+ * Map of old/informal field names → correct v0.4 spec field names.
11
+ * Used to give helpful error messages when users write from scratch without the template.
12
+ */
13
+ const OLD_FIELD_HINTS = {
14
+ statement: 'one_sentence or full_statement',
15
+ description: 'one_sentence',
16
+ summary: 'one_sentence',
17
+ claim: 'wrong',
18
+ misreading: 'wrong',
19
+ reality: 'correct',
20
+ definition: 'essence or one_sentence (on ontology entries)',
21
+ brief: 'title or context',
22
+ bad_pattern: 'what_happened',
23
+ master_pattern: 'structural_pattern',
24
+ conclusion: 'one_sentence',
25
+ capability_layers: 'stages',
26
+ name: 'id (on ontology entries — use id instead of name)',
27
+ input: 'from',
28
+ output: 'to',
29
+ judgment: 'via',
30
+ };
31
+
9
32
  /**
10
33
  * Lint a KDNA domain from a map of parsed JSON objects.
11
34
  *
@@ -23,6 +46,15 @@ function lintDomain(dataMap) {
23
46
  function req(o, k, loc, hint) {
24
47
  if (!has(o, k) || o[k] === '' || o[k] == null) {
25
48
  let msg = `${loc}: missing required field "${k}"`;
49
+ // Check for common old field name and suggest the correct one
50
+ if (o && typeof o === 'object') {
51
+ for (const [oldName, newName] of Object.entries(OLD_FIELD_HINTS)) {
52
+ if (has(o, oldName)) {
53
+ msg += `\n → Found field "${oldName}" — this looks like an old/informal field name. Use "${newName}" instead.`;
54
+ break;
55
+ }
56
+ }
57
+ }
26
58
  if (hint) msg += `\n → ${hint}`;
27
59
  errors.push(msg);
28
60
  }
package/src/loader.js CHANGED
@@ -118,6 +118,55 @@ function classifyInput(text) {
118
118
  return [...new Set(optional)];
119
119
  }
120
120
 
121
+ /**
122
+ * Known field name mapping for detecting old/incorrect field names.
123
+ * When a common old name is found, log a warning and suggest the correct name.
124
+ */
125
+ const FIELD_ALIASES = {
126
+ statement: 'one_sentence or full_statement',
127
+ description: 'one_sentence',
128
+ summary: 'one_sentence',
129
+ claim: 'wrong',
130
+ misreading: 'wrong',
131
+ reality: 'correct',
132
+ definition: 'essence or one_sentence (on ontology)',
133
+ brief: 'title or context',
134
+ bad_pattern: 'what_happened',
135
+ master_pattern: 'structural_pattern',
136
+ conclusion: 'one_sentence',
137
+ capability_layers: 'stages',
138
+ name: 'id (on ontology entries)',
139
+ input: 'from',
140
+ output: 'to',
141
+ judgment: 'via',
142
+ };
143
+
144
+ /**
145
+ * Deep-scan an object tree for known old field names and return warnings.
146
+ * @param {object} obj
147
+ * @param {string} path
148
+ * @param {string[]} [warnings]
149
+ * @returns {string[]}
150
+ */
151
+ function detectOldFieldNames(obj, path = '', warnings = []) {
152
+ if (!obj || typeof obj !== 'object') return warnings;
153
+ if (Array.isArray(obj)) {
154
+ obj.forEach((item, i) => detectOldFieldNames(item, `${path}[${i}]`, warnings));
155
+ return warnings;
156
+ }
157
+ for (const key of Object.keys(obj)) {
158
+ if (FIELD_ALIASES[key]) {
159
+ warnings.push(
160
+ `[KDNA LOADER] ${path}.${key}: field "${key}" is not in spec v0.4. Use "${FIELD_ALIASES[key]}" instead. See docs/authoring-guide.md §0.`,
161
+ );
162
+ }
163
+ if (typeof obj[key] === 'object' && obj[key] !== null) {
164
+ detectOldFieldNames(obj[key], `${path}.${key}`, warnings);
165
+ }
166
+ }
167
+ return warnings;
168
+ }
169
+
121
170
  /**
122
171
  * Format a loaded KDNA domain into a context string suitable for
123
172
  * inclusion in an agent's system prompt.
@@ -132,6 +181,17 @@ function formatContext(domain) {
132
181
  const core = domain.core;
133
182
  const pat = domain.patterns;
134
183
 
184
+ // Scan for old field names and warn
185
+ const warnings = detectOldFieldNames(domain, domain.core?.meta?.domain || 'domain');
186
+ if (warnings.length) {
187
+ parts.push('<!-- KDNA FIELD NAME WARNINGS:');
188
+ for (const w of warnings) parts.push(` ${w}`);
189
+ parts.push(' These fields will be SILENTLY IGNORED by the loader.');
190
+ parts.push(' See: docs/authoring-guide.md §0 (Field Name Reference)');
191
+ parts.push('-->');
192
+ parts.push('');
193
+ }
194
+
135
195
  parts.push('## Domain Cognition (KDNA)');
136
196
  parts.push(`Domain: ${core.meta.domain}`);
137
197
  parts.push('');
@@ -118,7 +118,7 @@ function validateCrossFile(dataMap) {
118
118
  if (version === null) {
119
119
  version = data.meta.version;
120
120
  } else if (data.meta.version !== version) {
121
- warnings.push(
121
+ errors.push(
122
122
  `${file}: version "${data.meta.version}" differs from "${version}" in other files`,
123
123
  );
124
124
  }