chati-dev 3.3.2 → 4.0.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.
@@ -2,32 +2,58 @@
2
2
  * Bracket Tracker — Pure arithmetic for context window management.
3
3
  *
4
4
  * Brackets determine how much context to inject:
5
- * FRESH (60-100%) → All 5 layers active
6
- * MODERATE (40-60%) → L0 + L1 + L2 + L3 (skip L4 task detail)
5
+ * FRESH (60-100%) → All 6 layers active (L0-L5)
6
+ * MODERATE (40-60%) → L0 + L1 + L2 + L3 + L5 (skip L4 task detail)
7
7
  * DEPLETED (25-40%) → L0 + L1 + L2 only
8
8
  * CRITICAL (<25%) → L0 + L1 only (handoff mandatory)
9
+ *
10
+ * Progressive Reinforcement Model (v4.0):
11
+ * As context window depletes, the model forgets initial instructions
12
+ * and needs MORE reinforcement, not less. Budget expressed as percentage
13
+ * of the provider's total context window — proportional across providers.
14
+ *
15
+ * FRESH = 1.5% → minimal reinforcement (context is plentiful)
16
+ * MODERATE = 2.5% → growing reinforcement (model starting to forget)
17
+ * DEPLETED = 4.0% → heavy reinforcement (significant context loss)
18
+ * CRITICAL = 5.0% → maximum reinforcement (last interactions before handoff)
9
19
  */
10
20
 
21
+ /** Provider context window limits (tokens). */
22
+ const PROVIDER_LIMITS = {
23
+ claude: 200_000,
24
+ gemini: 1_000_000,
25
+ codex: 128_000,
26
+ };
27
+
11
28
  const BRACKETS = {
12
- FRESH: { min: 60, max: 100, layers: ['L0', 'L1', 'L2', 'L3', 'L4'], tokenBudget: 8000 },
13
- MODERATE: { min: 40, max: 60, layers: ['L0', 'L1', 'L2', 'L3'], tokenBudget: 5000 },
14
- DEPLETED: { min: 25, max: 40, layers: ['L0', 'L1', 'L2'], tokenBudget: 3000 },
15
- CRITICAL: { min: 0, max: 25, layers: ['L0', 'L1'], tokenBudget: 1500 },
29
+ FRESH: { min: 60, max: 100, layers: ['L0', 'L1', 'L2', 'L3', 'L4', 'L5'], budgetRatio: 0.015 },
30
+ MODERATE: { min: 40, max: 60, layers: ['L0', 'L1', 'L2', 'L3', 'L5'], budgetRatio: 0.025 },
31
+ DEPLETED: { min: 25, max: 40, layers: ['L0', 'L1', 'L2'], budgetRatio: 0.040 },
32
+ CRITICAL: { min: 0, max: 25, layers: ['L0', 'L1'], budgetRatio: 0.050 },
16
33
  };
17
34
 
35
+ /**
36
+ * Memory injection level per bracket.
37
+ * As context degrades, memory becomes MORE important:
38
+ * FRESH = none (context abundant, no memory needed)
39
+ * MODERATE = metadata (light reminders of relevant memories)
40
+ * DEPLETED = chunks (recovery via memory summaries)
41
+ * CRITICAL = full (full memory dump for handoff continuity)
42
+ */
18
43
  const MEMORY_LEVELS = {
19
- FRESH: 'full',
20
- MODERATE: 'chunks',
21
- DEPLETED: 'metadata',
22
- CRITICAL: 'none',
44
+ FRESH: 'none',
45
+ MODERATE: 'metadata',
46
+ DEPLETED: 'chunks',
47
+ CRITICAL: 'full',
23
48
  };
24
49
 
25
50
  /**
26
51
  * Calculate bracket from remaining context percentage.
27
52
  * @param {number} remainingPercent - 0 to 100
28
- * @returns {{ bracket: string, activeLayers: string[], tokenBudget: number, memoryLevel: string, handoffRequired: boolean }}
53
+ * @param {string} [provider='claude'] - Provider name for context window scaling
54
+ * @returns {{ bracket: string, activeLayers: string[], tokenBudget: number, budgetRatio: number, memoryLevel: string, handoffRequired: boolean, provider: string }}
29
55
  */
