@compr/opscontext-mcp 2.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/LICENSE +83 -0
  3. package/README.md +470 -0
  4. package/defaults/learnings.json +146 -0
  5. package/dist/activation.d.ts +48 -0
  6. package/dist/activation.js +377 -0
  7. package/dist/adapters.d.ts +101 -0
  8. package/dist/adapters.js +171 -0
  9. package/dist/agents.d.ts +137 -0
  10. package/dist/agents.js +1638 -0
  11. package/dist/audit.d.ts +23 -0
  12. package/dist/audit.js +163 -0
  13. package/dist/cache.d.ts +15 -0
  14. package/dist/cache.js +117 -0
  15. package/dist/claude-integration.d.ts +95 -0
  16. package/dist/claude-integration.js +247 -0
  17. package/dist/cli.d.ts +18 -0
  18. package/dist/cli.js +1823 -0
  19. package/dist/code-chunker.d.ts +12 -0
  20. package/dist/code-chunker.js +270 -0
  21. package/dist/collectors.d.ts +63 -0
  22. package/dist/collectors.js +617 -0
  23. package/dist/config.d.ts +73 -0
  24. package/dist/config.js +239 -0
  25. package/dist/embeddings.d.ts +36 -0
  26. package/dist/embeddings.js +124 -0
  27. package/dist/firewall.d.ts +133 -0
  28. package/dist/firewall.js +631 -0
  29. package/dist/hooks.d.ts +76 -0
  30. package/dist/hooks.js +313 -0
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +1081 -0
  33. package/dist/ingest.d.ts +32 -0
  34. package/dist/ingest.js +162 -0
  35. package/dist/learnings.d.ts +108 -0
  36. package/dist/learnings.js +714 -0
  37. package/dist/license-sig.d.ts +47 -0
  38. package/dist/license-sig.js +104 -0
  39. package/dist/policy.d.ts +131 -0
  40. package/dist/policy.js +182 -0
  41. package/dist/search.d.ts +11 -0
  42. package/dist/search.js +99 -0
  43. package/dist/sessions.d.ts +46 -0
  44. package/dist/sessions.js +153 -0
  45. package/examples/adapters/notion-adapter.js +108 -0
  46. package/examples/adapters/rss-adapter.js +76 -0
  47. package/package.json +87 -0
  48. package/skills/opscontext/SKILL.md +260 -0
