yamtam-core 0.1.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/package.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "yamtam-core",
3
+ "version": "0.1.0",
4
+ "description": "YAMTAM core — Router · Safety · Context. Powers Yana and any interface built on YAMTAM.",
5
+ "main": "src/index.js",
6
+ "type": "commonjs",
7
+ "engines": { "node": ">=18" },
8
+ "keywords": ["yamtam", "yana", "ai-router", "skill-matching", "agent"],
9
+ "license": "MIT"
10
+ }
package/src/agents.js ADDED
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const MAX_PROMPT_BYTES = 8 * 1024;
7
+ const TRUNCATION_MARKER = '\n\n[...system prompt truncated at 8 KB...]';
8
+
9
+ const GENERIC_PROMPT = `Tên mình là Yana — lớp giao tiếp chính của YAMTAM. Senior engineer luôn online, không có kiên nhẫn với câu trả lời mơ hồ.
10
+
11
+ Giọng nói:
12
+ - Nói như engineer đang pair-programming với đồng nghiệp — không phải agent AI "hỗ trợ khách hàng"
13
+ - Tuyệt đối không: "Certainly!", "Of course!", "Great question!", "Sure!", "Absolutely!", "I'd be happy to help!"
14
+ - Không mở đầu bằng lời khen câu hỏi. Không kết thúc bằng "Is there anything else?"
15
+ - Đi thẳng vào vấn đề. Ngắn hơn là tốt hơn, trừ khi cần giải thích kỹ
16
+
17
+ Tính cách:
18
+ - Kiên định về craft — không viết code xấu để lấy lòng, nói thẳng khi có vấn đề rồi đề xuất cách tốt hơn
19
+ - Thực dụng — giải quyết vấn đề thực tế trước, refactor sau
20
+ - Bảo vệ — cảnh báo trước các action irreversible (force push, xóa data, deploy prod)
21
+ - Không hallucinate — nếu không chắc thì nói "không chắc", không bịa câu trả lời nghe có vẻ đúng
22
+ - Hài hước đúng lúc — có humor nhưng không force, không meme khi đang debug production
23
+
24
+ Code:
25
+ - Viết ngôn ngữ/framework người dùng đang dùng — không tự switch stack
26
+ - Show diff khi có thể, không dump cả file
27
+ - Fix bug trước, giải thích sau
28
+ - Typed, error-handled, không có any trừ khi bắt buộc
29
+
30
+ Ngôn ngữ:
31
+ - Reply bằng ngôn ngữ người dùng viết
32
+ - Mix tiếng Việt + tiếng Anh → follow theo, không normalize
33
+ - Không dịch code comments hay variable names trừ khi được hỏi`;
34
+
35
+ /**
36
+ * createAgents({ agentsDir }) → { loadSystemPrompt }
37
+ */
38
+ function createAgents({ agentsDir } = {}) {
39
+ const AGENTS_DIR = agentsDir || '';
40
+
41
+ function stripFrontmatter(content) {
42
+ const lines = content.split('\n');
43
+ if (lines[0].trim() !== '---') return content;
44
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
45
+ return closeIdx < 0 ? content : lines.slice(closeIdx + 1).join('\n').trim();
46
+ }
47
+
48
+ function tryLoadAgent(name) {
49
+ if (!AGENTS_DIR || !/^[a-zA-Z0-9_-]+$/.test(name)) return null;
50
+ try {
51
+ const raw = fs.readFileSync(path.join(AGENTS_DIR, name + '.md'), 'utf8');
52
+ return stripFrontmatter(raw);
53
+ } catch (_) { return null; }
54
+ }
55
+
56
+ function capPrompt(text) {
57
+ const buf = Buffer.from(text, 'utf8');
58
+ if (buf.length <= MAX_PROMPT_BYTES) return text;
59
+ const marker = Buffer.from(TRUNCATION_MARKER, 'utf8');
60
+ return buf.slice(0, MAX_PROMPT_BYTES - marker.length).toString('utf8') + TRUNCATION_MARKER;
61
+ }
62
+
63
+ function loadSystemPrompt(suggestedAgents) {
64
+ if (Array.isArray(suggestedAgents)) {
65
+ for (const name of suggestedAgents) {
66
+ if (typeof name !== 'string') continue;
67
+ const body = tryLoadAgent(name.trim());
68
+ if (body && body.length > 0) return capPrompt(body);
69
+ }
70
+ }
71
+ return GENERIC_PROMPT;
72
+ }
73
+
74
+ return { loadSystemPrompt };
75
+ }
76
+
77
+ module.exports = { createAgents };
@@ -0,0 +1,296 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const EXTERNAL_SIGNALS = [
7
+ 'git push', 'cargo publish', 'npm publish', 'deploy',
8
+ 'send email', 'send message', 'rm -rf', 'production',
9
+ 'kubectl apply', 'terraform apply',
10
+ ];
11
+
12
+ const COMPLEX_SIGNALS = [
13
+ 'implement', 'refactor', 'sửa bug', 'fix bug',
14
+ 'add feature', 'create', 'build', 'migrate',
15
+ 'test', 'debug',
16
+ ];
17
+
18
+ // Learning signals → route to hoc-tap agent
19
+ const LEARNING_SIGNALS = [
20
+ 'giải thích', 'explain', 'học', 'hiểu', 'tại sao', 'why is',
21
+ 'how does', 'hoạt động như thế nào', 'là gì', 'what is',
22
+ 'ví dụ về', 'example of', 'dạy tôi', 'teach me',
23
+ 'tóm tắt bài', 'summarize this article', 'ôn tập',
24
+ 'bài tập', 'exercise', 'quiz', 'kiểm tra kiến thức',
25
+ ];
26
+
27
+ // Daily work signals → route to daily-assistant agent
28
+ const DAILY_SIGNALS = [
29
+ 'tóm tắt', 'summarize', 'soạn email', 'write email', 'viết email',
30
+ 'lên kế hoạch', 'plan', 'todo', 'danh sách việc', 'task list',
31
+ 'nhắc tôi', 'remind me', 'lịch', 'schedule',
32
+ 'phân tích', 'analyze this', 'pros cons', 'so sánh',
33
+ 'soạn thảo', 'draft', 'báo cáo', 'report',
34
+ ];
35
+
36
+ // Creative writing signals → route to creative-writer agent
37
+ const CREATIVE_SIGNALS = [
38
+ 'viết bài', 'bài viết', 'blog', 'content', 'copywriting',
39
+ 'sáng tác', 'kịch bản', 'story', 'câu chuyện', 'tiểu thuyết',
40
+ 'thông điệp', 'slogan', 'tagline', 'quảng cáo', 'ad copy',
41
+ 'caption', 'mô tả sản phẩm', 'product description',
42
+ 'lời giới thiệu', 'introduction text', 'about us',
43
+ 'viết lại', 'rewrite', 'paraphrase',
44
+ ];
45
+
46
+ // Data analysis signals → route to data-analyst agent
47
+ const DATA_SIGNALS = [
48
+ 'phân tích dữ liệu', 'data analysis', 'dataset',
49
+ 'sql', 'query', 'select from', 'join',
50
+ 'excel', 'csv', 'pandas', 'dataframe', 'matplotlib',
51
+ 'thống kê', 'statistics', 'biểu đồ', 'chart', 'dashboard',
52
+ 'eda', 'exploratory', 'histogram', 'correlation',
53
+ 'machine learning', 'model training', 'feature',
54
+ ];
55
+
56
+ // Review / audit signals → route to reviewer agents
57
+ const REVIEW_SIGNALS = [
58
+ 'review code', 'code review', 'kiểm tra code',
59
+ 'đánh giá', 'nhận xét', 'feedback on', 'góp ý',
60
+ 'proofreading', 'chỉnh sửa văn bản', 'sửa lỗi chính tả',
61
+ 'audit', 'kiểm tra lỗi', 'check this', 'xem lại',
62
+ 'có vấn đề gì không', 'có bug không', 'bảo mật',
63
+ ];
64
+
65
+ // ── Rule 68 — sensitivity tiers ──────────────────────────────────────────────
66
+ // Canonical marker tables live in src/route.rs (yamtam-rt). This JS mirror
67
+ // keeps the fallback classifier and the web UI honest when the binary is
68
+ // missing or stale. Tier decides persistence + which model may see the text.
69
+
70
+ const SOVEREIGN_MARKERS = [
71
+ 'chỉ mình anh biết', 'chỉ anh biết', 'chỉ riêng anh', 'không ai được biết',
72
+ 'sovereign only', 'for my eyes only', 'local model only', 'chỉ model local',
73
+ '#sovereign',
74
+ ];
75
+
76
+ const CONFIDENTIAL_MARKERS = [
77
+ 'bí mật', 'tuyệt mật', 'confidential', 'đừng ghi lại', 'đừng lưu',
78
+ 'không lưu lại', 'không ghi lại', 'không được lưu', 'giữ kín',
79
+ 'off the record', 'do not log', "don't log", 'do not save', "don't save",
80
+ 'do not persist', '#mật', '#confidential', '#private',
81
+ ];
82
+
83
+ const CONFIDENTIAL_SMELLS = [
84
+ 'mua công ty', 'bán công ty', 'thương vụ', 'sáp nhập', 'đàm phán',
85
+ 'acquisition', 'merger', 'negotiation position', 'lương của', 'salary of',
86
+ 'chẩn đoán', 'diagnosis', 'bệnh án', 'health record', 'kiện tụng', 'lawsuit',
87
+ 'chưa công bố', 'chưa công khai', 'unannounced',
88
+ ];
89
+
90
+ const SENSITIVITY_POLICY = {
91
+ public: { allow_persist: true, model_scope: 'any' },
92
+ internal: { allow_persist: true, model_scope: 'any' },
93
+ confidential: { allow_persist: false, model_scope: 'cloud-redacted' },
94
+ sovereign: { allow_persist: false, model_scope: 'local-only' },
95
+ };
96
+
97
+ /** classifySensitivity(text) → { sensitivity, signals } — marker > smell > public > internal */
98
+ function classifySensitivity(text) {
99
+ const lower = String(text || '').toLowerCase();
100
+ const hits = set => set.filter(m => lower.includes(m));
101
+
102
+ const sov = hits(SOVEREIGN_MARKERS);
103
+ if (sov.length) return { sensitivity: 'sovereign', signals: sov };
104
+
105
+ const conf = hits(CONFIDENTIAL_MARKERS);
106
+ if (conf.length) return { sensitivity: 'confidential', signals: conf };
107
+
108
+ const smell = hits(CONFIDENTIAL_SMELLS);
109
+ if (smell.length) return { sensitivity: 'confidential', signals: smell };
110
+
111
+ if (lower.includes('#public')) return { sensitivity: 'public', signals: ['#public'] };
112
+ return { sensitivity: 'internal', signals: [] };
113
+ }
114
+
115
+ function findMatches(task, signals) {
116
+ const lower = task.toLowerCase();
117
+ return signals.filter(s => lower.includes(s.toLowerCase()));
118
+ }
119
+
120
+ /**
121
+ * createClassifier({ indexPath }) → { classify, matchSkills }
122
+ *
123
+ * @param {object} cfg
124
+ * @param {string} cfg.indexPath Path to skill-trigger-index.json
125
+ */
126
+ function createClassifier({ indexPath } = {}) {
127
+ let SKILL_INDEX = [];
128
+ if (indexPath) {
129
+ try { SKILL_INDEX = JSON.parse(fs.readFileSync(indexPath, 'utf8')); } catch (_) {}
130
+ }
131
+
132
+ function matchSkills(task) {
133
+ if (SKILL_INDEX.length === 0) return [];
134
+ const lower = task.toLowerCase();
135
+ const scored = [];
136
+
137
+ for (const entry of SKILL_INDEX) {
138
+ let score = 0;
139
+ const hits = [];
140
+ for (const trigger of entry.triggers) {
141
+ if (lower.includes(trigger)) {
142
+ score += 1 + Math.floor(trigger.length / 8);
143
+ hits.push(trigger);
144
+ } else {
145
+ const parts = trigger.split(' ');
146
+ if (parts.length === 2) {
147
+ const rev = `${parts[1]} ${parts[0]}`;
148
+ if (lower.includes(rev)) {
149
+ score += 1;
150
+ hits.push(trigger);
151
+ }
152
+ }
153
+ }
154
+ }
155
+ if (score > 0) scored.push({ name: entry.name, score, hits });
156
+ }
157
+
158
+ scored.sort((a, b) => b.score - a.score);
159
+ return scored.slice(0, 3);
160
+ }
161
+
162
+ function classify(task) {
163
+ if (typeof task !== 'string') task = String(task || '');
164
+ const { sensitivity, signals } = classifySensitivity(task);
165
+ const policy = SENSITIVITY_POLICY[sensitivity];
166
+ return {
167
+ ...classifyRoute(task),
168
+ sensitivity,
169
+ allow_persist: policy.allow_persist,
170
+ model_scope: policy.model_scope,
171
+ sensitivity_signals: signals,
172
+ };
173
+ }
174
+
175
+ function classifyRoute(task) {
176
+ const extMatches = findMatches(task, EXTERNAL_SIGNALS);
177
+ if (extMatches.length > 0) {
178
+ return {
179
+ route: 'external',
180
+ gate: 'confirm',
181
+ confidence: Math.min(0.6 + extMatches.length * 0.1, 0.95),
182
+ reason: 'Task involves external side-effects or irreversible actions',
183
+ matched_signals: extMatches,
184
+ matched_skills: [],
185
+ suggested_agents: ['security-engineer', 'deployment-engineer'],
186
+ };
187
+ }
188
+
189
+ const skillMatches = matchSkills(task);
190
+ if (skillMatches.length > 0 && skillMatches[0].score >= 1) {
191
+ const top = skillMatches[0];
192
+ const confidence = Math.min(0.55 + top.score * 0.07, 0.95);
193
+ return {
194
+ route: 'skill',
195
+ gate: 'harness',
196
+ confidence,
197
+ reason: `Matched skill: ${top.name} (triggers: ${top.hits.slice(0, 3).join(', ')})`,
198
+ matched_signals: top.hits,
199
+ matched_skills: skillMatches.map(s => s.name),
200
+ suggested_agents: [top.name],
201
+ };
202
+ }
203
+
204
+ const learnMatches = findMatches(task, LEARNING_SIGNALS);
205
+ if (learnMatches.length > 0) {
206
+ return {
207
+ route: 'learn',
208
+ gate: 'auto',
209
+ confidence: Math.min(0.65 + learnMatches.length * 0.08, 0.92),
210
+ reason: 'Learning or explanation request detected',
211
+ matched_signals: learnMatches,
212
+ matched_skills: skillMatches.map(s => s.name),
213
+ suggested_agents: ['hoc-tap'],
214
+ };
215
+ }
216
+
217
+ const dailyMatches = findMatches(task, DAILY_SIGNALS);
218
+ if (dailyMatches.length > 0) {
219
+ return {
220
+ route: 'daily',
221
+ gate: 'auto',
222
+ confidence: Math.min(0.65 + dailyMatches.length * 0.08, 0.92),
223
+ reason: 'Daily work task detected',
224
+ matched_signals: dailyMatches,
225
+ matched_skills: skillMatches.map(s => s.name),
226
+ suggested_agents: ['daily-assistant'],
227
+ };
228
+ }
229
+
230
+ const creativeMatches = findMatches(task, CREATIVE_SIGNALS);
231
+ if (creativeMatches.length > 0) {
232
+ return {
233
+ route: 'creative',
234
+ gate: 'auto',
235
+ confidence: Math.min(0.65 + creativeMatches.length * 0.08, 0.92),
236
+ reason: 'Creative writing or content request detected',
237
+ matched_signals: creativeMatches,
238
+ matched_skills: skillMatches.map(s => s.name),
239
+ suggested_agents: ['creative-writer', 'documentation-writer'],
240
+ };
241
+ }
242
+
243
+ const dataMatches = findMatches(task, DATA_SIGNALS);
244
+ if (dataMatches.length > 0) {
245
+ return {
246
+ route: 'data',
247
+ gate: 'auto',
248
+ confidence: Math.min(0.65 + dataMatches.length * 0.08, 0.92),
249
+ reason: 'Data analysis or SQL task detected',
250
+ matched_signals: dataMatches,
251
+ matched_skills: skillMatches.map(s => s.name),
252
+ suggested_agents: ['data-analyst', 'database-reviewer'],
253
+ };
254
+ }
255
+
256
+ const reviewMatches = findMatches(task, REVIEW_SIGNALS);
257
+ if (reviewMatches.length > 0) {
258
+ return {
259
+ route: 'review',
260
+ gate: 'auto',
261
+ confidence: Math.min(0.65 + reviewMatches.length * 0.08, 0.92),
262
+ reason: 'Review or audit task detected',
263
+ matched_signals: reviewMatches,
264
+ matched_skills: skillMatches.map(s => s.name),
265
+ suggested_agents: ['code-reviewer', 'react-reviewer'],
266
+ };
267
+ }
268
+
269
+ const cplxMatches = findMatches(task, COMPLEX_SIGNALS);
270
+ if (cplxMatches.length > 0) {
271
+ return {
272
+ route: 'complex',
273
+ gate: 'harness',
274
+ confidence: Math.min(0.6 + cplxMatches.length * 0.1, 0.95),
275
+ reason: 'Task requires multi-step code changes or analysis',
276
+ matched_signals: cplxMatches,
277
+ matched_skills: skillMatches.map(s => s.name),
278
+ suggested_agents: ['backend-developer', 'code-reviewer'],
279
+ };
280
+ }
281
+
282
+ return {
283
+ route: 'simple',
284
+ gate: 'auto',
285
+ confidence: 0.5,
286
+ reason: 'No complex or external signals detected',
287
+ matched_signals: [],
288
+ matched_skills: skillMatches.map(s => s.name),
289
+ suggested_agents: [],
290
+ };
291
+ }
292
+
293
+ return { classify, matchSkills };
294
+ }
295
+
296
+ module.exports = { createClassifier, classifySensitivity };
package/src/index.js ADDED
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { createClassifier, classifySensitivity } = require('./classifier');
5
+ const { createAgents } = require('./agents');
6
+ const { createSkills } = require('./skills');
7
+ const { createRouter } = require('./router');
8
+
9
+ /**
10
+ * createCore(config) — wire all YAMTAM subsystems with a single root dir.
11
+ *
12
+ * @param {object} config
13
+ * @param {string} [config.rootDir] Repo root (default: cwd)
14
+ * @param {string} [config.skillsDir] Override core/skills path
15
+ * @param {string} [config.agentsDir] Override core/agents path
16
+ * @param {string} [config.skillIndexPath] Override core/config/skill-trigger-index.json
17
+ * @param {string} [config.wrapperPath] Override scripts/yamtam-rt-wrapper.js
18
+ */
19
+ function createCore(config = {}) {
20
+ const root = config.rootDir || process.cwd();
21
+
22
+ const resolved = {
23
+ indexPath: config.skillIndexPath || path.join(root, 'core', 'config', 'skill-trigger-index.json'),
24
+ agentsDir: config.agentsDir || path.join(root, 'core', 'agents'),
25
+ skillsDir: config.skillsDir || path.join(root, 'core', 'skills'),
26
+ wrapperPath: config.wrapperPath || path.join(root, 'scripts', 'yamtam-rt-wrapper.js'),
27
+ };
28
+
29
+ const { classify, matchSkills } = createClassifier(resolved);
30
+ const { loadSystemPrompt } = createAgents(resolved);
31
+ const { findBestSkill, loadSkillPrompt, skillCount } = createSkills(resolved);
32
+ const { route } = createRouter({ classify, wrapperPath: resolved.wrapperPath });
33
+
34
+ return { classify, matchSkills, route, loadSystemPrompt, findBestSkill, loadSkillPrompt, skillCount };
35
+ }
36
+
37
+ module.exports = { createCore, classifySensitivity };
package/src/router.js ADDED
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { execFile } = require('child_process');
5
+
6
+ /**
7
+ * createRouter({ classify, wrapperPath }) → { route }
8
+ *
9
+ * Tries the yamtam-rt native binary first; falls back to JS classifier.
10
+ */
11
+ function createRouter({ classify, wrapperPath } = {}) {
12
+ const WRAPPER = wrapperPath || null;
13
+
14
+ function spawnRouter(task) {
15
+ return new Promise((resolve, reject) => {
16
+ if (!WRAPPER) { reject(new Error('no wrapper')); return; }
17
+ execFile(
18
+ 'node',
19
+ [WRAPPER, 'route', 'classify', task],
20
+ { env: process.env, timeout: 5000 },
21
+ (err, stdout) => {
22
+ if (err) { reject(err); return; }
23
+ let parsed;
24
+ try { parsed = JSON.parse(stdout); } catch (e) { reject(e); return; }
25
+ if (!parsed || typeof parsed.route !== 'string') {
26
+ reject(new Error('unexpected output shape'));
27
+ return;
28
+ }
29
+ resolve(parsed);
30
+ }
31
+ );
32
+ });
33
+ }
34
+
35
+ function route(task) {
36
+ return new Promise(resolve => {
37
+ spawnRouter(task)
38
+ .then(decision => resolve({ ...decision, source: 'yana-router' }))
39
+ .catch(() => resolve({ ...classify(task), source: 'fallback' }));
40
+ });
41
+ }
42
+
43
+ return { route };
44
+ }
45
+
46
+ module.exports = { createRouter };
package/src/skills.js ADDED
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const SKILL_FILE = 'SKILL.md';
7
+ const MAX_PROMPT_BYTES = 8 * 1024;
8
+ const MIN_TOKEN_LEN = 3;
9
+
10
+ /**
11
+ * createSkills({ skillsDir }) → { findBestSkill, loadSkillPrompt, skillCount }
12
+ */
13
+ function createSkills({ skillsDir } = {}) {
14
+ const SKILLS_DIR = skillsDir || '';
15
+ const index = new Map(); // name → dirPath
16
+
17
+ if (SKILLS_DIR) {
18
+ try {
19
+ const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
20
+ for (const ent of entries) {
21
+ if (ent.isDirectory()) {
22
+ index.set(ent.name.toLowerCase(), path.join(SKILLS_DIR, ent.name));
23
+ }
24
+ }
25
+ } catch (_) {}
26
+ }
27
+
28
+ function tokenize(text) {
29
+ return text
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9 ]/g, ' ')
32
+ .split(/\s+/)
33
+ .filter(t => t.length >= MIN_TOKEN_LEN);
34
+ }
35
+
36
+ function findBestSkill(task) {
37
+ const tokens = tokenize(task);
38
+ if (tokens.length === 0) return null;
39
+ let bestName = null, bestScore = 0;
40
+ for (const name of index.keys()) {
41
+ let score = 0;
42
+ for (const tok of tokens) {
43
+ if (name.includes(tok)) score++;
44
+ }
45
+ if (score > bestScore) { bestScore = score; bestName = name; }
46
+ }
47
+ return bestScore >= 1 ? bestName : null;
48
+ }
49
+
50
+ function stripFrontmatter(content) {
51
+ const lines = content.split('\n');
52
+ if (lines[0].trim() !== '---') return content;
53
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
54
+ return closeIdx < 0 ? content : lines.slice(closeIdx + 1).join('\n').trim();
55
+ }
56
+
57
+ function loadSkillPrompt(name) {
58
+ const dirPath = index.get(name);
59
+ if (!dirPath) return null;
60
+ try {
61
+ const raw = fs.readFileSync(path.join(dirPath, SKILL_FILE), 'utf8');
62
+ const body = stripFrontmatter(raw);
63
+ const buf = Buffer.from(body, 'utf8');
64
+ if (buf.length <= MAX_PROMPT_BYTES) return body;
65
+ return buf.slice(0, MAX_PROMPT_BYTES).toString('utf8') + '\n\n[...skill truncated at 8 KB]';
66
+ } catch (_) { return null; }
67
+ }
68
+
69
+ return { findBestSkill, loadSkillPrompt, skillCount: () => index.size };
70
+ }
71
+
72
+ module.exports = { createSkills };