30
- export function calculateBracket(remainingPercent) {
56
+ export function calculateBracket(remainingPercent, provider = 'claude') {
31
57
  const pct = Math.max(0, Math.min(100, remainingPercent));
32
58
 
33
59
  let name = 'CRITICAL';
@@ -36,13 +62,18 @@ export function calculateBracket(remainingPercent) {
36
62
  else if (pct >= 25) name = 'DEPLETED';
37
63
 
38
64
  const def = BRACKETS[name];
65
+ const providerLimit = PROVIDER_LIMITS[provider] || PROVIDER_LIMITS.claude;
66
+ const tokenBudget = Math.round(def.budgetRatio * providerLimit);
67
+
39
68
  return {
40
69
  bracket: name,
41
70
  activeLayers: [...def.layers],
42
- tokenBudget: def.tokenBudget,
71
+ tokenBudget,
72
+ budgetRatio: def.budgetRatio,
43
73
  memoryLevel: MEMORY_LEVELS[name],
44
74
  handoffRequired: pct < 15,
45
75
  remainingPercent: pct,
76
+ provider,
46
77
  };
47
78
  }
48
79
 
@@ -91,6 +91,28 @@ export function loadGlobalDomain(domainsDir) {
91
91
  return result.loaded ? result.data : null;
92
92
  }
93
93
 
94
+ /**
95
+ * Load all keyword domain files from chati.dev/domains/keywords/.
96
+ * @param {string} domainsDir - Path to chati.dev/domains/
97
+ * @returns {Map<string, object>} Map of topicName → domain data (keywords + rules)
98
+ */
99
+ export function loadKeywordDomains(domainsDir) {
100
+ const keywordsDir = join(domainsDir, 'keywords');
101
+ const domains = new Map();
102
+
103
+ if (!existsSync(keywordsDir)) return domains;
104
+
105
+ const files = readdirSync(keywordsDir).filter(f => f.endsWith('.yaml'));
106
+ for (const file of files) {
107
+ const name = basename(file, '.yaml');
108
+ const result = loadDomainFile(join(keywordsDir, file));
109
+ if (result.loaded) {
110
+ domains.set(name, result.data);
111
+ }
112
+ }
113
+ return domains;
114
+ }
115
+
94
116
  /**
95
117
  * Extract rules array from a domain object.
96
118
  * Domain files have a `rules` key with an array of rule objects.
@@ -1,7 +1,7 @@
1
1
  /**
2
- * PRISM Context Engine — 5-layer context injection pipeline.
2
+ * PRISM Context Engine — 6-layer context injection pipeline.
3
3
  *
4
- * Orchestrates L0-L4 layers, respects bracket constraints,
4
+ * Orchestrates L0-L5 layers, respects bracket constraints,
5
5
  * and produces formatted XML context for agent prompts.
6
6
  *
7
7
  * Pipeline: bracket calculation → layer processing → formatting → output
@@ -13,6 +13,7 @@ import { processL1 } from './layers/l1-global.js';
13
13
  import { processL2 } from './layers/l2-agent.js';
14
14
  import { processL3 } from './layers/l3-workflow.js';
15
15
  import { processL4 } from './layers/l4-task.js';
16
+ import { processL5 } from './layers/l5-keywords.js';
16
17
  import { formatContext } from './formatter.js';
17
18
 
18
19
  const LAYER_TIMEOUT_MS = 100;
@@ -31,14 +32,16 @@ const LAYER_TIMEOUT_MS = 100;
31
32
  * @param {object} [input.handoff] - Handoff data from previous agent
32
33
  * @param {string[]} [input.artifacts] - Relevant artifact paths
33
34
  * @param {string[]} [input.taskCriteria] - Active task criteria
35
+ * @param {string} [input.userPrompt] - User prompt text for L5 keyword matching
36
+ * @param {string} [input.provider] - CLI provider for context window scaling (claude, gemini, codex)
34
37
  * @returns {{ xml: string, bracket: object, layers: object[], errors: string[] }}
35
38
  */
36
39
  export function runPrism(input) {
37
40
  const errors = [];
38
41
  const layers = [];
39
42
 
40
- // 1. Calculate bracket
41
- const bracket = calculateBracket(input.remainingPercent);
43
+ // 1. Calculate bracket (provider-aware for proportional budgets)
44
+ const bracket = calculateBracket(input.remainingPercent, input.provider || 'claude');
42
45
 
43
46
  // 2. Build context for layer processors
44
47
  const ctx = {
@@ -52,6 +55,7 @@ export function runPrism(input) {
52
55
  handoff: input.handoff || {},
53
56
  artifacts: input.artifacts || [],
54
57
  taskCriteria: input.taskCriteria || [],
58
+ userPrompt: input.userPrompt || null,
55
59
  };
56
60
 
57
61
  // 3. Process each active layer with timeout protection
@@ -85,6 +89,12 @@ export function runPrism(input) {
85
89
  if (l4) { layers.push(l4); layerResults.l4 = l4; }
86
90
  }
87
91
 
92
+ // L5 — Keyword recall layer (dynamic rules based on user prompt)
93
+ if (isLayerActive(bracket.bracket, 'L5') && ctx.userPrompt) {
94
+ const l5 = safeProcess('L5', () => processL5(ctx), errors);
95
+ if (l5 && l5.matchCount > 0) { layers.push(l5); layerResults.l5 = l5; }
96
+ }
97
+
88
98
  // 4. Format output
89
99
  const xml = formatContext({
90
100
  bracket: bracket.bracket,
@@ -129,9 +139,9 @@ function safeProcess(layerName, processFn, errors) {
129
139
  export function getPrismInfo() {
130
140
  return {
131
141
  name: 'PRISM',
132
- version: '1.0.0',
133
- layers: 5,
134
- layerNames: ['L0 Constitution', 'L1 Global', 'L2 Agent', 'L3 Workflow', 'L4 Task'],
142
+ version: '1.1.0',
143
+ layers: 6,
144
+ layerNames: ['L0 Constitution', 'L1 Global', 'L2 Agent', 'L3 Workflow', 'L4 Task', 'L5 Keywords'],
135
145
  brackets: ['FRESH', 'MODERATE', 'DEPLETED', 'CRITICAL'],
136
146
  features: [
137
147
  'Priority-based context truncation',
@@ -139,6 +149,7 @@ export function getPrismInfo() {
139
149
  'Graceful degradation on layer failure',
140
150
  'XML structured output',
141
151
  'Bracket-aware layer activation',
152
+ 'Provider-aware proportional budgets',
142
153
  ],
143
154
  };
144
155
  }
@@ -15,10 +15,11 @@
15
15
  * @param {object} [options.l2] - L2 Agent result
16
16
  * @param {object} [options.l3] - L3 Workflow result
17
17
  * @param {object} [options.l4] - L4 Task result
18
+ * @param {object} [options.l5] - L5 Keywords result
18
19
  * @returns {string} XML context block
19
20
  */
20
21
  export function formatContext(options) {
21
- const { bracket, tokenBudget, l0, l1, l2, l3, l4 } = options;
22
+ const { bracket, tokenBudget, l0, l1, l2, l3, l4, l5 } = options;
22
23
  const sections = [];
23
24
 
24
25
  // L0 — Constitution (always present)
@@ -46,6 +47,11 @@ export function formatContext(options) {
46
47
  sections.push(formatTask(l4));
47
48
  }
48
49
 
50
+ // L5 — Keywords
51
+ if (l5 && l5.matchCount > 0) {
52
+ sections.push(formatKeywords(l5));
53
+ }
54
+
49
55
  let body = sections.join('\n\n');
50
56
 
51
57
  // Truncate if over budget (rough estimate: 1 token ≈ 4 chars)
@@ -154,6 +160,20 @@ function formatTask(l4) {
154
160
  return lines.join('\n');
155
161
  }
156
162
 
163
+ function formatKeywords(l5) {
164
+ const lines = [];
165
+ lines.push(` <keyword-rules matches="${l5.matchCount}">`);
166
+ for (const match of l5.matches) {
167
+ lines.push(` <topic name="${esc(match.topic)}">`);
168
+ for (const r of match.rules) {
169
+ lines.push(` <rule id="${esc(r.id)}" priority="${esc(r.priority)}">${esc(r.text)}</rule>`);
170
+ }
171
+ lines.push(` </topic>`);
172
+ }
173
+ lines.push(` </keyword-rules>`);
174
+ return lines.join('\n');
175
+ }
176
+
157
177
  /**
158
178
  * Truncate sections by removing lower-priority layers first (L4 → L3 → L2).
159
179
  * L0 and L1 are never truncated.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * L5 Keyword Recall Layer — Dynamic rule injection based on user prompt content.
3
+ *
4
+ * Scans the user prompt for topic keywords and loads matching rules from
5
+ * domains/keywords/*.yaml. This adds the WHAT dimension to PRISM context:
6
+ * L0-L4 handle WHO (agent) and WHERE (workflow/task)
7
+ * L5 handles WHAT (topic detection from user prompt)
8
+ *
9
+ * Only active in FRESH and MODERATE brackets (expensive layer).
10
+ */
11
+
12
+ import { loadKeywordDomains } from '../domain-loader.js';
13
+
14
+ /**
15
+ * Process L5: scan user prompt for keywords, return matching rules.
16
+ *
17
+ * @param {object} ctx - Pipeline context
18
+ * @param {string} ctx.domainsDir - Path to chati.dev/domains/
19
+ * @param {string} [ctx.userPrompt] - The user's prompt text to scan for keywords
20
+ * @returns {{ layer: string, matches: Array<{ topic: string, rules: object[] }>, matchCount: number }}
21
+ */
22
+ export function processL5(ctx) {
23
+ const result = {
24
+ layer: 'L5',
25
+ matches: [],
26
+ matchCount: 0,
27
+ };
28
+
29
+ if (!ctx.userPrompt || !ctx.domainsDir) {
30
+ return result;
31
+ }
32
+
33
+ const promptLower = ctx.userPrompt.toLowerCase();
34
+ const keywordDomains = loadKeywordDomains(ctx.domainsDir);
35
+
36
+ for (const [topic, domain] of keywordDomains) {
37
+ const keywords = domain.keywords || [];
38
+ const matched = keywords.some(kw => promptLower.includes(kw.toLowerCase()));
39
+
40
+ if (matched) {
41
+ const rules = (domain.rules || []).map(r => ({
42
+ id: r.id || `${topic}-unknown`,
43
+ text: r.text || '',
44
+ priority: r.priority || 'normal',
45
+ }));
46
+
47
+ result.matches.push({ topic, rules });
48
+ result.matchCount += rules.length;
49
+ }
50
+ }
51
+
52
+ return result;
53
+ }
@@ -1,16 +1,23 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
 
4
+ /** Provider context window limits (tokens). */
5
+ const PROVIDER_LIMITS = {
6
+ claude: 200_000,
7
+ gemini: 1_000_000,
8
+ codex: 128_000,
9
+ };
10
+
4
11
  const BRACKETS = [
5
- { name: 'FRESH', min: 60, max: 100, layers: ['L0', 'L1', 'L2', 'L3', 'L4'], budget: 8000 },
6
- { name: 'MODERATE', min: 40, max: 60, layers: ['L0', 'L1', 'L2', 'L3'], budget: 5000 },
7
- { name: 'DEPLETED', min: 25, max: 40, layers: ['L0', 'L1', 'L2'], budget: 3000 },
8
- { name: 'CRITICAL', min: 0, max: 25, layers: ['L0', 'L1'], budget: 1500 },
12
+ { name: 'FRESH', min: 60, max: 100, layers: ['L0', 'L1', 'L2', 'L3', 'L4', 'L5'], budgetRatio: 0.015 },
13
+ { name: 'MODERATE', min: 40, max: 60, layers: ['L0', 'L1', 'L2', 'L3', 'L5'], budgetRatio: 0.025 },
14
+ { name: 'DEPLETED', min: 25, max: 40, layers: ['L0', 'L1', 'L2'], budgetRatio: 0.040 },
15
+ { name: 'CRITICAL', min: 0, max: 25, layers: ['L0', 'L1'], budgetRatio: 0.050 },
9
16
  ];
10
17
  // NOTE: This BRACKETS array is intentionally duplicated from src/context/bracket-tracker.js
11
18
  // for module isolation. bracket-tracker.js is the canonical source of truth.
12
- // If bracket ranges or budgets change, update BOTH files.
13
- // Format differs: here = array with `budget`, there = object with `tokenBudget`.
19
+ // Progressive Reinforcement Model (v4.0): budgets as ratio of provider context window.
20
+ // If bracket ranges or ratios change, update BOTH files.
14
21
 
15
22
  /**
16
23
  * Get context status based on session state
@@ -19,10 +26,12 @@ const BRACKETS = [
19
26
  export function getContextStatus(targetDir) {
20
27
  const sessionPath = join(targetDir, '.chati', 'session.yaml');
21
28
  if (!existsSync(sessionPath)) {
29
+ const providerLimit = PROVIDER_LIMITS.claude;
22
30
  return {
23
31
  bracket: 'FRESH',
24
32
  activeLayers: BRACKETS[0].layers,
25
- tokenBudget: BRACKETS[0].budget,
33
+ tokenBudget: Math.round(BRACKETS[0].budgetRatio * providerLimit),
34
+ budgetRatio: BRACKETS[0].budgetRatio,
26
35
  memoryLevel: 'none',
27
36
  advisory: 'No active session — default FRESH bracket',
28
37
  };
@@ -44,12 +53,14 @@ export function getContextStatus(targetDir) {
44
53
 
45
54
  const bracket = BRACKETS.find(b => remainingPercent >= b.min && remainingPercent <= b.max) || BRACKETS[0];
46
55
 
47
- const memoryLevels = { FRESH: 'full', MODERATE: 'chunks', DEPLETED: 'metadata', CRITICAL: 'none' };
56
+ const memoryLevels = { FRESH: 'none', MODERATE: 'metadata', DEPLETED: 'chunks', CRITICAL: 'full' };
57
+ const providerLimit = PROVIDER_LIMITS.claude;
48
58
 
49
59
  return {
50
60
  bracket: bracket.name,
51
61
  activeLayers: bracket.layers,
52
- tokenBudget: bracket.budget,
62
+ tokenBudget: Math.round(bracket.budgetRatio * providerLimit),
63
+ budgetRatio: bracket.budgetRatio,
53
64
  memoryLevel: memoryLevels[bracket.name],
54
65
  remainingPercent,
55
66
  estimatedUsage,
@@ -0,0 +1,253 @@
1
+ /**
2
+ * IDS Decision Engine — Intelligent Deduplication & Similarity scoring.
3
+ *
4
+ * Before creating any new artifact, scores existing registry entities
5
+ * for similarity. Enforces Constitution Article XIV: REUSE > ADAPT > CREATE.
6
+ *
7
+ * Algorithm:
8
+ * similarity = (keywordOverlap * 0.6) + (purposeSimilarity * 0.4)
9
+ * >= 90% → REUSE, 60-89% → ADAPT, < 60% → CREATE
10
+ */
11
+
12
+ import { existsSync, readFileSync } from 'fs';
13
+ import { join } from 'path';
14
+
15
+ /**
16
+ * @typedef {object} DecisionIntent
17
+ * @property {string[]} keywords - Keywords describing what we want to create
18
+ * @property {string} purpose - One-line description of the intended artifact
19
+ */
20
+
21
+ /**
22
+ * @typedef {object} DecisionResult
23
+ * @property {string} decision - 'REUSE' | 'ADAPT' | 'CREATE'
24
+ * @property {string|null} matchId - The best matching entity ID (null for CREATE)
25
+ * @property {number} similarity - 0-100 similarity score
26
+ * @property {string} reasoning - Human-readable explanation
27
+ * @property {object|null} match - The best matching entity data
28
+ */
29
+
30
+ /**
31
+ * Evaluate a creation intent against the entity registry.
32
+ *
33
+ * @param {DecisionIntent} intent - What we want to create
34
+ * @param {string} registryPath - Absolute path to entity-registry.yaml
35
+ * @returns {DecisionResult}
36
+ */
37
+ export function evaluateDecision(intent, registryPath) {
38
+ if (!intent || !intent.keywords || !intent.purpose) {
39
+ return {
40
+ decision: 'CREATE',
41
+ matchId: null,
42
+ similarity: 0,
43
+ reasoning: 'No intent provided, defaulting to CREATE.',
44
+ match: null,
45
+ };
46
+ }
47
+
48
+ const entities = loadRegistryEntities(registryPath);
49
+ if (entities.length === 0) {
50
+ return {
51
+ decision: 'CREATE',
52
+ matchId: null,
53
+ similarity: 0,
54
+ reasoning: 'Registry empty or not found, defaulting to CREATE.',
55
+ match: null,
56
+ };
57
+ }
58
+
59
+ let bestMatch = null;
60
+ let bestScore = 0;
61
+ let bestId = null;
62
+
63
+ for (const entity of entities) {
64
+ const score = calculateSimilarity(intent, entity);
65
+ if (score > bestScore) {
66
+ bestScore = score;
67
+ bestMatch = entity;
68
+ bestId = entity.id;
69
+ }
70
+ }
71
+
72
+ const similarity = Math.round(bestScore * 100);
73
+ let decision;
74
+ let reasoning;
75
+
76
+ if (similarity >= 90) {
77
+ decision = 'REUSE';
78
+ reasoning = `Entity "${bestId}" is ${similarity}% similar. Reuse as-is (Article XIV).`;
79
+ } else if (similarity >= 60) {
80
+ decision = 'ADAPT';
81
+ reasoning = `Entity "${bestId}" is ${similarity}% similar. Adapt for current needs (adaptability: ${bestMatch?.adaptability ?? 'unknown'}).`;
82
+ } else {
83
+ decision = 'CREATE';
84
+ reasoning = bestId
85
+ ? `Best match "${bestId}" is only ${similarity}% similar. Create new artifact.`
86
+ : 'No sufficiently similar entity found. Create new artifact.';
87
+ }
88
+
89
+ return {
90
+ decision,
91
+ matchId: similarity >= 60 ? bestId : null,
92
+ similarity,
93
+ reasoning,
94
+ match: similarity >= 60 ? bestMatch : null,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Calculate combined similarity between an intent and an entity.
100
+ *
101
+ * similarity = (keywordOverlap * 0.6) + (purposeSimilarity * 0.4)
102
+ *
103
+ * @param {DecisionIntent} intent
104
+ * @param {{ keywords: string[], purpose: string }} entity
105
+ * @returns {number} 0.0 to 1.0
106
+ */
107
+ export function calculateSimilarity(intent, entity) {
108
+ const kwScore = keywordOverlap(intent.keywords, entity.keywords || []);
109
+ const purposeScore = purposeSimilarity(intent.purpose, entity.purpose || '');
110
+ return (kwScore * 0.6) + (purposeScore * 0.4);
111
+ }
112
+
113
+ /**
114
+ * Calculate keyword overlap using token-based Jaccard similarity.
115
+ *
116
+ * @param {string[]} a - Intent keywords
117
+ * @param {string[]} b - Entity keywords
118
+ * @returns {number} 0.0 to 1.0
119
+ */
120
+ export function keywordOverlap(a, b) {
121
+ if (!a || !b || a.length === 0 || b.length === 0) return 0;
122
+
123
+ const setA = new Set(a.map(k => k.toLowerCase().trim()));
124
+ const setB = new Set(b.map(k => k.toLowerCase().trim()));
125
+
126
+ let intersection = 0;
127
+ for (const k of setA) {
128
+ if (setB.has(k)) intersection++;
129
+ }
130
+
131
+ const union = new Set([...setA, ...setB]).size;
132
+ return union === 0 ? 0 : intersection / union;
133
+ }
134
+
135
+ /**
136
+ * Calculate purpose similarity using token overlap (bag-of-words).
137
+ *
138
+ * @param {string} a - Intent purpose
139
+ * @param {string} b - Entity purpose
140
+ * @returns {number} 0.0 to 1.0
141
+ */
142
+ export function purposeSimilarity(a, b) {
143
+ if (!a || !b) return 0;
144
+
145
+ const tokenize = (text) => {
146
+ return text
147
+ .toLowerCase()
148
+ .replace(/[^a-z0-9\s]/g, '')
149
+ .split(/\s+/)
150
+ .filter(t => t.length > 2); // Skip tiny words
151
+ };
152
+
153
+ const tokensA = new Set(tokenize(a));
154
+ const tokensB = new Set(tokenize(b));
155
+
156
+ if (tokensA.size === 0 || tokensB.size === 0) return 0;
157
+
158
+ let intersection = 0;
159
+ for (const t of tokensA) {
160
+ if (tokensB.has(t)) intersection++;
161
+ }
162
+
163
+ const union = new Set([...tokensA, ...tokensB]).size;
164
+ return union === 0 ? 0 : intersection / union;
165
+ }
166
+
167
+ /**
168
+ * Load and flatten entity registry into a searchable array.
169
+ *
170
+ * @param {string} registryPath - Absolute path to entity-registry.yaml
171
+ * @returns {Array<{ id: string, keywords: string[], purpose: string, path: string, type: string, adaptability: number }>}
172
+ */
173
+ export function loadRegistryEntities(registryPath) {
174
+ if (!registryPath || !existsSync(registryPath)) return [];
175
+
176
+ try {
177
+ const raw = readFileSync(registryPath, 'utf-8');
178
+ return parseRegistryEntities(raw);
179
+ } catch {
180
+ return [];
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Parse registry YAML into flat entity array.
186
+ * Uses simple regex parsing consistent with the codebase pattern.
187
+ *
188
+ * @param {string} raw - Raw YAML content
189
+ * @returns {Array<{ id: string, keywords: string[], purpose: string, path: string, type: string, adaptability: number }>}
190
+ */
191
+ export function parseRegistryEntities(raw) {
192
+ const entities = [];
193
+ const lines = raw.split('\n');
194
+
195
+ let currentCategory = null;
196
+ let currentEntity = null;
197
+ let currentData = {};
198
+
199
+ for (const line of lines) {
200
+ // Category header (agents:, templates:, etc.) — 2-space indent under entities:
201
+ const categoryMatch = line.match(/^ (\w[\w-]*):\s*$/);
202
+ if (categoryMatch && !line.match(/^\s{4}/)) {
203
+ // Save previous entity
204
+ if (currentEntity && currentData.purpose) {
205
+ entities.push({ id: `${currentCategory}/${currentEntity}`, ...currentData });
206
+ }
207
+ currentCategory = categoryMatch[1];
208
+ currentEntity = null;
209
+ currentData = {};
210
+ continue;
211
+ }
212
+
213
+ // Entity header (e.g., " orchestrator:") — 4-space indent
214
+ const entityMatch = line.match(/^ ([\w][\w-]*):\s*$/);
215
+ if (entityMatch) {
216
+ // Save previous entity
217
+ if (currentEntity && currentData.purpose) {
218
+ entities.push({ id: `${currentCategory}/${currentEntity}`, ...currentData });
219
+ }
220
+ currentEntity = entityMatch[1];
221
+ currentData = { keywords: [], purpose: '', path: '', type: '', adaptability: 0.5 };
222
+ continue;
223
+ }
224
+
225
+ if (!currentEntity) continue;
226
+
227
+ // Entity fields (6-space indent)
228
+ const pathMatch = line.match(/^\s{6}path:\s*(.+)/);
229
+ if (pathMatch) { currentData.path = pathMatch[1].trim(); continue; }
230
+
231
+ const typeMatch = line.match(/^\s{6}type:\s*(.+)/);
232
+ if (typeMatch) { currentData.type = typeMatch[1].trim(); continue; }
233
+
234
+ const purposeMatch = line.match(/^\s{6}purpose:\s*"?([^"]*)"?\s*$/);
235
+ if (purposeMatch) { currentData.purpose = purposeMatch[1].trim(); continue; }
236
+
237
+ const adaptMatch = line.match(/^\s{6}adaptability:\s*([\d.]+)/);
238
+ if (adaptMatch) { currentData.adaptability = parseFloat(adaptMatch[1]); continue; }
239
+
240
+ const kwMatch = line.match(/^\s{6}keywords:\s*\[(.+)\]/);
241
+ if (kwMatch) {
242
+ currentData.keywords = kwMatch[1].split(',').map(k => k.trim().replace(/^['"]|['"]$/g, ''));
243
+ continue;
244
+ }
245
+ }
246
+
247
+ // Save last entity
248
+ if (currentEntity && currentData.purpose) {
249
+ entities.push({ id: `${currentCategory}/${currentEntity}`, ...currentData });
250
+ }
251
+
252
+ return entities;
253
+ }