@@ -0,0 +1,714 @@
1
+ // LOCKED — verified March 3 2026 — learning store: quality gates, auto-categorize, dedup, project-scoped filtering
2
+ // DO NOT RE-AUDIT — min 15 chars, inferCategory(), autoImportFromSources() all verified v1.19.1
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
4
+ import { join, dirname } from "path";
5
+ import { homedir } from "os";
6
+ import { fileURLToPath } from "url";
7
+ import { safeAppend } from "./audit.js";
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ /**
11
+ * Learning Store — permanent operational rules that persist forever.
12
+ *
13
+ * Unlike sessions (ephemeral per-conversation context), learnings are
14
+ * **permanent rules** discovered during coding sessions. They get
15
+ * auto-indexed and surfaced via search_context so AI agents don't
16
+ * repeat the same mistakes.
17
+ *
18
+ * Storage: ~/.contextengine/learnings.json
19
+ *
20
+ * Examples:
21
+ * - "Always restart Flask backend after model changes — stale to_dict()"
22
+ * - "Expo --port flag only controls Metro, NOT webpack dev server"
23
+ * - "macOS sandbox blocks ~/Downloads access from VS Code terminal"
24
+ * - "Unicode NFC vs NFD causes false mismatches on Google Drive vs APFS"
25
+ */
26
+ const LEARNINGS_PATH = join(homedir(), ".contextengine", "learnings.json");
27
+ /** Valid categories for learnings */
28
+ export const LEARNING_CATEGORIES = [
29
+ "deployment",
30
+ "api",
31
+ "database",
32
+ "frontend",
33
+ "backend",
34
+ "devops",
35
+ "security",
36
+ "performance",
37
+ "testing",
38
+ "debugging",
39
+ "tooling",
40
+ "git",
41
+ "dependencies",
42
+ "architecture",
43
+ "data",
44
+ "infrastructure",
45
+ "mobile",
46
+ "other",
47
+ ];
48
+ function ensureDir() {
49
+ const dir = join(homedir(), ".contextengine");
50
+ if (!existsSync(dir)) {
51
+ mkdirSync(dir, { recursive: true });
52
+ }
53
+ }
54
+ /**
55
+ * Load bundled starter learnings from the npm package's defaults/ directory.
56
+ * These are curated, universal best practices shipped with every install.
57
+ */
58
+ function loadBundledDefaults() {
59
+ // defaults/ sits next to dist/ in the package root
60
+ const defaultsPath = join(__dirname, "..", "defaults", "learnings.json");
61
+ if (existsSync(defaultsPath)) {
62
+ try {
63
+ return JSON.parse(readFileSync(defaultsPath, "utf-8"));
64
+ }
65
+ catch {
66
+ // Malformed defaults — skip silently
67
+ }
68
+ }
69
+ return [];
70
+ }
71
+ /**
72
+ * Merge bundled defaults into user store if they don't already exist.
73
+ * Uses rule text (lowercased) for dedup — user learnings always win.
74
+ */
75
+ function mergeDefaults(store) {
76
+ const bundled = loadBundledDefaults();
77
+ if (bundled.length === 0)
78
+ return false;
79
+ const existingRules = new Set(store.learnings
80
+ .filter((l) => typeof l.rule === "string")
81
+ .map((l) => l.rule.toLowerCase().trim()));
82
+ let added = 0;
83
+ const now = new Date().toISOString();
84
+ for (const def of bundled) {
85
+ if (existingRules.has(def.rule.toLowerCase().trim()))
86
+ continue;
87
+ store.learnings.push({
88
+ id: generateId(),
89
+ category: def.category,
90
+ rule: def.rule,
91
+ context: def.context,
92
+ tags: def.tags || [],
93
+ created: now,
94
+ updated: now,
95
+ });
96
+ existingRules.add(def.rule.toLowerCase().trim());
97
+ added++;
98
+ }
99
+ return added > 0;
100
+ }
101
+ function loadStore() {
102
+ let store;
103
+ if (existsSync(LEARNINGS_PATH)) {
104
+ try {
105
+ store = JSON.parse(readFileSync(LEARNINGS_PATH, "utf-8"));
106
+ // Filter out corrupted entries missing required 'rule' field
107
+ store.learnings = store.learnings.filter((l) => typeof l.rule === "string" && l.rule.length > 0);
108
+ }
109
+ catch {
110
+ // Corrupted file — start fresh
111
+ store = { version: 1, count: 0, learnings: [] };
112
+ }
113
+ }
114
+ else {
115
+ store = { version: 1, count: 0, learnings: [] };
116
+ }
117
+ // Auto-merge bundled defaults on first load or when new defaults are added
118
+ if (mergeDefaults(store)) {
119
+ saveStore(store);
120
+ }
121
+ return store;
122
+ }
123
+ function saveStore(store) {
124
+ ensureDir();
125
+ store.count = store.learnings.length;
126
+ writeFileSync(LEARNINGS_PATH, JSON.stringify(store, null, 2));
127
+ }
128
+ /** Generate a short unique ID */
129
+ function generateId() {
130
+ return Date.now().toString(36) + Math.random().toString(36).substring(2, 6);
131
+ }
132
+ /** Extract tags from rule + context text */
133
+ function extractTags(rule, context, category) {
134
+ const text = `${rule} ${context}`.toLowerCase();
135
+ const tags = new Set([category]);
136
+ // Common tech keywords
137
+ const techWords = [
138
+ "flask", "laravel", "react", "expo", "docker", "nginx", "pm2",
139
+ "mysql", "postgres", "redis", "node", "python", "php", "typescript",
140
+ "git", "npm", "composer", "pip", "api", "cors", "jwt", "oauth",
141
+ "ssl", "https", "ssh", "dns", "gps", "macos", "linux", "windows",
142
+ "webpack", "vite", "cra", "nextjs", "flutter", "swift", "kotlin",
143
+ "supervisor", "cron", "smtp", "queue", "cache", "migration",
144
+ "unicode", "encoding", "permissions", "sandbox", "firewall",
145
+ ];
146
+ for (const word of techWords) {
147
+ if (text.includes(word)) {
148
+ tags.add(word);
149
+ }
150
+ }
151
+ return Array.from(tags);
152
+ }
153
+ /** Minimum rule length — anything shorter is noise, not knowledge */
154
+ const MIN_RULE_LENGTH = 15;
155
+ /**
156
+ * Save a new learning. Returns the created learning with ID.
157
+ * Rejects rules shorter than MIN_RULE_LENGTH and auto-corrects "other" category.
158
+ */
159
+ export function saveLearning(category, rule, context, project) {
160
+ const trimmedRule = rule.trim();
161
+ // Quality gate: reject junk rules
162
+ if (trimmedRule.length < MIN_RULE_LENGTH) {
163
+ throw new Error(`Rule too short (${trimmedRule.length} chars, min ${MIN_RULE_LENGTH}). ` +
164
+ `Learnings must be actionable sentences, not single words. Example: ` +
165
+ `"Always restart Flask after model changes — stale to_dict() cache"`);
166
+ }
167
+ // Quality gate: auto-correct "other" category by inferring from rule + context
168
+ if (category === "other") {
169
+ const inferred = inferCategory(trimmedRule, context);
170
+ if (inferred !== "other") {
171
+ category = inferred;
172
+ }
173
+ }
174
+ const store = loadStore();
175
+ const now = new Date().toISOString();
176
+ // Check for duplicate rules (fuzzy: same category + similar rule text)
177
+ const ruleLower = rule.toLowerCase().trim();
178
+ const existing = store.learnings.find((l) => l.category === category &&
179
+ typeof l.rule === "string" && l.rule.toLowerCase().trim() === ruleLower);
180
+ if (existing) {
181
+ // Update existing learning with new context
182
+ existing.context = context;
183
+ existing.updated = now;
184
+ if (project)
185
+ existing.project = project;
186
+ existing.tags = extractTags(existing.rule, context, category);
187
+ saveStore(store);
188
+ safeAppend("learning.save", {
189
+ id: existing.id,
190
+ category: existing.category,
191
+ project: existing.project,
192
+ rule_length: existing.rule.length,
193
+ mode: "update",
194
+ });
195
+ return existing;
196
+ }
197
+ const learning = {
198
+ id: generateId(),
199
+ category: category,
200
+ rule,
201
+ context,
202
+ project,
203
+ tags: extractTags(rule, context, category),
204
+ created: now,
205
+ updated: now,
206
+ };
207
+ store.learnings.push(learning);
208
+ saveStore(store);
209
+ safeAppend("learning.save", {
210
+ id: learning.id,
211
+ category: learning.category,
212
+ project: learning.project,
213
+ rule_length: learning.rule.length,
214
+ mode: "create",
215
+ });
216
+ return learning;
217
+ }
218
+ /**
219
+ * Search learnings by keyword. Returns matches sorted by relevance.
220
+ */
221
+ export function searchLearnings(query) {
222
+ const store = loadStore();
223
+ const tokens = query
224
+ .toLowerCase()
225
+ .split(/\s+/)
226
+ .filter((t) => t.length > 1);
227
+ if (tokens.length === 0)
228
+ return store.learnings;
229
+ const scored = [];
230
+ for (const learning of store.learnings) {
231
+ const text = `${learning.category} ${learning.rule} ${learning.context} ${learning.project || ""} ${learning.tags.join(" ")}`.toLowerCase();
232
+ let score = 0;
233
+ for (const token of tokens) {
234
+ if (text.includes(token)) {
235
+ score += 1;
236
+ // Bonus for matching rule text directly (the important part)
237
+ if (typeof learning.rule === "string" && learning.rule.toLowerCase().includes(token))
238
+ score += 2;
239
+ // Bonus for matching category
240
+ if (learning.category.toLowerCase().includes(token))
241
+ score += 1;
242
+ }
243
+ }
244
+ // Multi-term bonus
245
+ const distinctMatches = tokens.filter((t) => text.includes(t)).length;
246
+ if (distinctMatches > 1)
247
+ score += distinctMatches * 2;
248
+ if (score > 0) {
249
+ scored.push({ learning, score });
250
+ }
251
+ }
252
+ scored.sort((a, b) => b.score - a.score);
253
+ return scored.map((s) => s.learning);
254
+ }
255
+ /**
256
+ * Get learnings, optionally filtered by category and/or project.
257
+ *
258
+ * When `projects` is provided, only returns learnings that:
259
+ * - match one of the given project names (case-insensitive), OR
260
+ * - have no project set (universal learnings)
261
+ *
262
+ * This prevents cross-project IP leakage — e.g. CROWLR learnings
263
+ * won't appear when working on VOILA.
264
+ */
265
+ export function listLearnings(category, projects) {
266
+ const store = loadStore();
267
+ let result = store.learnings;
268
+ if (projects && projects.length > 0) {
269
+ const lowerProjects = projects.map((p) => p.toLowerCase());
270
+ result = result.filter((l) => !l.project || lowerProjects.includes(l.project.toLowerCase()));
271
+ }
272
+ if (category) {
273
+ result = result.filter((l) => l.category.toLowerCase() === category.toLowerCase());
274
+ }
275
+ return result;
276
+ }
277
+ /**
278
+ * Delete a learning by ID.
279
+ */
280
+ export function deleteLearning(id) {
281
+ const store = loadStore();
282
+ const index = store.learnings.findIndex((l) => l.id === id);
283
+ if (index === -1)
284
+ return false;
285
+ const removed = store.learnings[index];
286
+ store.learnings.splice(index, 1);
287
+ saveStore(store);
288
+ safeAppend("learning.delete", {
289
+ id: removed.id,
290
+ category: removed.category,
291
+ project: removed.project,
292
+ rule_length: typeof removed.rule === "string" ? removed.rule.length : 0,
293
+ });
294
+ return true;
295
+ }
296
+ export function importLearningsFromFile(filePath, defaultCategory = "other", defaultProject) {
297
+ if (!existsSync(filePath)) {
298
+ return { imported: 0, updated: 0, skipped: 0, errors: [`File not found: ${filePath}`] };
299
+ }
300
+ const content = readFileSync(filePath, "utf-8");
301
+ const ext = filePath.split(".").pop()?.toLowerCase();
302
+ const result = ext === "json"
303
+ ? importFromJson(content, defaultProject)
304
+ : importFromMarkdown(content, defaultCategory, defaultProject);
305
+ // Aggregate event correlating the individual learning.save records emitted
306
+ // inside the loop. Useful for compliance attribution: "this batch came from
307
+ // file X".
308
+ safeAppend("learning.import", {
309
+ source: filePath,
310
+ format: ext === "json" ? "json" : "markdown",
311
+ project: defaultProject,
312
+ imported: result.imported,
313
+ updated: result.updated,
314
+ skipped: result.skipped,
315
+ errors: result.errors.length,
316
+ });
317
+ return result;
318
+ }
319
+ function importFromJson(content, defaultProject) {
320
+ const result = { imported: 0, updated: 0, skipped: 0, errors: [] };
321
+ try {
322
+ const data = JSON.parse(content);
323
+ const items = Array.isArray(data)
324
+ ? data
325
+ : data.learnings
326
+ ? data.learnings
327
+ : [];
328
+ for (const item of items) {
329
+ if (!item.rule || !item.category) {
330
+ result.skipped++;
331
+ result.errors.push(`Skipped entry missing rule or category: ${JSON.stringify(item).substring(0, 80)}`);
332
+ continue;
333
+ }
334
+ if (item.rule.trim().length < MIN_RULE_LENGTH) {
335
+ result.skipped++;
336
+ continue;
337
+ }
338
+ const cat = LEARNING_CATEGORIES.includes(item.category) ? item.category : "other";
339
+ const store = loadStore();
340
+ const existing = store.learnings.find((l) => l.category === cat && typeof l.rule === "string" && l.rule.toLowerCase().trim() === item.rule.toLowerCase().trim());
341
+ try {
342
+ saveLearning(cat, item.rule, item.context || "", item.project || defaultProject);
343
+ if (existing) {
344
+ result.updated++;
345
+ }
346
+ else {
347
+ result.imported++;
348
+ }
349
+ }
350
+ catch {
351
+ result.skipped++;
352
+ }
353
+ }
354
+ }
355
+ catch (e) {
356
+ result.errors.push(`JSON parse error: ${e.message}`);
357
+ }
358
+ return result;
359
+ }
360
+ function importFromMarkdown(content, defaultCategory, defaultProject) {
361
+ const result = { imported: 0, updated: 0, skipped: 0, errors: [] };
362
+ const lines = content.split("\n");
363
+ let currentCategory = defaultCategory;
364
+ let currentRule = "";
365
+ let currentContext = [];
366
+ function flushRule() {
367
+ if (!currentRule)
368
+ return;
369
+ // Quality gate: skip rules that are too short (catches junk from headings/bullets)
370
+ if (currentRule.trim().length < MIN_RULE_LENGTH) {
371
+ result.skipped++;
372
+ currentRule = "";
373
+ currentContext = [];
374
+ return;
375
+ }
376
+ const cat = normalizeCategory(currentCategory);
377
+ const ctx = currentContext.join(" ").trim() || `Imported from file`;
378
+ const store = loadStore();
379
+ const existing = store.learnings.find((l) => l.category === cat && typeof l.rule === "string" && l.rule.toLowerCase().trim() === currentRule.toLowerCase().trim());
380
+ try {
381
+ saveLearning(cat, currentRule, ctx, defaultProject);
382
+ if (existing) {
383
+ result.updated++;
384
+ }
385
+ else {
386
+ result.imported++;
387
+ }
388
+ }
389
+ catch {
390
+ result.skipped++;
391
+ }
392
+ currentRule = "";
393
+ currentContext = [];
394
+ }
395
+ for (const line of lines) {
396
+ const trimmed = line.trim();
397
+ // H1 — file title, skip
398
+ if (trimmed.startsWith("# ") && !trimmed.startsWith("## "))
399
+ continue;
400
+ // H2 — category (e.g., "## deployment" or "## Security & Server Administration")
401
+ if (trimmed.startsWith("## ")) {
402
+ flushRule();
403
+ const heading = trimmed.replace(/^##\s+/, "").toLowerCase().trim();
404
+ currentCategory = heading;
405
+ continue;
406
+ }
407
+ // H3 — rule (e.g., "### Never docker build | tee")
408
+ if (trimmed.startsWith("### ")) {
409
+ flushRule();
410
+ const candidate = trimmed.replace(/^###\s+/, "").trim();
411
+ // Quality filter: skip short headings ("Fix", "UI", "DB")
412
+ if (candidate.length >= MIN_RULE_LENGTH) {
413
+ currentRule = candidate;
414
+ }
415
+ continue;
416
+ }
417
+ // H4+ — sub-rule, treat as context for current rule
418
+ if (trimmed.startsWith("#### ")) {
419
+ if (currentRule) {
420
+ currentContext.push(trimmed.replace(/^####\s+/, "").trim());
421
+ }
422
+ continue;
423
+ }
424
+ // Bullet with inline category: "- [deployment] Rule text → Context"
425
+ const inlineCatMatch = trimmed.match(/^[-*]\s+\[(\w+)\]\s+(.+)/);
426
+ if (inlineCatMatch) {
427
+ flushRule();
428
+ const [, cat, rest] = inlineCatMatch;
429
+ currentCategory = cat;
430
+ // Split on → or — for rule/context separation
431
+ const sepMatch = rest.match(/^(.+?)(?:\s*[→—]\s*|\s+[-–]\s+)(.+)$/);
432
+ if (sepMatch) {
433
+ const candidate = sepMatch[1].trim();
434
+ if (candidate.length >= MIN_RULE_LENGTH) {
435
+ currentRule = candidate;
436
+ currentContext = [sepMatch[2].trim()];
437
+ flushRule();
438
+ }
439
+ }
440
+ else {
441
+ const candidate = rest.trim();
442
+ if (candidate.length >= MIN_RULE_LENGTH) {
443
+ currentRule = candidate;
444
+ flushRule();
445
+ }
446
+ }
447
+ continue;
448
+ }
449
+ // Table rows: | Pattern | Example | Description |
450
+ const tableMatch = trimmed.match(/^\|\s*\*\*(.+?)\*\*\s*\|(.+)\|(.+)\|/);
451
+ if (tableMatch) {
452
+ flushRule();
453
+ const candidate = tableMatch[1].trim();
454
+ if (candidate.length >= MIN_RULE_LENGTH) {
455
+ currentRule = candidate;
456
+ currentContext = [tableMatch[2].trim() + " — " + tableMatch[3].trim()];
457
+ flushRule();
458
+ }
459
+ continue;
460
+ }
461
+ // Regular bullet — either starts a new rule or adds context to current
462
+ if (trimmed.match(/^[-*]\s+\*\*(.+?)\*\*/)) {
463
+ // Bold-start bullet = likely a rule
464
+ flushRule();
465
+ const boldMatch = trimmed.match(/^[-*]\s+\*\*(.+?)\*\*\s*(.*)$/);
466
+ if (boldMatch) {
467
+ const candidate = boldMatch[1].trim();
468
+ // Quality filter: skip short/single-word headings
469
+ if (candidate.length < MIN_RULE_LENGTH) {
470
+ continue;
471
+ }
472
+ currentRule = candidate;
473
+ if (boldMatch[2]) {
474
+ // Strip leading separators
475
+ currentContext = [boldMatch[2].replace(/^[\s—→:]+/, "").trim()];
476
+ }
477
+ }
478
+ continue;
479
+ }
480
+ // Regular bullet or numbered item — context for current rule
481
+ if ((trimmed.startsWith("- ") || trimmed.startsWith("* ") || trimmed.match(/^\d+\.\s/)) && currentRule) {
482
+ const text = trimmed.replace(/^[-*\d.]+\s+/, "").trim();
483
+ if (text)
484
+ currentContext.push(text);
485
+ continue;
486
+ }
487
+ // Plain text after a rule heading = context
488
+ if (currentRule && trimmed.length > 10 && !trimmed.startsWith("|") && !trimmed.startsWith("```")) {
489
+ currentContext.push(trimmed);
490
+ }
491
+ }
492
+ flushRule(); // Flush last rule
493
+ return result;
494
+ }
495
+ /** Infer a category from rule text + context when "other" is provided */
496
+ function inferCategory(rule, context) {
497
+ const text = `${rule} ${context}`.toLowerCase();
498
+ const keywords = {
499
+ "deploy": "deployment", "rsync": "deployment", "publish": "deployment", "release": "deployment",
500
+ "ci/cd": "devops", "ci cd": "devops", "pipeline": "devops", "github actions": "devops", "docker": "devops",
501
+ "nginx": "infrastructure", "ssl": "infrastructure", "server": "infrastructure", "pm2": "infrastructure", "vps": "infrastructure",
502
+ "api": "api", "endpoint": "api", "rest": "api", "graphql": "api", "webhook": "api",
503
+ "sql": "database", "sqlite": "database", "mysql": "database", "postgres": "database", "query": "database", "migration": "database",
504
+ "react": "frontend", "vue": "frontend", "css": "frontend", "html": "frontend", "dom": "frontend", "component": "frontend", "ui": "frontend",
505
+ "express": "backend", "node": "backend", "flask": "backend", "middleware": "backend",
506
+ "auth": "security", "cors": "security", "xss": "security", "csrf": "security", "helmet": "security", "encrypt": "security", "password": "security",
507
+ "test": "testing", "vitest": "testing", "jest": "testing", "spec": "testing", "assert": "testing",
508
+ "debug": "debugging", "error": "debugging", "stack trace": "debugging", "breakpoint": "debugging", "log": "debugging",
509
+ "npm": "dependencies", "package": "dependencies", "yarn": "dependencies", "pnpm": "dependencies", "version": "dependencies",
510
+ "git": "git", "commit": "git", "branch": "git", "merge": "git", "rebase": "git",
511
+ "perf": "performance", "latency": "performance", "cache": "performance", "optimize": "performance",
512
+ "eslint": "tooling", "lint": "tooling", "prettier": "tooling", "vscode": "tooling", "editor": "tooling",
513
+ "pattern": "architecture", "refactor": "architecture", "module": "architecture", "design": "architecture",
514
+ "ios": "mobile", "android": "mobile", "expo": "mobile", "react native": "mobile",
515
+ };
516
+ for (const [keyword, cat] of Object.entries(keywords)) {
517
+ if (text.includes(keyword))
518
+ return cat;
519
+ }
520
+ return "other";
521
+ }
522
+ /** Map free-form heading text to closest LEARNING_CATEGORIES value */
523
+ function normalizeCategory(heading) {
524
+ const h = heading.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
525
+ // Direct match
526
+ for (const cat of LEARNING_CATEGORIES) {
527
+ if (h === cat || h.startsWith(cat))
528
+ return cat;
529
+ }
530
+ // Keyword mapping
531
+ const map = {
532
+ "deploy": "deployment",
533
+ "ci/cd": "devops",
534
+ "ci cd": "devops",
535
+ "pipeline": "devops",
536
+ "docker": "devops",
537
+ "nginx": "infrastructure",
538
+ "server": "infrastructure",
539
+ "hosting": "infrastructure",
540
+ "ssl": "security",
541
+ "cors": "security",
542
+ "auth": "security",
543
+ "malware": "security",
544
+ "hack": "security",
545
+ "hardening": "security",
546
+ "terminal": "tooling",
547
+ "command": "tooling",
548
+ "monitoring": "tooling",
549
+ "vs code": "tooling",
550
+ "test": "testing",
551
+ "jest": "testing",
552
+ "spec": "testing",
553
+ "debug": "debugging",
554
+ "bug": "debugging",
555
+ "fix": "debugging",
556
+ "react": "frontend",
557
+ "vue": "frontend",
558
+ "css": "frontend",
559
+ "ui": "frontend",
560
+ "laravel": "backend",
561
+ "django": "backend",
562
+ "flask": "backend",
563
+ "express": "backend",
564
+ "mysql": "database",
565
+ "postgres": "database",
566
+ "sql": "database",
567
+ "migration": "database",
568
+ "npm": "dependencies",
569
+ "composer": "dependencies",
570
+ "pip": "dependencies",
571
+ "package": "dependencies",
572
+ "git": "git",
573
+ "commit": "git",
574
+ "branch": "git",
575
+ "hook": "git",
576
+ "perf": "performance",
577
+ "speed": "performance",
578
+ "cache": "performance",
579
+ "mobile": "mobile",
580
+ "expo": "mobile",
581
+ "flutter": "mobile",
582
+ "react native": "mobile",
583
+ "swift": "mobile",
584
+ "pattern": "architecture",
585
+ "design": "architecture",
586
+ "struct": "architecture",
587
+ "data type": "data",
588
+ "csv": "data",
589
+ "import": "data",
590
+ "export": "data",
591
+ "api": "api",
592
+ "endpoint": "api",
593
+ "rest": "api",
594
+ "smtp": "infrastructure",
595
+ "email": "infrastructure",
596
+ "queue": "infrastructure",
597
+ "audit": "security",
598
+ "version": "dependencies",
599
+ "upgrade": "dependencies",
600
+ };
601
+ for (const [keyword, cat] of Object.entries(map)) {
602
+ if (h.includes(keyword))
603
+ return cat;
604
+ }
605
+ return "other";
606
+ }
607
+ /**
608
+ * Convert learnings to Chunks so they can be included in search_context.
609
+ * This is the key integration — learnings auto-surface in hybrid search.
610
+ *
611
+ * When `projects` is provided, only includes learnings for those projects
612
+ * (+ universal learnings with no project). This prevents cross-project
613
+ * IP leakage — CROWLR secrets won't appear when searching in VOILA.
614
+ */
615
+ export function learningsToChunks(projects) {
616
+ const store = loadStore();
617
+ let learnings = store.learnings;
618
+ if (projects && projects.length > 0) {
619
+ const lowerProjects = projects.map((p) => p.toLowerCase());
620
+ learnings = learnings.filter((l) => !l.project || lowerProjects.includes(l.project.toLowerCase()));
621
+ }
622
+ return learnings.map((l) => ({
623
+ source: "💡 Learnings Store",
624
+ section: `[${l.category}] ${l.rule}`,
625
+ content: [
626
+ `**Rule:** ${l.rule}`,
627
+ `**Category:** ${l.category}`,
628
+ l.project ? `**Project:** ${l.project}` : "",
629
+ l.context ? `**Context:** ${l.context}` : "",
630
+ l.tags?.length ? `**Tags:** ${l.tags.join(", ")}` : "",
631
+ l.created ? `_Learned: ${l.created.split("T")[0]}_` : "",
632
+ ]
633
+ .filter(Boolean)
634
+ .join("\n"),
635
+ lineStart: 0,
636
+ lineEnd: 0,
637
+ }));
638
+ }
639
+ /**
640
+ * Auto-import learnings from discovered knowledge source files.
641
+ *
642
+ * Called during reindex — scans all source markdown files and extracts
643
+ * rules into the permanent learning store. Deduplication is built-in,
644
+ * so calling repeatedly on the same files is safe (no duplicates).
645
+ *
646
+ * This ensures documentation rules become searchable learnings without
647
+ * requiring the user or agent to manually trigger `import_learnings`.
648
+ */
649
+ export function autoImportFromSources(sources) {
650
+ let totalImported = 0;
651
+ let totalUpdated = 0;
652
+ let processed = 0;
653
+ for (const source of sources) {
654
+ // Only process markdown files
655
+ if (!source.path.endsWith(".md"))
656
+ continue;
657
+ if (!existsSync(source.path))
658
+ continue;
659
+ // Extract project name from source name (e.g., "ContextEngine — copilot-instructions.md")
660
+ const project = source.name.split(" — ")[0]?.trim() || undefined;
661
+ const result = importLearningsFromFile(source.path, "other", project);
662
+ totalImported += result.imported;
663
+ totalUpdated += result.updated;
664
+ if (result.imported > 0 || result.updated > 0)
665
+ processed++;
666
+ }
667
+ return { total: processed, imported: totalImported, updated: totalUpdated };
668
+ }
669
+ /**
670
+ * Get the store stats.
671
+ */
672
+ export function learningsStats() {
673
+ const store = loadStore();
674
+ const categories = {};
675
+ for (const l of store.learnings) {
676
+ categories[l.category] = (categories[l.category] || 0) + 1;
677
+ }
678
+ return { total: store.learnings.length, categories };
679
+ }
680
+ /**
681
+ * Format learnings for display.
682
+ */
683
+ export function formatLearnings(learnings) {
684
+ if (learnings.length === 0) {
685
+ return "No learnings stored yet. Use `save_learning` to add operational rules.";
686
+ }
687
+ const lines = [];
688
+ lines.push(`# 💡 Learnings Store (${learnings.length} rules)\n`);
689
+ // Group by category
690
+ const byCategory = new Map();
691
+ for (const l of learnings) {
692
+ const list = byCategory.get(l.category) || [];
693
+ list.push(l);
694
+ byCategory.set(l.category, list);
695
+ }
696
+ for (const [category, items] of byCategory) {
697
+ lines.push(`## ${category} (${items.length})\n`);
698
+ for (const l of items) {
699
+ lines.push(`### ${l.rule}`);
700
+ lines.push(`- **ID:** \`${l.id}\``);
701
+ if (l.project)
702
+ lines.push(`- **Project:** ${l.project}`);
703
+ if (l.context)
704
+ lines.push(`- **Context:** ${l.context}`);
705
+ if (l.tags?.length)
706
+ lines.push(`- **Tags:** ${l.tags.join(", ")}`);
707
+ if (l.created)
708
+ lines.push(`- **Learned:** ${l.created.split("T")[0]}`);
709
+ lines.push("");
710
+ }
711
+ }
712
+ return lines.join("\n");
713
+ }
714
+ //# sourceMappingURL=learnings.js.map