@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.
- package/CHANGELOG.md +313 -0
- package/LICENSE +83 -0
- package/README.md +470 -0
- package/defaults/learnings.json +146 -0
- package/dist/activation.d.ts +48 -0
- package/dist/activation.js +377 -0
- package/dist/adapters.d.ts +101 -0
- package/dist/adapters.js +171 -0
- package/dist/agents.d.ts +137 -0
- package/dist/agents.js +1638 -0
- package/dist/audit.d.ts +23 -0
- package/dist/audit.js +163 -0
- package/dist/cache.d.ts +15 -0
- package/dist/cache.js +117 -0
- package/dist/claude-integration.d.ts +95 -0
- package/dist/claude-integration.js +247 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +1823 -0
- package/dist/code-chunker.d.ts +12 -0
- package/dist/code-chunker.js +270 -0
- package/dist/collectors.d.ts +63 -0
- package/dist/collectors.js +617 -0
- package/dist/config.d.ts +73 -0
- package/dist/config.js +239 -0
- package/dist/embeddings.d.ts +36 -0
- package/dist/embeddings.js +124 -0
- package/dist/firewall.d.ts +133 -0
- package/dist/firewall.js +631 -0
- package/dist/hooks.d.ts +76 -0
- package/dist/hooks.js +313 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1081 -0
- package/dist/ingest.d.ts +32 -0
- package/dist/ingest.js +162 -0
- package/dist/learnings.d.ts +108 -0
- package/dist/learnings.js +714 -0
- package/dist/license-sig.d.ts +47 -0
- package/dist/license-sig.js +104 -0
- package/dist/policy.d.ts +131 -0
- package/dist/policy.js +182 -0
- package/dist/search.d.ts +11 -0
- package/dist/search.js +99 -0
- package/dist/sessions.d.ts +46 -0
- package/dist/sessions.js +153 -0
- package/examples/adapters/notion-adapter.js +108 -0
- package/examples/adapters/rss-adapter.js +76 -0
- package/package.json +87 -0
- 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
|