@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { loadSources, loadProjectDirs, loadConfig } from "./config.js";
|
|
6
|
+
import { ingestSources } from "./ingest.js";
|
|
7
|
+
import { searchChunks } from "./search.js";
|
|
8
|
+
import { initEmbeddings, embedChunks, vectorSearch, isEmbeddingsReady, } from "./embeddings.js";
|
|
9
|
+
import { collectProjectOps, collectSystemOps } from "./collectors.js";
|
|
10
|
+
import { loadCache, saveCache } from "./cache.js";
|
|
11
|
+
import { listProjects, checkPorts, runComplianceAudit, formatProjectList, formatPortMap, formatPlan, scoreProject, formatScoreReport, } from "./agents.js";
|
|
12
|
+
import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
|
|
13
|
+
import { verifyChain, readAuditLog, filterByRange } from "./audit.js";
|
|
14
|
+
import { saveLearning, searchLearnings, listLearnings, deleteLearning, learningsToChunks, learningsStats, formatLearnings, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
|
|
15
|
+
import { readFileSync, existsSync, watch, statSync } from "fs";
|
|
16
|
+
import { basename, join, dirname } from "path";
|
|
17
|
+
import { execSync } from "child_process";
|
|
18
|
+
import { scanCodeDir } from "./code-chunker.js";
|
|
19
|
+
import { fileURLToPath } from "url";
|
|
20
|
+
// Read version from package.json at startup
|
|
21
|
+
let PKG_VERSION = "1.21.3";
|
|
22
|
+
try {
|
|
23
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
+
const __dirname = dirname(__filename);
|
|
25
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
26
|
+
PKG_VERSION = pkg.version || PKG_VERSION;
|
|
27
|
+
}
|
|
28
|
+
catch { /* fallback */ }
|
|
29
|
+
import { loadAdapters, collectFromAdapters, } from "./adapters.js";
|
|
30
|
+
import { gateCheck, activate, getActivationStatus, } from "./activation.js";
|
|
31
|
+
import { ProtocolFirewall } from "./firewall.js";
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// State
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
let sources = [];
|
|
36
|
+
let chunks = [];
|
|
37
|
+
let embeddedChunks = [];
|
|
38
|
+
let activeProjectNames = [];
|
|
39
|
+
const firewall = new ProtocolFirewall();
|
|
40
|
+
// Wire up learning search for auto-injection (avoids circular import)
|
|
41
|
+
firewall.setLearningSearchFn((query, projects) => {
|
|
42
|
+
return searchLearnings(query)
|
|
43
|
+
.filter((l) => {
|
|
44
|
+
// Project-scoped: include if no project set OR project matches
|
|
45
|
+
if (!projects || projects.length === 0)
|
|
46
|
+
return true;
|
|
47
|
+
if (!l.project)
|
|
48
|
+
return true; // universal learning
|
|
49
|
+
return projects.some((p) => p.toLowerCase() === l.project.toLowerCase());
|
|
50
|
+
})
|
|
51
|
+
.slice(0, 10) // return generous set, firewall trims to INJECT_MAX
|
|
52
|
+
.map((l) => ({ rule: l.rule, project: l.project, category: l.category }));
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* (Re-)ingest all sources. Called at startup and on file changes.
|
|
56
|
+
*/
|
|
57
|
+
async function reindex() {
|
|
58
|
+
sources = loadSources();
|
|
59
|
+
chunks = ingestSources(sources);
|
|
60
|
+
// Collect operational data from project directories
|
|
61
|
+
const config = loadConfig();
|
|
62
|
+
const projectDirs = loadProjectDirs();
|
|
63
|
+
activeProjectNames = projectDirs.map((d) => d.name);
|
|
64
|
+
firewall.setProjectDirs(projectDirs);
|
|
65
|
+
if (config.collectOps !== false) {
|
|
66
|
+
let opsChunks = 0;
|
|
67
|
+
for (const dir of projectDirs) {
|
|
68
|
+
const ops = collectProjectOps(dir.path, dir.name);
|
|
69
|
+
chunks.push(...ops);
|
|
70
|
+
opsChunks += ops.length;
|
|
71
|
+
}
|
|
72
|
+
if (opsChunks > 0) {
|
|
73
|
+
console.error(`[ContextEngine] ⚙ Collected ${opsChunks} operational chunks from ${projectDirs.length} projects`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Collect system-wide operational data
|
|
77
|
+
if (config.collectSystemOps !== false) {
|
|
78
|
+
const sysOps = collectSystemOps();
|
|
79
|
+
if (sysOps.length > 0) {
|
|
80
|
+
chunks.push(...sysOps);
|
|
81
|
+
console.error(`[ContextEngine] 🖥 Collected ${sysOps.length} system operational chunks`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Scan code files if configured
|
|
85
|
+
if (config.codeDirs && config.codeDirs.length > 0) {
|
|
86
|
+
let codeChunks = 0;
|
|
87
|
+
for (const dir of projectDirs) {
|
|
88
|
+
for (const codeDir of config.codeDirs) {
|
|
89
|
+
const codePath = join(dir.path, codeDir);
|
|
90
|
+
if (existsSync(codePath)) {
|
|
91
|
+
const codeResults = scanCodeDir(codePath, dir.name);
|
|
92
|
+
chunks.push(...codeResults);
|
|
93
|
+
codeChunks += codeResults.length;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (codeChunks > 0) {
|
|
98
|
+
console.error(`[ContextEngine] 💻 Parsed ${codeChunks} code chunks from source files`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Auto-import learnings from discovered doc sources
|
|
102
|
+
// Dedup is built-in — safe to call on every reindex, no duplicates created
|
|
103
|
+
const autoImport = autoImportFromSources(sources.map((s) => ({ path: s.path, name: s.name })));
|
|
104
|
+
if (autoImport.imported > 0) {
|
|
105
|
+
console.error(`[ContextEngine] 📥 Auto-imported ${autoImport.imported} new learnings from ${autoImport.total} doc sources (${autoImport.updated} updated)`);
|
|
106
|
+
}
|
|
107
|
+
// Inject learnings as searchable chunks (project-scoped to prevent IP leakage)
|
|
108
|
+
const learningChunks = learningsToChunks(activeProjectNames);
|
|
109
|
+
if (learningChunks.length > 0) {
|
|
110
|
+
chunks.push(...learningChunks);
|
|
111
|
+
console.error(`[ContextEngine] 💡 Injected ${learningChunks.length} learning chunks into search index (scoped to ${activeProjectNames.length} projects)`);
|
|
112
|
+
}
|
|
113
|
+
// Collect from plugin adapters
|
|
114
|
+
if (config.adapters && config.adapters.length > 0) {
|
|
115
|
+
const adapterChunks = await collectFromAdapters(config.adapters);
|
|
116
|
+
if (adapterChunks.length > 0) {
|
|
117
|
+
chunks.push(...adapterChunks);
|
|
118
|
+
console.error(`[ContextEngine] 🔌 Adapters contributed ${adapterChunks.length} chunks`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (isEmbeddingsReady()) {
|
|
122
|
+
console.error(`[ContextEngine] 🧠 Re-embedding ${chunks.length} chunks...`);
|
|
123
|
+
embeddedChunks = await embedChunks(chunks);
|
|
124
|
+
saveCache(chunks, embeddedChunks);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Hybrid Search: combine keyword + vector scores with temporal decay
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
/**
|
|
131
|
+
* Temporal decay half-life in days.
|
|
132
|
+
* Chunks older than this get a ~50% penalty; very recent chunks get a boost.
|
|
133
|
+
* Set to 0 to disable temporal decay.
|
|
134
|
+
*/
|
|
135
|
+
const DECAY_HALF_LIFE_DAYS = 90;
|
|
136
|
+
/**
|
|
137
|
+
* Compute a temporal decay multiplier for a chunk based on its indexedAt time.
|
|
138
|
+
* Returns a value between 0.5 and 1.0 (exponential decay).
|
|
139
|
+
* Formula: 0.5 + 0.5 * exp(-age_days * ln(2) / half_life)
|
|
140
|
+
*
|
|
141
|
+
* Age 0 days → 1.0 (no decay)
|
|
142
|
+
* Age = half_life → 0.75
|
|
143
|
+
* Age = 2 * half_life → 0.625
|
|
144
|
+
* Very old → approaches 0.5
|
|
145
|
+
*/
|
|
146
|
+
function temporalDecay(chunk) {
|
|
147
|
+
if (DECAY_HALF_LIFE_DAYS <= 0)
|
|
148
|
+
return 1.0;
|
|
149
|
+
if (!chunk.indexedAt)
|
|
150
|
+
return 0.85; // Default for chunks without timestamp
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
const indexedMs = new Date(chunk.indexedAt).getTime();
|
|
153
|
+
if (isNaN(indexedMs))
|
|
154
|
+
return 0.85;
|
|
155
|
+
const ageDays = (now - indexedMs) / (1000 * 60 * 60 * 24);
|
|
156
|
+
const lambda = Math.LN2 / DECAY_HALF_LIFE_DAYS;
|
|
157
|
+
return 0.5 + 0.5 * Math.exp(-ageDays * lambda);
|
|
158
|
+
}
|
|
159
|
+
function hybridSearch(query, keywordResults, vectorResults, topK) {
|
|
160
|
+
const map = new Map();
|
|
161
|
+
// Normalize keyword scores (max = 1.0)
|
|
162
|
+
const maxKw = keywordResults.length > 0 ? keywordResults[0].score : 1;
|
|
163
|
+
for (const r of keywordResults) {
|
|
164
|
+
map.set(r.chunk, {
|
|
165
|
+
chunk: r.chunk,
|
|
166
|
+
keywordScore: r.score / maxKw,
|
|
167
|
+
vectorScore: 0,
|
|
168
|
+
temporalMultiplier: temporalDecay(r.chunk),
|
|
169
|
+
combinedScore: 0,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// Merge vector scores
|
|
173
|
+
for (const r of vectorResults) {
|
|
174
|
+
const existing = map.get(r.chunk);
|
|
175
|
+
if (existing) {
|
|
176
|
+
existing.vectorScore = r.score;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
map.set(r.chunk, {
|
|
180
|
+
chunk: r.chunk,
|
|
181
|
+
keywordScore: 0,
|
|
182
|
+
vectorScore: r.score,
|
|
183
|
+
temporalMultiplier: temporalDecay(r.chunk),
|
|
184
|
+
combinedScore: 0,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Combined: 40% keyword + 60% semantic, multiplied by temporal decay
|
|
189
|
+
for (const r of map.values()) {
|
|
190
|
+
const rawScore = r.keywordScore * 0.4 + r.vectorScore * 0.6;
|
|
191
|
+
r.combinedScore = rawScore * r.temporalMultiplier;
|
|
192
|
+
}
|
|
193
|
+
const results = Array.from(map.values());
|
|
194
|
+
results.sort((a, b) => b.combinedScore - a.combinedScore);
|
|
195
|
+
return results.slice(0, topK);
|
|
196
|
+
}
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// File Watching
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
const watchers = [];
|
|
201
|
+
function startWatching() {
|
|
202
|
+
// Clean up old watchers
|
|
203
|
+
for (const w of watchers) {
|
|
204
|
+
try {
|
|
205
|
+
w.close();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
/* ignore */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
watchers.length = 0;
|
|
212
|
+
let debounceTimer = null;
|
|
213
|
+
for (const source of sources) {
|
|
214
|
+
if (!existsSync(source.path))
|
|
215
|
+
continue;
|
|
216
|
+
try {
|
|
217
|
+
const w = watch(source.path, () => {
|
|
218
|
+
// Debounce: wait 500ms after last change before re-indexing
|
|
219
|
+
if (debounceTimer)
|
|
220
|
+
clearTimeout(debounceTimer);
|
|
221
|
+
debounceTimer = setTimeout(async () => {
|
|
222
|
+
console.error(`[ContextEngine] 📝 File changed: ${basename(source.path)} — re-indexing...`);
|
|
223
|
+
await reindex();
|
|
224
|
+
console.error(`[ContextEngine] ✅ Re-indexed: ${chunks.length} chunks from ${sources.length} sources`);
|
|
225
|
+
}, 500);
|
|
226
|
+
});
|
|
227
|
+
watchers.push(w);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Can't watch this file (permission, network drive, etc.)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
console.error(`[ContextEngine] 👁 Watching ${watchers.length} source files for changes`);
|
|
234
|
+
}
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// MCP Server
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
const server = new McpServer({
|
|
239
|
+
name: "ContextEngine",
|
|
240
|
+
version: PKG_VERSION,
|
|
241
|
+
});
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Enforcement: Protocol Firewall — progressive response degradation
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// The firewall instance is created above (in State section).
|
|
246
|
+
// It wraps EVERY tool response and escalates: silent → footer → header → degraded.
|
|
247
|
+
// At "degraded" level, tool output is truncated until the agent complies.
|
|
248
|
+
// See src/firewall.ts for the full design.
|
|
249
|
+
/** Helper: wrap a single-text tool response through the firewall */
|
|
250
|
+
function respond(toolName, text, contextHint) {
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text: firewall.wrap(toolName, text, contextHint) }],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Tool: search_context (hybrid: keyword + vector)
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
server.tool("search_context", "Search across all indexed project knowledge (copilot-instructions, skills docs, runbooks, session docs). Uses hybrid BM25 keyword + semantic search with temporal decay. Returns the most relevant chunks with source file, section, and line numbers.", {
|
|
259
|
+
query: z.string().describe("Natural language search query"),
|
|
260
|
+
top_k: z
|
|
261
|
+
.number()
|
|
262
|
+
.int()
|
|
263
|
+
.min(1)
|
|
264
|
+
.max(30)
|
|
265
|
+
.default(5)
|
|
266
|
+
.describe("Number of results to return (default 5)"),
|
|
267
|
+
mode: z
|
|
268
|
+
.enum(["hybrid", "keyword", "semantic"])
|
|
269
|
+
.default("hybrid")
|
|
270
|
+
.describe("Search mode: hybrid (default), keyword-only, or semantic-only"),
|
|
271
|
+
}, async ({ query, top_k, mode }) => {
|
|
272
|
+
let results = [];
|
|
273
|
+
if (mode === "keyword" || mode === "hybrid") {
|
|
274
|
+
const kwResults = searchChunks(chunks, query, top_k * 2);
|
|
275
|
+
if (mode === "keyword" || !isEmbeddingsReady()) {
|
|
276
|
+
results = kwResults.map((r) => ({
|
|
277
|
+
chunk: r.chunk,
|
|
278
|
+
score: r.score,
|
|
279
|
+
label: "keyword",
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
// Hybrid
|
|
284
|
+
const vecResults = await vectorSearch(query, embeddedChunks, top_k * 2);
|
|
285
|
+
const hybrid = hybridSearch(query, kwResults, vecResults, top_k);
|
|
286
|
+
results = hybrid.map((r) => ({
|
|
287
|
+
chunk: r.chunk,
|
|
288
|
+
score: r.combinedScore,
|
|
289
|
+
label: `kw:${r.keywordScore.toFixed(2)} sem:${r.vectorScore.toFixed(2)} age:${r.temporalMultiplier.toFixed(2)}`,
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
else if (mode === "semantic") {
|
|
294
|
+
if (!isEmbeddingsReady()) {
|
|
295
|
+
return {
|
|
296
|
+
content: [
|
|
297
|
+
{
|
|
298
|
+
type: "text",
|
|
299
|
+
text: "Semantic search unavailable — embeddings model not loaded. Use mode='keyword' or 'hybrid'.",
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
isError: true,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const vecResults = await vectorSearch(query, embeddedChunks, top_k);
|
|
306
|
+
results = vecResults.map((r) => ({
|
|
307
|
+
chunk: r.chunk,
|
|
308
|
+
score: r.score,
|
|
309
|
+
label: "semantic",
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
results = results.slice(0, top_k);
|
|
313
|
+
if (results.length === 0) {
|
|
314
|
+
return {
|
|
315
|
+
content: [
|
|
316
|
+
{
|
|
317
|
+
type: "text",
|
|
318
|
+
text: `No results found for: "${query}"`,
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
// Track how many results came from learnings (for value meter)
|
|
324
|
+
const learningRecalls = results.filter((r) => r.chunk.source.includes("Learnings") || r.chunk.source.includes("learning")).length;
|
|
325
|
+
if (learningRecalls > 0) {
|
|
326
|
+
firewall.recordSearchRecalls(learningRecalls);
|
|
327
|
+
}
|
|
328
|
+
const searchMode = isEmbeddingsReady() ? mode : "keyword (embeddings loading)";
|
|
329
|
+
const text = [
|
|
330
|
+
`Search: "${query}" | Mode: ${searchMode} | ${results.length} results`,
|
|
331
|
+
"",
|
|
332
|
+
...results.map((r, i) => [
|
|
333
|
+
`--- Result ${i + 1} (${r.label}: ${r.score.toFixed(3)}) ---`,
|
|
334
|
+
...(r.chunk.locked
|
|
335
|
+
? ["🔒 LOCKED — This content has been verified. DO NOT re-audit or re-implement."]
|
|
336
|
+
: []),
|
|
337
|
+
`Source: ${r.chunk.source}`,
|
|
338
|
+
`Section: ${r.chunk.section}`,
|
|
339
|
+
`Lines: ${r.chunk.lineStart}-${r.chunk.lineEnd}`,
|
|
340
|
+
"",
|
|
341
|
+
r.chunk.content,
|
|
342
|
+
].join("\n")),
|
|
343
|
+
].join("\n\n");
|
|
344
|
+
return respond("search_context", text, query);
|
|
345
|
+
});
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Tool: list_sources
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
server.tool("list_sources", "List all knowledge sources indexed by ContextEngine, with their status (found/missing) and chunk counts.", {}, async () => {
|
|
350
|
+
const lines = sources.map((s) => {
|
|
351
|
+
const exists = existsSync(s.path);
|
|
352
|
+
const count = chunks.filter((c) => c.source === s.name).length;
|
|
353
|
+
const embeddedCount = embeddedChunks.filter((ec) => ec.chunk.source === s.name).length;
|
|
354
|
+
const status = exists
|
|
355
|
+
? `✅ ${count} chunks${embeddedCount > 0 ? ` (${embeddedCount} embedded)` : ""}`
|
|
356
|
+
: "⚠ file not found";
|
|
357
|
+
return `${s.name}: ${status}\n ${s.path}`;
|
|
358
|
+
});
|
|
359
|
+
const embStatus = isEmbeddingsReady()
|
|
360
|
+
? `✅ ${embeddedChunks.length} vectors`
|
|
361
|
+
: "⏳ loading...";
|
|
362
|
+
const text = [
|
|
363
|
+
`ContextEngine v${PKG_VERSION}`,
|
|
364
|
+
`Sources: ${sources.length} | Chunks: ${chunks.length} | Embeddings: ${embStatus}`,
|
|
365
|
+
"",
|
|
366
|
+
...lines,
|
|
367
|
+
].join("\n");
|
|
368
|
+
return respond("list_sources", text);
|
|
369
|
+
});
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Tool: read_source
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
server.tool("read_source", "Read the full content of a specific knowledge source by name.", {
|
|
374
|
+
source_name: z
|
|
375
|
+
.string()
|
|
376
|
+
.describe("Name of the source (from list_sources output)"),
|
|
377
|
+
}, async ({ source_name }) => {
|
|
378
|
+
const source = sources.find((s) => s.name.toLowerCase() === source_name.toLowerCase());
|
|
379
|
+
if (!source) {
|
|
380
|
+
return {
|
|
381
|
+
content: [
|
|
382
|
+
{
|
|
383
|
+
type: "text",
|
|
384
|
+
text: `Unknown source: "${source_name}". Use list_sources to see available sources.`,
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
isError: true,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (!existsSync(source.path)) {
|
|
391
|
+
return {
|
|
392
|
+
content: [
|
|
393
|
+
{
|
|
394
|
+
type: "text",
|
|
395
|
+
text: `Source file not found: ${source.path}`,
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
isError: true,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const content = readFileSync(source.path, "utf-8");
|
|
402
|
+
return respond("read_source", `# ${source.name}\n\n${content}`, source_name);
|
|
403
|
+
});
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// Tool: reindex
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
server.tool("reindex", "Force a full re-index of all knowledge sources. Use after adding new files or changing contextengine.json.", {}, async () => {
|
|
408
|
+
await reindex();
|
|
409
|
+
return respond("reindex", `Re-indexed: ${chunks.length} chunks from ${sources.length} sources. Embeddings: ${embeddedChunks.length} vectors.`);
|
|
410
|
+
});
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Tool: list_projects (Multi-Agent Phase 1)
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
server.tool("list_projects", "Discover and analyze all projects in the workspace. Shows tech stack (framework, runtime, key dependencies), infrastructure (git, docker, pm2), and git remote status for each project. Requires Pro license.", {}, async () => {
|
|
415
|
+
const gate = gateCheck("list_projects");
|
|
416
|
+
if (gate)
|
|
417
|
+
return { content: [{ type: "text", text: gate }] };
|
|
418
|
+
const projectDirs = loadProjectDirs();
|
|
419
|
+
const projects = listProjects(projectDirs);
|
|
420
|
+
const text = formatProjectList(projects);
|
|
421
|
+
return respond("list_projects", text, "projects infrastructure stack");
|
|
422
|
+
});
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// Tool: check_ports (Multi-Agent Phase 1)
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
server.tool("check_ports", "Scan all projects for port declarations (ecosystem.config.js, docker-compose.yml, .env, package.json) and detect port conflicts. Returns a port allocation map with conflict warnings. Requires Pro license.", {}, async () => {
|
|
427
|
+
const gate = gateCheck("check_ports");
|
|
428
|
+
if (gate)
|
|
429
|
+
return { content: [{ type: "text", text: gate }] };
|
|
430
|
+
const projectDirs = loadProjectDirs();
|
|
431
|
+
const { ports, conflicts } = checkPorts(projectDirs);
|
|
432
|
+
const text = formatPortMap(ports, conflicts);
|
|
433
|
+
return respond("check_ports", text, "port conflicts allocation");
|
|
434
|
+
});
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// Tool: run_audit (Multi-Agent Phase 1 — Compliance Agent)
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
server.tool("run_audit", "Run the Compliance Agent audit across all projects. Checks: port conflicts, git remotes (origin + gdrive), git hooks (post-commit auto-push), .env files (existence + gitignore), Docker config (restart policy, workdir), PM2 config (treekill, kill_timeout, no bash wrappers), version issues (EOL runtimes, outdated deps, MUI v4/v5 coexistence). Returns a structured plan with findings and remediation steps.", {
|
|
439
|
+
scope: z
|
|
440
|
+
.enum(["all", "compliance", "versions", "ports"])
|
|
441
|
+
.default("all")
|
|
442
|
+
.describe("Audit scope: all checks, compliance only, version checks only, or port conflicts only"),
|
|
443
|
+
}, async ({ scope }) => {
|
|
444
|
+
const gate = gateCheck("run_audit");
|
|
445
|
+
if (gate)
|
|
446
|
+
return { content: [{ type: "text", text: gate }] };
|
|
447
|
+
const projectDirs = loadProjectDirs();
|
|
448
|
+
const plan = runComplianceAudit(projectDirs);
|
|
449
|
+
const text = formatPlan(plan);
|
|
450
|
+
return respond("run_audit", text, `audit ${scope}`);
|
|
451
|
+
});
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
// Tool: score_project (AI-Readiness Scoring)
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
server.tool("score_project", "Score one or all projects on AI-readiness (0-100%). Checks documentation (copilot-instructions, README, CLAUDE.md, .cursorrules, SKILLS.md, .env.example), infrastructure (git, hooks, Docker, CI, deploy scripts, PM2), code quality (tests, TypeScript, linting, npm scripts), and security (.env gitignored, secrets exposure, lockfiles). Returns letter grade (A+ to F) with detailed breakdown.", {
|
|
456
|
+
project: z
|
|
457
|
+
.string()
|
|
458
|
+
.optional()
|
|
459
|
+
.describe("Project name to score. Omit to score all projects."),
|
|
460
|
+
}, async ({ project }) => {
|
|
461
|
+
const gate = gateCheck("score_project");
|
|
462
|
+
if (gate)
|
|
463
|
+
return { content: [{ type: "text", text: gate }] };
|
|
464
|
+
const projectDirs = loadProjectDirs();
|
|
465
|
+
let scores;
|
|
466
|
+
if (project) {
|
|
467
|
+
const dir = projectDirs.find((d) => d.name.toLowerCase() === project.toLowerCase());
|
|
468
|
+
if (!dir) {
|
|
469
|
+
return {
|
|
470
|
+
content: [
|
|
471
|
+
{
|
|
472
|
+
type: "text",
|
|
473
|
+
text: `Project "${project}" not found. Available: ${projectDirs.map((d) => d.name).join(", ")}`,
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
scores = [scoreProject(dir)];
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
scores = projectDirs.map(scoreProject);
|
|
482
|
+
}
|
|
483
|
+
const text = formatScoreReport(scores);
|
|
484
|
+
return respond("score_project", text, project || "all projects scoring");
|
|
485
|
+
});
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// Tool: save_session (Session Persistence)
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
server.tool("save_session", "Save a key-value entry to a named session. Use to persist decisions, context, plans, and findings between coding sessions. Each session can hold multiple keys (e.g., 'summary', 'active_tasks', 'decisions'). Keys are updated in place if they already exist.", {
|
|
490
|
+
session: z
|
|
491
|
+
.string()
|
|
492
|
+
.describe("Session name (e.g., 'admin-crowlr-upgrade', 'compr-app-v2'). Will be created if it doesn't exist."),
|
|
493
|
+
key: z
|
|
494
|
+
.string()
|
|
495
|
+
.describe("Entry key within the session (e.g., 'summary', 'active_tasks', 'decisions', 'blockers')"),
|
|
496
|
+
value: z
|
|
497
|
+
.string()
|
|
498
|
+
.describe("Content to save — can be a summary, list of tasks, decisions, notes, code snippets, etc."),
|
|
499
|
+
}, async ({ session, key, value }) => {
|
|
500
|
+
const result = saveSession(session, key, value);
|
|
501
|
+
return respond("save_session", `✅ Saved key "${key}" to session "${session}" (${result.entries.length} entries total)`);
|
|
502
|
+
});
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// Tool: load_session (Session Persistence)
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
server.tool("load_session", "Load a previously saved session by name. Returns all stored key-value entries with timestamps. Use at the start of a session to restore context from a previous conversation.", {
|
|
507
|
+
session: z
|
|
508
|
+
.string()
|
|
509
|
+
.describe("Session name to load"),
|
|
510
|
+
}, async ({ session }) => {
|
|
511
|
+
const result = loadSession(session);
|
|
512
|
+
if (!result) {
|
|
513
|
+
return {
|
|
514
|
+
content: [
|
|
515
|
+
{
|
|
516
|
+
type: "text",
|
|
517
|
+
text: `No session found with name "${session}". Use \`list_sessions\` to see available sessions.`,
|
|
518
|
+
},
|
|
519
|
+
],
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
const text = formatSession(result);
|
|
523
|
+
return respond("load_session", text);
|
|
524
|
+
});
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// Tool: list_sessions (Session Persistence)
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
server.tool("list_sessions", "List all saved sessions. Shows session names, entry counts, and timestamps. Use to discover what context is available from previous conversations.", {}, async () => {
|
|
529
|
+
const sessions = listSessions();
|
|
530
|
+
const text = formatSessionList(sessions);
|
|
531
|
+
return respond("list_sessions", text);
|
|
532
|
+
});
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
// Tool: delete_session (Session Persistence)
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
server.tool("delete_session", "Delete a saved session by name. Returns success/not-found. Use for cleanup of stale or obsolete session context.", {
|
|
537
|
+
name: z.string().describe("Session name to delete"),
|
|
538
|
+
}, async ({ name }) => {
|
|
539
|
+
const ok = deleteSession(name);
|
|
540
|
+
if (ok) {
|
|
541
|
+
return respond("delete_session", `✅ Deleted session "${name}".`);
|
|
542
|
+
}
|
|
543
|
+
const available = listSessions().map((s) => s.name);
|
|
544
|
+
const hint = available.length
|
|
545
|
+
? `\n\nAvailable sessions: ${available.join(", ")}`
|
|
546
|
+
: "";
|
|
547
|
+
return respond("delete_session", `Session "${name}" not found.${hint}`);
|
|
548
|
+
});
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
// Tool: audit_verify (Compliance — tamper-evident audit log)
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
server.tool("audit_verify", "Verify the integrity of the local audit log chain. Returns OK + record count, or BROKEN + break index when a record has been edited or the chain otherwise diverges. Compliance basis: SOC2 CC7.2, ISO 27001 A.12.4.1. The audit log lives at ~/.contextengine/audit.log and records every state-changing operation (learning save/delete/import, session save/delete, activation activate/deactivate) as a hash-chained JSONL line.", {
|
|
553
|
+
since: z.string().optional().describe("ISO date — restrict integrity report counters to records on/after this timestamp (chain still verified end-to-end)"),
|
|
554
|
+
until: z.string().optional().describe("ISO date — restrict counters to records on/before this timestamp"),
|
|
555
|
+
}, async ({ since, until }) => {
|
|
556
|
+
const report = verifyChain();
|
|
557
|
+
const records = (() => {
|
|
558
|
+
try {
|
|
559
|
+
return readAuditLog();
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
return [];
|
|
563
|
+
}
|
|
564
|
+
})();
|
|
565
|
+
const filtered = filterByRange(records, since, until);
|
|
566
|
+
const summary = [];
|
|
567
|
+
summary.push(`Audit chain: ${report.ok ? "✅ INTACT" : "❌ BROKEN"}`);
|
|
568
|
+
summary.push(`Total records: ${report.total}`);
|
|
569
|
+
if (since || until) {
|
|
570
|
+
summary.push(`Range filter: ${since ?? "start"} → ${until ?? "now"} (${filtered.length} record(s) in range)`);
|
|
571
|
+
}
|
|
572
|
+
if (!report.ok) {
|
|
573
|
+
summary.push(`Break at index: ${report.breakAtIndex}`);
|
|
574
|
+
summary.push(`Reason: ${report.breakReason}`);
|
|
575
|
+
summary.push("");
|
|
576
|
+
summary.push("A broken chain means the log was either edited after the fact or partially");
|
|
577
|
+
summary.push("written during a crash. For compliance evidence, treat all records from the");
|
|
578
|
+
summary.push("break onward as unverified.");
|
|
579
|
+
}
|
|
580
|
+
return respond("audit_verify", summary.join("\n"));
|
|
581
|
+
});
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// Tool: end_session (End-of-Session Protocol Enforcer)
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
server.tool("end_session", "MUST be called before ending any coding session. Checks all project repos for uncommitted changes, verifies documentation freshness (copilot-instructions.md, SKILLS.md, session docs), and returns a checklist of required actions. Will report PASS/FAIL for each check. The AI agent should resolve all FAIL items before ending.", {}, async () => {
|
|
586
|
+
const projectDirs = loadProjectDirs();
|
|
587
|
+
const checks = [];
|
|
588
|
+
let passCount = 0;
|
|
589
|
+
let failCount = 0;
|
|
590
|
+
checks.push("# End-of-Session Protocol\n");
|
|
591
|
+
// --- Check 1: Uncommitted changes across all repos ---
|
|
592
|
+
checks.push("## 1. Uncommitted Changes\n");
|
|
593
|
+
const reposChecked = new Set();
|
|
594
|
+
for (const dir of projectDirs) {
|
|
595
|
+
try {
|
|
596
|
+
// Find the git root for this project
|
|
597
|
+
const gitRoot = execSync("git rev-parse --show-toplevel", {
|
|
598
|
+
cwd: dir.path,
|
|
599
|
+
encoding: "utf-8",
|
|
600
|
+
timeout: 5000,
|
|
601
|
+
}).trim();
|
|
602
|
+
if (reposChecked.has(gitRoot))
|
|
603
|
+
continue;
|
|
604
|
+
reposChecked.add(gitRoot);
|
|
605
|
+
const status = execSync("git status --porcelain", {
|
|
606
|
+
cwd: gitRoot,
|
|
607
|
+
encoding: "utf-8",
|
|
608
|
+
timeout: 5000,
|
|
609
|
+
}).trim();
|
|
610
|
+
const repoName = basename(gitRoot);
|
|
611
|
+
if (status) {
|
|
612
|
+
const fileCount = status.split("\n").length;
|
|
613
|
+
checks.push(`- ❌ **FAIL** — \`${repoName}\` has ${fileCount} uncommitted file(s)`);
|
|
614
|
+
// Show first 5 files
|
|
615
|
+
const files = status.split("\n").slice(0, 5);
|
|
616
|
+
for (const f of files) {
|
|
617
|
+
checks.push(` - \`${f.trim()}\``);
|
|
618
|
+
}
|
|
619
|
+
if (fileCount > 5)
|
|
620
|
+
checks.push(` - ... and ${fileCount - 5} more`);
|
|
621
|
+
failCount++;
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
checks.push(`- ✅ **PASS** — \`${repoName}\` is clean`);
|
|
625
|
+
passCount++;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
// Not a git repo or git not available
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// Also check common doc repos that might not be in projectDirs
|
|
633
|
+
const extraRepoPaths = [
|
|
634
|
+
join(process.env.HOME || "", "FASTPROD"),
|
|
635
|
+
];
|
|
636
|
+
for (const repoPath of extraRepoPaths) {
|
|
637
|
+
if (!existsSync(repoPath) || reposChecked.has(repoPath))
|
|
638
|
+
continue;
|
|
639
|
+
try {
|
|
640
|
+
const gitRoot = execSync("git rev-parse --show-toplevel", {
|
|
641
|
+
cwd: repoPath,
|
|
642
|
+
encoding: "utf-8",
|
|
643
|
+
timeout: 5000,
|
|
644
|
+
}).trim();
|
|
645
|
+
if (reposChecked.has(gitRoot))
|
|
646
|
+
continue;
|
|
647
|
+
reposChecked.add(gitRoot);
|
|
648
|
+
const status = execSync("git status --porcelain", {
|
|
649
|
+
cwd: gitRoot,
|
|
650
|
+
encoding: "utf-8",
|
|
651
|
+
timeout: 5000,
|
|
652
|
+
}).trim();
|
|
653
|
+
const repoName = basename(gitRoot);
|
|
654
|
+
if (status) {
|
|
655
|
+
const fileCount = status.split("\n").length;
|
|
656
|
+
checks.push(`- ❌ **FAIL** — \`${repoName}\` has ${fileCount} uncommitted file(s)`);
|
|
657
|
+
failCount++;
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
checks.push(`- ✅ **PASS** — \`${repoName}\` is clean`);
|
|
661
|
+
passCount++;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
// Not a git repo
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
checks.push("");
|
|
669
|
+
// --- Check 2: Documentation freshness ---
|
|
670
|
+
checks.push("## 2. Documentation Freshness\n");
|
|
671
|
+
const now = Date.now();
|
|
672
|
+
const SESSION_THRESHOLD_MS = 4 * 60 * 60 * 1000; // 4 hours — if not modified in current session, flag it
|
|
673
|
+
// Find copilot-instructions.md files across projects
|
|
674
|
+
let copilotFound = false;
|
|
675
|
+
for (const dir of projectDirs) {
|
|
676
|
+
const copilotPath = join(dir.path, ".github", "copilot-instructions.md");
|
|
677
|
+
if (existsSync(copilotPath)) {
|
|
678
|
+
copilotFound = true;
|
|
679
|
+
try {
|
|
680
|
+
const stat = statSync(copilotPath);
|
|
681
|
+
const ageMs = now - stat.mtimeMs;
|
|
682
|
+
if (ageMs < SESSION_THRESHOLD_MS) {
|
|
683
|
+
const mins = Math.round(ageMs / 60000);
|
|
684
|
+
checks.push(`- ✅ **PASS** — \`${dir.name}/copilot-instructions.md\` updated ${mins}m ago`);
|
|
685
|
+
passCount++;
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
const hours = Math.round(ageMs / 3600000);
|
|
689
|
+
checks.push(`- ⚠️ **CHECK** — \`${dir.name}/copilot-instructions.md\` last modified ${hours}h ago — update if anything changed`);
|
|
690
|
+
failCount++;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
checks.push(`- ⚠️ **CHECK** — \`${dir.name}/copilot-instructions.md\` could not be read`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (!copilotFound) {
|
|
699
|
+
checks.push("- ⚠️ **CHECK** — No copilot-instructions.md found in any project");
|
|
700
|
+
}
|
|
701
|
+
// Check SKILLS.md
|
|
702
|
+
const skillsPaths = [
|
|
703
|
+
join(process.env.HOME || "", "Projects", "EXO", "SKILLS.md"),
|
|
704
|
+
];
|
|
705
|
+
for (const sp of skillsPaths) {
|
|
706
|
+
if (existsSync(sp)) {
|
|
707
|
+
try {
|
|
708
|
+
const stat = statSync(sp);
|
|
709
|
+
const ageMs = now - stat.mtimeMs;
|
|
710
|
+
if (ageMs < SESSION_THRESHOLD_MS) {
|
|
711
|
+
const mins = Math.round(ageMs / 60000);
|
|
712
|
+
checks.push(`- ✅ **PASS** — \`SKILLS.md\` updated ${mins}m ago`);
|
|
713
|
+
passCount++;
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
const hours = Math.round(ageMs / 3600000);
|
|
717
|
+
checks.push(`- ⚠️ **CHECK** — \`SKILLS.md\` last modified ${hours}h ago — update if new capabilities were learned`);
|
|
718
|
+
failCount++;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
// Can't stat
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
// Check session doc
|
|
727
|
+
const sessionDocPath = join(process.env.HOME || "", "FASTPROD", "docs", "CROWLR_COMPR_APPS_SESSION.md");
|
|
728
|
+
if (existsSync(sessionDocPath)) {
|
|
729
|
+
try {
|
|
730
|
+
const stat = statSync(sessionDocPath);
|
|
731
|
+
const ageMs = now - stat.mtimeMs;
|
|
732
|
+
if (ageMs < SESSION_THRESHOLD_MS) {
|
|
733
|
+
const mins = Math.round(ageMs / 60000);
|
|
734
|
+
checks.push(`- ✅ **PASS** — \`SESSION.md\` updated ${mins}m ago`);
|
|
735
|
+
passCount++;
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
const hours = Math.round(ageMs / 3600000);
|
|
739
|
+
checks.push(`- ⚠️ **CHECK** — \`SESSION.md\` last modified ${hours}h ago — append session summary`);
|
|
740
|
+
failCount++;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
// Can't stat
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
checks.push("");
|
|
748
|
+
// --- Summary ---
|
|
749
|
+
checks.push("## Summary\n");
|
|
750
|
+
const total = passCount + failCount;
|
|
751
|
+
if (failCount === 0) {
|
|
752
|
+
checks.push(`✅ **ALL CLEAR** — ${passCount}/${total} checks passed. Safe to end session.`);
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
checks.push(`⚠️ **${failCount} item(s) need attention** — ${passCount}/${total} passed.`);
|
|
756
|
+
checks.push("");
|
|
757
|
+
checks.push("**Before ending this session, please:**");
|
|
758
|
+
checks.push("1. Commit and push all uncommitted changes");
|
|
759
|
+
checks.push("2. Update copilot-instructions.md with version/feature changes");
|
|
760
|
+
checks.push("3. Update SKILLS.md if new capabilities were used");
|
|
761
|
+
checks.push("4. Append a session summary to SESSION.md");
|
|
762
|
+
checks.push("5. Run `end_session` again to verify all clear");
|
|
763
|
+
}
|
|
764
|
+
return respond("end_session", checks.join("\n"));
|
|
765
|
+
});
|
|
766
|
+
// ---------------------------------------------------------------------------
|
|
767
|
+
// Tool: save_learning (Permanent Learning Store)
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
server.tool("save_learning", "Save a permanent operational rule learned during a coding session. Unlike sessions (ephemeral), learnings persist forever and auto-surface in search_context results so AI agents don't repeat mistakes. Duplicate rules (same category + rule text) are updated in place.", {
|
|
770
|
+
category: z
|
|
771
|
+
.enum(LEARNING_CATEGORIES)
|
|
772
|
+
.describe("Category: deployment, api, database, frontend, backend, devops, security, performance, testing, debugging, tooling, git, dependencies, architecture, data, infrastructure, mobile, other"),
|
|
773
|
+
rule: z
|
|
774
|
+
.string()
|
|
775
|
+
.describe("The operational rule — concise, actionable (e.g., 'Always restart Flask after model changes')"),
|
|
776
|
+
context: z
|
|
777
|
+
.string()
|
|
778
|
+
.describe("Full context of how this was discovered — the bug, the fix, the symptoms (e.g., 'Avatar save returned 200 but field missing from API response — stale to_dict() cache')"),
|
|
779
|
+
project: z
|
|
780
|
+
.string()
|
|
781
|
+
.optional()
|
|
782
|
+
.describe("Project this learning applies to (e.g., 'CROWLR.io'). Omit if it's a general rule."),
|
|
783
|
+
}, async ({ category, rule, context, project }) => {
|
|
784
|
+
try {
|
|
785
|
+
const learning = saveLearning(category, rule, context, project);
|
|
786
|
+
const stats = learningsStats();
|
|
787
|
+
// Re-inject learnings into search index (project-scoped)
|
|
788
|
+
const newChunks = learningsToChunks(activeProjectNames);
|
|
789
|
+
// Remove old learning chunks and add new ones
|
|
790
|
+
const nonLearningChunks = chunks.filter((c) => c.source !== "💡 Learnings Store");
|
|
791
|
+
chunks.length = 0;
|
|
792
|
+
chunks.push(...nonLearningChunks, ...newChunks);
|
|
793
|
+
return respond("save_learning", [
|
|
794
|
+
`✅ Learning saved: **${rule}**`,
|
|
795
|
+
``,
|
|
796
|
+
`- **ID:** \`${learning.id}\``,
|
|
797
|
+
`- **Category:** ${category}`,
|
|
798
|
+
project ? `- **Project:** ${project}` : "",
|
|
799
|
+
`- **Tags:** ${learning.tags.join(", ")}`,
|
|
800
|
+
``,
|
|
801
|
+
`📊 Store: ${stats.total} learnings across ${Object.keys(stats.categories).length} categories`,
|
|
802
|
+
``,
|
|
803
|
+
`This learning will now auto-surface in \`search_context\` results when relevant.`,
|
|
804
|
+
]
|
|
805
|
+
.filter(Boolean)
|
|
806
|
+
.join("\n"));
|
|
807
|
+
}
|
|
808
|
+
catch (e) {
|
|
809
|
+
return respond("save_learning", `❌ Learning rejected: ${e.message}`);
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
// Tool: list_learnings (Permanent Learning Store)
|
|
814
|
+
// ---------------------------------------------------------------------------
|
|
815
|
+
server.tool("list_learnings", "List all permanent learnings, optionally filtered by category. Shows operational rules that have been discovered across sessions. Use search_context to find learnings by keyword — they're automatically included in search results.", {
|
|
816
|
+
category: z
|
|
817
|
+
.string()
|
|
818
|
+
.optional()
|
|
819
|
+
.describe("Filter by category (deployment, api, database, etc.). Omit to show all."),
|
|
820
|
+
}, async ({ category }) => {
|
|
821
|
+
// Project-scoped: only show learnings for active workspace projects + universal (no project)
|
|
822
|
+
const learnings = listLearnings(category, activeProjectNames);
|
|
823
|
+
const text = formatLearnings(learnings);
|
|
824
|
+
return respond("list_learnings", text);
|
|
825
|
+
});
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
// Tool: delete_learning (Permanent Learning Store)
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
server.tool("delete_learning", "Delete a learning by its ID. Use list_learnings first to find the ID of the learning you want to remove.", {
|
|
830
|
+
id: z.string().describe("The unique ID of the learning to delete"),
|
|
831
|
+
}, async ({ id }) => {
|
|
832
|
+
const deleted = deleteLearning(id);
|
|
833
|
+
if (!deleted) {
|
|
834
|
+
return respond("delete_learning", `❌ No learning found with ID "${id}". Use \`list_learnings\` to see available IDs.`);
|
|
835
|
+
}
|
|
836
|
+
// Re-inject learnings into search index
|
|
837
|
+
const newChunks = learningsToChunks(activeProjectNames);
|
|
838
|
+
const nonLearningChunks = chunks.filter((c) => c.source !== "💡 Learnings Store");
|
|
839
|
+
chunks.length = 0;
|
|
840
|
+
chunks.push(...nonLearningChunks, ...newChunks);
|
|
841
|
+
return respond("delete_learning", `✅ Learning "${id}" deleted successfully.`);
|
|
842
|
+
});
|
|
843
|
+
// ---------------------------------------------------------------------------
|
|
844
|
+
// Tool: import_learnings (Bulk Import from Files)
|
|
845
|
+
// ---------------------------------------------------------------------------
|
|
846
|
+
server.tool("import_learnings", "Bulk-import learnings from a Markdown or JSON file. Parses headings, bullets, and tables to extract operational rules. Supports: (1) Structured Markdown (H2=category, H3=rule, bullets=context), (2) Inline bullets with [category] prefix, (3) JSON arrays of {category, rule, context}. Deduplicates against existing learnings.", {
|
|
847
|
+
file_path: z
|
|
848
|
+
.string()
|
|
849
|
+
.describe("Absolute path to the Markdown (.md) or JSON (.json) file to import from"),
|
|
850
|
+
default_category: z
|
|
851
|
+
.string()
|
|
852
|
+
.optional()
|
|
853
|
+
.describe("Default category for rules where category cannot be inferred. Defaults to 'other'."),
|
|
854
|
+
project: z
|
|
855
|
+
.string()
|
|
856
|
+
.optional()
|
|
857
|
+
.describe("Project name to tag all imported learnings with (e.g., 'FC_project')"),
|
|
858
|
+
}, async ({ file_path, default_category, project }) => {
|
|
859
|
+
const result = importLearningsFromFile(file_path, default_category || "other", project);
|
|
860
|
+
// Re-inject learnings into search index (project-scoped)
|
|
861
|
+
const newChunks = learningsToChunks(activeProjectNames);
|
|
862
|
+
const nonLearningChunks = chunks.filter((c) => c.source !== "💡 Learnings Store");
|
|
863
|
+
chunks.length = 0;
|
|
864
|
+
chunks.push(...nonLearningChunks, ...newChunks);
|
|
865
|
+
const stats = learningsStats();
|
|
866
|
+
const lines = [
|
|
867
|
+
`# Import Results\n`,
|
|
868
|
+
`- **Imported:** ${result.imported} new learnings`,
|
|
869
|
+
`- **Updated:** ${result.updated} existing learnings (dedup match)`,
|
|
870
|
+
`- **Skipped:** ${result.skipped} entries (missing data)`,
|
|
871
|
+
``,
|
|
872
|
+
`📊 Store total: ${stats.total} learnings across ${Object.keys(stats.categories).length} categories`,
|
|
873
|
+
``,
|
|
874
|
+
];
|
|
875
|
+
if (result.errors.length > 0) {
|
|
876
|
+
lines.push(`## ⚠️ Errors (${result.errors.length})\n`);
|
|
877
|
+
for (const err of result.errors.slice(0, 10)) {
|
|
878
|
+
lines.push(`- ${err}`);
|
|
879
|
+
}
|
|
880
|
+
if (result.errors.length > 10) {
|
|
881
|
+
lines.push(`- ... and ${result.errors.length - 10} more`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
lines.push(`\nAll imported learnings now auto-surface in \`search_context\` results.`);
|
|
885
|
+
return respond("import_learnings", lines.join("\n"));
|
|
886
|
+
});
|
|
887
|
+
// ---------------------------------------------------------------------------
|
|
888
|
+
// Tool: activate (License Activation)
|
|
889
|
+
// ---------------------------------------------------------------------------
|
|
890
|
+
server.tool("activate", "Activate a ContextEngine Pro license to unlock premium tools (score_project, run_audit, check_ports, list_projects, HTML reports). Get a license at https://compr.ch/contextengine/pricing", {
|
|
891
|
+
license_key: z.string().describe("Your ContextEngine license key"),
|
|
892
|
+
email: z.string().describe("Email associated with the license"),
|
|
893
|
+
}, async ({ license_key, email }) => {
|
|
894
|
+
const result = await activate(license_key, email);
|
|
895
|
+
return respond("activate", result.message);
|
|
896
|
+
});
|
|
897
|
+
// ---------------------------------------------------------------------------
|
|
898
|
+
// Tool: activation_status (Check License)
|
|
899
|
+
// ---------------------------------------------------------------------------
|
|
900
|
+
server.tool("activation_status", "Check current ContextEngine license status, plan, and available premium tools.", {}, async () => {
|
|
901
|
+
const status = getActivationStatus();
|
|
902
|
+
const lines = [
|
|
903
|
+
`## ContextEngine License Status\n`,
|
|
904
|
+
`- **Activated**: ${status.activated ? "✅ Yes" : "❌ No"}`,
|
|
905
|
+
`- **Plan**: ${status.plan}`,
|
|
906
|
+
`- **Expires**: ${status.expiresAt}`,
|
|
907
|
+
`- **Delta version**: ${status.deltaVersion}`,
|
|
908
|
+
`- **Machine ID**: ${status.machineId}`,
|
|
909
|
+
``,
|
|
910
|
+
];
|
|
911
|
+
if (status.premiumTools.length > 0) {
|
|
912
|
+
lines.push(`### 🔓 Premium Tools Available`);
|
|
913
|
+
for (const t of status.premiumTools) {
|
|
914
|
+
lines.push(`- ${t}`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
lines.push(`### 🔒 Premium Tools (requires activation)`);
|
|
919
|
+
lines.push(`- score_project, run_audit, check_ports, list_projects`);
|
|
920
|
+
lines.push(``);
|
|
921
|
+
lines.push(`Get a license: https://compr.ch/contextengine/pricing`);
|
|
922
|
+
lines.push(`Activate: \`npx contextengine activate <key> <email>\``);
|
|
923
|
+
}
|
|
924
|
+
return respond("activation_status", lines.join("\n"));
|
|
925
|
+
});
|
|
926
|
+
// ---------------------------------------------------------------------------
|
|
927
|
+
// MCP Resources: expose each source as a browsable resource
|
|
928
|
+
// ---------------------------------------------------------------------------
|
|
929
|
+
function registerResources() {
|
|
930
|
+
// Static resources for each discovered source — deduplicate by URI
|
|
931
|
+
const registered = new Set();
|
|
932
|
+
for (const source of sources) {
|
|
933
|
+
if (!existsSync(source.path))
|
|
934
|
+
continue;
|
|
935
|
+
const uri = `context://${encodeURIComponent(source.name)}`;
|
|
936
|
+
if (registered.has(uri))
|
|
937
|
+
continue;
|
|
938
|
+
registered.add(uri);
|
|
939
|
+
server.resource(source.name, uri, {
|
|
940
|
+
description: `Knowledge source: ${source.name}`,
|
|
941
|
+
mimeType: "text/markdown",
|
|
942
|
+
}, async () => {
|
|
943
|
+
const content = existsSync(source.path)
|
|
944
|
+
? readFileSync(source.path, "utf-8")
|
|
945
|
+
: `Source file not found: ${source.path}`;
|
|
946
|
+
return {
|
|
947
|
+
contents: [
|
|
948
|
+
{
|
|
949
|
+
uri,
|
|
950
|
+
mimeType: "text/markdown",
|
|
951
|
+
text: content,
|
|
952
|
+
},
|
|
953
|
+
],
|
|
954
|
+
};
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
console.error(`[ContextEngine] 📚 Registered ${registered.size} MCP resources (${sources.length - registered.size} duplicates skipped)`);
|
|
958
|
+
}
|
|
959
|
+
// ---------------------------------------------------------------------------
|
|
960
|
+
// Start
|
|
961
|
+
// ---------------------------------------------------------------------------
|
|
962
|
+
async function main() {
|
|
963
|
+
// 1. Ingest all sources (fast — keyword search available immediately)
|
|
964
|
+
sources = loadSources();
|
|
965
|
+
chunks = ingestSources(sources);
|
|
966
|
+
// 1b. Collect operational data (git, deps, env, docker, pm2, etc.)
|
|
967
|
+
const config = loadConfig();
|
|
968
|
+
const projectDirs = loadProjectDirs();
|
|
969
|
+
activeProjectNames = projectDirs.map((d) => d.name);
|
|
970
|
+
firewall.setProjectDirs(projectDirs);
|
|
971
|
+
if (config.collectOps !== false) {
|
|
972
|
+
let opsChunks = 0;
|
|
973
|
+
for (const dir of projectDirs) {
|
|
974
|
+
const ops = collectProjectOps(dir.path, dir.name);
|
|
975
|
+
chunks.push(...ops);
|
|
976
|
+
opsChunks += ops.length;
|
|
977
|
+
}
|
|
978
|
+
if (opsChunks > 0) {
|
|
979
|
+
console.error(`[ContextEngine] ⚙ Collected ${opsChunks} operational chunks from ${projectDirs.length} projects`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (config.collectSystemOps !== false) {
|
|
983
|
+
const sysOps = collectSystemOps();
|
|
984
|
+
if (sysOps.length > 0) {
|
|
985
|
+
chunks.push(...sysOps);
|
|
986
|
+
console.error(`[ContextEngine] 🖥 Collected ${sysOps.length} system operational chunks`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
// 1c. Scan code files (TS/JS/Python) if configured
|
|
990
|
+
if (config.codeDirs && config.codeDirs.length > 0) {
|
|
991
|
+
let codeChunks = 0;
|
|
992
|
+
for (const dir of projectDirs) {
|
|
993
|
+
for (const codeDir of config.codeDirs) {
|
|
994
|
+
const codePath = join(dir.path, codeDir);
|
|
995
|
+
if (existsSync(codePath)) {
|
|
996
|
+
const codeResults = scanCodeDir(codePath, dir.name);
|
|
997
|
+
chunks.push(...codeResults);
|
|
998
|
+
codeChunks += codeResults.length;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (codeChunks > 0) {
|
|
1003
|
+
console.error(`[ContextEngine] 💻 Parsed ${codeChunks} code chunks from source files`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
// 1d. Auto-import learnings from discovered doc sources
|
|
1007
|
+
const autoImport = autoImportFromSources(sources.map((s) => ({ path: s.path, name: s.name })));
|
|
1008
|
+
if (autoImport.imported > 0) {
|
|
1009
|
+
console.error(`[ContextEngine] 📥 Auto-imported ${autoImport.imported} new learnings from ${autoImport.total} doc sources (${autoImport.updated} updated)`);
|
|
1010
|
+
}
|
|
1011
|
+
// 1e. Inject learnings into search index (project-scoped)
|
|
1012
|
+
const learningChunks = learningsToChunks(activeProjectNames);
|
|
1013
|
+
if (learningChunks.length > 0) {
|
|
1014
|
+
chunks.push(...learningChunks);
|
|
1015
|
+
console.error(`[ContextEngine] 💡 Injected ${learningChunks.length} learning chunks into search index (scoped)`);
|
|
1016
|
+
}
|
|
1017
|
+
// 1f. Load and collect from plugin adapters
|
|
1018
|
+
if (config.adapters && config.adapters.length > 0) {
|
|
1019
|
+
const adapterCount = await loadAdapters(config.adapters);
|
|
1020
|
+
if (adapterCount > 0) {
|
|
1021
|
+
const adapterChunks = await collectFromAdapters(config.adapters);
|
|
1022
|
+
if (adapterChunks.length > 0) {
|
|
1023
|
+
chunks.push(...adapterChunks);
|
|
1024
|
+
console.error(`[ContextEngine] 🔌 Adapters contributed ${adapterChunks.length} chunks from ${adapterCount} adapters`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
// 2. Register MCP resources
|
|
1029
|
+
registerResources();
|
|
1030
|
+
// 2b. Auto-inject recent session context into search index
|
|
1031
|
+
const recentSessions = listSessions();
|
|
1032
|
+
if (recentSessions.length > 0) {
|
|
1033
|
+
// Sort by updated desc, take the most recent
|
|
1034
|
+
recentSessions.sort((a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime());
|
|
1035
|
+
const recent = recentSessions[0];
|
|
1036
|
+
const recentSession = loadSession(recent.name);
|
|
1037
|
+
if (recentSession) {
|
|
1038
|
+
const ageHours = (Date.now() - new Date(recentSession.updated).getTime()) / 3600000;
|
|
1039
|
+
if (ageHours < 72) { // Only inject if session is less than 3 days old
|
|
1040
|
+
const sessionContent = recentSession.entries
|
|
1041
|
+
.map((e) => `### ${e.key}\n${e.value}`)
|
|
1042
|
+
.join("\n\n");
|
|
1043
|
+
chunks.push({
|
|
1044
|
+
source: `Session: ${recentSession.name}`,
|
|
1045
|
+
section: "Last Session Context",
|
|
1046
|
+
content: `# Previous Session: ${recentSession.name}\n_Updated: ${recentSession.updated}_\n\n${sessionContent}`,
|
|
1047
|
+
lineStart: 0,
|
|
1048
|
+
lineEnd: 0,
|
|
1049
|
+
});
|
|
1050
|
+
console.error(`[ContextEngine] 📋 Auto-injected session "${recentSession.name}" (${recentSession.entries.length} entries, ${Math.round(ageHours)}h ago)`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
// 3. Connect MCP transport (server is usable with keyword search now)
|
|
1055
|
+
const transport = new StdioServerTransport();
|
|
1056
|
+
await server.connect(transport);
|
|
1057
|
+
console.error("[ContextEngine] 🚀 MCP server running on stdio (keyword search ready)");
|
|
1058
|
+
// 4. Load embeddings — try cache first, then model (non-blocking)
|
|
1059
|
+
const cached = loadCache(chunks);
|
|
1060
|
+
if (cached) {
|
|
1061
|
+
embeddedChunks = cached;
|
|
1062
|
+
console.error(`[ContextEngine] ✅ Semantic search ready from cache (${embeddedChunks.length} vectors)`);
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
initEmbeddings().then(async (ready) => {
|
|
1066
|
+
if (ready) {
|
|
1067
|
+
console.error(`[ContextEngine] 🧠 Embedding ${chunks.length} chunks...`);
|
|
1068
|
+
embeddedChunks = await embedChunks(chunks);
|
|
1069
|
+
saveCache(chunks, embeddedChunks);
|
|
1070
|
+
console.error(`[ContextEngine] ✅ Semantic search ready (${embeddedChunks.length} vectors)`);
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
// 5. Start file watchers
|
|
1075
|
+
startWatching();
|
|
1076
|
+
}
|
|
1077
|
+
main().catch((err) => {
|
|
1078
|
+
console.error("[ContextEngine] Fatal:", err);
|
|
1079
|
+
process.exit(1);
|
|
1080
|
+
});
|
|
1081
|
+
//# sourceMappingURL=index.js.map
|