@compr/opscontext-mcp 2.5.8 → 2.6.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 +52 -0
- package/dist/audit.d.ts +1 -1
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +26 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.js +1 -1
- package/dist/embedding-store.d.ts +46 -0
- package/dist/embedding-store.js +141 -0
- package/dist/embeddings.d.ts +15 -3
- package/dist/embeddings.js +48 -18
- package/dist/index.js +261 -91
- package/dist/learnings.d.ts +2 -0
- package/dist/learnings.js +58 -19
- package/dist/server-registry.d.ts +47 -0
- package/dist/server-registry.js +162 -0
- package/dist/shared-index.d.ts +45 -0
- package/dist/shared-index.js +113 -0
- package/package.json +1 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// [LOCKED] [SERVERS-ARE-INVENTORIED] 2026-09-05
|
|
2
|
+
// [NEVER] let an MCP server start without writing its registry record, or answer "which
|
|
3
|
+
// servers run and on which build?" from a `ps` grep instead of this registry.
|
|
4
|
+
// WHY: on 2026-09-05 two servers started before a build kept running the old importer for
|
|
5
|
+
// two hours and re-imported 1,766 records the owner had just had deleted. `ps` found them
|
|
6
|
+
// only on the second look: the first grep matched the absolute script path and the two
|
|
7
|
+
// had been started with a relative one. Nine other servers, one per open chat, were each
|
|
8
|
+
// re-embedding the whole corpus after every doc change (load average 230) and nothing
|
|
9
|
+
// said so. `server-meta.json` held one version: the last server to start.
|
|
10
|
+
// FIX: every server writes ~/.contextengine/servers/<pid>.json on start (pid, parent, start
|
|
11
|
+
// time, version, a hash of the script it loaded, cwd) and refreshes a heartbeat; the file
|
|
12
|
+
// goes on exit, and a lister removes records whose pid is dead. `contextengine servers`
|
|
13
|
+
// and the end-session checklist compare each record's build hash with the file on disk
|
|
14
|
+
// now, and warn when more than SERVER_COUNT_WARN servers run at once.
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "fs";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { homedir } from "os";
|
|
18
|
+
import { createHash } from "crypto";
|
|
19
|
+
import { execFileSync } from "child_process";
|
|
20
|
+
/** More concurrent servers than this and every doc change costs that many re-embeds. */
|
|
21
|
+
export const SERVER_COUNT_WARN = 3;
|
|
22
|
+
const HEARTBEAT_MS = 60_000;
|
|
23
|
+
function registryDir() {
|
|
24
|
+
return join(process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine"), "servers");
|
|
25
|
+
}
|
|
26
|
+
/** Short content hash of the script a server loaded; the build identity. */
|
|
27
|
+
export function buildHashOf(scriptPath) {
|
|
28
|
+
try {
|
|
29
|
+
return createHash("sha256").update(readFileSync(scriptPath)).digest("hex").slice(0, 12);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function parentName(ppid) {
|
|
36
|
+
try {
|
|
37
|
+
// Hardcoded argv, no shell: the only variable is a number.
|
|
38
|
+
return execFileSync("ps", ["-o", "comm=", "-p", String(ppid)], { encoding: "utf8", timeout: 2000 })
|
|
39
|
+
.trim().split("/").pop() || "?";
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return "?";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function isAlive(pid) {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return e?.code === "EPERM"; // exists, not ours
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Register the running server. Returns a stop() that removes the record; exit handlers call it too.
|
|
56
|
+
*/
|
|
57
|
+
export function registerServer(opts) {
|
|
58
|
+
const dir = registryDir();
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
const record = {
|
|
62
|
+
pid: process.pid,
|
|
63
|
+
ppid: process.ppid,
|
|
64
|
+
parent: parentName(process.ppid),
|
|
65
|
+
started: now,
|
|
66
|
+
heartbeat: now,
|
|
67
|
+
version: opts.version,
|
|
68
|
+
script: opts.script,
|
|
69
|
+
build: buildHashOf(opts.script) || "unknown",
|
|
70
|
+
cwd: process.cwd(),
|
|
71
|
+
node: process.version,
|
|
72
|
+
...(opts.corpus ? { corpus: opts.corpus } : {}),
|
|
73
|
+
...(opts.role ? { role: opts.role } : {}),
|
|
74
|
+
};
|
|
75
|
+
const file = join(dir, `${process.pid}.json`);
|
|
76
|
+
const write = () => { try {
|
|
77
|
+
writeFileSync(file, JSON.stringify(record, null, 2));
|
|
78
|
+
}
|
|
79
|
+
catch { /* registry is diagnostics, never fatal */ } };
|
|
80
|
+
write();
|
|
81
|
+
const timer = setInterval(() => { record.heartbeat = new Date().toISOString(); write(); }, HEARTBEAT_MS);
|
|
82
|
+
timer.unref();
|
|
83
|
+
let stopped = false;
|
|
84
|
+
const stop = () => {
|
|
85
|
+
if (stopped)
|
|
86
|
+
return;
|
|
87
|
+
stopped = true;
|
|
88
|
+
clearInterval(timer);
|
|
89
|
+
try {
|
|
90
|
+
unlinkSync(file);
|
|
91
|
+
}
|
|
92
|
+
catch { /* already gone */ }
|
|
93
|
+
};
|
|
94
|
+
process.on("exit", stop);
|
|
95
|
+
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
96
|
+
process.on(sig, () => { stop(); process.exit(0); });
|
|
97
|
+
}
|
|
98
|
+
const setRole = (role) => { record.role = role; write(); };
|
|
99
|
+
return { record, stop, setRole };
|
|
100
|
+
}
|
|
101
|
+
/** Read every record, drop the dead ones, compare builds with the files on disk now. */
|
|
102
|
+
export function listServers() {
|
|
103
|
+
const dir = registryDir();
|
|
104
|
+
const report = { servers: [], removed: 0, warnings: [] };
|
|
105
|
+
if (!existsSync(dir))
|
|
106
|
+
return report;
|
|
107
|
+
for (const f of readdirSync(dir)) {
|
|
108
|
+
if (!f.endsWith(".json"))
|
|
109
|
+
continue;
|
|
110
|
+
const path = join(dir, f);
|
|
111
|
+
let rec;
|
|
112
|
+
try {
|
|
113
|
+
rec = JSON.parse(readFileSync(path, "utf8"));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
try {
|
|
117
|
+
unlinkSync(path);
|
|
118
|
+
}
|
|
119
|
+
catch { /* */ }
|
|
120
|
+
report.removed++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!isAlive(rec.pid)) {
|
|
124
|
+
try {
|
|
125
|
+
unlinkSync(path);
|
|
126
|
+
}
|
|
127
|
+
catch { /* */ }
|
|
128
|
+
report.removed++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const currentBuild = buildHashOf(rec.script);
|
|
132
|
+
const staleBuild = currentBuild !== null && rec.build !== "unknown" && currentBuild !== rec.build;
|
|
133
|
+
report.servers.push({ ...rec, alive: true, currentBuild, staleBuild });
|
|
134
|
+
}
|
|
135
|
+
report.servers.sort((a, b) => a.started.localeCompare(b.started));
|
|
136
|
+
const stale = report.servers.filter((s) => s.staleBuild);
|
|
137
|
+
if (stale.length > 0) {
|
|
138
|
+
report.warnings.push(`${stale.length} server(s) run a build older than the file on disk (pid ${stale.map((s) => s.pid).join(", ")}): restart them or they keep the old behaviour`);
|
|
139
|
+
}
|
|
140
|
+
// Only servers that index on their own cost a re-index per doc change; readers of a shared
|
|
141
|
+
// index do not. [LOCK] [ONE-INDEXER-MANY-READERS]
|
|
142
|
+
const indexing = report.servers.filter((s) => s.role !== "reader");
|
|
143
|
+
if (indexing.length > SERVER_COUNT_WARN) {
|
|
144
|
+
report.warnings.push(`${indexing.length} of ${report.servers.length} servers index on their own; every doc change makes each of them re-index the corpus (${SERVER_COUNT_WARN} is the comfortable ceiling; CONTEXTENGINE_SHARED_INDEX=1 makes all but one per corpus readers)`);
|
|
145
|
+
}
|
|
146
|
+
return report;
|
|
147
|
+
}
|
|
148
|
+
export function formatServers(report, home = homedir()) {
|
|
149
|
+
const short = (p) => p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
150
|
+
const lines = [];
|
|
151
|
+
lines.push(`${report.servers.length} server(s) running${report.removed ? `, ${report.removed} dead record(s) removed` : ""}`);
|
|
152
|
+
for (const s of report.servers) {
|
|
153
|
+
const t = s.started.slice(11, 19) + "Z";
|
|
154
|
+
const flag = s.staleBuild ? `STALE BUILD (disk ${s.currentBuild})` : s.currentBuild === null ? "script missing on disk" : "current";
|
|
155
|
+
const role = s.role ? ` ${s.role.padEnd(7)} corpus ${s.corpus ?? "?"}` : "";
|
|
156
|
+
lines.push(` pid ${String(s.pid).padEnd(6)} ${t} v${s.version} build ${s.build} ${flag}${role} parent ${s.parent} cwd ${short(s.cwd)}`);
|
|
157
|
+
}
|
|
158
|
+
for (const w of report.warnings)
|
|
159
|
+
lines.push(` ⚠ ${w}`);
|
|
160
|
+
return lines.join("\n");
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=server-registry.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type KnowledgeSource } from "./config.js";
|
|
2
|
+
import type { Chunk } from "./ingest.js";
|
|
3
|
+
import type { ServerReport } from "./server-registry.js";
|
|
4
|
+
export type ServerRole = "indexer" | "reader";
|
|
5
|
+
export declare const SHARED_INDEX_VERSION = 1;
|
|
6
|
+
export declare function sharedIndexEnabled(): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* What a server's corpus is made of, as a short id: the config file it resolved (path and
|
|
9
|
+
* content) or the discovery fallback, and the env flags that change discovery. Two servers with
|
|
10
|
+
* the same id index the same thing and can share one index; different ids get their own writer.
|
|
11
|
+
*/
|
|
12
|
+
export declare function corpusId(): string;
|
|
13
|
+
export interface SharedIndexFile {
|
|
14
|
+
version: number;
|
|
15
|
+
corpus: string;
|
|
16
|
+
seq: number;
|
|
17
|
+
stamp: string;
|
|
18
|
+
writer: number;
|
|
19
|
+
sources: KnowledgeSource[];
|
|
20
|
+
activeProjectNames: string[];
|
|
21
|
+
chunks: Chunk[];
|
|
22
|
+
/** One embedding-store key per chunk, same order. */
|
|
23
|
+
keys: string[];
|
|
24
|
+
}
|
|
25
|
+
export declare function sharedIndexPath(corpus: string): string;
|
|
26
|
+
/** Temp file + rename: a reader never sees a half-written index. */
|
|
27
|
+
export declare function writeSharedIndex(data: Omit<SharedIndexFile, "version" | "stamp">): {
|
|
28
|
+
path: string;
|
|
29
|
+
bytes: number;
|
|
30
|
+
ms: number;
|
|
31
|
+
};
|
|
32
|
+
export declare function readSharedIndex(corpus: string): SharedIndexFile | null;
|
|
33
|
+
/** Cheap change detector for readers: the file's mtime, or null when absent. */
|
|
34
|
+
export declare function sharedIndexMtime(corpus: string): number | null;
|
|
35
|
+
/**
|
|
36
|
+
* Who indexes this corpus. Among the live registered servers of the corpus: a server whose
|
|
37
|
+
* build equals the file on disk beats a stale one, then the earliest start, then the lowest pid.
|
|
38
|
+
* A server that does not find itself in the registry indexes on its own: never wait on a
|
|
39
|
+
* registry that failed.
|
|
40
|
+
*/
|
|
41
|
+
export declare function electIndexer(corpus: string, servers: ServerReport["servers"], myPid: number): {
|
|
42
|
+
indexer: number | null;
|
|
43
|
+
role: ServerRole;
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=shared-index.d.ts.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// [LOCKED] [ONE-INDEXER-MANY-READERS] 2026-09-05
|
|
2
|
+
// [NEVER] let every MCP server watch the corpus and re-index it on its own again once the
|
|
3
|
+
// shared index is on, and [NEVER] let a server whose build is older than the file on
|
|
4
|
+
// disk win the election while a current one is alive.
|
|
5
|
+
// WHY: every Claude Code chat spawns its own MCP server (`.mcp.json` per project, the user-scope
|
|
6
|
+
// entry elsewhere), plus launchd, plus VS Code. Each one parsed the same ~820 doc sources,
|
|
7
|
+
// collected ops from 40 projects, watched the same files and re-embedded the whole corpus
|
|
8
|
+
// on every save. Measured 2026-09-05 (SESSION_26): eleven servers, 9.3 CPU-hours in 1.4 h
|
|
9
|
+
// of wall clock, load average 230, a test suite that timed out, and until 2.5.6 nine
|
|
10
|
+
// writers racing on one learnings.json. A stale-build server that kept indexing after a
|
|
11
|
+
// rebuild re-imported 1,766 records the owner had just had deleted (SESSION_25).
|
|
12
|
+
// FIX: one writer per corpus, chosen from the server registry (current build first, then the
|
|
13
|
+
// earliest start, then the lowest pid), parses, embeds and writes this file with a stamp;
|
|
14
|
+
// every other server of that corpus loads it read-only and reloads when the stamp moves.
|
|
15
|
+
// Off unless CONTEXTENGINE_SHARED_INDEX=1 until the trial has run on a real fleet. A reader
|
|
16
|
+
// that finds no index runs the old pipeline once, without importing learnings: the fallback
|
|
17
|
+
// is today's code path, not a second one.
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { createHash } from "crypto";
|
|
22
|
+
import { findConfigFile } from "./config.js";
|
|
23
|
+
export const SHARED_INDEX_VERSION = 1;
|
|
24
|
+
export function sharedIndexEnabled() {
|
|
25
|
+
return process.env.CONTEXTENGINE_SHARED_INDEX === "1";
|
|
26
|
+
}
|
|
27
|
+
function ceHome() {
|
|
28
|
+
return process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What a server's corpus is made of, as a short id: the config file it resolved (path and
|
|
32
|
+
* content) or the discovery fallback, and the env flags that change discovery. Two servers with
|
|
33
|
+
* the same id index the same thing and can share one index; different ids get their own writer.
|
|
34
|
+
*/
|
|
35
|
+
export function corpusId() {
|
|
36
|
+
const h = createHash("sha256");
|
|
37
|
+
const cfg = findConfigFile();
|
|
38
|
+
h.update(`config=${cfg ?? "none"}\0`);
|
|
39
|
+
if (cfg) {
|
|
40
|
+
try {
|
|
41
|
+
h.update(readFileSync(cfg));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
h.update("unreadable");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
h.update(`\0ws=${process.env.CONTEXTENGINE_WORKSPACES ?? ""}`);
|
|
48
|
+
h.update(`\0skipmem=${process.env.OPSCONTEXT_SKIP_CLAUDE_MEMORY ?? ""}`);
|
|
49
|
+
h.update(`\0home=${homedir()}`);
|
|
50
|
+
return h.digest("hex").slice(0, 12);
|
|
51
|
+
}
|
|
52
|
+
export function sharedIndexPath(corpus) {
|
|
53
|
+
return join(ceHome(), "index", `${corpus}.json`);
|
|
54
|
+
}
|
|
55
|
+
/** Temp file + rename: a reader never sees a half-written index. */
|
|
56
|
+
export function writeSharedIndex(data) {
|
|
57
|
+
const t0 = Date.now();
|
|
58
|
+
const path = sharedIndexPath(data.corpus);
|
|
59
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
60
|
+
const file = { version: SHARED_INDEX_VERSION, stamp: new Date().toISOString(), ...data };
|
|
61
|
+
const json = JSON.stringify(file);
|
|
62
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
63
|
+
writeFileSync(tmp, json);
|
|
64
|
+
renameSync(tmp, path);
|
|
65
|
+
return { path, bytes: Buffer.byteLength(json), ms: Date.now() - t0 };
|
|
66
|
+
}
|
|
67
|
+
export function readSharedIndex(corpus) {
|
|
68
|
+
const path = sharedIndexPath(corpus);
|
|
69
|
+
if (!existsSync(path))
|
|
70
|
+
return null;
|
|
71
|
+
try {
|
|
72
|
+
const f = JSON.parse(readFileSync(path, "utf8"));
|
|
73
|
+
if (f.version !== SHARED_INDEX_VERSION || f.corpus !== corpus || !Array.isArray(f.chunks) || !Array.isArray(f.keys))
|
|
74
|
+
return null;
|
|
75
|
+
if (f.keys.length !== f.chunks.length)
|
|
76
|
+
return null;
|
|
77
|
+
return f;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Cheap change detector for readers: the file's mtime, or null when absent. */
|
|
84
|
+
export function sharedIndexMtime(corpus) {
|
|
85
|
+
try {
|
|
86
|
+
return statSync(sharedIndexPath(corpus)).mtimeMs;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Who indexes this corpus. Among the live registered servers of the corpus: a server whose
|
|
94
|
+
* build equals the file on disk beats a stale one, then the earliest start, then the lowest pid.
|
|
95
|
+
* A server that does not find itself in the registry indexes on its own: never wait on a
|
|
96
|
+
* registry that failed.
|
|
97
|
+
*/
|
|
98
|
+
export function electIndexer(corpus, servers, myPid) {
|
|
99
|
+
const mine = servers.filter((s) => s.corpus === corpus);
|
|
100
|
+
if (!mine.some((s) => s.pid === myPid))
|
|
101
|
+
return { indexer: null, role: "indexer" };
|
|
102
|
+
mine.sort((a, b) => {
|
|
103
|
+
if (a.staleBuild !== b.staleBuild)
|
|
104
|
+
return a.staleBuild ? 1 : -1;
|
|
105
|
+
const t = a.started.localeCompare(b.started);
|
|
106
|
+
if (t !== 0)
|
|
107
|
+
return t;
|
|
108
|
+
return a.pid - b.pid;
|
|
109
|
+
});
|
|
110
|
+
const winner = mine[0];
|
|
111
|
+
return { indexer: winner.pid, role: winner.pid === myPid ? "indexer" : "reader" };
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=shared-index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|