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.
- package/README.md +4 -4
- package/framework/config.yaml +18 -3
- package/framework/constitution.md +12 -10
- package/framework/context/governance.md +2 -0
- package/framework/context/root.md +1 -1
- package/framework/intelligence/context-engine.md +22 -17
- package/package.json +1 -1
- package/scripts/doctor/checks/agents.js +77 -0
- package/scripts/doctor/checks/constitution.js +41 -0
- package/scripts/doctor/checks/domain-alignment.js +58 -0
- package/scripts/doctor/checks/prism-layers.js +84 -0
- package/scripts/doctor/checks/registry.js +55 -0
- package/scripts/doctor/checks/schemas.js +61 -0
- package/scripts/doctor/fixes/reference-fix.js +100 -0
- package/scripts/doctor/fixes/registry-fix.js +56 -0
- package/scripts/doctor/index.js +212 -0
- package/scripts/health-check.js +8 -8
- package/src/autonomy/surface-criteria.js +226 -0
- package/src/context/bracket-tracker.js +44 -13
- package/src/context/domain-loader.js +22 -0
- package/src/context/engine.js +18 -7
- package/src/context/formatter.js +21 -1
- package/src/context/layers/l5-keywords.js +53 -0
- package/src/intelligence/context-status.js +20 -9
- package/src/intelligence/decision-engine.js +253 -0
- package/src/terminal/prompt-builder.js +341 -1
- package/src/terminal/run-agent.js +15 -0
- package/src/terminal/run-parallel.js +77 -5
- package/src/terminal/spawner.js +13 -0
- package/src/utils/feature-flags.js +106 -0
|
@@ -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
|
|
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'],
|
|
13
|
-
MODERATE: { min: 40, max: 60, layers: ['L0', 'L1', 'L2', 'L3'],
|
|
14
|
-
DEPLETED: { min: 25, max: 40, layers: ['L0', 'L1', 'L2'],
|
|
15
|
-
CRITICAL: { min: 0, max: 25, layers: ['L0', 'L1'],
|
|
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: '
|
|
20
|
-
MODERATE: '
|
|
21
|
-
DEPLETED: '
|
|
22
|
-
CRITICAL: '
|
|
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
|
-
* @
|
|
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
|
|
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.
|
package/src/context/engine.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PRISM Context Engine —
|
|
2
|
+
* PRISM Context Engine — 6-layer context injection pipeline.
|
|
3
3
|
*
|
|
4
|
-
* Orchestrates L0-
|
|
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.
|
|
133
|
-
layers:
|
|
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
|
}
|
package/src/context/formatter.js
CHANGED
|
@@ -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'],
|
|
6
|
-
{ name: 'MODERATE', min: 40, max: 60, layers: ['L0', 'L1', 'L2', 'L3'],
|
|
7
|
-
{ name: 'DEPLETED', min: 25, max: 40, layers: ['L0', 'L1', 'L2'],
|
|
8
|
-
{ name: 'CRITICAL', min: 0, max: 25, layers: ['L0', 'L1'],
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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].
|
|
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: '
|
|
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.
|
|
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
|
+
}
|